Julia REPL 的启动过程 & 从脚本启动自定义的 REPL

REPL 相关的需求

关于 REPL 可以能有以下几种需求:

  1. 自定义 Julia 的 REPL 让它更好看、更好用
    类似增加更多的代码高亮、设置自定义的自动补全等等

  2. 为 Julia 自带的 REPL 添加新的类似 ?help]pkg;shell 的模式
    一般包开发者可能有这个需求,加载包后能进入自定义的另一个 REPL

  3. 从脚本启动自定义的 REPL。
    这种主要是只想利用下 julia 的 libreadline 库。循环 + eachline 当然能解决一点问题,不过不能用方向键还是令人不爽。

第一种有包可用

OhMyREPL.jl 做了 语法高亮、括号匹配高亮、彩虹括号等等

HeaderREPLs.jl: They allow you to define a custom “header” type that you can use to print extra information above the prompt.

欢迎补充


第二种也有包可以用

ReplMaker.jl 能在 julia 自带的 REPL 的基础上,通过自定义的按键进入特定的模式。
就是类似 ?help 那种模式,去看一眼包给的例子就懂了。


第三种目前我还没发现可用的包。所以需要探究一下原生 Julia 的 REPL 的启动过程。
Basestdlib 里抄点代码。

我从英文论坛的帖子里找到了起点

See _start in base/client.jl

Julia 的 REPL 启动流程分析

以下行号基于 v"1.3.1" 版本.
从上至下为调用关系

调用 _start() 的函数在 repl.c 里,涉及到 C 语言所以折叠了。

main() @ ui/repl.c

  • main() @ repl.c#L151
    做了一些系统相关的初始化工作。
    并且如果带有 --lisp 参数,会在这里启动 femtolisp REPL( jl_lisp_prompt()
  • true_main() @ repl.c#L85
    julia 在 C-API 层面的一些初始化工作。
    很有意思的是,这里也做了 fallback,当 Base._start 未定义时,REPL 依旧能正常工作。
    L116-L146 演示了,怎样通过 julia 的 C-API 搭建一个 REPL。

默认情况下会通过 jl_get_global 取得符号 _start 对应的函数指针,然后用 jl_apply 执行它。

_start() @ base/client.jl

  1. _start() @base/client.jl#L452
    使用了 JLOptions() 的全部默认值
  2. exec_options() @base/client.jl#L213
    开始解析命令行参数 ARGS
  3. run_main_repl() @base/client.jl#L351
    全局变量 active_repl 在此定义,这个变量的定义与否( isdefined(Base, :active_repl) )可以用来区分程序是否在 Julia 的 REPL 中运行。
    函数还会根据参数、环境变量以及系统的差异决定使用:BasicREPLLineEditREPL 还是作为 fallback 的循环。( L405-L415 是循环实现简单 REPL 的例子。)
    我更关心基于 LineEditREPL 的 REPL 怎么启动,那么抄代码就要从 L367-L382 开始。

下一步调用了 REPL.run_repl 这里要换文件了。

run_repl() @stdlib/REPL.jl

@L196 const JULIA_PROMPT = "julia> " REPL 的提示符 PROMPT 在这个文件头定义。

  1. run_repl() @stdlib/REPL.jl#L196
    这个函数很短一共就 8 行。它声明了两个 Channel 用于输入和输出,然后分别调用了 REPL 的前后端。
    我们更关心后端,因为要替换成自己的处理函数。所以先看后端。
  2. start_repl_backend() @stdlib/REPL.jl#L105
    @async 开了个死循环持续调用 eval_user_input
    这里输入 repl_channel 里放着的是元组 ast, show_valueshow_value==-1 时后端退出。
  3. eval_user_input() @stdlib/REPL.jl#L76
    这里又是一个死循环,默认是通过 Core.eval(Main, ast) 对输入求值,当然我们会想实现自己的求值函数,替代掉这个。
    ans 变量也是在这里设置的。
    求值成功返回 (value,false) 报错则返回 (lasterr,true),返回值塞到 response_channel 里。

后端(5、6)到这里就结束了。返回去看前端。
前端有3个版本,分别针对:BasicREPLLineEditREPLStreamREPL

  1. run_frontend(::BasicREPL) @stdlib/REPL.jl#L215
    BasicREPL 比较简单,Base.parse_input_line 输入、REPL.eval_with_backend 执行、REPL.print_response 输出,需要修改就复制源代码,直接覆盖掉原函数就好了。
  2. run_frontend(::LineEditREPL) @stdlib/REPL.jl#L1034
    分三步:配置 setup_interface、初始化 LineEdit.init_state、启动 run_interface
  3. setup_interface() @stdlib/REPL.jl#L772
    这个函数一看就很重要,因为函数体开头整齐的写了不少注释。
    函数功能如下:初始化需要的组件、并配置 TAB 补全。
    # We setup the interface in two stages.
    # First, we set up all components (prompt,rsearch,shell,help)
    # Second, we create keymaps with appropriate transitions between them
    #   and assign them to the components
    
    函数接受的参数太少了 setup_interface(repl, hascolor, extra_repl_keymap) 通过参数无法自定义太多东西,所以这个函数,需要魔改。

LineEdit.init_staterun_interface 跑到 stdlib/REPL/LineEdit.jl 里了

init_state() @stdlib/REPL/LineEdit.jl

  1. init_state() @stdlib/REPL/LineEdit.jl#L2290
    新建了一个 MIState 然后拿传入的参数初始化它。没什么可配置的。
  2. run_interface() @stdlib/REPL/LineEdit.jl#L2299
    如果未设置中止 flag ( !s::MIState.aborted )则循环执行 prompt!mode(state(s)).on_done,后者通过 Base.invokelatest 执行。on_done 是个函数,默认为永远返回 nothing 的匿名函数。
    ReplMaker.jl 通过设置 on_done 来达到自行处理输入的目的。
  1. prompt!() @stdlib/REPL/LineEdit.jl#L2381
    初步判断是用来处理 keymap 的,暂时应该不用动它。

到这里前端也结束了。
暂时没有进一步深入的必要了。


从脚本启动自定义的 REPL

  • BasicREPL 的主要工作都在 run_frontend(::BasicREPL) 里面,用自己改过的函数覆盖掉原函数即可。
  • LineEditREPL 涉及到的函数比较多,还好他的可配置性也更高,先手动初始化,然后修改对应的配置。

eval_user_input 中调用了 Core.eval(Main, ast) 用来执行 ast,需要的时候也有必要覆盖掉这个函数。

理论分析就是如此。我们再来实际的试一试。

保存代码为脚本后可以直接执行,会打开一个 REPL 输入什么输出什么。
处理函数(f: String → String)为 start_repl() 的参数,替换它就能处理输入,默认为 id 即什么都不做,原样输出。

并且通过 ReplMaker 包,支持在 julia 的 REPL 中进入这个模式。
方法是 include 这个脚本,然后按 ) (左括号)。

代码在 v1.3.1 中测试,测试环境为 win10 + wsl

Mal_REPL.jl

module Mal_REPL

import REPL     # for script without REPL
using ReplMaker # for Julia's REPL

# 判断是否在 Julia 的 REPL 中
const IN_JULIA_REPL = isdefined(Base, :active_repl)
const NOT_IN_JULIA_REPL = ! IN_JULIA_REPL
const MAL_PROMPT = "user> " # 自定义的 prompt

# 全局变量,用于处理输入
const global BasicREPL_INPUT_LINE_FUNC = Vector{Function}()
push!(BasicREPL_INPUT_LINE_FUNC, identity)

export start_repl, IN_JULIA_REPL

#= COPY FROM `REPL.jl` && `client.jl` ====================================== =#
if NOT_IN_JULIA_REPL

# overwrite output prompt
# ref: https://github.com/KristofferC/OhMyREPL.jl/blob/master/src/output_prompt_overwrite.jl
# stdlib/REPL.jl#L129
# 修改:用于自定义输出
#   1. show => print
#   2. 注释掉 println(io) 消除换行
function REPL.display(d::REPL.REPLDisplay, mime::MIME"text/plain", x)
    io = REPL.outstream(d.repl)
    get(io, :color, false) && write(io, REPL.answer_color(d.repl))
    # show(IOContext(io, :limit => true, :module => Main), mime, x)
    print(IOContext(io, :limit => true, :module => Main), x)
    print
    # println(io)
    nothing
end

# stdlib/REPL.jl#L215
# 修改:用于支持 BasicREPL 的自定义
#   1. IN_JULIA_REPL
#   2. BasicREPL_INPUT_LINE_FUNC
#   3. 注释掉 write(repl.terminal, '\n') 消除换行
function REPL.run_frontend(repl::REPL.BasicREPL, backend::REPL.REPLBackendRef)
    d = REPL.REPLDisplay(repl)
    dopushdisplay = !in(d,Base.Multimedia.displays)
    dopushdisplay && pushdisplay(d)
    hit_eof = false
    while true
        Base.reseteof(repl.terminal)
  #=1=# write(repl.terminal, MAL_PROMPT)
        line = ""
        ast = nothing
        interrupted = false
        while true
            try
                line *= readline(repl.terminal, keep=true)
            catch e
                if isa(e,InterruptException)
                    try # raise the debugger if present
                        ccall(:jl_raise_debugger, Int, ())
                    catch
                    end
                    line = ""
                    interrupted = true
                    break
                elseif isa(e,EOFError)
                    hit_eof = true
                    break
                else
                    rethrow()
                end
            end
            # ast = Base.parse_input_line(line)
  #=2=#     ast = Base.invokelatest(BasicREPL_INPUT_LINE_FUNC[], line)
            (isa(ast,Expr) && ast.head === :incomplete) || break
        end
        if !isempty(line)
            response = REPL.eval_with_backend(ast, backend)
            REPL.print_response(repl, response, !REPL.ends_with_semicolon(line), false)
        end
  #=3=# # write(repl.terminal, '\n')
        ((!interrupted && isempty(line)) || hit_eof) && break
    end
    # terminate backend
    put!(backend.repl_channel, (nothing, -1))
    dopushdisplay && popdisplay(d)
    nothing
end

end # end if NOT_IN_JULIA_REPL
#= COPY END ================================================================ =#


"""
单独定制的 REPL。包含 LineEdit 功能。

可自定义 prompt,输入处理函数,输出格式。
支持从 julia 中启动;或者直接从脚本文件启动。
"""
function start_repl(repl_func::Function=identity)
    if IN_JULIA_REPL
        ReplMaker.initrepl(
            repl_func,
            prompt_text = "user> ",
            # prompt_color = :blue,
            start_key = ')',
            repl = Base.active_repl,
            mode_name = :mal_lisp,
            # valid_input_checker::Function = (s -> true),
            # keymap::Dict = REPL.LineEdit.default_keymap_dict,
            # completion_provider = REPL.REPLCompletionProvider(),
            # sticky_mode = true,
            startup_text = false
        )
    else # NOT_IN_JULIA_REPL: ref: https://discourse.juliacn.com/t/topic/3038
    # copy from: base/client.jl#L367-L382
    term_env = get(ENV, "TERM", @static Sys.iswindows() ? "" : "dumb")
    term = REPL.Terminals.TTYTerminal(term_env, stdin, stdout, stderr)
    have_color = REPL.Terminals.hascolor(term)
    if term.term_type == "dumb"
        # overwrite REPL.run_frontend(repl::BasicREPL)
        BasicREPL_INPUT_LINE_FUNC[] = repl_func
        active_repl = REPL.BasicREPL(term)
    else
        active_repl = REPL.LineEditREPL(term, have_color, true)
        active_repl.history_file = true

        # set prompt
        active_repl.interface = REPL.setup_interface(active_repl)
        main_mode = active_repl.interface.modes[1]
        main_mode.prompt = MAL_PROMPT
        main_mode.on_done = REPL.respond(repl_func, active_repl, main_mode)
    end
    # active_repl = REPL.BasicREPL(term)
    pushdisplay(REPL.REPLDisplay(active_repl))

    REPL.run_repl(active_repl)
    end
end # end of start_repl()

end # end of module Mal_REPL

# # for test
# using .Mal_REPL
# start_repl()

测试发现替换 on_done 的定义就能自行处理输入,所以应该有更简单的方法。

Update1:改为直接修改配置,对于 LineEditREPL 无需复制 Base 的代码。
Update2:通过覆盖 REPL.display 解决输出控制的问题;覆盖 REPL.run_frontend(repl::BasicREPL) 解决 BasicREPL 的自定义。
Update3:bugfix BasicREPL_INPUT_LINE_FUNC 改用数组,便于更新

关于 ReplMaker.jl 等包的分析。