spec/harness.sx — spec-level test harness with: - Mock platform (30+ default IO mocks: fetch, query, DOM, storage, etc.) - Session management (make-harness, harness-reset!, harness-set!/get) - IO interception (make-interceptor, install-interceptors) - IO log queries (io-calls, io-call-count, io-call-nth, io-call-args) - IO assertions (assert-io-called, assert-no-io, assert-io-count, etc.) 15 harness tests passing on both OCaml (1116/1116) and JS (15/15). Loaded automatically by both test runners. MCP tool: sx_harness_eval — evaluate SX with mock IO, returns result + IO trace. The harness is extensible: new platforms just add entries to the platform dict. Components can ship with deftest forms that verify IO behavior against mocks. Tests are independent objects that can be published separately (by CID). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1105 lines
52 KiB
OCaml
1105 lines
52 KiB
OCaml
(** MCP server for SX tree tools — structural reading and editing of .sx files.
|
|
|
|
Stdio JSON-RPC transport following the MCP specification.
|
|
Loads tree-tools.sx into the SX evaluator and exposes comprehension
|
|
and editing functions as MCP tools. *)
|
|
|
|
open Sx_types
|
|
|
|
(* ------------------------------------------------------------------ *)
|
|
(* SX evaluator setup — minimal env for parser + tree-tools *)
|
|
(* ------------------------------------------------------------------ *)
|
|
|
|
let env = ref (make_env ())
|
|
|
|
let load_sx_file e path =
|
|
let src = In_channel.with_open_text path In_channel.input_all in
|
|
let exprs = Sx_parser.parse_all src in
|
|
List.iter (fun expr ->
|
|
try ignore (Sx_ref.cek_call
|
|
(NativeFn ("eval", fun args ->
|
|
match args with
|
|
| [ex] -> Sx_ref.eval_expr ex (Env e)
|
|
| _ -> Nil))
|
|
(List [expr]))
|
|
with _ ->
|
|
(* Fallback: direct eval *)
|
|
ignore (Sx_ref.eval_expr expr (Env e))
|
|
) exprs
|
|
|
|
let setup_env () =
|
|
let e = make_env () in
|
|
(* Primitives are auto-registered at module init *)
|
|
(* Trampoline ref for HO primitives *)
|
|
Sx_primitives._sx_trampoline_fn := (fun v ->
|
|
match v with
|
|
| Thunk (body, closure_env) -> Sx_ref.eval_expr body (Env closure_env)
|
|
| other -> other);
|
|
(* Character classification for parser *)
|
|
let bind name fn = ignore (env_bind e name (NativeFn (name, fn))) in
|
|
bind "is-whitespace?" (fun args -> match args with
|
|
| [String s] when String.length s = 1 ->
|
|
let c = s.[0] in Bool (c = ' ' || c = '\t' || c = '\n' || c = '\r')
|
|
| _ -> Bool false);
|
|
bind "is-digit?" (fun args -> match args with
|
|
| [String s] when String.length s = 1 ->
|
|
Bool (s.[0] >= '0' && s.[0] <= '9')
|
|
| _ -> Bool false);
|
|
bind "is-alpha?" (fun args -> match args with
|
|
| [String s] when String.length s = 1 ->
|
|
let c = s.[0] in Bool ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'))
|
|
| _ -> Bool false);
|
|
bind "char-code" (fun args -> match args with
|
|
| [String s] when String.length s > 0 -> Number (float_of_int (Char.code s.[0]))
|
|
| _ -> Number 0.0);
|
|
bind "code-char" (fun args -> match args with
|
|
| [Number n] -> String (String.make 1 (Char.chr (int_of_float n)))
|
|
| _ -> String "");
|
|
bind "parse-number" (fun args -> match args with
|
|
| [String s] -> (try Number (float_of_string s) with _ -> Nil)
|
|
| _ -> Nil);
|
|
bind "identical?" (fun args -> match args with
|
|
| [a; b] -> Bool (a == b)
|
|
| _ -> Bool false);
|
|
(* Character classification for SX parser.sx *)
|
|
bind "ident-start?" (fun args -> match args with
|
|
| [String s] when String.length s = 1 ->
|
|
let c = s.[0] in
|
|
Bool ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
|
c = '_' || c = '~' || c = '*' || c = '+' || c = '-' ||
|
|
c = '>' || c = '<' || c = '=' || c = '/' || c = '!' ||
|
|
c = '?' || c = '&' || c = '@' || c = '^' || c = '%' ||
|
|
Char.code c > 127)
|
|
| _ -> Bool false);
|
|
bind "ident-char?" (fun args -> match args with
|
|
| [String s] when String.length s = 1 ->
|
|
let c = s.[0] in
|
|
Bool ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
|
(c >= '0' && c <= '9') ||
|
|
c = '_' || c = '~' || c = '*' || c = '+' || c = '-' ||
|
|
c = '>' || c = '<' || c = '=' || c = '/' || c = '!' ||
|
|
c = '?' || c = '&' || c = '.' || c = ':' || c = '#' ||
|
|
c = ',' || c = '@' || c = '^' || c = '%' ||
|
|
Char.code c > 127)
|
|
| _ -> Bool false);
|
|
bind "make-keyword" (fun args -> match args with
|
|
| [String s] -> Keyword s | _ -> Nil);
|
|
bind "escape-string" (fun args -> match args with
|
|
| [String s] ->
|
|
let buf = Buffer.create (String.length s) in
|
|
String.iter (fun c -> match c with
|
|
| '"' -> Buffer.add_string buf "\\\""
|
|
| '\\' -> Buffer.add_string buf "\\\\"
|
|
| '\n' -> Buffer.add_string buf "\\n"
|
|
| '\t' -> Buffer.add_string buf "\\t"
|
|
| '\r' -> Buffer.add_string buf "\\r"
|
|
| c -> Buffer.add_char buf c) s;
|
|
String (Buffer.contents buf)
|
|
| _ -> String "");
|
|
bind "sx-expr-source" (fun args -> match args with
|
|
| [SxExpr s] -> String s | _ -> String "");
|
|
(* Runtime functions needed by tree-tools *)
|
|
bind "symbol-name" (fun args -> match args with
|
|
| [Symbol s] -> String s | _ -> String "");
|
|
bind "keyword-name" (fun args -> match args with
|
|
| [Keyword k] -> String k | _ -> String "");
|
|
bind "make-symbol" (fun args -> match args with
|
|
| [String s] -> Symbol s | _ -> Nil);
|
|
(* Environment operations needed by harness *)
|
|
bind "env-bind!" (fun args -> match args with
|
|
| [Env env_val; String name; v] -> ignore (env_bind env_val name v); v
|
|
| _ -> Nil);
|
|
bind "env-get" (fun args -> match args with
|
|
| [Env env_val; String name] -> env_get env_val name
|
|
| _ -> Nil);
|
|
bind "env-has?" (fun args -> match args with
|
|
| [Env env_val; String name] -> Bool (env_has env_val name)
|
|
| _ -> Bool false);
|
|
bind "make-env" (fun _args -> Env (make_env ()));
|
|
bind "keys" (fun args -> match args with
|
|
| [Dict d] -> List (Hashtbl.fold (fun k _ acc -> String k :: acc) d [])
|
|
| _ -> List []);
|
|
bind "get" (fun args -> match args with
|
|
| [Dict d; String k] -> (match Hashtbl.find_opt d k with Some v -> v | None -> Nil)
|
|
| [Dict d; Keyword k] -> (match Hashtbl.find_opt d k with Some v -> v | None -> Nil)
|
|
| [List items; Number n] -> (let i = int_of_float n in if i >= 0 && i < List.length items then List.nth items i else Nil)
|
|
| _ -> Nil);
|
|
bind "dict-set!" (fun args -> match args with
|
|
| [Dict d; String k; v] -> Hashtbl.replace d k v; v
|
|
| [Dict d; Keyword k; v] -> Hashtbl.replace d k v; v
|
|
| _ -> Nil);
|
|
bind "merge" (fun args -> match args with
|
|
| [Dict a; Dict b] ->
|
|
let d = Hashtbl.create (Hashtbl.length a + Hashtbl.length b) in
|
|
Hashtbl.iter (fun k v -> Hashtbl.replace d k v) a;
|
|
Hashtbl.iter (fun k v -> Hashtbl.replace d k v) b;
|
|
Dict d
|
|
| _ -> Nil);
|
|
bind "apply" (fun args -> match args with
|
|
| [f; List items] | [f; ListRef { contents = items }] ->
|
|
Sx_ref.cek_call f (List items)
|
|
| _ -> Nil);
|
|
bind "current-env" (fun _args -> Env e);
|
|
bind "type-of" (fun args -> match args with
|
|
| [v] -> String (type_of v) | _ -> String "nil");
|
|
bind "list?" (fun args -> match args with
|
|
| [List _ | ListRef _] -> Bool true | _ -> Bool false);
|
|
bind "nil?" (fun args -> match args with
|
|
| [v] -> Bool (is_nil v) | _ -> Bool true);
|
|
bind "string?" (fun args -> match args with
|
|
| [String _] -> Bool true | _ -> Bool false);
|
|
bind "number?" (fun args -> match args with
|
|
| [Number _] -> Bool true | _ -> Bool false);
|
|
bind "callable?" (fun args -> match args with
|
|
| [NativeFn _ | Lambda _ | Component _ | Island _] -> Bool true | _ -> Bool false);
|
|
bind "empty?" (fun args -> match args with
|
|
| [List []] | [ListRef { contents = [] }] -> Bool true
|
|
| [Nil] -> Bool true | _ -> Bool false);
|
|
bind "contains?" (fun args -> match args with
|
|
| [String s; String sub] ->
|
|
let rec find i =
|
|
if i > String.length s - String.length sub then false
|
|
else if String.sub s i (String.length sub) = sub then true
|
|
else find (i + 1)
|
|
in Bool (String.length sub = 0 || find 0)
|
|
| [List l; v] | [ListRef { contents = l }; v] ->
|
|
Bool (List.exists (fun x -> x = v) l)
|
|
| _ -> Bool false);
|
|
bind "starts-with?" (fun args -> match args with
|
|
| [String s; String prefix] ->
|
|
Bool (String.length s >= String.length prefix &&
|
|
String.sub s 0 (String.length prefix) = prefix)
|
|
| _ -> Bool false);
|
|
bind "append!" (fun args -> match args with
|
|
| [ListRef r; v] -> r := !r @ [v]; v
|
|
| _ -> Nil);
|
|
bind "map-indexed" (fun args -> match args with
|
|
| [f; List l] | [f; ListRef { contents = l }] ->
|
|
List (List.mapi (fun i x -> Sx_ref.cek_call f (List [Number (float_of_int i); x])) l)
|
|
| _ -> List []);
|
|
bind "trim" (fun args -> match args with
|
|
| [String s] -> String (String.trim s) | _ -> String "");
|
|
bind "split" (fun args -> match args with
|
|
| [String s; String d] ->
|
|
List (List.map (fun p -> String p) (String.split_on_char d.[0] s))
|
|
| _ -> List []);
|
|
(* sx-parse — use the native OCaml parser for bootstrapping *)
|
|
bind "sx-parse" (fun args -> match args with
|
|
| [String s] -> List (Sx_parser.parse_all s)
|
|
| _ -> List []);
|
|
bind "sx-serialize" (fun args -> match args with
|
|
| [v] -> String (Sx_runtime.value_to_str v)
|
|
| _ -> String "");
|
|
(* Load parser.sx for the SX-level sx-parse/sx-serialize *)
|
|
let spec_dir = try Sys.getenv "SX_SPEC_DIR" with Not_found -> "spec" in
|
|
let lib_dir = try Sys.getenv "SX_LIB_DIR" with Not_found -> "lib" in
|
|
(try load_sx_file e (Filename.concat spec_dir "parser.sx")
|
|
with exn -> Printf.eprintf "[mcp] Warning: parser.sx load failed: %s\n%!" (Printexc.to_string exn));
|
|
(* Load tree-tools *)
|
|
(try load_sx_file e (Filename.concat lib_dir "tree-tools.sx")
|
|
with exn -> Printf.eprintf "[mcp] Error: tree-tools.sx load failed: %s\n%!" (Printexc.to_string exn); exit 1);
|
|
(* Load harness *)
|
|
(try load_sx_file e (Filename.concat spec_dir "harness.sx")
|
|
with exn -> Printf.eprintf "[mcp] Warning: harness.sx load failed: %s\n%!" (Printexc.to_string exn));
|
|
Printf.eprintf "[mcp] SX tree-tools + harness loaded\n%!";
|
|
env := e
|
|
|
|
(* ------------------------------------------------------------------ *)
|
|
(* Call SX tree-tools functions *)
|
|
(* ------------------------------------------------------------------ *)
|
|
|
|
let call_sx fn_name args =
|
|
let e = !env in
|
|
let fn = env_get e fn_name in
|
|
Sx_ref.cek_call fn (List args)
|
|
|
|
let parse_file path =
|
|
let src = In_channel.with_open_text path In_channel.input_all in
|
|
let exprs = Sx_parser.parse_all src in
|
|
List exprs
|
|
|
|
let parse_path_str s =
|
|
(* Parse SX path string: "(0 3 2)" or "0 3 2" → SX list of numbers *)
|
|
let exprs = Sx_parser.parse_all s in
|
|
match exprs with
|
|
| [List items] ->
|
|
(* (0 3 2) → list of numbers *)
|
|
List (List.map (fun x -> match x with Number _ -> x | _ -> Number 0.0) items)
|
|
| _ ->
|
|
(* Bare numbers: "0 3 2" → parsed as separate exprs *)
|
|
List (List.map (fun x -> match x with Number _ -> x | _ -> Number 0.0) exprs)
|
|
|
|
let _json_to_path j =
|
|
let open Yojson.Safe.Util in
|
|
parse_path_str (to_string j)
|
|
|
|
(* Resolve path: if it contains ">", use resolve-named-path; else parse as index path *)
|
|
let resolve_path tree path_str =
|
|
if String.contains path_str '>' then
|
|
call_sx "resolve-named-path" [tree; String path_str]
|
|
else
|
|
parse_path_str path_str
|
|
|
|
let value_to_string v =
|
|
match v with
|
|
| String s -> s
|
|
| _ -> Sx_runtime.value_to_str v
|
|
|
|
let text_result s =
|
|
`Assoc [("content", `List [`Assoc [
|
|
("type", `String "text");
|
|
("text", `String s)
|
|
]])]
|
|
|
|
let error_result s =
|
|
`Assoc [("content", `List [`Assoc [
|
|
("type", `String "text");
|
|
("text", `String s)
|
|
]]);
|
|
("isError", `Bool true)]
|
|
|
|
(* ------------------------------------------------------------------ *)
|
|
(* Recursive .sx file discovery *)
|
|
(* ------------------------------------------------------------------ *)
|
|
|
|
let glob_sx_files dir =
|
|
let results = ref [] in
|
|
let rec walk path =
|
|
if Sys.is_directory path then
|
|
let entries = Sys.readdir path in
|
|
Array.iter (fun e -> walk (Filename.concat path e)) entries
|
|
else if Filename.check_suffix path ".sx" then
|
|
results := path :: !results
|
|
in
|
|
(try walk dir with Sys_error _ -> ());
|
|
List.sort String.compare !results
|
|
|
|
let relative_path ~base path =
|
|
let blen = String.length base in
|
|
if String.length path > blen && String.sub path 0 blen = base then
|
|
let rest = String.sub path (blen + 1) (String.length path - blen - 1) in
|
|
rest
|
|
else path
|
|
|
|
(* ------------------------------------------------------------------ *)
|
|
(* Pretty printer *)
|
|
(* ------------------------------------------------------------------ *)
|
|
|
|
let pp_atom = Sx_types.inspect
|
|
|
|
(* Estimate single-line width of a value *)
|
|
let rec est_width = function
|
|
| Nil -> 3 | Bool true -> 4 | Bool false -> 5
|
|
| Number n -> String.length (if Float.is_integer n then string_of_int (int_of_float n) else Printf.sprintf "%g" n)
|
|
| String s -> String.length s + 2
|
|
| Symbol s -> String.length s
|
|
| Keyword k -> String.length k + 1
|
|
| SxExpr s -> String.length s + 2
|
|
| List items | ListRef { contents = items } ->
|
|
2 + List.fold_left (fun acc x -> acc + est_width x + 1) 0 items
|
|
| _ -> 10
|
|
|
|
let pretty_print_value ?(max_width=80) v =
|
|
let buf = Buffer.create 4096 in
|
|
let rec pp indent v =
|
|
match v with
|
|
| List items | ListRef { contents = items } when items <> [] ->
|
|
if est_width v <= max_width - indent then
|
|
(* Fits on one line *)
|
|
Buffer.add_string buf (pp_atom v)
|
|
else begin
|
|
(* Multi-line *)
|
|
Buffer.add_char buf '(';
|
|
let head = List.hd items in
|
|
Buffer.add_string buf (pp_atom head);
|
|
let child_indent = indent + 2 in
|
|
let rest = List.tl items in
|
|
(* Special case: keyword args stay on same line as their value *)
|
|
let rec emit = function
|
|
| [] -> ()
|
|
| Keyword k :: v :: rest ->
|
|
Buffer.add_char buf '\n';
|
|
Buffer.add_string buf (String.make child_indent ' ');
|
|
Buffer.add_char buf ':';
|
|
Buffer.add_string buf k;
|
|
Buffer.add_char buf ' ';
|
|
pp child_indent v;
|
|
emit rest
|
|
| item :: rest ->
|
|
Buffer.add_char buf '\n';
|
|
Buffer.add_string buf (String.make child_indent ' ');
|
|
pp child_indent item;
|
|
emit rest
|
|
in
|
|
emit rest;
|
|
Buffer.add_char buf ')'
|
|
end
|
|
| _ -> Buffer.add_string buf (pp_atom v)
|
|
in
|
|
pp 0 v;
|
|
Buffer.contents buf
|
|
|
|
let pretty_print_file exprs =
|
|
String.concat "\n\n" (List.map pretty_print_value exprs) ^ "\n"
|
|
|
|
(* ------------------------------------------------------------------ *)
|
|
(* Tool handlers *)
|
|
(* ------------------------------------------------------------------ *)
|
|
|
|
let rec handle_tool name args =
|
|
let open Yojson.Safe.Util in
|
|
match name with
|
|
| "sx_read_tree" ->
|
|
let file = args |> member "file" |> to_string in
|
|
let tree = parse_file file in
|
|
let focus = args |> member "focus" |> to_string_option in
|
|
let max_depth = args |> member "max_depth" |> to_int_option in
|
|
let max_lines = args |> member "max_lines" |> to_int_option in
|
|
let offset = args |> member "offset" |> to_int_option |> Option.value ~default:0 in
|
|
(match focus with
|
|
| Some pattern ->
|
|
(* Focus mode: expand matching subtrees, collapse rest *)
|
|
text_result (value_to_string (call_sx "annotate-focused" [tree; String pattern]))
|
|
| None ->
|
|
match max_lines with
|
|
| Some limit ->
|
|
(* Paginated mode *)
|
|
text_result (value_to_string (call_sx "annotate-paginated"
|
|
[tree; Number (float_of_int offset); Number (float_of_int limit)]))
|
|
| None ->
|
|
match max_depth with
|
|
| Some depth ->
|
|
(* Depth-limited mode *)
|
|
text_result (value_to_string (call_sx "summarise" [tree; Number (float_of_int depth)]))
|
|
| None ->
|
|
(* Auto mode: full tree if small, summarise if large *)
|
|
let full = value_to_string (call_sx "annotate-tree" [tree]) in
|
|
let line_count = 1 + String.fold_left (fun n c -> if c = '\n' then n + 1 else n) 0 full in
|
|
if line_count <= 200 then text_result full
|
|
else
|
|
let summary = value_to_string (call_sx "summarise" [tree; Number 2.0]) in
|
|
text_result (Printf.sprintf ";; File has %d lines — showing depth-2 summary. Use max_depth, max_lines, or focus to control output.\n%s" line_count summary))
|
|
|
|
| "sx_summarise" ->
|
|
let tree = parse_file (args |> member "file" |> to_string) in
|
|
let depth = args |> member "depth" |> to_int in
|
|
text_result (value_to_string (call_sx "summarise" [tree; Number (float_of_int depth)]))
|
|
|
|
| "sx_read_subtree" ->
|
|
let tree = parse_file (args |> member "file" |> to_string) in
|
|
let path = resolve_path tree (args |> member "path" |> to_string) in
|
|
text_result (value_to_string (call_sx "read-subtree" [tree; path]))
|
|
|
|
| "sx_get_context" ->
|
|
let tree = parse_file (args |> member "file" |> to_string) in
|
|
let path = resolve_path tree (args |> member "path" |> to_string) in
|
|
text_result (value_to_string (call_sx "get-context" [tree; path]))
|
|
|
|
| "sx_find_all" ->
|
|
let tree = parse_file (args |> member "file" |> to_string) in
|
|
let pattern = args |> member "pattern" |> to_string in
|
|
let results = call_sx "find-all" [tree; String pattern] in
|
|
let lines = match results with
|
|
| List items | ListRef { contents = items } ->
|
|
List.map (fun item ->
|
|
match item with
|
|
| List [p; s] | ListRef { contents = [p; s] } ->
|
|
value_to_string (call_sx "path-str" [p]) ^ " " ^ value_to_string s
|
|
| _ -> value_to_string item
|
|
) items
|
|
| _ -> [value_to_string results]
|
|
in
|
|
text_result (String.concat "\n" lines)
|
|
|
|
| "sx_get_siblings" ->
|
|
let tree = parse_file (args |> member "file" |> to_string) in
|
|
let path = resolve_path tree (args |> member "path" |> to_string) in
|
|
text_result (value_to_string (call_sx "get-siblings" [tree; path]))
|
|
|
|
| "sx_validate" ->
|
|
let tree = parse_file (args |> member "file" |> to_string) in
|
|
text_result (value_to_string (call_sx "validate" [tree]))
|
|
|
|
| "sx_replace_node" ->
|
|
let file = args |> member "file" |> to_string in
|
|
let tree = parse_file file in
|
|
let path = resolve_path tree (args |> member "path" |> to_string) in
|
|
let src = args |> member "new_source" |> to_string in
|
|
write_edit file (call_sx "replace-node" [tree; path; String src])
|
|
|
|
| "sx_insert_child" ->
|
|
let file = args |> member "file" |> to_string in
|
|
let tree = parse_file file in
|
|
let path = resolve_path tree (args |> member "path" |> to_string) in
|
|
let index = args |> member "index" |> to_int in
|
|
let src = args |> member "new_source" |> to_string in
|
|
write_edit file (call_sx "insert-child" [tree; path; Number (float_of_int index); String src])
|
|
|
|
| "sx_delete_node" ->
|
|
let file = args |> member "file" |> to_string in
|
|
let tree = parse_file file in
|
|
let path = resolve_path tree (args |> member "path" |> to_string) in
|
|
write_edit file (call_sx "delete-node" [tree; path])
|
|
|
|
| "sx_wrap_node" ->
|
|
let file = args |> member "file" |> to_string in
|
|
let tree = parse_file file in
|
|
let path = resolve_path tree (args |> member "path" |> to_string) in
|
|
let wrapper = args |> member "wrapper" |> to_string in
|
|
write_edit file (call_sx "wrap-node" [tree; path; String wrapper])
|
|
|
|
| "sx_format_check" ->
|
|
let file = args |> member "file" |> to_string in
|
|
let tree = parse_file file in
|
|
let warnings = call_sx "lint-file" [tree] in
|
|
(match warnings with
|
|
| List [] | ListRef { contents = [] } -> text_result "OK — no issues found"
|
|
| List items | ListRef { contents = items } ->
|
|
text_result (String.concat "\n" (List.map value_to_string items))
|
|
| _ -> text_result (value_to_string warnings))
|
|
|
|
| "sx_macroexpand" ->
|
|
let file = args |> member "file" |> to_string_option in
|
|
let expr_str = args |> member "expr" |> to_string in
|
|
(* Create a fresh env with file definitions loaded *)
|
|
let e = !env in
|
|
(* Optionally load a file's definitions to get its macros *)
|
|
(match file with
|
|
| Some f ->
|
|
(try load_sx_file e f
|
|
with exn -> Printf.eprintf "[mcp] Warning: failed to load %s: %s\n%!" f (Printexc.to_string exn))
|
|
| None -> ());
|
|
let exprs = Sx_parser.parse_all expr_str in
|
|
let result = List.fold_left (fun _acc expr ->
|
|
Sx_ref.eval_expr expr (Env e)
|
|
) Nil exprs in
|
|
text_result (Sx_types.inspect result)
|
|
|
|
| "sx_build" ->
|
|
let target = args |> member "target" |> to_string_option |> Option.value ~default:"js" in
|
|
let full = args |> member "full" |> to_bool_option |> Option.value ~default:false in
|
|
let project_dir = try Sys.getenv "SX_PROJECT_DIR" with Not_found ->
|
|
let spec_dir = try Sys.getenv "SX_SPEC_DIR" with Not_found -> "spec" in
|
|
Filename.dirname spec_dir
|
|
in
|
|
let cmd = match target with
|
|
| "ocaml" ->
|
|
Printf.sprintf "cd %s/hosts/ocaml && eval $(opam env 2>/dev/null) && dune build 2>&1" project_dir
|
|
| "js" | _ ->
|
|
let extra = if full then " --extensions continuations --spec-modules types" else "" in
|
|
Printf.sprintf "cd %s && python3 hosts/javascript/cli.py%s --output shared/static/scripts/sx-browser.js 2>&1" project_dir extra
|
|
in
|
|
let ic = Unix.open_process_in cmd in
|
|
let lines = ref [] in
|
|
(try while true do lines := input_line ic :: !lines done with End_of_file -> ());
|
|
let status = Unix.close_process_in ic in
|
|
let output = String.concat "\n" (List.rev !lines) in
|
|
(match status with
|
|
| Unix.WEXITED 0 -> text_result (Printf.sprintf "OK — %s build succeeded\n%s" target (String.trim output))
|
|
| _ -> error_result (Printf.sprintf "%s build failed:\n%s" target output))
|
|
|
|
| "sx_test" ->
|
|
let host = args |> member "host" |> to_string_option |> Option.value ~default:"js" in
|
|
let full = args |> member "full" |> to_bool_option |> Option.value ~default:false in
|
|
let project_dir = try Sys.getenv "SX_PROJECT_DIR" with Not_found ->
|
|
(* Walk up from spec dir to find project root *)
|
|
let spec_dir = try Sys.getenv "SX_SPEC_DIR" with Not_found -> "spec" in
|
|
Filename.dirname spec_dir
|
|
in
|
|
let cmd = match host with
|
|
| "ocaml" ->
|
|
Printf.sprintf "cd %s/hosts/ocaml && eval $(opam env 2>/dev/null) && dune exec bin/run_tests.exe%s 2>&1"
|
|
project_dir (if full then " -- --full" else "")
|
|
| "js" | _ ->
|
|
Printf.sprintf "cd %s && node hosts/javascript/run_tests.js%s 2>&1"
|
|
project_dir (if full then " --full" else "")
|
|
in
|
|
let ic = Unix.open_process_in cmd in
|
|
let lines = ref [] in
|
|
(try while true do lines := input_line ic :: !lines done with End_of_file -> ());
|
|
ignore (Unix.close_process_in ic);
|
|
let all_lines = List.rev !lines in
|
|
(* Extract summary and failures *)
|
|
let fails = List.filter (fun l -> let t = String.trim l in
|
|
String.length t > 5 && String.sub t 0 4 = "FAIL") all_lines in
|
|
let summary = List.find_opt (fun l -> try let _ = Str.search_forward (Str.regexp "Results:") l 0 in true with Not_found -> false) all_lines in
|
|
let result = match summary with
|
|
| Some s ->
|
|
if fails = [] then s
|
|
else s ^ "\n\nFailures:\n" ^ String.concat "\n" fails
|
|
| None ->
|
|
let last_n = List.filteri (fun i _ -> i >= List.length all_lines - 5) all_lines in
|
|
String.concat "\n" last_n
|
|
in
|
|
text_result result
|
|
|
|
| "sx_pretty_print" ->
|
|
let file = args |> member "file" |> to_string in
|
|
let exprs = Sx_parser.parse_all (In_channel.with_open_text file In_channel.input_all) in
|
|
let source = pretty_print_file exprs in
|
|
Out_channel.with_open_text file (fun oc -> output_string oc source);
|
|
text_result (Printf.sprintf "OK — reformatted %s (%d bytes, %d forms)" file (String.length source) (List.length exprs))
|
|
|
|
| "sx_changed" ->
|
|
let base_ref = args |> member "ref" |> to_string_option |> Option.value ~default:"main" in
|
|
let project_dir = try Sys.getenv "SX_PROJECT_DIR" with Not_found ->
|
|
let spec_dir = try Sys.getenv "SX_SPEC_DIR" with Not_found -> "spec" in
|
|
Filename.dirname spec_dir
|
|
in
|
|
let cmd = Printf.sprintf "cd %s && git diff --name-only %s -- '*.sx' '*.sxc' 2>/dev/null" project_dir base_ref in
|
|
let ic = Unix.open_process_in cmd in
|
|
let files = ref [] in
|
|
(try while true do files := input_line ic :: !files done with End_of_file -> ());
|
|
ignore (Unix.close_process_in ic);
|
|
let changed = List.rev !files in
|
|
if changed = [] then text_result (Printf.sprintf "No .sx files changed since %s" base_ref)
|
|
else begin
|
|
let lines = List.map (fun rel ->
|
|
let full = Filename.concat project_dir rel in
|
|
try
|
|
let tree = parse_file full in
|
|
let summary = value_to_string (call_sx "summarise" [tree; Number 1.0]) in
|
|
Printf.sprintf "=== %s ===\n%s" rel summary
|
|
with _ -> Printf.sprintf "=== %s === (parse error or deleted)" rel
|
|
) changed in
|
|
text_result (String.concat "\n\n" lines)
|
|
end
|
|
|
|
| "sx_diff_branch" ->
|
|
let base_ref = args |> member "ref" |> to_string_option |> Option.value ~default:"main" in
|
|
let project_dir = try Sys.getenv "SX_PROJECT_DIR" with Not_found ->
|
|
let spec_dir = try Sys.getenv "SX_SPEC_DIR" with Not_found -> "spec" in
|
|
Filename.dirname spec_dir
|
|
in
|
|
let cmd = Printf.sprintf "cd %s && git diff --name-only %s -- '*.sx' '*.sxc' 2>/dev/null" project_dir base_ref in
|
|
let ic = Unix.open_process_in cmd in
|
|
let files = ref [] in
|
|
(try while true do files := input_line ic :: !files done with End_of_file -> ());
|
|
ignore (Unix.close_process_in ic);
|
|
let changed = List.rev !files in
|
|
if changed = [] then text_result (Printf.sprintf "No .sx files changed since %s" base_ref)
|
|
else begin
|
|
let lines = List.filter_map (fun rel ->
|
|
let full = Filename.concat project_dir rel in
|
|
(* Get the base version via git show *)
|
|
let base_cmd = Printf.sprintf "cd %s && git show %s:%s 2>/dev/null" project_dir base_ref rel in
|
|
let ic2 = Unix.open_process_in base_cmd in
|
|
let base_lines = ref [] in
|
|
(try while true do base_lines := input_line ic2 :: !base_lines done with End_of_file -> ());
|
|
ignore (Unix.close_process_in ic2);
|
|
let base_src = String.concat "\n" (List.rev !base_lines) in
|
|
try
|
|
let tree_b = parse_file full in
|
|
if base_src = "" then
|
|
Some (Printf.sprintf "=== %s (new file) ===\n%s" rel
|
|
(value_to_string (call_sx "summarise" [tree_b; Number 1.0])))
|
|
else begin
|
|
let tree_a = List (Sx_parser.parse_all base_src) in
|
|
let diff = value_to_string (call_sx "tree-diff" [tree_a; tree_b]) in
|
|
if diff = "No differences" then None
|
|
else Some (Printf.sprintf "=== %s ===\n%s" rel diff)
|
|
end
|
|
with _ -> Some (Printf.sprintf "=== %s === (parse error)" rel)
|
|
) changed in
|
|
if lines = [] then text_result "All changed .sx files are structurally identical to base"
|
|
else text_result (String.concat "\n\n" lines)
|
|
end
|
|
|
|
| "sx_blame" ->
|
|
let file = args |> member "file" |> to_string in
|
|
let path_str_arg = args |> member "path" |> to_string_option in
|
|
let project_dir = try Sys.getenv "SX_PROJECT_DIR" with Not_found ->
|
|
let spec_dir = try Sys.getenv "SX_SPEC_DIR" with Not_found -> "spec" in
|
|
Filename.dirname spec_dir
|
|
in
|
|
(* Get the node's source span by parsing and finding line numbers *)
|
|
let tree = parse_file file in
|
|
let target_src = match path_str_arg with
|
|
| Some ps ->
|
|
let path = resolve_path tree ps in
|
|
let node = call_sx "navigate" [tree; path] in
|
|
if is_nil node then None
|
|
else Some (Sx_types.inspect node)
|
|
| None -> None
|
|
in
|
|
let rel_file = relative_path ~base:project_dir file in
|
|
let cmd = match target_src with
|
|
| Some src ->
|
|
(* Find the line range containing this source fragment *)
|
|
let first_line = String.sub src 0 (min 40 (String.length src)) in
|
|
let escaped = String.concat "" (List.of_seq (Seq.map (fun c ->
|
|
if c = '(' || c = ')' || c = '[' || c = ']' || c = '.' || c = '*' || c = '+' || c = '?' || c = '{' || c = '}' || c = '\\' || c = '|' || c = '^' || c = '$'
|
|
then Printf.sprintf "\\%c" c else String.make 1 c
|
|
) (String.to_seq first_line))) in
|
|
Printf.sprintf "cd %s && git blame -L '/%s/,+10' -- %s 2>/dev/null || git blame -- %s 2>/dev/null | head -20" project_dir escaped rel_file rel_file
|
|
| None ->
|
|
Printf.sprintf "cd %s && git blame -- %s 2>/dev/null | head -30" project_dir rel_file
|
|
in
|
|
let ic = Unix.open_process_in cmd in
|
|
let lines = ref [] in
|
|
(try while true do lines := input_line ic :: !lines done with End_of_file -> ());
|
|
ignore (Unix.close_process_in ic);
|
|
text_result (String.concat "\n" (List.rev !lines))
|
|
|
|
| "sx_doc_gen" ->
|
|
let dir = args |> member "dir" |> to_string in
|
|
let files = glob_sx_files dir in
|
|
let all_docs = List.concat_map (fun path ->
|
|
let rel = relative_path ~base:dir path in
|
|
try
|
|
let exprs = Sx_parser.parse_all (In_channel.with_open_text path In_channel.input_all) in
|
|
List.filter_map (fun expr ->
|
|
match expr with
|
|
| List (Symbol head :: Symbol name :: rest) | ListRef { contents = Symbol head :: Symbol name :: rest } ->
|
|
(match head with
|
|
| "defcomp" | "defisland" ->
|
|
let params_str = match rest with
|
|
| List ps :: _ | ListRef { contents = ps } :: _ ->
|
|
let keys = List.filter_map (fun p -> match p with
|
|
| Symbol s when s <> "&key" && s <> "&rest" && not (String.length s > 0 && s.[0] = '&') -> Some s
|
|
| List (Symbol s :: _) when s <> "&key" && s <> "&rest" -> Some (Printf.sprintf "%s (typed)" s)
|
|
| _ -> None) ps
|
|
in
|
|
let has_rest = List.exists (fun p -> match p with Symbol "&rest" -> true | _ -> false) ps in
|
|
let key_str = if keys = [] then "" else " Keys: " ^ String.concat ", " keys ^ "\n" in
|
|
let rest_str = if has_rest then " Children: yes\n" else "" in
|
|
key_str ^ rest_str
|
|
| _ -> ""
|
|
in
|
|
Some (Printf.sprintf "## %s `%s`\nDefined in: %s\nType: %s\n%s" head name rel head params_str)
|
|
| "defmacro" ->
|
|
Some (Printf.sprintf "## %s `%s`\nDefined in: %s\nType: macro\n" head name rel)
|
|
| _ -> None)
|
|
| _ -> None
|
|
) exprs
|
|
with _ -> []
|
|
) files in
|
|
if all_docs = [] then text_result "(no components found)"
|
|
else text_result (String.concat "\n" all_docs)
|
|
|
|
| "sx_playwright" ->
|
|
let project_dir = try Sys.getenv "SX_PROJECT_DIR" with Not_found ->
|
|
let spec_dir = try Sys.getenv "SX_SPEC_DIR" with Not_found -> "spec" in
|
|
Filename.dirname spec_dir
|
|
in
|
|
let spec = args |> member "spec" |> to_string_option in
|
|
let spec_arg = match spec with Some s -> " " ^ s | None -> "" in
|
|
let cmd = Printf.sprintf "cd %s/tests/playwright && npx playwright test%s --reporter=line 2>&1" project_dir spec_arg in
|
|
let ic = Unix.open_process_in cmd in
|
|
let lines = ref [] in
|
|
(try while true do lines := input_line ic :: !lines done with End_of_file -> ());
|
|
ignore (Unix.close_process_in ic);
|
|
let all_lines = List.rev !lines in
|
|
let fails = List.filter (fun l -> let t = String.trim l in
|
|
String.length t > 1 && (t.[0] = '\xE2' (* ✘ *) || (String.length t > 4 && String.sub t 0 4 = "FAIL"))) all_lines in
|
|
let summary = List.find_opt (fun l ->
|
|
try let _ = Str.search_forward (Str.regexp "passed\\|failed") l 0 in true
|
|
with Not_found -> false) (List.rev all_lines) in
|
|
let result = match summary with
|
|
| Some s ->
|
|
if fails = [] then s
|
|
else s ^ "\n\nFailures:\n" ^ String.concat "\n" fails
|
|
| None ->
|
|
let last_n = List.filteri (fun i _ -> i >= List.length all_lines - 10) all_lines in
|
|
String.concat "\n" last_n
|
|
in
|
|
text_result result
|
|
|
|
| "sx_harness_eval" ->
|
|
let expr_str = args |> member "expr" |> to_string in
|
|
let mock_str = args |> member "mock" |> to_string_option in
|
|
let file = args |> member "file" |> to_string_option in
|
|
let e = !env in
|
|
(* Optionally load a file's definitions *)
|
|
(match file with
|
|
| Some f ->
|
|
(try load_sx_file e f
|
|
with exn -> Printf.eprintf "[mcp] Warning: %s: %s\n%!" f (Printexc.to_string exn))
|
|
| None -> ());
|
|
(* Create harness with optional mock overrides *)
|
|
let mock_arg = match mock_str with
|
|
| Some s ->
|
|
let parsed = Sx_parser.parse_all s in
|
|
if parsed <> [] then List [Keyword "platform"; List.hd parsed] else List []
|
|
| None -> List []
|
|
in
|
|
let session = Sx_ref.cek_call (env_get e "make-harness") mock_arg in
|
|
(* Install interceptors *)
|
|
ignore (call_sx "install-interceptors" [session; Env e]);
|
|
(* Evaluate the expression *)
|
|
let exprs = Sx_parser.parse_all expr_str in
|
|
let result = List.fold_left (fun _acc expr ->
|
|
try Sx_ref.eval_expr expr (Env e)
|
|
with exn -> String (Printf.sprintf "Error: %s" (Printexc.to_string exn))
|
|
) Nil exprs in
|
|
(* Get the IO log *)
|
|
let log = call_sx "harness-log" [session] in
|
|
let log_str = match log with
|
|
| List items | ListRef { contents = items } when items <> [] ->
|
|
"\n\nIO Log:\n" ^ String.concat "\n" (List.map (fun entry ->
|
|
let op = value_to_string (call_sx "get" [entry; String "op"]) in
|
|
let args_val = call_sx "get" [entry; String "args"] in
|
|
Printf.sprintf " %s(%s)" op (Sx_types.inspect args_val)
|
|
) items)
|
|
| _ -> "\n\n(no IO calls)"
|
|
in
|
|
text_result (Printf.sprintf "Result: %s%s" (Sx_types.inspect result) log_str)
|
|
|
|
| "sx_write_file" ->
|
|
let file = args |> member "file" |> to_string in
|
|
let source = args |> member "source" |> to_string in
|
|
(* Validate by parsing first *)
|
|
(try
|
|
let exprs = Sx_parser.parse_all source in
|
|
if exprs = [] then error_result "Source parsed to empty — nothing to write"
|
|
else begin
|
|
let output = pretty_print_file exprs in
|
|
Out_channel.with_open_text file (fun oc -> output_string oc output);
|
|
text_result (Printf.sprintf "OK — wrote %d bytes (%d top-level forms) to %s" (String.length output) (List.length exprs) file)
|
|
end
|
|
with e -> error_result (Printf.sprintf "Parse error — file not written: %s" (Printexc.to_string e)))
|
|
|
|
| "sx_rename_symbol" ->
|
|
let file = args |> member "file" |> to_string in
|
|
let tree = parse_file file in
|
|
let old_name = args |> member "old_name" |> to_string in
|
|
let new_name = args |> member "new_name" |> to_string in
|
|
let new_tree = call_sx "rename-symbol" [tree; String old_name; String new_name] in
|
|
let count = call_sx "count-renames" [tree; String old_name] in
|
|
let count_str = value_to_string count in
|
|
write_edit file (Dict (let d = Hashtbl.create 2 in Hashtbl.replace d "ok" new_tree; d))
|
|
|> (fun result ->
|
|
match result with
|
|
| `Assoc [("content", `List [`Assoc [("type", _); ("text", `String s)]])] when not (String.starts_with ~prefix:"Error" s) ->
|
|
text_result (Printf.sprintf "Renamed %s occurrences of '%s' → '%s' in %s" count_str old_name new_name file)
|
|
| other -> other)
|
|
|
|
| "sx_replace_by_pattern" ->
|
|
let file = args |> member "file" |> to_string in
|
|
let tree = parse_file file in
|
|
let pattern = args |> member "pattern" |> to_string in
|
|
let src = args |> member "new_source" |> to_string in
|
|
let all = args |> member "all" |> to_bool_option |> Option.value ~default:false in
|
|
if all then
|
|
write_edit file (call_sx "replace-all-by-pattern" [tree; String pattern; String src])
|
|
else
|
|
write_edit file (call_sx "replace-by-pattern" [tree; String pattern; String src])
|
|
|
|
| "sx_insert_near" ->
|
|
let file = args |> member "file" |> to_string in
|
|
let tree = parse_file file in
|
|
let pattern = args |> member "pattern" |> to_string in
|
|
let position = args |> member "position" |> to_string_option |> Option.value ~default:"after" in
|
|
let src = args |> member "new_source" |> to_string in
|
|
write_edit file (call_sx "insert-near-pattern" [tree; String pattern; String position; String src])
|
|
|
|
| "sx_rename_across" ->
|
|
let dir = args |> member "dir" |> to_string in
|
|
let old_name = args |> member "old_name" |> to_string in
|
|
let new_name = args |> member "new_name" |> to_string in
|
|
let dry_run = args |> member "dry_run" |> to_bool_option |> Option.value ~default:false in
|
|
let files = glob_sx_files dir in
|
|
let results = List.filter_map (fun path ->
|
|
let rel = relative_path ~base:dir path in
|
|
try
|
|
let tree = parse_file path in
|
|
let count = call_sx "count-renames" [tree; String old_name] in
|
|
match count with
|
|
| Number n when n > 0.0 ->
|
|
if dry_run then
|
|
Some (Printf.sprintf "%s: %d occurrences (dry run)" rel (int_of_float n))
|
|
else begin
|
|
let new_tree = call_sx "rename-symbol" [tree; String old_name; String new_name] in
|
|
let items = match new_tree with
|
|
| List items | ListRef { contents = items } -> items
|
|
| _ -> [new_tree]
|
|
in
|
|
let source = pretty_print_file items in
|
|
Out_channel.with_open_text path (fun oc -> output_string oc source);
|
|
Some (Printf.sprintf "%s: %d occurrences renamed" rel (int_of_float n))
|
|
end
|
|
| _ -> None
|
|
with _ -> None
|
|
) files in
|
|
if results = [] then text_result (Printf.sprintf "No occurrences of '%s' found" old_name)
|
|
else text_result (String.concat "\n" results)
|
|
|
|
| "sx_comp_list" ->
|
|
let dir = args |> member "dir" |> to_string in
|
|
let files = glob_sx_files dir in
|
|
let all_lines = List.concat_map (fun path ->
|
|
let rel = relative_path ~base:dir path in
|
|
try
|
|
let exprs = Sx_parser.parse_all (In_channel.with_open_text path In_channel.input_all) in
|
|
List.filter_map (fun expr ->
|
|
match expr with
|
|
| List (Symbol head :: Symbol name :: rest) | ListRef { contents = Symbol head :: Symbol name :: rest } ->
|
|
(match head with
|
|
| "defcomp" | "defisland" | "defmacro" | "defpage" | "define" ->
|
|
let params = match rest with
|
|
| List ps :: _ | ListRef { contents = ps } :: _ ->
|
|
String.concat " " (List.map Sx_runtime.value_to_str ps)
|
|
| _ -> ""
|
|
in
|
|
Some (Printf.sprintf "%-10s %-40s %-50s %s" head name rel params)
|
|
| _ -> None)
|
|
| _ -> None
|
|
) exprs
|
|
with _ -> []
|
|
) files in
|
|
if all_lines = [] then text_result "(no definitions found)"
|
|
else text_result (Printf.sprintf "%-10s %-40s %-50s %s\n%s" "TYPE" "NAME" "FILE" "PARAMS" (String.concat "\n" all_lines))
|
|
|
|
| "sx_find_across" ->
|
|
let dir = args |> member "dir" |> to_string in
|
|
let pattern = args |> member "pattern" |> to_string in
|
|
let files = glob_sx_files dir in
|
|
let all_lines = List.concat_map (fun path ->
|
|
let rel = relative_path ~base:dir path in
|
|
try
|
|
let tree = parse_file path in
|
|
let results = call_sx "find-all" [tree; String pattern] in
|
|
(match results with
|
|
| List items | ListRef { contents = items } ->
|
|
List.map (fun item ->
|
|
match item with
|
|
| List [p; s] | ListRef { contents = [p; s] } ->
|
|
rel ^ " " ^ value_to_string (call_sx "path-str" [p]) ^ " " ^ value_to_string s
|
|
| _ -> rel ^ " " ^ value_to_string item
|
|
) items
|
|
| _ -> [])
|
|
with _ -> []
|
|
) files in
|
|
if all_lines = [] then text_result "(no matches)"
|
|
else text_result (String.concat "\n" all_lines)
|
|
|
|
| "sx_diff" ->
|
|
let file_a = args |> member "file_a" |> to_string in
|
|
let file_b = args |> member "file_b" |> to_string in
|
|
let tree_a = parse_file file_a in
|
|
let tree_b = parse_file file_b in
|
|
text_result (value_to_string (call_sx "tree-diff" [tree_a; tree_b]))
|
|
|
|
| "sx_comp_usage" ->
|
|
let dir = args |> member "dir" |> to_string in
|
|
let name = args |> member "name" |> to_string in
|
|
let files = glob_sx_files dir in
|
|
let all_lines = List.concat_map (fun path ->
|
|
let rel = relative_path ~base:dir path in
|
|
try
|
|
let tree = parse_file path in
|
|
let results = call_sx "find-all" [tree; String name] in
|
|
(match results with
|
|
| List items | ListRef { contents = items } ->
|
|
List.map (fun item ->
|
|
match item with
|
|
| List [p; s] | ListRef { contents = [p; s] } ->
|
|
rel ^ " " ^ value_to_string (call_sx "path-str" [p]) ^ " " ^ value_to_string s
|
|
| _ -> rel ^ " " ^ value_to_string item
|
|
) items
|
|
| _ -> [])
|
|
with _ -> []
|
|
) files in
|
|
if all_lines = [] then text_result "(no usages found)"
|
|
else text_result (String.concat "\n" all_lines)
|
|
|
|
| "sx_eval" ->
|
|
let expr_str = args |> member "expr" |> to_string in
|
|
let exprs = Sx_parser.parse_all expr_str in
|
|
let e = !env in
|
|
let result = List.fold_left (fun _acc expr ->
|
|
Sx_ref.eval_expr expr (Env e)
|
|
) Nil exprs in
|
|
text_result (Sx_runtime.value_to_str result)
|
|
|
|
| _ -> error_result ("Unknown tool: " ^ name)
|
|
|
|
and write_edit file result =
|
|
match result with
|
|
| Dict d ->
|
|
(match Hashtbl.find_opt d "ok" with
|
|
| Some new_tree ->
|
|
let items = match new_tree with
|
|
| List items | ListRef { contents = items } -> items
|
|
| _ -> [new_tree]
|
|
in
|
|
let source = pretty_print_file items in
|
|
Out_channel.with_open_text file (fun oc -> output_string oc source);
|
|
text_result (Printf.sprintf "OK — wrote %d bytes to %s" (String.length source) file)
|
|
| None ->
|
|
let err = match Hashtbl.find_opt d "error" with
|
|
| Some (String s) -> s | Some v -> value_to_string v | None -> "Unknown error"
|
|
in
|
|
error_result ("Error: " ^ err))
|
|
| _ -> error_result "Unexpected result type"
|
|
|
|
(* ------------------------------------------------------------------ *)
|
|
(* MCP tool definitions *)
|
|
(* ------------------------------------------------------------------ *)
|
|
|
|
let tool name desc props required =
|
|
`Assoc [
|
|
("name", `String name);
|
|
("description", `String desc);
|
|
("inputSchema", `Assoc [
|
|
("type", `String "object");
|
|
("required", `List (List.map (fun r -> `String r) required));
|
|
("properties", `Assoc props)])]
|
|
|
|
let file_prop = ("file", `Assoc [("type", `String "string"); ("description", `String "Path to .sx file")])
|
|
let path_prop = ("path", `Assoc [("type", `String "string"); ("description", `String "SX path, e.g. \"(0 2 1)\"")])
|
|
let dir_prop = ("dir", `Assoc [("type", `String "string"); ("description", `String "Directory to scan recursively")])
|
|
|
|
let tool_definitions = `List [
|
|
tool "sx_read_tree" "Read an .sx file as an annotated tree with path labels. Auto-summarises large files (>200 lines). Use focus to expand only matching subtrees, max_depth for depth limit, or max_lines+offset for pagination."
|
|
[file_prop;
|
|
("focus", `Assoc [("type", `String "string"); ("description", `String "Pattern — expand matching subtrees, collapse rest")]);
|
|
("max_depth", `Assoc [("type", `String "integer"); ("description", `String "Depth limit (like summarise)")]);
|
|
("max_lines", `Assoc [("type", `String "integer"); ("description", `String "Max lines to return (pagination)")]);
|
|
("offset", `Assoc [("type", `String "integer"); ("description", `String "Line offset for pagination (default 0)")])]
|
|
["file"];
|
|
tool "sx_summarise" "Folded structural overview of an .sx file. Use to orient before drilling into a region."
|
|
[file_prop; ("depth", `Assoc [("type", `String "integer"); ("description", `String "Max depth (0=heads only)")])] ["file"; "depth"];
|
|
tool "sx_read_subtree" "Expand a specific subtree by path. Use after summarise to drill in."
|
|
[file_prop; path_prop] ["file"; "path"];
|
|
tool "sx_get_context" "Show enclosing chain from root to a target node."
|
|
[file_prop; path_prop] ["file"; "path"];
|
|
tool "sx_find_all" "Search for nodes matching a pattern. Returns paths and summaries."
|
|
[file_prop; ("pattern", `Assoc [("type", `String "string"); ("description", `String "Search pattern")])] ["file"; "pattern"];
|
|
tool "sx_get_siblings" "Show siblings of a node with target marked."
|
|
[file_prop; path_prop] ["file"; "path"];
|
|
tool "sx_validate" "Check structural integrity of an .sx file."
|
|
[file_prop] ["file"];
|
|
tool "sx_replace_node" "Replace node at path with new SX source. Fragment is parsed before file is touched."
|
|
[file_prop; path_prop; ("new_source", `Assoc [("type", `String "string"); ("description", `String "New SX source")])] ["file"; "path"; "new_source"];
|
|
tool "sx_insert_child" "Insert new child at index within a list node."
|
|
[file_prop; path_prop; ("index", `Assoc [("type", `String "integer"); ("description", `String "Insert position")]); ("new_source", `Assoc [("type", `String "string"); ("description", `String "New SX source")])] ["file"; "path"; "index"; "new_source"];
|
|
tool "sx_delete_node" "Remove node at path. Siblings shift to fill gap."
|
|
[file_prop; path_prop] ["file"; "path"];
|
|
tool "sx_wrap_node" "Wrap node in a new form. Use _ as placeholder, e.g. \"(when cond _)\"."
|
|
[file_prop; path_prop; ("wrapper", `Assoc [("type", `String "string"); ("description", `String "Wrapper with _ placeholder")])] ["file"; "path"; "wrapper"];
|
|
tool "sx_eval" "Evaluate an SX expression. Environment has parser + tree-tools + primitives."
|
|
[("expr", `Assoc [("type", `String "string"); ("description", `String "SX expression to evaluate")])] ["expr"];
|
|
tool "sx_find_across" "Search for a pattern across all .sx files under a directory. Returns file paths, tree paths, and summaries."
|
|
[dir_prop; ("pattern", `Assoc [("type", `String "string"); ("description", `String "Search pattern")])] ["dir"; "pattern"];
|
|
tool "sx_comp_list" "List all definitions (defcomp, defisland, defmacro, defpage, define) across .sx files in a directory."
|
|
[dir_prop] ["dir"];
|
|
tool "sx_comp_usage" "Find all uses of a component or symbol name across .sx files in a directory."
|
|
[dir_prop; ("name", `Assoc [("type", `String "string"); ("description", `String "Component or symbol name to search for")])] ["dir"; "name"];
|
|
tool "sx_diff" "Structural diff between two .sx files. Reports ADDED, REMOVED, CHANGED nodes with paths."
|
|
[("file_a", `Assoc [("type", `String "string"); ("description", `String "Path to first .sx file")]);
|
|
("file_b", `Assoc [("type", `String "string"); ("description", `String "Path to second .sx file")])] ["file_a"; "file_b"];
|
|
tool "sx_format_check" "Lint an .sx file for common issues: empty let bindings, missing bodies, duplicate params, structural problems."
|
|
[file_prop] ["file"];
|
|
tool "sx_macroexpand" "Evaluate an SX expression with a file's definitions loaded. Use to test macros — the file's defmacro forms are available."
|
|
[("file", `Assoc [("type", `String "string"); ("description", `String "Optional .sx file to load for macro/component definitions")]);
|
|
("expr", `Assoc [("type", `String "string"); ("description", `String "Expression to expand/evaluate")])]
|
|
["expr"];
|
|
tool "sx_build" "Build the SX runtime. Target \"js\" (default) builds sx-browser.js, \"ocaml\" runs dune build. Set full=true for extensions+types."
|
|
[("target", `Assoc [("type", `String "string"); ("description", `String "Build target: \"js\" (default) or \"ocaml\"")]);
|
|
("full", `Assoc [("type", `String "boolean"); ("description", `String "Include extensions and type system (default: false)")])]
|
|
[];
|
|
tool "sx_test" "Run SX test suite. Returns pass/fail summary and any failures."
|
|
[("host", `Assoc [("type", `String "string"); ("description", `String "Test host: \"js\" (default) or \"ocaml\"")]);
|
|
("full", `Assoc [("type", `String "boolean"); ("description", `String "Run full test suite including extensions (default: false)")])]
|
|
[];
|
|
tool "sx_pretty_print" "Reformat an .sx file with indentation. Short forms stay on one line, longer forms break across lines."
|
|
[file_prop] ["file"];
|
|
tool "sx_write_file" "Create or overwrite an .sx file. Source is parsed first — malformed SX is rejected and the file is not touched."
|
|
[file_prop;
|
|
("source", `Assoc [("type", `String "string"); ("description", `String "SX source to write")])]
|
|
["file"; "source"];
|
|
tool "sx_rename_symbol" "Rename all occurrences of a symbol in an .sx file. Structural — only renames symbols, not strings."
|
|
[file_prop;
|
|
("old_name", `Assoc [("type", `String "string"); ("description", `String "Current symbol name")]);
|
|
("new_name", `Assoc [("type", `String "string"); ("description", `String "New symbol name")])]
|
|
["file"; "old_name"; "new_name"];
|
|
tool "sx_replace_by_pattern" "Find nodes matching a pattern and replace with new source. Set all=true to replace all matches (default: first only)."
|
|
[file_prop;
|
|
("pattern", `Assoc [("type", `String "string"); ("description", `String "Search pattern to match")]);
|
|
("new_source", `Assoc [("type", `String "string"); ("description", `String "Replacement SX source")]);
|
|
("all", `Assoc [("type", `String "boolean"); ("description", `String "Replace all matches (default: first only)")])]
|
|
["file"; "pattern"; "new_source"];
|
|
tool "sx_insert_near" "Insert new source before or after the first node matching a pattern. No path needed."
|
|
[file_prop;
|
|
("pattern", `Assoc [("type", `String "string"); ("description", `String "Pattern to find insertion point")]);
|
|
("new_source", `Assoc [("type", `String "string"); ("description", `String "SX source to insert")]);
|
|
("position", `Assoc [("type", `String "string"); ("description", `String "\"before\" or \"after\" (default: after)")])]
|
|
["file"; "pattern"; "new_source"];
|
|
tool "sx_rename_across" "Rename a symbol across all .sx files in a directory. Use dry_run=true to preview without writing."
|
|
[dir_prop;
|
|
("old_name", `Assoc [("type", `String "string"); ("description", `String "Current symbol name")]);
|
|
("new_name", `Assoc [("type", `String "string"); ("description", `String "New symbol name")]);
|
|
("dry_run", `Assoc [("type", `String "boolean"); ("description", `String "Preview changes without writing (default: false)")])]
|
|
["dir"; "old_name"; "new_name"];
|
|
tool "sx_changed" "List .sx files changed since a git ref (default: main) with depth-1 summaries."
|
|
[("ref", `Assoc [("type", `String "string"); ("description", `String "Git ref to diff against (default: main)")])]
|
|
[];
|
|
tool "sx_diff_branch" "Structural diff of all .sx changes on current branch vs a base ref. Shows ADDED/REMOVED/CHANGED per file."
|
|
[("ref", `Assoc [("type", `String "string"); ("description", `String "Base ref (default: main)")])]
|
|
[];
|
|
tool "sx_blame" "Git blame for an .sx file, optionally focused on a tree path."
|
|
[file_prop; path_prop] ["file"];
|
|
tool "sx_doc_gen" "Generate component documentation from all defcomp/defisland/defmacro signatures in a directory."
|
|
[dir_prop] ["dir"];
|
|
tool "sx_harness_eval" "Evaluate SX in a test harness with mock IO. Returns result + IO trace. Use mock param to override default mock responses."
|
|
[("expr", `Assoc [("type", `String "string"); ("description", `String "SX expression to evaluate")]);
|
|
("mock", `Assoc [("type", `String "string"); ("description", `String "Optional mock platform overrides as SX dict, e.g. {:fetch (fn (url) {:status 200})}")]);
|
|
("file", `Assoc [("type", `String "string"); ("description", `String "Optional .sx file to load for definitions")])]
|
|
["expr"];
|
|
tool "sx_playwright" "Run Playwright browser tests for the SX docs site. Optionally specify a single spec file."
|
|
[("spec", `Assoc [("type", `String "string"); ("description", `String "Optional spec file name (e.g. demo-interactions.spec.js)")])]
|
|
[];
|
|
]
|
|
|
|
(* ------------------------------------------------------------------ *)
|
|
(* JSON-RPC dispatch *)
|
|
(* ------------------------------------------------------------------ *)
|
|
|
|
let dispatch method_name params =
|
|
match method_name with
|
|
| "initialize" ->
|
|
`Assoc [
|
|
("protocolVersion", `String "2024-11-05");
|
|
("capabilities", `Assoc [("tools", `Assoc [])]);
|
|
("serverInfo", `Assoc [
|
|
("name", `String "sx-tree-tools");
|
|
("version", `String "0.1.0")])]
|
|
| "notifications/initialized" -> `Null
|
|
| "tools/list" -> `Assoc [("tools", tool_definitions)]
|
|
| "tools/call" ->
|
|
let open Yojson.Safe.Util in
|
|
let name = params |> member "name" |> to_string in
|
|
let args = params |> member "arguments" in
|
|
(try handle_tool name args
|
|
with e -> error_result ("Error: " ^ Printexc.to_string e))
|
|
| _ -> `Null
|
|
|
|
(* ------------------------------------------------------------------ *)
|
|
(* Stdio JSON-RPC main loop *)
|
|
(* ------------------------------------------------------------------ *)
|
|
|
|
let () =
|
|
setup_env ();
|
|
try while true do
|
|
let line = input_line stdin in
|
|
if String.length line > 0 then begin
|
|
let json = Yojson.Safe.from_string line in
|
|
let open Yojson.Safe.Util in
|
|
let meth = json |> member "method" |> to_string_option |> Option.value ~default:"" in
|
|
let params = json |> member "params" in
|
|
let id = json |> member "id" in
|
|
let result = dispatch meth params in
|
|
if id <> `Null then begin
|
|
let resp = `Assoc [
|
|
("jsonrpc", `String "2.0");
|
|
("id", id);
|
|
("result", result)] in
|
|
print_string (Yojson.Safe.to_string resp);
|
|
print_char '\n';
|
|
flush stdout
|
|
end
|
|
end
|
|
done
|
|
with End_of_file -> ()
|