From ca67b525ae2d349995deb8ae3c6b7a3d54fad7d6 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Thu, 6 Aug 2026 15:41:44 +0300 Subject: [PATCH 1/5] Swap the utop README examples for Clojure and Erlang ones The old examples leaned on utop's nREPL server mode, which lives on an experimental branch that will probably never ship. The new sessions were captured against real servers - the Clojure reference implementation and dialtone. --- README.md | 49 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e58a994..b9d672a 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,11 @@ and is spoken by a growing family of servers and editor clients ([CIDER](https://cider.mx), [neat](https://github.com/nrepl/neat), [conjure](https://github.com/Olical/conjure) and friends). mezcaml is the OCaml counterpart: a small client library plus a command-line tool, in the -spirit of neat's "do the protocol, skip the magic" philosophy. It pairs -nicely with utop's (experimental, for now) nREPL server mode, but it should -work against any nREPL server, whatever the language behind it. +spirit of neat's "do the protocol, skip the magic" philosophy. It works +against any nREPL server, whatever the language on the other end - the +Clojure reference implementation, the BEAM servers +([dialtone](https://github.com/nrepl/nrepl-beam) for Erlang, repartee for +Elixir), and anything else that speaks the protocol. ## Status @@ -27,22 +29,39 @@ dune install ## Command-line usage -Start an nREPL server somewhere. For OCaml that would be: +Start an nREPL server somewhere. The Clojure reference server, for +instance: ``` -utop -nrepl 7888 +clj -Sdeps '{:deps {nrepl/nrepl {:mvn/version "1.7.0"}}}' -M -m nrepl.cmdline --port 7888 ``` Then: ``` $ mezcaml -p 7888 -mezcaml, a minimal nREPL client. Ctrl-D to quit. -Connected to 127.0.0.1:7888 (nrepl 1.0, ocaml 5.3.0, utop 2.16.0) -mezcaml> 1 + 2 -- : int = 3 -mezcaml> let square x = x * x -val square : int -> int = +mezcaml 0.1.0, a minimal nREPL client. Ctrl-D to quit. +Connected to 127.0.0.1:7888 (clojure 1.12.5, java 21.0.5, nrepl 1.7.0) +mezcaml> (+ 1 2) +3 +mezcaml> (defn square [x] (* x x)) +#'user/square +mezcaml> (square 7) +49 +``` + +The language on the other end makes no difference. Here's the same +client talking to [dialtone](https://github.com/nrepl/nrepl-beam), the +Erlang server (started with `bin/dialtone --port 7888`): + +``` +$ mezcaml -p 7888 +mezcaml 0.1.0, a minimal nREPL client. Ctrl-D to quit. +Connected to 127.0.0.1:7888 (dialtone 0.1.0, erlang 29, nrepl 1.0.0) +mezcaml> Square = fun(X) -> X * X end. +#Fun +mezcaml> Square(7). +49 ``` With no `-p`, mezcaml walks up from the current directory looking for the @@ -58,14 +77,14 @@ Ctrl-C cancels the current line, Ctrl-D quits. One-shot evaluation (exit code reflects success, handy for scripts): ``` -$ mezcaml -e '21 * 2' -- : int = 42 +$ mezcaml -e '(* 21 2)' +42 ``` Send a whole file: ``` -$ mezcaml --load scratch.ml +$ mezcaml --load scratch.clj ``` For scripting there's also `--timeout SECONDS` (bounds connecting and each @@ -79,7 +98,7 @@ and `--version`. let () = let conn = Mezcaml.Client.connect ~host:"127.0.0.1" ~port:7888 () in let _session = Mezcaml.Client.clone conn in - let responses = Mezcaml.Client.eval conn "1 + 2" in + let responses = Mezcaml.Client.eval conn "(+ 1 2)" in List.iter (fun msg -> match Mezcaml.Bencode.get_string msg "value" with From 75a4fca1cbf4bf755f53541fde2344337da730c2 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Fri, 7 Aug 2026 10:18:30 +0300 Subject: [PATCH 2/5] Complete the trailing token instead of the whole line nREPL servers complete bare symbol prefixes ("ma", "lists:re"), not whole lines, so Tab only ever worked when the line held nothing but the prefix. Split the line at the last delimiter, ask the server about the trailing token and splice the candidates back into the line for linenoise. The splitting lives in a small private repl_input library so the tests can reach it. --- bin/dune | 7 ++++++- bin/main.ml | 16 +++++++++------- bin/repl_input.ml | 19 +++++++++++++++++++ bin/repl_input.mli | 8 ++++++++ test/dune | 6 +++--- test/test_repl_input.ml | 31 +++++++++++++++++++++++++++++++ 6 files changed, 76 insertions(+), 11 deletions(-) create mode 100644 bin/repl_input.ml create mode 100644 bin/repl_input.mli create mode 100644 test/test_repl_input.ml diff --git a/bin/dune b/bin/dune index 94f0703..96196fd 100644 --- a/bin/dune +++ b/bin/dune @@ -1,5 +1,10 @@ +(library + (name repl_input) + (modules repl_input)) + (executable (name main) (public_name mezcaml) (package mezcaml) - (libraries mezcaml linenoise unix dune-build-info)) + (modules main) + (libraries mezcaml repl_input linenoise unix dune-build-info)) diff --git a/bin/main.ml b/bin/main.ml index f5e3b57..df80565 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -142,16 +142,18 @@ let setup_line_editor conn = (try mkdir_p (Filename.dirname file) with Unix.Unix_error _ -> ()); ignore (LNoise.history_load ~filename:file : (unit, string) result) | None -> ()); - (* Tab completion, powered by the server. The server completes the - whole line and returns full-line candidates, so they can be - handed to linenoise as they come. A server hiccup must not take - down the line editor. *) + (* Tab completion: ask the server about the trailing token (see + Repl_input). A server hiccup must not take down the line editor. *) LNoise.set_completion_callback (fun line completions -> - if String.trim line <> "" then - match Mezcaml.Client.completions conn line with + let head, token = Repl_input.split_for_completion line in + if token <> "" then + match Mezcaml.Client.completions conn token with | candidates -> - List.iter (LNoise.add_completion completions) candidates + List.iter + (fun candidate -> + LNoise.add_completion completions (head ^ candidate)) + candidates | exception _ -> ()); history diff --git a/bin/repl_input.ml b/bin/repl_input.ml new file mode 100644 index 0000000..4405525 --- /dev/null +++ b/bin/repl_input.ml @@ -0,0 +1,19 @@ +(* Delimiters end the token being completed. Besides whitespace, + brackets and quotes, the set covers prefix syntax that sits + directly against a symbol: Clojure's @deref, ^meta, ~unquote, + #'var and `syntax-quote, Elixir's %Struct and &capture. Module + separators (lists:reverse, clojure.string, str/join, Enum.map) + stay in the token. *) +let is_delimiter = function + | ' ' | '\t' | '(' | ')' | '[' | ']' | '{' | '}' + | '"' | '\'' | '`' | ',' | ';' | '@' | '#' | '^' | '~' | '\\' + | '&' | '%' -> true + | _ -> false + +let split_for_completion line = + let n = String.length line in + let rec token_start i = + if i = 0 || is_delimiter line.[i - 1] then i else token_start (i - 1) + in + let s = token_start n in + (String.sub line 0 s, String.sub line s (n - s)) diff --git a/bin/repl_input.mli b/bin/repl_input.mli new file mode 100644 index 0000000..af00bb1 --- /dev/null +++ b/bin/repl_input.mli @@ -0,0 +1,8 @@ +(** Input handling for the interactive REPL. *) + +val split_for_completion : string -> string * string +(** [split_for_completion line] splits [line] into the part to keep + verbatim and the trailing token to complete: ["(map str"] becomes + [("(map ", "str")]. nREPL servers complete bare symbol prefixes + while linenoise wants full-line replacements, so candidates are + requested for the token and spliced back after the kept part. *) diff --git a/test/dune b/test/dune index 120101d..5d2d89e 100644 --- a/test/dune +++ b/test/dune @@ -1,3 +1,3 @@ -(test - (name test_bencode) - (libraries mezcaml)) +(tests + (names test_bencode test_repl_input) + (libraries mezcaml repl_input)) diff --git a/test/test_repl_input.ml b/test/test_repl_input.ml new file mode 100644 index 0000000..aa58cfa --- /dev/null +++ b/test/test_repl_input.ml @@ -0,0 +1,31 @@ +open Repl_input + +let () = + (* Bare prefixes pass through untouched. *) + assert (split_for_completion "ma" = ("", "ma")); + assert (split_for_completion "clojure.st" = ("", "clojure.st")); + assert (split_for_completion "Enum.ma" = ("", "Enum.ma")); + + (* The token starts after the last delimiter. *) + assert (split_for_completion "(ma" = ("(", "ma")); + assert (split_for_completion "(map str" = ("(map ", "str")); + assert (split_for_completion "(-> x ma" = ("(-> x ", "ma")); + assert (split_for_completion "X = lists:re" = ("X = ", "lists:re")); + assert (split_for_completion "'ma" = ("'", "ma")); + assert (split_for_completion "(str/jo" = ("(", "str/jo")); + + (* Prefix syntax that sits directly against a symbol delimits it. *) + assert (split_for_completion "@ma" = ("@", "ma")); + assert (split_for_completion "^Str" = ("^", "Str")); + assert (split_for_completion "~ma" = ("~", "ma")); + assert (split_for_completion "#'ma" = ("#'", "ma")); + assert (split_for_completion "`ma" = ("`", "ma")); + assert (split_for_completion "%En" = ("%", "En")); + assert (split_for_completion "&Enum.ma" = ("&", "Enum.ma")); + + (* Nothing to complete. *) + assert (split_for_completion "" = ("", "")); + assert (split_for_completion "(foo)" = ("(foo)", "")); + assert (split_for_completion "(map " = ("(map ", "")); + + print_endline "all repl_input tests passed" From 599ef2d7fc86bd54ab27d0df9bb207996281b3c8 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Fri, 7 Aug 2026 10:19:31 +0300 Subject: [PATCH 3/5] Expose describe's version table as structured data The CLI banner parsed the versions dict inline into a display string, which left no way for other code to ask what is on the other end of the connection. Extract the (name, version-string) pairs in Client.versions and keep only the formatting in the CLI; multi-line input support is about to need the names to pick an input dialect. Names without a version-string now show up bare in the banner instead of being dropped. --- bin/main.ml | 25 ++++++++----------------- lib/client.ml | 11 +++++++++++ lib/client.mli | 6 ++++++ test/dune | 2 +- test/test_client.ml | 23 +++++++++++++++++++++++ 5 files changed, 49 insertions(+), 18 deletions(-) create mode 100644 test/test_client.ml diff --git a/bin/main.ml b/bin/main.ml index df80565..a5d8fc0 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -92,22 +92,12 @@ let print_response msg = flush stdout; flush stderr -let server_description conn = - let versions = - List.find_map - (fun msg -> Mezcaml.Bencode.get msg "versions") - (Mezcaml.Client.describe conn) - in - match versions with - | Some (Mezcaml.Bencode.Dict entries) -> - entries - |> List.filter_map - (fun (name, v) -> - match Mezcaml.Bencode.get_string v "version-string" with - | Some version -> Some (name ^ " " ^ version) - | None -> None) - |> String.concat ", " - | _ -> "unknown server" +let server_description versions = + if versions = [] then "unknown server" + else + versions + |> List.map (fun (name, v) -> if v = "" then name else name ^ " " ^ v) + |> String.concat ", " (* +-----------------------------------------------------------------+ | Interactive REPL | @@ -161,8 +151,9 @@ let repl conn = (* The banner is for interactive users; keep scripted (piped) runs clean. *) if Unix.isatty Unix.stdin then begin + let versions = Mezcaml.Client.versions (Mezcaml.Client.describe conn) in Printf.printf "mezcaml %s, a minimal nREPL client. Ctrl-D to quit.\n" (version ()); - Printf.printf "Connected to %s:%d (%s)\n" !host !port (server_description conn) + Printf.printf "Connected to %s:%d (%s)\n" !host !port (server_description versions) end; let history = setup_line_editor conn in let remember line = diff --git a/lib/client.ml b/lib/client.ml index 63cbb38..31ed485 100644 --- a/lib/client.ml +++ b/lib/client.ml @@ -132,3 +132,14 @@ let eval_error responses = List.exists (fun msg -> List.mem "eval-error" (Bencode.get_strings msg "status")) responses + +let versions responses = + match List.find_map (fun msg -> Bencode.get msg "versions") responses with + | Some (Bencode.Dict entries) -> + List.map + (fun (name, entry) -> + (name, + Option.value ~default:"" + (Bencode.get_string entry "version-string"))) + entries + | _ -> [] diff --git a/lib/client.mli b/lib/client.mli index fa23f2a..eee507e 100644 --- a/lib/client.mli +++ b/lib/client.mli @@ -52,3 +52,9 @@ val completions : t -> string -> string list val eval_error : Bencode.t list -> bool (** Whether any response in the list carries an "eval-error" status. *) + +val versions : Bencode.t list -> (string * string) list +(** The version table from {!describe} responses, as (name, + version-string) pairs: [("clojure", "1.12.5"); ("nrepl", "1.7.0")]. + A name whose entry carries no version-string maps to [""]. Empty + when the responses hold no versions dictionary. *) diff --git a/test/dune b/test/dune index 5d2d89e..cd50a02 100644 --- a/test/dune +++ b/test/dune @@ -1,3 +1,3 @@ (tests - (names test_bencode test_repl_input) + (names test_bencode test_client test_repl_input) (libraries mezcaml repl_input)) diff --git a/test/test_client.ml b/test/test_client.ml new file mode 100644 index 0000000..dcd4c0d --- /dev/null +++ b/test/test_client.ml @@ -0,0 +1,23 @@ +open Mezcaml + +let describe_response = + Bencode.Dict + [("versions", + Bencode.Dict + [("clojure", Bencode.Dict [("version-string", Bencode.String "1.12.5")]); + ("nrepl", Bencode.Dict [("major", Bencode.Int 1)])])] + +let () = + assert (Client.versions [describe_response] + = [("clojure", "1.12.5"); ("nrepl", "")]); + + (* The versions dict may sit in any response of the batch. *) + assert (Client.versions + [Bencode.Dict [("id", Bencode.String "1")]; describe_response] + = [("clojure", "1.12.5"); ("nrepl", "")]); + + (* Missing or malformed versions yield an empty table. *) + assert (Client.versions [] = []); + assert (Client.versions [Bencode.Dict [("versions", Bencode.Int 3)]] = []); + + print_endline "all client tests passed" From 105d2655ca9620a7b04587ac4170a37960445155 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Fri, 7 Aug 2026 10:28:31 +0300 Subject: [PATCH 4/5] Read whole forms in the REPL instead of single lines Multi-line definitions used to be --load-only. The REPL now keeps prompting for continuation lines (with a ...> prompt) until the input reads as a complete form: balanced brackets and closed strings for Lisp-family servers, a terminating dot for Erlang. The dialect is picked from the server names in describe's version table; Elixir deliberately falls back to the generic bracket rule since do/end blocks can't be bracket-counted. When the heuristic sends too eagerly the server reports the syntax error it would have reported anyway. History is now saved once per form instead of once per line, since linenoise rewrites the whole file on every save. --- README.md | 15 ++++--- bin/main.ml | 45 ++++++++++++++------ bin/repl_input.ml | 91 ++++++++++++++++++++++++++++++++++++++++- bin/repl_input.mli | 20 +++++++++ test/test_repl_input.ml | 45 ++++++++++++++++++++ 5 files changed, 198 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b9d672a..81c73ec 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,8 @@ mezcaml 0.1.0, a minimal nREPL client. Ctrl-D to quit. Connected to 127.0.0.1:7888 (clojure 1.12.5, java 21.0.5, nrepl 1.7.0) mezcaml> (+ 1 2) 3 -mezcaml> (defn square [x] (* x x)) +mezcaml> (defn square [x] + ...> (* x x)) #'user/square mezcaml> (square 7) 49 @@ -70,9 +71,11 @@ does the right thing from inside a project. The REPL has line editing and persistent history (via linenoise; history lives in `$XDG_STATE_HOME/mezcaml/history`), and Tab completion powered by -the server's `completions` op. Evaluation results are shown in green and +the server's `completions` op. It reads whole forms, not lines: the +`...>` prompt keeps going until brackets balance (or, on an Erlang +server, until the closing `.`). Evaluation results are shown in green and errors in red when stdout is a terminal; set `NO_COLOR` to turn that off. -Ctrl-C cancels the current line, Ctrl-D quits. +Ctrl-C cancels the current input, Ctrl-D quits. One-shot evaluation (exit code reflects success, handy for scripts): @@ -123,8 +126,10 @@ By design, at least for now: - Synchronous, one request in flight at a time. Fine for a CLI and simple tooling; an editor integration would want an async layer on top. - No TLS, no EDN transport, only the default bencode one. -- Input is evaluated line by line; multi-line definitions have to go - through `--load` for now. +- Whole-form reading is a heuristic: balanced brackets and strings for + Lisp-family servers, the terminating `.` for Erlang. Elixir's + `do ... end` blocks aren't recognized, so multi-line Elixir goes + through `--load`. - Ctrl-C cancels the line being edited but doesn't send the `interrupt` op during a running evaluation yet. diff --git a/bin/main.ml b/bin/main.ml index a5d8fc0..e0e49a3 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -148,35 +148,56 @@ let setup_line_editor conn = history let repl conn = + let versions = Mezcaml.Client.versions (Mezcaml.Client.describe conn) in (* The banner is for interactive users; keep scripted (piped) runs clean. *) if Unix.isatty Unix.stdin then begin - let versions = Mezcaml.Client.versions (Mezcaml.Client.describe conn) in Printf.printf "mezcaml %s, a minimal nREPL client. Ctrl-D to quit.\n" (version ()); Printf.printf "Connected to %s:%d (%s)\n" !host !port (server_description versions) end; + let dialect = Repl_input.dialect_of_versions (List.map fst versions) in let history = setup_line_editor conn in let remember line = - ignore (LNoise.history_add line : (unit, string) result); + ignore (LNoise.history_add line : (unit, string) result) + in + (* Saved once per form rather than per line: linenoise rewrites + the whole file on every save, which adds up when a big form is + pasted in. *) + let save_history () = match history with | Some file -> ignore (LNoise.history_save ~filename:file : (unit, string) result) | None -> () in - let rec loop () = - match LNoise.linenoise "mezcaml> " with + (* Keep reading continuation lines until the input amounts to a + whole form. Each physical line is remembered separately because + the history file is line-based. *) + let rec read_form acc = + let prompt = if acc = [] then "mezcaml> " else " ...> " in + match LNoise.linenoise prompt with | exception Sys.Break -> - (* Ctrl-C cancels the current line, not the session. *) + (* Ctrl-C cancels the input being edited, not the session. *) print_newline (); - loop () - | None -> + read_form [] + | None -> `Eof + | Some "" when acc = [] -> read_form [] + | Some line -> + (* Blank continuation lines aren't worth a history entry. *) + if line <> "" then remember line; + let acc = line :: acc in + let form = String.concat "\n" (List.rev acc) in + if Repl_input.complete dialect form then `Form form + else read_form acc + in + let rec loop () = + match read_form [] with + | `Eof -> + save_history (); print_newline (); ignore (Mezcaml.Client.close_session conn : Mezcaml.Bencode.t list) - | Some "" -> - loop () - | Some line -> - remember line; - ignore (Mezcaml.Client.eval conn ~f:print_response line + | `Form form -> + save_history (); + ignore (Mezcaml.Client.eval conn ~f:print_response form : Mezcaml.Bencode.t list); loop () in diff --git a/bin/repl_input.ml b/bin/repl_input.ml index 4405525..024a1f2 100644 --- a/bin/repl_input.ml +++ b/bin/repl_input.ml @@ -3,7 +3,10 @@ directly against a symbol: Clojure's @deref, ^meta, ~unquote, #'var and `syntax-quote, Elixir's %Struct and &capture. Module separators (lists:reverse, clojure.string, str/join, Enum.map) - stay in the token. *) + stay in the token. One dialect-agnostic set on purpose: a + mis-split token only costs a completion the server declines, so + unlike form completeness below it earns no per-dialect + machinery. *) let is_delimiter = function | ' ' | '\t' | '(' | ')' | '[' | ']' | '{' | '}' | '"' | '\'' | '`' | ',' | ';' | '@' | '#' | '^' | '~' | '\\' @@ -17,3 +20,89 @@ let split_for_completion line = in let s = token_start n in (String.sub line 0 s, String.sub line s (n - s)) + +type dialect = Lisp | Erlang | Generic + +(* Elixir is checked before Erlang: repartee reports both, and the + dot rule must not win there. Elixir's do/end blocks can't be + bracket-counted either, so it falls back to Generic. *) +let dialect_of_versions names = + let has name = List.mem name names in + if has "clojure" || has "babashka" || has "basilisp" then Lisp + else if has "elixir" then Generic + else if has "erlang" then Erlang + else Generic + +(* Helpers shared by the scanners below. [scan_quoted] returns the + position just past the closing quote, or [None] when the input + ends inside the quoted region (or on a dangling escape), in which + case the form cannot be complete. *) +let rec skip_line input i = + if i >= String.length input || input.[i] = '\n' then i + else skip_line input (i + 1) + +let rec scan_quoted input i quote = + if i >= String.length input then None + else + match input.[i] with + | '\\' -> + if i + 1 >= String.length input then None + else scan_quoted input (i + 2) quote + | c when c = quote -> Some (i + 1) + | _ -> scan_quoted input (i + 1) quote + +(* Bracket/string scanner shared by the Lisp and Generic rules. With + [lisp], ;-comments hide the rest of the line and a backslash + starts a character literal (\a, \(, \newline). *) +let balanced ~lisp input = + let n = String.length input in + let rec scan i depth = + if i >= n then Some depth + else + match input.[i] with + | ';' when lisp -> scan (skip_line input (i + 1)) depth + | '\\' when lisp -> if i + 1 >= n then None else scan (i + 2) depth + | '"' -> + (match scan_quoted input (i + 1) '"' with + | Some j -> scan j depth + | None -> None) + | '(' | '[' | '{' -> scan (i + 1) (depth + 1) + | ')' | ']' | '}' -> scan (i + 1) (depth - 1) + | _ -> scan (i + 1) depth + in + match scan 0 0 with + | Some depth -> depth <= 0 + | None -> false + +(* Erlang forms end with a dot. The dot flag set here survives only + across whitespace and %-comments to the end of the input, so 3.14 + is not a terminator, and neither is a dot inside a string, a + quoted atom or a [$c] character literal. *) +let erlang_complete input = + let n = String.length input in + let rec scan i depth dot = + if i >= n then Some (depth, dot) + else + match input.[i] with + | '%' -> scan (skip_line input (i + 1)) depth dot + | '$' -> if i + 1 >= n then None else scan (i + 2) depth false + | ('"' | '\'') as quote -> + (match scan_quoted input (i + 1) quote with + | Some j -> scan j depth false + | None -> None) + | '(' | '[' | '{' -> scan (i + 1) (depth + 1) false + | ')' | ']' | '}' -> scan (i + 1) (depth - 1) false + | '.' -> scan (i + 1) depth true + | ' ' | '\t' | '\n' -> scan (i + 1) depth dot + | _ -> scan (i + 1) depth false + in + match scan 0 0 false with + | Some (depth, dot) -> dot && depth <= 0 + | None -> false + +let complete dialect input = + String.trim input <> "" + && (match dialect with + | Lisp -> balanced ~lisp:true input + | Generic -> balanced ~lisp:false input + | Erlang -> erlang_complete input) diff --git a/bin/repl_input.mli b/bin/repl_input.mli index af00bb1..2ac985b 100644 --- a/bin/repl_input.mli +++ b/bin/repl_input.mli @@ -1,5 +1,25 @@ (** Input handling for the interactive REPL. *) +(** Which flavor of input the connected server evaluates, as far as + reading whole forms is concerned. *) +type dialect = + | Lisp (** Clojure and friends: a form ends when brackets balance. *) + | Erlang (** Forms end with a [.] outside strings and comments. *) + | Generic (** Balanced brackets and closed strings, nothing more. *) + +val dialect_of_versions : string list -> dialect +(** Pick the dialect from the names in [describe]'s version table + (see [Mezcaml.Client.versions]). Unknown servers get [Generic], + and so does Elixir on purpose: its do/end blocks can't be + bracket-counted, and repartee also reports "erlang", where the + dot rule must not win. *) + +val complete : dialect -> string -> bool +(** Whether the input reads as a whole form, or the REPL should keep + asking for continuation lines. A heuristic: when it sends too + eagerly the server reports the syntax error it would have + reported anyway, so it only has to be good, not perfect. *) + val split_for_completion : string -> string * string (** [split_for_completion line] splits [line] into the part to keep verbatim and the trailing token to complete: ["(map str"] becomes diff --git a/test/test_repl_input.ml b/test/test_repl_input.ml index aa58cfa..ec48674 100644 --- a/test/test_repl_input.ml +++ b/test/test_repl_input.ml @@ -28,4 +28,49 @@ let () = assert (split_for_completion "(foo)" = ("(foo)", "")); assert (split_for_completion "(map " = ("(map ", "")); + (* Dialect detection from describe's version names. *) + assert (dialect_of_versions ["clojure"; "java"; "nrepl"] = Lisp); + assert (dialect_of_versions ["babashka"; "nrepl"] = Lisp); + assert (dialect_of_versions ["dialtone"; "erlang"; "nrepl"] = Erlang); + (* repartee reports elixir alongside erlang; the dot rule must not win. *) + assert (dialect_of_versions ["repartee"; "elixir"; "erlang"] = Generic); + assert (dialect_of_versions ["nrepl"; "ocaml"; "utop"] = Generic); + assert (dialect_of_versions [] = Generic); + + (* Lisp: a form ends when brackets balance outside strings. *) + assert (complete Lisp "(+ 1 2)"); + assert (complete Lisp "42"); + assert (not (complete Lisp "(defn foo")); + assert (complete Lisp "(defn square [x]\n (* x x))"); + assert (not (complete Lisp "\"unclosed")); + assert (not (complete Lisp "(str \"a)\"")); + assert (complete Lisp "(str \"a)\")"); + assert (not (complete Lisp "(foo ; )")); + assert (complete Lisp "(+ 1 2) ; done)"); + assert (complete Lisp "(list \\( \\))"); + assert (not (complete Lisp "")); + assert (not (complete Lisp " ")); + assert (complete Lisp ")"); + + (* Erlang: forms end with a dot outside strings and comments. *) + assert (complete Erlang "1 + 2."); + assert (not (complete Erlang "1 + 2")); + assert (not (complete Erlang "Square = fun(X) ->")); + assert (complete Erlang "Square = fun(X) ->\n X * X\nend."); + assert (not (complete Erlang "3.14")); + assert (complete Erlang "3.14."); + assert (complete Erlang "1 + 2. % done"); + assert (not (complete Erlang "io:format(\"a.b\")")); + assert (complete Erlang "io:format(\"a.b\")."); + assert (not (complete Erlang "[1, 2.")); + assert (not (complete Erlang "'quoted.atom")); + assert (complete Erlang "X = $.."); + assert (not (complete Erlang "X = $.")); + + (* Generic: balanced brackets and closed strings. *) + assert (complete Generic "1 + 2"); + assert (not (complete Generic "foo(")); + assert (not (complete Generic "\"open")); + assert (complete Generic "[1, 2, 3]"); + print_endline "all repl_input tests passed" From 0b1cc49836cd661b651a14a17d20d5d69509f015 Mon Sep 17 00:00:00 2001 From: Bozhidar Batsov Date: Sat, 8 Aug 2026 15:59:24 +0300 Subject: [PATCH 5/5] Document the REPL key bindings in the README The linenoise fork we link ships most of the emacs-flavored readline set, including Ctrl-R incremental history search, but none of it is discoverable from the prompt. Spell it out. --- README.md | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 81c73ec..d6b2c55 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,30 @@ the server's `completions` op. It reads whole forms, not lines: the `...>` prompt keeps going until brackets balance (or, on an Erlang server, until the closing `.`). Evaluation results are shown in green and errors in red when stdout is a terminal; set `NO_COLOR` to turn that off. -Ctrl-C cancels the current input, Ctrl-D quits. + +### Key bindings + +The usual emacs-flavored readline bindings work: + +| Keys | Action | +|---|---| +| Ctrl-A / Ctrl-E | start / end of line | +| Ctrl-B / Ctrl-F | move by character | +| Alt-B / Alt-F, Ctrl-Left / Ctrl-Right | move by word | +| Ctrl-W, Alt-Backspace | delete the word before the cursor | +| Alt-D | delete the word after the cursor | +| Ctrl-K | delete to the end of the line | +| Ctrl-U | delete the whole line | +| Ctrl-T | transpose characters | +| Up / Down, Ctrl-P / Ctrl-N | walk the history | +| Ctrl-R | incremental reverse history search; Ctrl-R again for older matches, Enter keeps the match, Ctrl-G puts your line back | +| Ctrl-L | clear the screen | +| Tab | completion, powered by the server | +| Ctrl-C | cancel the input being edited | +| Ctrl-D | delete the character under the cursor; on an empty line, quit | + +No kill ring and no undo - that's where linenoise draws the line, and +mezcaml with it. One-shot evaluation (exit code reflects success, handy for scripts):