ludic/tools/ludic-cli/lsp_test.ludic
Orkuncakilkaya eb2d6106af ludic-lsp: every declaration by its own kind, every field a child, attribute arguments resolve
documentSymbol named `export property X` and `export registry` a function called
"property" / "registry": `export`, `unsafe` and leading @attributes are now a
lead-in to the declaration after them, which keeps its doc comment and is marked
exported. A record's field default was skipped with padv, which crosses lines, so
a property showed only its first field; a member now ends at a comma, the brace
or its line, past a fn type's parameters and a generic's arguments, and an
attribute's arguments are skipped rather than read as fields.

New symbols: state, event (and cancellable), action and port as records (struct,
event, event, interface) with their members; registry (with its record, for
go-to-type, and its line as detail); enum and its members; component and view
with props, state, fields, functions and events; reducer, named "S on A"; a
module-level let as a constant; def and bind bodies, module / friend / numbers
lines read past. A member of a component is not a name for the whole unit.

Hover and definition on an attribute's argument: @Node(model) / @Clip / @Material
to the field of the record it is written in, @Ref(Kits) to the registry (as any
top-level name), and hover on the attribute shows its docs/language page. A
fn-typed or generic field's type is shown whole. lsp_test (test-lsp) holds all of
it on a module file with each kind of declaration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:59:40 +03:00

512 lines
30 KiB
Text

# lsp_test.ludic — drive bin/ludic-lsp over real LSP traffic against the real
# source tree (`ludic-dev test-lsp`; replaces tools/test-lsp.py). Every assertion is
# about a feature an editor actually calls: if this passes, VS Code, JetBrains,
# Neovim and the rest are exercising code paths that have been checked, because
# they all speak to this one binary.
#
# The server is driven in one batch: every request is framed into a single
# stdin stream (positions are computed from the same files the server reads),
# the server runs to `exit`, and its stdout is parsed back into a result table
# keyed by request id plus the last publishDiagnostics per file — exactly what
# an interactive client would have observed, without a bidirectional pipe.
# ---- the request stream -------------------------------------------------------
var lsp_req: Sb = null
var lsp_next_id: int = 0
# one framed JSON-RPC message appended to the stream
function lsp_frame(body: pointer) -> void {
sb_puts(lsp_req, "Content-Length: "); sb_puts(lsp_req, string(len(body)))
sb_puts(lsp_req, "\r\n\r\n"); sb_puts(lsp_req, body)
}
# queue a request; returns its id (the key into lsp_res after the run)
function lsp_request(method: pointer, params: pointer) -> int {
lsp_next_id += 1
lsp_frame(`{{"jsonrpc":"2.0","id":{lsp_next_id},"method":"{method}","params":{params}}}`)
return lsp_next_id
}
function lsp_notify(method: pointer, params: pointer) -> void {
lsp_frame(`{{"jsonrpc":"2.0","method":"{method}","params":{params}}}`)
}
# didOpen for a file on disk; returns its text (for position lookups)
function lsp_open(path: pointer, lang: pointer) -> pointer {
let text = read_file(path)
lsp_notify("textDocument/didOpen", `{{"textDocument":{{"uri":"file://{path}","languageId":"{lang}","version":1,"text":{j_quote(text)}}}}}`)
return text
}
function lsp_doc(path: pointer) -> pointer { return `{{"uri":"file://{path}"}}` }
# the LSP position of the first occurrence of `needle` (+ `off` characters)
function position_of(text: pointer, needle: pointer, off: int) -> pointer {
let n = len(text)
var line = 0; var a = 0
while a <= n {
var b = a
while b < n and text[b] != '\n' { b += 1 }
let row = text[a..b]
let at = Text.index_of(row, needle)
if at >= 0 { return `{{"line":{line},"character":{at + off}}}` }
line += 1; a = b + 1
}
err(`lsp_test: {needle} not found\n`)
exit(1)
return ""
}
# ---- the response stream ------------------------------------------------------
property DiagSet { file: pointer = "", msgs: []pointer }
var lsp_res: []JVal = new []JVal # index = request id (0 unused)
var lsp_diags: []DiagSet = new []DiagSet # the last publish per file basename
function lsp_result(id: int) -> JVal {
if id < len(lsp_res) { return lsp_res[id] }
return jval_new(JV_NULL)
}
function lsp_diag_set(file: pointer) -> DiagSet {
var i = 0
while i < len(lsp_diags) { if lsp_diags[i].file == file { return lsp_diags[i] }; i += 1 }
return null
}
# messages published for `file` (an empty slice when nothing was published)
function lsp_diag_msgs(file: pointer) -> []pointer {
let d = lsp_diag_set(file)
if d == null { return new []pointer }
return d.msgs
}
function basename_of(uri: pointer) -> pointer {
var i = len(uri)
while i > 0 and uri[i - 1] != '/' { i -= 1 }
return uri[i..len(uri)]
}
function lsp_record(msg: JVal) -> void {
let id = j_get(msg, "id")
if id.t == JV_NUM {
while len(lsp_res) <= id.num { push(lsp_res, jval_new(JV_NULL)) }
lsp_res[id.num] = j_get(msg, "result")
return
}
if j_get(msg, "method").s == "textDocument/publishDiagnostics" {
let p = j_get(msg, "params")
let file = basename_of(j_get(p, "uri").s)
let msgs = new []pointer
let list = j_get(p, "diagnostics")
var i = 0
while i < len(list.kids) { push(msgs, j_get(list.kids[i], "message").s); i += 1 }
var d = lsp_diag_set(file)
if d == null { d = new DiagSet; d.file = file; push(lsp_diags, d) }
d.msgs = msgs
}
}
# split a Content-Length framed stream into messages
function lsp_parse_stream(s: pointer) -> void {
let n = len(s)
var p = 0
while p < n {
let h = Text.index_of(s[p..n], "Content-Length:")
if h < 0 { return }
var q = p + h + 15
while s[q] == ' ' { q += 1 }
var size = 0
while s[q] >= '0' and s[q] <= '9' { size = size * 10 + (s[q] - '0'); q += 1 }
let body_at = Text.index_of(s[q..n], "\r\n\r\n")
if body_at < 0 { return }
let start = q + body_at + 4
lsp_record(json_parse(s[start..start + size]))
p = start + size
}
}
# ---- JVal conveniences --------------------------------------------------------
function j_has_key(v: JVal, key: pointer) -> bool { return j_get(v, key).t != JV_NULL }
# does any element of array `arr` have field `key` equal to `want`?
function j_any_field(arr: JVal, key: pointer, want: pointer) -> bool {
var i = 0
while i < len(arr.kids) { if j_get(arr.kids[i], key).s == want { return true }; i += 1 }
return false
}
# the sorted, de-duplicated start lines of a TextEdit array, as "1,7"
function lines_key(edits: JVal) -> pointer {
let lines = new []int
var i = 0
while i < len(edits.kids) {
let l = j_get(j_get(j_get(edits.kids[i], "range"), "start"), "line").num
if not List.contains(lines, l) { push(lines, l) }
i += 1
}
List.sort(lines)
var out = ""
i = 0
while i < len(lines) { if i > 0 { out += "," }; out += string(lines[i]); i += 1 }
return out
}
# the sorted, de-duplicated start lines of a Location array, as "2,11"
function loc_lines_key(locs: JVal) -> pointer {
let lines = new []int
var i = 0
while i < len(locs.kids) {
let l = j_get(j_get(j_get(locs.kids[i], "range"), "start"), "line").num
if not List.contains(lines, l) { push(lines, l) }
i += 1
}
List.sort(lines)
var out = ""
i = 0
while i < len(lines) { if i > 0 { out += "," }; out += string(lines[i]); i += 1 }
return out
}
# the edits of a WorkspaceEdit's first (only) changed document
function first_change(ws: JVal) -> JVal {
let changes = j_get(ws, "changes")
if len(changes.kids) == 0 { return jval_new(JV_ARR) }
return changes.kids[0]
}
# how many distinct file basenames a Location array spans
function distinct_files(locs: JVal) -> int {
let seen = new []pointer
var i = 0
while i < len(locs.kids) {
let f = basename_of(j_get(locs.kids[i], "uri").s)
if not set_has(seen, f) { push(seen, f) }
i += 1
}
return len(seen)
}
function check_true(label: pointer, cond: bool) -> void {
if cond { ok(label) } else { bad(label) }
}
# ---- the suite ----------------------------------------------------------------
# Exit 2 when there is no server to test (the caller reports a skip, not a pass).
function cmd_test_lsp() -> int {
let server = getenv_or("LUDIC_LSP", "bin/ludic-lsp")
if not is_exec(server) { skip("ludic-lsp missing"); return 2 }
let root = capture_line("pwd")
lsp_req = sb_new()
let init = lsp_request("initialize", `{{"rootUri":"file://{root}","capabilities":{{}},"initializationOptions":{{"compilerDiagnostics":true,"compilerPath":"{root}/bin/ludicc"}}}}`)
lsp_notify("initialized", "{}")
# ---- a fragment file: no `program` block, only reachable through an import --
let frag = `{root}/examples/games/chronorift/combat.ludic`
let text = lsp_open(frag, "ludic")
let uri = lsp_doc(frag)
let syms = lsp_request("textDocument/documentSymbol", `{{"textDocument":{uri}}}`)
# a field reached through a query binding, declared in ANOTHER file
let pos = position_of(text, "st.guard", 3)
let hov = lsp_request("textDocument/hover", `{{"textDocument":{uri},"position":{pos}}}`)
let def = lsp_request("textDocument/definition", `{{"textDocument":{uri},"position":{pos}}}`)
# a binding is not yet inside its own scope at the point it is written, so
# resolving it has to go through the declaration table, not scope lookup
let qpos = position_of(text, "for (e) in query", 5)
let qhov = lsp_request("textDocument/hover", `{{"textDocument":{uri},"position":{qpos}}}`)
let comp = lsp_request("textDocument/completion", `{{"textDocument":{uri},"position":{pos}}}`)
# a query's term list admits properties and models and nothing else —
# not the loop variables, not builtins, not keywords
let qsrc = "program QT {\n property Pos { x: int = 0 }\n property Vel { dx: int = 0 }\n handler S phase Update {\n for (a, b) in query [Pos, ] { }\n }\n}\n"
let qfile = tmp_path("lsp_query.ludic")
write_file(qfile, qsrc)
lsp_open(qfile, "ludic")
let quri = lsp_doc(qfile)
let qcomp = lsp_request("textDocument/completion", `{{"textDocument":{quri},"position":{position_of(qsrc, "[Pos, ]", 6)}}}`)
let body = lsp_request("textDocument/completion", `{{"textDocument":{quri},"position":{position_of(qsrc, "] { }", 3)}}}`)
let refs = lsp_request("textDocument/references", `{{"textDocument":{uri},"position":{position_of(text, "query [Stats", 7)},"context":{{"includeDeclaration":true}}}}`)
let hints = lsp_request("textDocument/inlayHint", `{{"textDocument":{uri},"range":{{"start":{{"line":0,"character":0}},"end":{{"line":9999,"character":0}}}}}}`)
# a namespaced call resolves to its underlying bare-builtin signature
let sig = lsp_request("textDocument/signatureHelp", `{{"textDocument":{uri},"position":{position_of(text, "Random.chance(", 14)}}}`)
let toks = lsp_request("textDocument/semanticTokens/full", `{{"textDocument":{uri}}}`)
let folds = lsp_request("textDocument/foldingRange", `{{"textDocument":{uri}}}`)
# ---- structural diagnostics arrive while typing --------------------------
lsp_notify("textDocument/didChange", `{{"textDocument":{{"uri":"file://{frag}","version":2}},"contentChanges":[{{"text":{j_quote(text + "\n{ oops")}}}]}}`)
lsp_request("textDocument/documentSymbol", `{{"textDocument":{uri}}}`)
# ---- the LSP analyses opened files on its own ----------------------------
let badf = tmp_path("lsp_bad.ludic")
write_file(badf, "program Bad {\n property P { x: nosuchtype = 0 }\n}\n")
lsp_open(badf, "ludic")
let bsyms = lsp_request("textDocument/documentSymbol", `{{"textDocument":{lsp_doc(badf)}}}`)
# ---- markdown: ```ludic fences are real Ludic ---------------------------
let md = tmp_path("lsp_test.md")
write_file(md, "# Title\n\nprose\n\n```ludic\nprogram D { property Pt { x: int = 0 } }\n```\n\n```python\nz = 1\n```\n")
lsp_open(md, "markdown")
let mduri = lsp_doc(md)
let mdsyms = lsp_request("textDocument/documentSymbol", `{{"textDocument":{mduri}}}`)
let mdtoks = lsp_request("textDocument/semanticTokens/full", `{{"textDocument":{mduri}}}`)
let fmt = lsp_request("textDocument/formatting", `{{"textDocument":{mduri},"options":{{"tabSize":2,"insertSpaces":true}}}}`)
# the repository's own documentation must not light up with false errors
let docs = ["LANGUAGE.md", "README.md", "COMPILING.md"]
var di = 0
while di < len(docs) {
let path = `{root}/{docs[di]}`
if shq(`test -f {path}`) {
lsp_open(path, "markdown")
lsp_request("textDocument/documentSymbol", `{{"textDocument":{lsp_doc(path)}}}`)
}
di += 1
}
let langdoc = lsp_doc(`{root}/LANGUAGE.md`)
let langtoks = lsp_request("textDocument/semanticTokens/full", `{{"textDocument":{langdoc}}}`)
# ---- rename has to be resolution-based, not textual ----------------------
# `x` here is a field of Pos, a field of Vel, a local, and a parameter.
# Renaming any one of them must leave the other three alone.
let amb = tmp_path("lsp_rename.ludic")
write_file(amb, "program R {\n property Pos { x: int = 0, y: int = 0 }\n property Vel { x: int = 0 }\n function helper(x: int) -> int { return x + 1 }\n handler A phase Update {\n for (p) in query [Pos] {\n let x = 5\n p.x = p.x + x\n }\n }\n handler B phase Update {\n for (v) in query [Vel] { v.x = v.x + 1 }\n }\n}\n")
let atext = lsp_open(amb, "ludic")
let auri = lsp_doc(amb)
let field = lsp_request("textDocument/rename", `{{"textDocument":{auri},"position":{position_of(atext, "property Pos { x", 15)},"newName":"ZZZ"}}`)
let local = lsp_request("textDocument/rename", `{{"textDocument":{auri},"position":{position_of(atext, "let x = 5", 4)},"newName":"ZZZ"}}`)
let param = lsp_request("textDocument/rename", `{{"textDocument":{auri},"position":{position_of(atext, "function helper(x", 16)},"newName":"ZZZ"}}`)
# find-usages must be equally precise
let refs_x = lsp_request("textDocument/references", `{{"textDocument":{auri},"position":{position_of(atext, "property Vel { x", 15)},"context":{{"includeDeclaration":true}}}}`)
# ---- the compiler's own errors arrive on save ----------------------------
let ccf = tmp_path("lsp_cc.ludic")
write_file(ccf, "program CcErr {\n entry {\n let x = (5 +\n }\n}\n")
lsp_open(ccf, "ludic")
lsp_notify("textDocument/didSave", `{{"textDocument":{lsp_doc(ccf)}}}`)
lsp_request("textDocument/documentSymbol", `{{"textDocument":{lsp_doc(ccf)}}}`)
# ---- the declarations a module file holds (R8): exported records, a registry with its
# attributes, states, events, actions, ports, enums, reducers and components, every member
let dsrc = "module kits uses base\n\n# a kit\nexport property Kit {\n key: string = \"\"\n @Asset(\"gltf\") model: string = \"\"\n @Node(model) grip: string = \"\"\n @Ref(Kits) spare: int = 0\n pick: fn(int, float) -> bool\n}\n\n# every kit\n@AppendOnly @ByKey\nexport registry Kits of Kit as KIT from \"kits.lres\"\nexport state KitState {\n held: int = -1\n seen: []int\n}\nexport event KitDropped { kit: int, x: float }\nexport action PickKit { kit: int }\nexport port KitWorld {\n ground: fn(float, float) -> float\n}\nenum Grade { Low, High }\nreducer KitState on PickKit(st: mut KitState, a: PickKit) {\n st.held = a.kit\n}\ncomponent Tray(k: KitState) {\n prop step: int = 1\n state open: bool = false\n on press() { }\n}\nexport function kit_weight(k: Kit) -> int { return k.spare }\n"
let dfile = tmp_path("lsp_decls.ludic")
write_file(dfile, dsrc)
lsp_open(dfile, "ludic")
let duri = lsp_doc(dfile)
let dsyms = lsp_request("textDocument/documentSymbol", `{{"textDocument":{duri}}}`)
let dref = lsp_request("textDocument/definition", `{{"textDocument":{duri},"position":{position_of(dsrc, "@Ref(Kits)", 6)}}}`)
let dhov = lsp_request("textDocument/hover", `{{"textDocument":{duri},"position":{position_of(dsrc, "@Ref(Kits)", 6)}}}`)
let dnode = lsp_request("textDocument/definition", `{{"textDocument":{duri},"position":{position_of(dsrc, "@Node(model)", 7)}}}`)
let dty = lsp_request("textDocument/definition", `{{"textDocument":{duri},"position":{position_of(dsrc, "kit_weight(k: Kit)", 14)}}}`)
let dat = lsp_request("textDocument/hover", `{{"textDocument":{duri},"position":{position_of(dsrc, "@Ref(Kits)", 1)}}}`)
# ---- workspace-wide symbol search ---------------------------------------
let ws = lsp_request("workspace/symbol", `{{"query":"Stats"}}`)
# ---- navigation an IDE leans on --------------------------------------------
# package imports resolve from the toolchain's packages/, import strings are
# links, locals of both kinds have a type, built-ins lead to their reference
# page, and a call's argument list completes its parameter names.
let nsrc = "program Nav {\n import \"ludic.render3d/fmath.ludic\"\n function area(width: int, height: int) -> int { return width * height }\n entry {\n var total = 0\n let name = \"x\"\n total = total + area(width: 2, )\n Screen.fill_circle(x: 1, y: f_clamp(1, 0, 2), )\n let s = Screen.\n }\n}\n"
let nfile = tmp_path("lsp_nav.ludic")
write_file(nfile, nsrc)
lsp_open(nfile, "ludic")
let nuri = lsp_doc(nfile)
let links = lsp_request("textDocument/documentLink", `{{"textDocument":{nuri}}}`)
let pkgdef = lsp_request("textDocument/definition", `{{"textDocument":{nuri},"position":{position_of(nsrc, "ludic.render3d", 3)}}}`)
let xdef = lsp_request("textDocument/definition", `{{"textDocument":{nuri},"position":{position_of(nsrc, "f_clamp", 2)}}}`)
let vhov = lsp_request("textDocument/hover", `{{"textDocument":{nuri},"position":{position_of(nsrc, "total = total", 10)}}}`)
let lhov = lsp_request("textDocument/hover", `{{"textDocument":{nuri},"position":{position_of(nsrc, "name = ", 1)}}}`)
let vrefs = lsp_request("textDocument/references", `{{"textDocument":{nuri},"position":{position_of(nsrc, "var total", 5)},"context":{{"includeDeclaration":true}}}}`)
let bdef = lsp_request("textDocument/definition", `{{"textDocument":{nuri},"position":{position_of(nsrc, "fill_circle", 2)}}}`)
let bhov = lsp_request("textDocument/hover", `{{"textDocument":{nuri},"position":{position_of(nsrc, "fill_circle", 2)}}}`)
let tdef = lsp_request("textDocument/definition", `{{"textDocument":{nuri},"position":{position_of(nsrc, "int, height", 1)}}}`)
let acomp = lsp_request("textDocument/completion", `{{"textDocument":{nuri},"position":{position_of(nsrc, "2, )", 3)}}}`)
let nscomp = lsp_request("textDocument/completion", `{{"textDocument":{nuri},"position":{position_of(nsrc, "= Screen.", 9)}}}`)
let ahelp = lsp_request("textDocument/signatureHelp", `{{"textDocument":{nuri},"position":{position_of(nsrc, "2), )", 4)}}}`)
# contextual keywords, highlights, barrels, semantic modifiers
let kdir = tmp_path("lsp_barrel")
shell(`mkdir -p {kdir}/camp`)
write_file(`{kdir}/camp/index.ludic`, "import \"tent.ludic\"\n")
write_file(`{kdir}/camp/tent.ludic`, "function pitch(size: int) -> int { return size }\n")
let ksrc = "program K {\n import \"camp\"\n import \"ludic.render3d/fmath.ludic\"\n function go() -> int {\n var model = pitch(2)\n model = model + f_clamp(1, 0, 2)\n Screen.clear(0)\n return model\n }\n}\n"
let kfile = `{kdir}/k.ludic`
write_file(kfile, ksrc)
lsp_open(kfile, "ludic")
let kuri = lsp_doc(kfile)
let khov = lsp_request("textDocument/hover", `{{"textDocument":{kuri},"position":{position_of(ksrc, "var model", 5)}}}`)
let krefs = lsp_request("textDocument/references", `{{"textDocument":{kuri},"position":{position_of(ksrc, "var model", 5)},"context":{{"includeDeclaration":true}}}}`)
let kbarrel = lsp_request("textDocument/definition", `{{"textDocument":{kuri},"position":{position_of(ksrc, "pitch(2)", 1)}}}`)
let khl = lsp_request("textDocument/documentHighlight", `{{"textDocument":{kuri},"position":{position_of(ksrc, "f_clamp", 1)}}}`)
let ktoks = lsp_request("textDocument/semanticTokens/full", `{{"textDocument":{kuri}}}`)
# names inside `template {holes}`, and positions after a multi-line template
let tsrc = "program TT {\n function tag(variant: int) -> pointer {\n let multi = `a\nb {variant}`\n return `rock_{variant}` + multi\n }\n}\n"
let tfile = tmp_path("lsp_tmpl.ludic")
write_file(tfile, tsrc)
lsp_open(tfile, "ludic")
let turi = lsp_doc(tfile)
let thov = lsp_request("textDocument/hover", `{{"textDocument":{turi},"position":{position_of(tsrc, "rock_{variant", 7)}}}`)
let trefs = lsp_request("textDocument/references", `{{"textDocument":{turi},"position":{position_of(tsrc, "tag(variant", 5)},"context":{{"includeDeclaration":true}}}}`)
let tafter = lsp_request("textDocument/hover", `{{"textDocument":{turi},"position":{position_of(tsrc, "+ multi", 3)}}}`)
lsp_request("shutdown", "null")
lsp_notify("exit", "null")
# ---- run the server over the whole stream --------------------------------
let req_path = tmp_path("lsp_req.bin")
let res_path = tmp_path("lsp_res.bin")
write_file(req_path, sb_str(lsp_req))
shell(`{server} --stdio < {req_path} > {res_path} 2>{tmp_path("lsp_err.txt")}`)
let stream = read_file(res_path)
if stream == null { bad("ludic-lsp produced no output"); return report() }
lsp_parse_stream(stream)
# ---- and judge ----------------------------------------------------------
let caps = j_get(lsp_result(init), "capabilities")
let wanted = ["completionProvider", "hoverProvider", "definitionProvider", "referencesProvider", "renameProvider", "documentSymbolProvider", "workspaceSymbolProvider", "documentFormattingProvider", "semanticTokensProvider", "foldingRangeProvider", "signatureHelpProvider", "inlayHintProvider", "documentLinkProvider"]
var wi = 0
while wi < len(wanted) { check_true(`advertises {wanted[wi]}`, j_has_key(caps, wanted[wi])); wi += 1 }
check_true("document symbols in an imported fragment", len(lsp_result(syms).kids) >= 3)
let hov_text = j_get(j_get(lsp_result(hov), "contents"), "value").s
check_true("hover resolves a field through a query binding", Text.contains(hov_text, "Stats.guard: int"))
check_true("go-to-definition crosses files", Text.ends_with(j_get(lsp_result(def), "uri").s, "world.ludic"))
let qhov_text = j_get(j_get(lsp_result(qhov), "contents"), "value").s
check_true("hover works on a query binding at its declaration site", Text.contains(qhov_text, "query binding"))
let items = j_get(lsp_result(comp), "items")
check_true("completion after '.' offers only that property's fields", j_any_field(items, "label", "guard") and j_any_field(items, "label", "hp") and not j_any_field(items, "label", "clear"))
let qitems = j_get(lsp_result(qcomp), "items")
check_true("completion inside query terms lists properties only", len(qitems.kids) == 2 and j_any_field(qitems, "label", "Pos") and j_any_field(qitems, "label", "Vel"))
let bitems = j_get(lsp_result(body), "items")
check_true("completion in the loop body still has locals and builtins", j_any_field(bitems, "label", "a") and j_any_field(bitems, "label", "clear"))
check_true("find-usages spans the compilation unit", distinct_files(lsp_result(refs)) >= 3)
check_true("inlay hints name query binding types", j_any_field(lsp_result(hints), "label", ": Stats"))
let sigs = j_get(lsp_result(sig), "signatures")
# the documented, namespaced spelling (docs/language) when the reference is
# present, else the builtin the method lowers to
check_true("signature help for a namespaced builtin", len(sigs.kids) > 0 and (Text.starts_with(j_get(sigs.kids[0], "label").s, "Random.chance(") or Text.starts_with(j_get(sigs.kids[0], "label").s, "rng_chance(")))
check_true("signature help splits the parameters", len(sigs.kids) > 0 and len(j_get(sigs.kids[0], "parameters").kids) == 1)
let tdata = j_get(lsp_result(toks), "data")
check_true("semantic tokens produced", len(tdata.kids) / 5 > 100)
check_true("semantic token stream is well formed", len(tdata.kids) % 5 == 0)
check_true("folding ranges produced", len(lsp_result(folds).kids) > 5)
let cmsgs = lsp_diag_msgs("combat.ludic")
var unclosed = false
var ci = 0
while ci < len(cmsgs) { if Text.contains(cmsgs[ci], "unclosed") { unclosed = true }; ci += 1 }
check_true("unbalanced brace reported while typing", unclosed)
check_true("LSP opens and analyses a file with no compiler present", j_any_field(lsp_result(bsyms), "name", "Bad"))
let mdnames = lsp_result(mdsyms)
check_true("symbols found inside a markdown fence", len(mdnames.kids) == 1 and j_get(mdnames.kids[0], "name").s == "D")
let mdata = j_get(lsp_result(mdtoks), "data")
var first_tok = -1
if len(mdata.kids) > 0 { first_tok = mdata.kids[0].num }
check("markdown tokens start on the fence body line", string(first_tok), "5")
# A fence is a snippet: elisions and half-shown blocks are the documentation
# doing its job, so errors there would be noise. Highlighting still applies.
check_true("no diagnostics are reported for markdown", lsp_diag_set("lsp_test.md") != null and len(lsp_diag_msgs("lsp_test.md")) == 0)
let fedits = lsp_result(fmt)
var ftext = ""
if len(fedits.kids) > 0 { ftext = j_get(fedits.kids[0], "newText").s }
check_true("markdown formatting leaves prose and other fences alone", Text.contains(ftext, "prose") and Text.contains(ftext, "z = 1"))
di = 0
while di < len(docs) {
check(`{docs[di]} opens with no diagnostics`, string(len(lsp_diag_msgs(docs[di]))), "0")
di += 1
}
check_true("LANGUAGE.md fences are highlighted", len(j_get(lsp_result(langtoks), "data").kids) / 5 > 100)
check("renaming Pos.x touches only its declaration and its uses", lines_key(first_change(lsp_result(field))), "1,7")
check("renaming a local touches only that local", lines_key(first_change(lsp_result(local))), "6,7")
check("renaming a parameter touches only that function", lines_key(first_change(lsp_result(param))), "3")
check("renaming a parameter makes exactly two edits", string(len(first_change(lsp_result(param)).kids)), "2")
check("find-usages of Vel.x does not pick up Pos.x", loc_lines_key(lsp_result(refs_x)), "2,11")
check_true("workspace symbol search finds a property", j_any_field(lsp_result(ws), "name", "Stats"))
let ccmsgs = lsp_diag_msgs("lsp_cc.ludic")
var cc_seen = false
ci = 0
while ci < len(ccmsgs) { if Text.contains(ccmsgs[ci], "expected expression") { cc_seen = true }; ci += 1 }
check_true("ludicc's own error is published on save (compilerDiagnostics)", cc_seen)
let link0 = lsp_result(links)
check_true("an import string is a link to its file", len(link0.kids) == 1 and Text.ends_with(j_get(link0.kids[0], "target").s, "packages/ludic.render3d/fmath.ludic"))
let pd = lsp_result(pkgdef)
check_true("go-to-definition on a package import opens the package file", len(pd.kids) == 1 and Text.ends_with(j_get(pd.kids[0], "uri").s, "packages/ludic.render3d/fmath.ludic"))
check_true("a name from an imported package resolves into it", Text.ends_with(j_get(lsp_result(xdef), "uri").s, "fmath.ludic"))
check_true("hover gives a var local its inferred type", Text.contains(j_get(j_get(lsp_result(vhov), "contents"), "value").s, "var total: int"))
check_true("hover gives a let local its inferred type", Text.contains(j_get(j_get(lsp_result(lhov), "contents"), "value").s, "let name: string"))
check("find-usages of a var local", loc_lines_key(lsp_result(vrefs)), "4,6")
check_true("go-to-definition on a namespace method opens its reference page", Text.ends_with(j_get(lsp_result(bdef), "uri").s, "screen-fill_circle.md"))
check_true("hover on a namespace method links its documentation", Text.contains(j_get(j_get(lsp_result(bhov), "contents"), "value").s, "screen-fill_circle.html"))
check_true("go-to-definition on a built-in type opens its reference page", Text.ends_with(j_get(lsp_result(tdef), "uri").s, "type-int.md"))
let aitems = j_get(lsp_result(acomp), "items")
check_true("an argument list completes the parameters not given yet", j_any_field(aitems, "label", "height:") and not j_any_field(aitems, "label", "width:") and not j_any_field(aitems, "label", "let"))
check_true("Namespace. completes its members", j_any_field(j_get(lsp_result(nscomp), "items"), "label", "fill_circle"))
let ah = lsp_result(ahelp)
check_true("signature help tracks the argument after a named one", j_get(ah, "activeParameter").num == 2 and len(j_get(j_get(ah, "signatures").kids[0], "parameters").kids) == 4)
check_true("a declaration word used as a variable is a variable", Text.contains(j_get(j_get(lsp_result(khov), "contents"), "value").s, "var model: int"))
check("find-usages of a local named like a keyword", loc_lines_key(lsp_result(krefs)), "4,5,7")
check_true("an import of a directory reads its index.ludic barrel", Text.ends_with(j_get(lsp_result(kbarrel), "uri").s, "camp/tent.ludic"))
let hl = lsp_result(khl)
check_true("highlight usages stays inside the document", len(hl.kids) == 1 and j_get(j_get(j_get(hl.kids[0], "range"), "start"), "line").num == 5)
let kd = j_get(lsp_result(ktoks), "data")
var vendor_seen = false
var lib_seen = false
var ti = 0
while ti + 4 < len(kd.kids) {
if (kd.kids[ti + 4].num & 8) != 0 { vendor_seen = true }
if (kd.kids[ti + 4].num & 4) != 0 and kd.kids[ti + 3].num == 5 { lib_seen = true }
ti += 5
}
check_true("semantic tokens mark package symbols as vendor", vendor_seen)
check_true("semantic tokens mark library calls as defaultLibrary", lib_seen)
check_true("a name inside a template hole resolves", Text.contains(j_get(j_get(lsp_result(thov), "contents"), "value").s, "param variant: int"))
check("find-usages reaches into template holes", loc_lines_key(lsp_result(trefs)), "1,3,4")
check_true("positions after a multi-line template stay on the right line", Text.contains(j_get(j_get(lsp_result(tafter), "contents"), "value").s, "let multi"))
# a module file's declarations, each by its own name and kind, with every member as a child
let dk = lsp_result(dsyms)
check_true("an exported property is a struct named for itself, all its fields children", lsp_sym_is(dk, "Kit", 23, 5))
check_true("a registry is a symbol of its own, with what it holds", lsp_sym_is(dk, "Kits", 18, 0) and Text.contains(lsp_sym_detail(dk, "Kits"), "of Kit as KIT"))
check_true("a state is a struct with its fields", lsp_sym_is(dk, "KitState", 23, 2))
check_true("an event and an action are events", lsp_sym_is(dk, "KitDropped", 24, 2) and lsp_sym_is(dk, "PickKit", 24, 1))
check_true("a port is an interface, a fn-typed member written out", lsp_sym_is(dk, "KitWorld", 11, 1) and Text.contains(lsp_sym_detail(dk, "KitWorld"), "fn(float, float) -> float"))
check_true("an enum and its members", lsp_sym_is(dk, "Grade", 10, 2))
check_true("a reducer is named for its state and its action", lsp_sym_is(dk, "KitState on PickKit", 12, -1))
check_true("a component holds its props, state and events", lsp_sym_is(dk, "Tray", 5, 3))
check_true("an exported function is still a function", lsp_sym_is(dk, "kit_weight", 12, -1))
check_true("go-to-definition on @Ref(Kits) reaches the registry", j_get(j_get(j_get(lsp_result(dref), "range"), "start"), "line").num == 13)
check_true("hover on @Ref(Kits) shows the registry", Text.contains(j_get(j_get(lsp_result(dhov), "contents"), "value").s, "registry Kits of Kit"))
check_true("go-to-definition on @Node(model) reaches the field it names", j_get(j_get(j_get(lsp_result(dnode), "range"), "start"), "line").num == 5)
check_true("go-to-definition on an exported record's type name", j_get(j_get(j_get(lsp_result(dty), "range"), "start"), "line").num == 3)
check_true("hover on an attribute shows its reference page", Text.contains(j_get(j_get(lsp_result(dat), "contents"), "value").s, "@Ref(Registry)"))
return report()
}
# the symbol named `name` among `syms` has LSP kind `kind` and, unless n is -1, n children
function lsp_sym_is(syms: JVal, name: pointer, kind: int, n: int) -> bool {
var i = 0
while i < len(syms.kids) {
let y = syms.kids[i]
if j_get(y, "name").s == name {
if j_get(y, "kind").num != kind { return false }
return n < 0 or len(j_get(y, "children").kids) == n
}
i += 1
}
return false
}
function lsp_sym_detail(syms: JVal, name: pointer) -> pointer {
var i = 0
while i < len(syms.kids) {
if j_get(syms.kids[i], "name").s == name { return j_get(syms.kids[i], "detail").s }
i += 1
}
return ""
}