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>
512 lines
30 KiB
Text
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 ""
|
|
}
|