# include("repl.jl") import REPL import REPL: LineEdit, REPLCompletions using Markdown ########### # Options # ########### struct OptionSpec name::String short_name::Union{Nothing,String} api::Pair{Symbol, Any} is_switch::Bool end @enum(OptionClass, OPT_ARG, OPT_SWITCH) const OptionDeclaration = Tuple{Union{String,Vector{String}}, # name + short_name? OptionClass, # arg or switch Pair{Symbol, Any} # api keywords } function OptionSpec(x::OptionDeclaration)::OptionSpec get_names(name::String) = (name, nothing) function get_names(names::Vector{String}) @assert length(names) == 2 return (names[1], names[2]) end is_switch = x[2] == OPT_SWITCH api = x[3] (name, short_name) = get_names(x[1]) #TODO assert matching lex regex if !is_switch @assert api.second === nothing || hasmethod(api.second, Tuple{String}) end return OptionSpec(name, short_name, api, is_switch) end function OptionSpecs(decs::Vector{OptionDeclaration})::Dict{String, OptionSpec} specs = Dict() for x in decs opt_spec = OptionSpec(x) @assert get(specs, opt_spec.name, nothing) === nothing # don't overwrite specs[opt_spec.name] = opt_spec if opt_spec.short_name !== nothing @assert get(specs, opt_spec.short_name, nothing) === nothing # don't overwrite specs[opt_spec.short_name] = opt_spec end end return specs end struct Option val::String argument::Union{Nothing,String} end Base.show(io::IO, opt::Option) = print(io, "--$(opt.val)", opt.argument == nothing ? "" : "=$(opt.argument)") function parse_option(word::AbstractString)::Option m = match(r"^(?: -([a-z]) | --([a-z]{2,})(?:\s*=\s*(\S*))? )$"ix, word) m == nothing && matrixerror("malformed option: ", repr(word)) option_name = (m.captures[1] != nothing ? m.captures[1] : m.captures[2]) option_arg = (m.captures[3] == nothing ? nothing : String(m.captures[3])) return Option(option_name, option_arg) end meta_option_declarations = OptionDeclaration[ ("preview", OPT_SWITCH, :preview => true) ] meta_option_specs = OptionSpecs(meta_option_declarations) ################ # Command Spec # ################ @enum(CommandKind, CMD_HELP, CMD_MSG, CMD_DIALOG_LIST, CMD_HISTORY, CMD_LOAD_GROUP_PHOTO) @enum(ArgClass, ARG_RAW, ARG_PKG, ARG_VERSION, ARG_REV, ARG_ALL) struct ArgSpec count::Pair parser::Function parser_keys::Vector{Pair{Symbol, Any}} end const CommandDeclaration = Tuple{CommandKind, Vector{String}, # names Union{Nothing,Function}, # handler Tuple{Pair, # count Function, # parser Vector{Pair{Symbol, Any}}, # parser keys }, # arguments Vector{OptionDeclaration}, # options String, #description Union{Nothing}, #help } struct CommandSpec kind::CommandKind canonical_name::String short_name::Union{Nothing,String} handler::Union{Nothing,Function} argument_spec::ArgSpec option_specs::Dict{String, OptionSpec} description::String help::Union{Nothing} end function SuperSpecs(foo)::Dict{String,Dict{String,CommandSpec}} super_specs = Dict() for x in foo sub_specs = CommandSpecs(x.second) for name in x.first @assert get(super_specs, name, nothing) === nothing super_specs[name] = sub_specs end end return super_specs end # populate a dictionary: command_name -> command_spec function CommandSpecs(declarations::Vector{CommandDeclaration})::Dict{String,CommandSpec} specs = Dict() for dec in declarations names = dec[2] spec = CommandSpec(dec[1], names[1], length(names) == 2 ? names[2] : nothing, dec[3], ArgSpec(dec[4]...), OptionSpecs(dec[5]), dec[6], dec[end]) for name in names # TODO regex check name @assert get(specs, name, nothing) === nothing specs[name] = spec end end return specs end ################### # Package parsing # ################### let uuid = raw"(?i)[0-9a-z]{8}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{12}(?-i)", name = raw"(\w+)(?:\.jl)?" global const name_re = Regex("^$name\$") global const uuid_re = Regex("^$uuid\$") global const name_uuid_re = Regex("^$name\\s*=\\s*($uuid)\$") end # packages can be identified through: uuid, name, or name+uuid # additionally valid for add/develop are: local path, url function parse_package(word::AbstractString; add_or_develop=false)::PackageSpec if add_or_develop && casesensitive_isdir(expanduser(word)) return PackageSpec(Types.GitRepo(expanduser(word))) elseif occursin(uuid_re, word) return PackageSpec(UUID(word)) elseif occursin(name_re, word) return PackageSpec(String(match(name_re, word).captures[1])) elseif occursin(name_uuid_re, word) m = match(name_uuid_re, word) return PackageSpec(String(m.captures[1]), UUID(m.captures[2])) elseif add_or_develop # Guess it is a url then return PackageSpec(Types.GitRepo(word)) else matrixerror("`$word` cannot be parsed as a package") end end ################ # REPL parsing # ################ mutable struct Statement command::Union{Nothing,CommandSpec} options::Vector{String} arguments::Vector{String} meta_options::Vector{String} Statement() = new(nothing, [], [], []) end struct QuotedWord word::String isquoted::Bool end function unwrap_option(option::String) if startswith(option, "--") return length(option) == 2 ? "" : option[3:end] elseif length(option) == 2 return option[end] end end wrap_option(option::String) = length(option) == 1 ? "-$option" : "--$option" function _statement(words) is_option(word) = first(word) == '-' word = popfirst!(words) # meta options while is_option(word) if isempty(words) if unwrap_option(word) in keys(meta_option_specs) return :cmd, "", nothing, true else return :meta, word, nothing, true end end word = popfirst!(words) end # command if word in keys(super_specs) # have a super command super_name = word super = super_specs[word] if isempty(words) return :sub, "", super_name, true end word = popfirst!(words) command = get(super, word, nothing) if command === nothing if isempty(words) return :sub, word, super_name, true else return nothing end end elseif get(super_specs["matrix"], word, nothing) !== nothing # given a "matrix" command command = get(super_specs["matrix"], word, nothing) elseif isempty(words) # try to complete the super command return :cmd, word, nothing, true else return nothing end if isempty(words) return :arg, "", command, true end word = words[end] manifest = any(x->x in ["--manifest", "-m"], filter(is_option, words)) return is_option(word) ? (:opt, word, command, true) : (:arg, word, command, !manifest) end function parse(cmd::String; for_completions=false) # replace new lines with ; to support multiline commands cmd = replace(replace(cmd, "\r\n" => "; "), "\n" => "; ") # tokenize accoring to whitespace / quotes # WIP qwords = parse_quotes(cmd) # tokenzie unquoted tokens according to matrix REPL syntax words = lex(qwords) # break up words according to ";"(doing this early makes subsequent processing easier) word_groups = group_words(words) # create statements if for_completions if length(word_groups) > 0 return _statement(word_groups[end]) else return [] end end return map(Statement, word_groups) end # vector of words -> structured statement # minimal checking is done in this phase function Statement(words)::Statement is_option(word) = first(word) == '-' statement = Statement() word = popfirst!(words) # meta options while is_option(word) push!(statement.meta_options, word) isempty(words) && matrixerror("no command specified") word = popfirst!(words) end # command # special handling for `preview`, just convert it to a meta option under the hood if word == "preview" if !("--preview" in statement.meta_options) push!(statement.meta_options, "--preview") end isempty(words) && matrixerror("preview requires a command") word = popfirst!(words) end if word in keys(super_specs) super = super_specs[word] isempty(words) && matrixerror("no subcommand specified") word = popfirst!(words) else super = super_specs["matrix"] end command = get(super, word, nothing) command !== nothing || matrixerror("expected command. instead got [$word]") statement.command = command # command arguments for word in words push!((is_option(word) ? statement.options : statement.arguments), word) end return statement end # break up words according to `;`(doing this early makes subsequent processing easier) # the final group does not require a trailing `;` function group_words(words)::Vector{Vector{String}} statements = Vector{String}[] x = String[] for word in words if word == ";" isempty(x) ? matrixerror("empty statement") : push!(statements, x) x = String[] else push!(x, word) end end isempty(x) || push!(statements, x) return statements end const lex_re = r"^[\?\./\+\-](?!\-) | ((git|ssh|http(s)?)|(git@[\w\-\.]+))(:(//)?)([\w\.@\:/\-~]+)(\.git)(/)? | [^@\#\s;]+\s*=\s*[^@\#\s;]+ | \#\s*[^@\#\s;]* | @\s*[^@\#\s;]* | [^@\#\s;]+|;"x function lex(qwords::Vector{QuotedWord})::Vector{String} words = String[] for qword in qwords if qword.isquoted push!(words, qword.word) else append!(words, map(m->m.match, eachmatch(lex_re, qword.word))) end end return words end function parse_quotes(cmd::String)::Vector{QuotedWord} in_doublequote = false in_singlequote = false qwords = QuotedWord[] token_in_progress = Char[] push_token!(is_quoted) = begin push!(qwords, QuotedWord(String(token_in_progress), is_quoted)) empty!(token_in_progress) end for c in cmd if c == '"' if in_singlequote # raw char push!(token_in_progress, c) else # delimiter in_doublequote ? push_token!(true) : push_token!(false) in_doublequote = !in_doublequote end elseif c == '\'' if in_doublequote # raw char push!(token_in_progress, c) else # delimiter in_singlequote ? push_token!(true) : push_token!(false) in_singlequote = !in_singlequote end else push!(token_in_progress, c) end end if (in_doublequote || in_singlequote) matrixerror("unterminated quote") else push_token!(false) end # to avoid complexity in the main loop, empty tokens are allowed above and # filtered out before returning return filter(x->!isempty(x.word), qwords) end ############## # MatrixCommand # ############## function APIOptions(options::Vector{Option}, specs::Dict{String, OptionSpec}, )::Dict{Symbol, Any} keyword_vec = map(options) do opt spec = specs[opt.val] # opt is switch spec.is_switch && return spec.api # no opt wrapper -> just use raw argument spec.api.second === nothing && return spec.api.first => opt.argument # given opt wrapper return spec.api.first => spec.api.second(opt.argument) end return Dict(keyword_vec) end # Only for PkgSpec function word2token(word::AbstractString)::Token if first(word) == '@' return VersionRange(word[2:end]) elseif first(word) == '#' return Rev(word[2:end]) else return String(word) end end function parse_matrix(raw_args::Vector{String}; valid=[], add_or_dev=false) args::Vector{PkgToken} = map(word2token, raw_args) enforce_argument_order(args) # enforce spec push!(valid, String) # always want at least PkgSpec identifiers if !all(x->typeof(x) in valid, args) matrixerror("invalid token") end # convert to final arguments return package_args(args; add_or_dev=add_or_dev) end function enforce_argument(raw_args::Vector{String}, spec::ArgSpec) args = spec.parser(raw_args; spec.parser_keys...) # enforce_argument_count(spec.count, args) return args end function enforce_option(option::String, specs::Dict{String,OptionSpec})::Option opt = parse_option(option) spec = get(specs, opt.val, nothing) spec !== nothing || matrixerror("option '$(opt.val)' is not a valid option") if spec.is_switch opt.argument === nothing || matrixerror("option '$(opt.val)' does not take an argument, but '$(opt.argument)' given") else # option takes an argument opt.argument !== nothing || matrixerror("option '$(opt.val)' expects an argument, but no argument given") end return opt end function enforce_meta_options(options::Vector{String}, specs::Dict{String,OptionSpec})::Vector{Option} meta_opt_names = keys(specs) return map(options) do opt tok = enforce_option(opt, specs) tok.val in meta_opt_names || matrixerror("option '$opt' is not a valid meta option.") #TODO hint that maybe they intended to use it as a command option return tok end end function enforce_opts(options::Vector{String}, specs::Dict{String,OptionSpec})::Vector{Option} unique_keys = Symbol[] get_key(opt::Option) = specs[opt.val].api.first # final parsing toks = map(x->enforce_option(x,specs),options) # checking for opt in toks # valid option opt.val in keys(specs) || matrixerror("option '$(opt.val)' is not supported") # conflicting options key = get_key(opt) if key in unique_keys conflicting = filter(opt->get_key(opt) == key, toks) matrixerror("Conflicting options: $conflicting") else push!(unique_keys, key) end end return toks end struct MatrixCommand spec::CommandSpec arguments::Vector{String} end Option(v::String) = Option(v, nothing) # this the entry point for the majority of input checks function MatrixCommand(statement::Statement)::MatrixCommand meta_opts = enforce_meta_options(statement.meta_options, meta_option_specs) # return MatrixCommand(statement.command) return MatrixCommand(statement.command, statement.arguments) end ############# # Execution # ############# function do_cmd!(command::MatrixCommand, repl) global user if command.spec.kind == CMD_MSG send(user, command.arguments[1], join(command.arguments[2:end], " ")) elseif command.spec.kind == CMD_HISTORY if length(command.arguments) == 0 return elseif length(command.arguments) == 1 history_size = 10 else history_size = command.arguments end history(user, command.arguments[1], limit = history_size) elseif command.spec.kind in [ CMD_DIALOG_LIST ] global roomlist map(println,roomlist["joined_rooms"]) elseif command.spec.kind in [ CMD_LOAD_GROUP_PHOTO ] end end function do_cmd(repl::REPL.AbstractREPL, input::String; do_rethrow=false) try statements = parse(input) commands = map(MatrixCommand, statements) for cmd in commands do_cmd!(cmd, repl) end catch err if do_rethrow rethrow(err) end Base.display_error(repl.t.err_stream, err, Base.catch_backtrace()) end end function CommandSpec(command_name::String)::Union{Nothing,CommandSpec} # maybe a "matrix" command spec = get(super_specs["matrix"], command_name, nothing) if spec !== nothing return spec end # maybe a "compound command" m = match(r"(\w+)-(\w+)", command_name) m !== nothing || (return nothing) super = get(super_specs, m.captures[1], nothing) super !== nothing || (return nothing) return get(super, m.captures[2], nothing) end ###################### # REPL mode creation # ###################### # Provide a string macro matrix"cmd" that can be used in the same way # as the REPLMode `matrix> cmd`. Useful for testing and in environments # where we do not have a REPL, e.g. IJulia. struct MiniREPL <: REPL.AbstractREPL display::TextDisplay t::REPL.Terminals.TTYTerminal end function MiniREPL() MiniREPL(TextDisplay(stdout), REPL.Terminals.TTYTerminal(get(ENV, "TERM", Sys.iswindows() ? "" : "dumb"), stdin, stdout, stderr)) end REPL.REPLDisplay(repl::MiniREPL) = repl.display const minirepl = Ref{MiniREPL}() __init__() = minirepl[] = MiniREPL() macro matrix_str(str::String) :($(do_cmd)(minirepl[], $str; do_rethrow=true)) end matrixstr(str::String) = do_cmd(minirepl[], str; do_rethrow=true) # handle completions mutable struct CompletionCache commands::Vector{String} canonical_names::Vector{String} meta_options::Vector{String} options::Dict{CommandKind, Vector{String}} subcommands::Dict{String, Vector{String}} CompletionCache() = new([],[],[],Dict(),Dict()) end completion_cache = CompletionCache() struct MatrixCompletionProvider <: LineEdit.CompletionProvider end function LineEdit.complete_line(c::MatrixCompletionProvider, s) partial = REPL.beforecursor(s.input_buffer) full = LineEdit.input_string(s) ret, range, should_complete = completions(full, lastindex(partial)) return ret, partial[range], should_complete end function complete_local_path(s, i1, i2) cmp = REPL.REPLCompletions.complete_path(s, i2) [REPL.REPLCompletions.completion_text(p) for p in cmp[1]], cmp[2], !isempty(cmp[1]) end function complete_installed_package(s, i1, i2, project_opt) matrixs = project_opt ? API.__installed(PKGMODE_PROJECT) : API.__installed() matrixs = sort!(collect(keys(filter((p) -> p[2] != nothing, matrixs)))) cmp = filter(cmd -> startswith(cmd, s), matrixs) return cmp, i1:i2, !isempty(cmp) end function complete_remote_package(s, i1, i2) cmp = String[] julia_version = VERSION for reg in Types.registries(;clone_default=false) data = Types.read_registry(joinpath(reg, "Registry.toml")) for (uuid, matrixinfo) in data["packages"] name = matrixinfo["name"] if startswith(name, s) compat_data = Operations.load_package_data_raw( VersionSpec, joinpath(reg, matrixinfo["path"], "Compat.toml")) supported_julia_versions = VersionSpec(VersionRange[]) for (ver_range, compats) in compat_data for (compat, v) in compats if compat == "julia" union!(supported_julia_versions, VersionSpec(v)) end end end if VERSION in supported_julia_versions push!(cmp, name) end end end end return cmp, i1:i2, !isempty(cmp) end function complete_argument(to_complete, i1, i2, lastcommand, project_opt )::Tuple{Vector{String},UnitRange{Int},Bool} # to_complete -> new letters typed # lastcommand -> CMD_TYPE # project_opt -> true # i1 -> end command index # i2 -> current index if lastcommand in [ CMD_MSG, CMD_HISTORY ] return complete_user_list(to_complete, i1, i2, project_opt) elseif lastcommand in [ ] # file completition end return String[], 0:-1, false end function complete_user_list(to_complete, i1, i2, _) global roomlist l = filter(x -> startswith(x, to_complete), roomlist["joined_rooms"]) return l, i1:i2, true end function completions(full, index)::Tuple{Vector{String},UnitRange{Int},Bool} # Full holds the whole line, while index the current cursor position! pre = full[1:index] # Returns all commands if first position if isempty(pre) return completion_cache.commands, 0:-1, false end x = parse(pre; for_completions=true) if x === nothing # failed parse (invalid command name) return String[], 0:-1, false end (key::Symbol, to_complete::String, spec, proj::Bool) = x last = split(pre, ' ', keepempty=true)[end] offset = isempty(last) ? index+1 : last.offset+1 if last != to_complete # require a space before completing next field return String[], 0:-1, false end if key == :arg return complete_argument(to_complete, offset, index, spec.kind, proj) end possible::Vector{String} = key == :meta ? completion_cache.meta_options : key == :cmd ? completion_cache.commands : key == :sub ? completion_cache.subcommands[spec] : key == :opt ? completion_cache.options[spec.kind] : String[] completions = filter(x->startswith(x,to_complete), possible) return completions, offset:index, !isempty(completions) end prev_project_file = nothing prev_project_timestamp = nothing prev_prefix = "" function promptf() global prev_project_timestamp, prev_prefix, prev_project_file project_file = try Types.find_project_file() catch nothing end prefix = "" if project_file !== nothing if prev_project_file == project_file && prev_project_timestamp == mtime(project_file) prefix = prev_prefix else project = try Types.read_project(project_file) catch nothing end if project !== nothing projname = get(project, "name", nothing) if projname !== nothing name = projname else name = basename(dirname(project_file)) end prefix = string("(", name, ") ") prev_prefix = prefix prev_project_timestamp = mtime(project_file) prev_project_file = project_file end end end return prefix * "matrix> " end # Set up the repl Pkg REPLMode function create_mode(repl, main) matrix_mode = LineEdit.Prompt(promptf; prompt_prefix = Base.text_colors[:magenta], prompt_suffix = "", complete = MatrixCompletionProvider(), sticky = true) matrix_mode.repl = repl hp = main.hist hp.mode_mapping[:matrix] = matrix_mode matrix_mode.hist = hp search_prompt, skeymap = LineEdit.setup_search_keymap(hp) prefix_prompt, prefix_keymap = LineEdit.setup_prefix_keymap(hp, matrix_mode) matrix_mode.on_done = (s, buf, ok) -> begin ok || return REPL.transition(s, :abort) input = String(take!(buf)) REPL.reset(repl) do_cmd(repl, input) REPL.prepare_next(repl) REPL.reset_state(s) s.current_mode.sticky || REPL.transition(s, main) end mk = REPL.mode_keymap(main) repl_keymap = Dict() b = Dict{Any,Any}[ skeymap, repl_keymap, mk, prefix_keymap, LineEdit.history_keymap, LineEdit.default_keymap, LineEdit.escape_defaults ] matrix_mode.keymap_dict = LineEdit.keymap(b) return matrix_mode end function repl_init(repl) main_mode = repl.interface.modes[1] matrix_mode = create_mode(repl, main_mode) push!(repl.interface.modes, matrix_mode) keymap = Dict{Any,Any}( '/' => function (s,args...) if isempty(s) || position(LineEdit.buffer(s)) == 0 buf = copy(LineEdit.buffer(s)) LineEdit.transition(s, matrix_mode) do LineEdit.state(s, matrix_mode).input_buffer = buf end else LineEdit.edit_insert(s, '/') end end ) main_mode.keymap_dict = LineEdit.keymap_merge(main_mode.keymap_dict, keymap) return end function load_config_file(; configfile::String = expanduser("~/matrix/config.yaml")) if isfile(configfile) config = YAML.load_file(configfile) else # First run, create a new user/login and create the config file @error "First run not implemented!" end config end function load_secret_or_login(config; secretfile = expanduser("~/matrix/secret.json")) username = config["user"]["name"] server = MatrixServer(config["user"]["server"]) if !isfile(secretfile) # login @info "Logging in..." # FIXME: Don't know how to use a SecretBuffer password = Base.getpass("@$(username):$(URI(server.instance).host)") response = MatrixChat.login(server, username, join(map(x -> read(seek(password, x), Char), (1:password.size).-1),"")) write(secretfile, response.body |> String) end sec = JSON.parse(open(secretfile, "r")) @info "Session loaded from secret" token = sec["access_token"] deviceid = sec["device_id"] (server, username, token, deviceid) end """Initialize the REPL mode, read config files and start the TUI client """ function repl() global user userinfo = load_secret_or_login(load_config_file()) repl_init(Base.active_repl) user = MatrixUser(userinfo...) MatrixChat.updateroomlist(user) syncstatus = MatrixSync(user) user end ######## # SPEC # ######## command_declarations = [ ["matrix"] => CommandDeclaration[ ( CMD_MSG, [ "msg", "send_message" ], nothing, (0=>Inf, identity, []), [], "send message to peer", nothing ), ( CMD_HISTORY, [ "history" ], nothing, (0=>Inf, identity, []), [], "show peer history", nothing ), ( CMD_DIALOG_LIST, [ "dialog_list" ], nothing, (0=>Inf, identity, []), [], "show available dialogs", nothing ), ( CMD_LOAD_GROUP_PHOTO, [ "load_chat_photo", "load_group_photo" ], nothing, (0=>Inf, identity, []), [], "Download a group's profile picture", nothing ) ], #matrix ] #command_declarations super_specs = SuperSpecs(command_declarations) # cache things you need for completions completion_cache.meta_options = sort(map(wrap_option, collect(keys(meta_option_specs)))) completion_cache.commands = sort(append!(collect(keys(super_specs)), collect(keys(super_specs["matrix"])))) let names = String[] for (super, specs) in pairs(super_specs) super == "matrix" && continue # skip "matrix" for spec in unique(values(specs)) push!(names, join([super, spec.canonical_name], "-")) end end for spec in unique(values(super_specs["matrix"])) push!(names, spec.canonical_name) end completion_cache.canonical_names = names sort!(completion_cache.canonical_names) end for (k, v) in pairs(super_specs) completion_cache.subcommands[k] = sort(collect(keys(v))) for spec in values(v) completion_cache.options[spec.kind] = sort(map(wrap_option, collect(keys(spec.option_specs)))) end end const help = md""" **Welcome to the Matrix REPL-mode**. To return to the `julia>` prompt, either press backspace when the input line is empty or press Ctrl+C. **Synopsis** matrix> cmd [opts] [args] Multiple commands can be given on the same line by interleaving a `;` between the commands. **Commands** """