Skip to content

Commit ccfc615

Browse files
hyperpolymathclaude
andcommitted
feat(lsp-phase-c): completion candidates subcommand
Add `complete FILE LINE COL` to the AffineScript CLI, powering LSP completion. Three moving parts: * `lib/json_output.ml` — `extract_prefix_at` scans backward from the cursor column to extract the identifier prefix and detect a dot-access context; `collect_completions` filters the symbol table by prefix match and appends keyword candidates (suppressed in dot context); `emit_completions` serialises the list as a JSON array with `{name, kind, type, detail}` per item. * `bin/main.ml` — `complete_file` reads the source, calls `extract_prefix_at` + `collect_completions`, and emits the result; `complete_cmd` (FILE LINE COL, 1-based LSP convention) added and wired into the default command group. * `test/test_e2e.ml` — 6 new E2E tests under "E2E LSP Phase C" covering: prefix extraction correctness, prefix match returning a symbol, empty prefix returning candidates, unknown prefix returning empty, keyword inclusion, dot-context keyword suppression. 101 total tests, 0 regressions. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9f8e1e8 commit ccfc615

4 files changed

Lines changed: 248 additions & 2 deletions

File tree

.machine_readable/6a2/STATE.a2ml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ project = "affinescript"
66
version = "0.1.0"
77
last-updated = "2026-04-11"
88
status = "active"
9+
session-note-2026-04-11-f = "LSP PHASE C COMPLETE: completion candidates subcommand shipped. extract_prefix_at scans backward from cursor col to extract ident prefix + dot-context flag. collect_completions filters symbol table by prefix and appends affine_keywords (suppressed in dot context). emit_completions emits JSON array {name,kind,type,detail}. complete_file + complete_cmd added to bin/main.ml (FILE LINE COL args). 6 E2E tests. 101 total, 0 regressions. NEXT: LSP Phase D."
910
session-note-2026-04-11-e = "LSP PHASE B COMPLETE (commit 79c0829): hover/goto-def subcommands shipped. span_contains, find_symbol_at (refs-first then def-spans), hover_to_json/goto_def_to_json, emit_hover/emit_goto_def, run_pipeline_for_query. 89 tests. NEXT: LSP Phase C (completion candidates from symbol table)."
1011
session-note-2026-04-11-d = "TRAITS WIRED (commit 1ca143e): trait_registry in context; TopTrait/TopImpl in forward pass; ExprField trait fallback; find_impl unification-based; TopImpl bodies typechecked; 80 tests. NEXT: LSP Phase B."
1112
session-note-2026-04-11-c = "LINEAR ARROWS ENFORCED (commit d2f9b7b): (1) lambda synth — |@linear x: T| e now synthesises T -[1]-> U; (2) lambda check mode — explicit quantity annotation validated against expected TArrow; (3) quantity.ml ExprLambda — annotated params declared in env, usage verified, violations accumulated in env.errors and drained after check_function_quantities step 3; scope leakage prevented via quantities save/restore. Also fixed codegen.ml missing custom_sections field (pre-existing wasm.ml change). 75 tests, 0 regressions. NEXT: traits generic resolution → unification integration; then LSP Phase B."
@@ -30,7 +31,7 @@ wasm-gc-codegen = "70% (WasmGC proposal target: struct.new/struct.get/array.new_
3031
julia-codegen = "exists"
3132
lsp-phase-a = "complete"
3233
lsp-phase-b = "complete (2026-04-11, commit 79c0829): hover/goto-def subcommands shipped. json_output.ml: span_contains (1-based, single+multi-line), find_symbol_at (references-first then def-spans), hover_to_json/goto_def_to_json/not_found_json, emit_hover/emit_goto_def. bin/main.ml: hover + goto-def subcommands, run_pipeline_for_query tolerates typecheck errors. 4 E2E tests. 89 total."
33-
lsp-phase-c = "pending"
34+
lsp-phase-c = "complete (2026-04-11): completion candidates subcommand shipped. json_output.ml: extract_prefix_at (scans backward from cursor col to find ident prefix + dot-context flag), collect_completions (symbol table prefix filter + affine_keywords list suppressed in dot context), emit_completions (JSON array). bin/main.ml: complete_file handler + complete_cmd (FILE LINE COL args). 6 E2E tests added (prefix extraction, prefix match, empty prefix, no-match, keyword included, dot-ctx suppresses keywords). 101 total tests, 0 regressions."
3435
lsp-phase-d = "pending"
3536
stdlib = "95% (5 stubs remain as extern builtins)"
3637

bin/main.ml

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -792,6 +792,43 @@ let goto_def_cmd =
792792
let info = Cmd.info "goto-def" ~doc ~man in
793793
Cmd.v info Term.(ret (const goto_def_file $ face_arg $ path_arg $ line_arg $ col_arg))
794794

795+
(** {1 Phase C: completion subcommand} *)
796+
797+
(** Complete subcommand handler.
798+
799+
Reads the source file, extracts the identifier prefix at (line, col),
800+
resolves the program to build the symbol table, and emits a JSON array
801+
of completion candidates on stdout. Emits an empty array on pipeline
802+
failure so the editor doesn't break. *)
803+
let complete_file face path line col =
804+
let face = resolve_face face path in
805+
let source = read_file path in
806+
(match run_pipeline_for_query face path with
807+
| None ->
808+
Affinescript.Json_output.emit_completions []
809+
| Some (symbols, _refs) ->
810+
let (prefix, dot_ctx) =
811+
Affinescript.Json_output.extract_prefix_at source line col
812+
in
813+
let items =
814+
Affinescript.Json_output.collect_completions symbols prefix dot_ctx
815+
in
816+
Affinescript.Json_output.emit_completions items);
817+
`Ok ()
818+
819+
(** [complete FILE LINE COL] — return completion candidates at cursor. *)
820+
let complete_cmd =
821+
let doc = "Return completion candidates at a cursor position" in
822+
let man = [
823+
`S Manpage.s_description;
824+
`P "Extracts the identifier prefix at (LINE, COL), filters the symbol \
825+
table by prefix match, and prints a JSON array of completion \
826+
candidates on stdout. Each item has {name, kind, type, detail}.";
827+
`P "Lines and columns are 1-based integers (LSP convention).";
828+
] in
829+
let info = Cmd.info "complete" ~doc ~man in
830+
Cmd.v info Term.(ret (const complete_file $ face_arg $ path_arg $ line_arg $ col_arg))
831+
795832
let default_cmd =
796833
let doc = "The AffineScript compiler" in
797834
let info = Cmd.info "affinescript" ~version ~doc in
@@ -800,7 +837,7 @@ let default_cmd =
800837
lex_cmd; parse_cmd; check_cmd; eval_cmd; repl_cmd; compile_cmd;
801838
fmt_cmd; lint_cmd;
802839
tea_bridge_cmd;
803-
hover_cmd; goto_def_cmd;
840+
hover_cmd; goto_def_cmd; complete_cmd;
804841
preview_python_cmd; preview_js_cmd; preview_pseudocode_cmd
805842
]
806843

lib/json_output.ml

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,3 +588,136 @@ let emit_goto_def (sym_opt : Symbol.symbol option) : unit =
588588
in
589589
print_string (Yojson.Basic.to_string json);
590590
print_newline ()
591+
592+
(** {1 Phase C: Completion candidates}
593+
594+
Given a file path and cursor position (1-based), the [complete] subcommand
595+
extracts the identifier prefix before the cursor, filters the symbol table
596+
by that prefix, and emits a JSON array of completion candidates.
597+
598+
Output schema:
599+
{[
600+
[
601+
{ "name": "add",
602+
"kind": "function",
603+
"type": "Int -> Int -> Int" | null,
604+
"detail": "function" }
605+
]
606+
]}
607+
608+
Keywords are included as candidates with kind ["keyword"] and null type
609+
unless the cursor is in a dot-access context (e.g. [record.fie|]).
610+
*)
611+
612+
(** AffineScript keywords eligible for completion. *)
613+
let affine_keywords : string list = [
614+
"fn"; "let"; "match"; "if"; "else"; "return";
615+
"effect"; "handle"; "trait"; "impl"; "type"; "where";
616+
"forall"; "use"; "pub"; "mut"; "true"; "false";
617+
"Self"; "Int"; "Bool"; "Float"; "String"; "Unit";
618+
]
619+
620+
(** Extract the identifier prefix ending at column [col] on [line] in [source].
621+
622+
Scans backward from the character just before [col] (1-based) collecting
623+
[a-zA-Z0-9_] characters. Returns [(prefix, dot_ctx)] where [dot_ctx] is
624+
[true] when the character immediately preceding the prefix is ['.'],
625+
indicating a field-access / method completion context. *)
626+
let extract_prefix_at (source : string) (line : int) (col : int) : string * bool =
627+
let lines = String.split_on_char '\n' source in
628+
match List.nth_opt lines (line - 1) with
629+
| None -> ("", false)
630+
| Some line_text ->
631+
(* col is 1-based; the character to the left of the cursor is at index col-2. *)
632+
let end_idx = min (col - 2) (String.length line_text - 1) in
633+
if end_idx < 0 then ("", false)
634+
else begin
635+
let is_ident_char c =
636+
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
637+
|| (c >= '0' && c <= '9') || c = '_'
638+
in
639+
(* Collect identifier chars in reverse order. *)
640+
let buf = Buffer.create 16 in
641+
let i = ref end_idx in
642+
while !i >= 0 && is_ident_char line_text.[!i] do
643+
Buffer.add_char buf line_text.[!i];
644+
decr i
645+
done;
646+
(* Reverse to get forward order. *)
647+
let rev_s = Buffer.contents buf in
648+
let n = String.length rev_s in
649+
let prefix = String.init n (fun k -> rev_s.[n - 1 - k]) in
650+
let preceded_by_dot = !i >= 0 && line_text.[!i] = '.' in
651+
(prefix, preceded_by_dot)
652+
end
653+
654+
(** A single completion candidate. *)
655+
type completion_item = {
656+
comp_name : string;
657+
comp_kind : string;
658+
comp_type : string option;
659+
comp_detail : string;
660+
}
661+
662+
(** Serialize a completion item to JSON. *)
663+
let completion_item_to_json (item : completion_item) : Yojson.Basic.t =
664+
`Assoc [
665+
("name", `String item.comp_name);
666+
("kind", `String item.comp_kind);
667+
("type", (match item.comp_type with Some t -> `String t | None -> `Null));
668+
("detail", `String item.comp_detail);
669+
]
670+
671+
(** Collect completion candidates from [symbols] whose name begins with [prefix].
672+
673+
All symbols match when [prefix] is empty. AffineScript keywords are
674+
appended after symbol candidates unless [dot_ctx] is [true] (field-access
675+
position — keywords don't apply there). *)
676+
let collect_completions
677+
(symbols : Symbol.t)
678+
(prefix : string)
679+
(dot_ctx : bool)
680+
: completion_item list =
681+
let prefix_len = String.length prefix in
682+
let starts_with name =
683+
String.length name >= prefix_len
684+
&& String.sub name 0 prefix_len = prefix
685+
in
686+
(* Symbol candidates. *)
687+
let sym_items = ref [] in
688+
Hashtbl.iter (fun _id (sym : Symbol.symbol) ->
689+
if starts_with sym.sym_name then begin
690+
let kind = symbol_kind_to_string sym.sym_kind in
691+
let type_str = match sym.sym_type with
692+
| Some te -> Some (Ast.show_type_expr te)
693+
| None -> None
694+
in
695+
sym_items := {
696+
comp_name = sym.sym_name;
697+
comp_kind = kind;
698+
comp_type = type_str;
699+
comp_detail = kind;
700+
} :: !sym_items
701+
end
702+
) symbols.Symbol.all_symbols;
703+
let sym_sorted =
704+
List.sort (fun a b -> compare a.comp_name b.comp_name) !sym_items
705+
in
706+
(* Keyword candidates (not in dot-access context). *)
707+
let keyword_items =
708+
if dot_ctx then []
709+
else
710+
List.filter_map (fun kw ->
711+
if starts_with kw then
712+
Some { comp_name = kw; comp_kind = "keyword";
713+
comp_type = None; comp_detail = "keyword" }
714+
else None
715+
) affine_keywords
716+
in
717+
sym_sorted @ keyword_items
718+
719+
(** Emit a completion JSON array on stdout. *)
720+
let emit_completions (items : completion_item list) : unit =
721+
let json = `List (List.map completion_item_to_json items) in
722+
print_string (Yojson.Basic.to_string json);
723+
print_newline ()

test/test_e2e.ml

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1599,6 +1599,80 @@ let lsp_phase_b_tests = [
15991599
Alcotest.test_case "goto-def JSON fields" `Quick test_goto_def_json;
16001600
]
16011601

1602+
(* ============================================================================
1603+
Section N: LSP Phase C — Completion candidates
1604+
============================================================================
1605+
1606+
These tests validate [Json_output.extract_prefix_at] and
1607+
[Json_output.collect_completions] — the two functions powering the
1608+
[complete FILE LINE COL] subcommand.
1609+
*)
1610+
1611+
(** Cursor placed right after "add(" on line 5 of arithmetic.affine:
1612+
col 7 puts end_idx at 5 (0-based), scanning back collects 'd','d','a'
1613+
→ prefix "add". *)
1614+
let test_complete_prefix_extracted () =
1615+
let path = fixture "arithmetic.affine" in
1616+
let source = read_file path in
1617+
(* Line 5: "fn add(a: Int, b: Int) -> Int = a + b;"
1618+
Cols: 1234567 — col 7 is '(' so prefix ends at col 6 *)
1619+
let (prefix, dot_ctx) = Json_output.extract_prefix_at source 5 7 in
1620+
Alcotest.(check string) "prefix is 'add'" "add" prefix;
1621+
Alcotest.(check bool) "not dot context" false dot_ctx
1622+
1623+
(** Prefix "add" matches the [add] symbol in the arithmetic fixture. *)
1624+
let test_complete_prefix_match () =
1625+
let (symbols, _refs) = pipeline_for_hover (fixture "arithmetic.affine") in
1626+
let items = Json_output.collect_completions symbols "add" false in
1627+
let names =
1628+
List.map (fun (i : Json_output.completion_item) -> i.Json_output.comp_name) items
1629+
in
1630+
Alcotest.(check bool) "add is a candidate" true (List.mem "add" names)
1631+
1632+
(** Empty prefix returns all symbols + keywords — at least the 6 functions
1633+
defined in the arithmetic fixture are present. *)
1634+
let test_complete_empty_prefix () =
1635+
let (symbols, _refs) = pipeline_for_hover (fixture "arithmetic.affine") in
1636+
let items = Json_output.collect_completions symbols "" false in
1637+
Alcotest.(check bool) "non-empty for empty prefix"
1638+
true (List.length items > 0)
1639+
1640+
(** An unrecognised prefix produces an empty candidate list. *)
1641+
let test_complete_no_match () =
1642+
let (symbols, _refs) = pipeline_for_hover (fixture "arithmetic.affine") in
1643+
let items = Json_output.collect_completions symbols "zzznotfound" false in
1644+
Alcotest.(check int) "zero candidates for unknown prefix" 0 (List.length items)
1645+
1646+
(** Keyword "fn" appears in completions when the prefix matches and we are
1647+
not in a dot-access context. *)
1648+
let test_complete_keyword_included () =
1649+
let (symbols, _refs) = pipeline_for_hover (fixture "arithmetic.affine") in
1650+
let items = Json_output.collect_completions symbols "fn" false in
1651+
let kinds =
1652+
List.map (fun (i : Json_output.completion_item) -> i.Json_output.comp_kind) items
1653+
in
1654+
Alcotest.(check bool) "keyword item present" true (List.mem "keyword" kinds)
1655+
1656+
(** In a dot-access context, keyword candidates are suppressed. *)
1657+
let test_complete_dot_suppresses_keywords () =
1658+
let (symbols, _refs) = pipeline_for_hover (fixture "arithmetic.affine") in
1659+
(* dot_ctx = true → no keywords, even for empty prefix *)
1660+
let items = Json_output.collect_completions symbols "" true in
1661+
let kinds =
1662+
List.map (fun (i : Json_output.completion_item) -> i.Json_output.comp_kind) items
1663+
in
1664+
Alcotest.(check bool) "no keyword items in dot context"
1665+
false (List.mem "keyword" kinds)
1666+
1667+
let lsp_phase_c_tests = [
1668+
Alcotest.test_case "prefix extracted correctly" `Quick test_complete_prefix_extracted;
1669+
Alcotest.test_case "prefix match returns symbol" `Quick test_complete_prefix_match;
1670+
Alcotest.test_case "empty prefix returns candidates" `Quick test_complete_empty_prefix;
1671+
Alcotest.test_case "unknown prefix returns empty" `Quick test_complete_no_match;
1672+
Alcotest.test_case "keyword included when prefix ok" `Quick test_complete_keyword_included;
1673+
Alcotest.test_case "dot ctx suppresses keywords" `Quick test_complete_dot_suppresses_keywords;
1674+
]
1675+
16021676
(* ============================================================================
16031677
Test Suite Export
16041678
============================================================================ *)
@@ -1622,4 +1696,5 @@ let tests =
16221696
("E2E TEA", tea_tests);
16231697
("E2E TEA Bridge", tea_bridge_tests);
16241698
("E2E LSP Phase B", lsp_phase_b_tests);
1699+
("E2E LSP Phase C", lsp_phase_c_tests);
16251700
]

0 commit comments

Comments
 (0)