The CLI's shell commands go through shell(), which is run() on POSIX and a scratch script handed to Git for Windows' bash on Windows (exit codes read directly there). compile_app links through `ludicc -o` on Windows, ludic run starts the .exe, and ludic-dev build and ensure_ludicc assemble selfhost/ludicc.win.seed.ll, which ludic-dev reseed now writes beside the macOS seed. ludic bundle makes build/<name>/ with a GUI-subsystem exe carrying the .ico beside `app icon` as an llvm-rc resource, game.lpak and packs.index; ludicc gains --gui and --link, and quotes its whole link line for cmd.exe. Verified: ludic-dev test 135/135, selfhost-test 32/32; on the PC a checkout bootstraps from the Windows seed, ludic-dev build makes the toolchain, and `ludic bundle` makes build/Maroon Lake/, which runs from its own folder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
373 lines
18 KiB
Text
373 lines
18 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)}}}`)
|
|
|
|
# ---- workspace-wide symbol search ---------------------------------------
|
|
let ws = lsp_request("workspace/symbol", `{{"query":"Stats"}}`)
|
|
|
|
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")
|
|
check_true("signature help for a namespaced builtin", len(sigs.kids) > 0 and Text.starts_with(j_get(sigs.kids[0], "label").s, "rng_chance("))
|
|
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)
|
|
|
|
return report()
|
|
}
|