feat(tooling): JetBrains IDE support, LSP navigation, barrel imports, package scripts and hooks

- JetBrains plugin 1.4.0: semantic colours (builtin / vendor / own), template strings,
  brace handling, run configurations and a test console, package.ludic and
  package.lock.ludic editing (completion, docs, app preview, colour previews, asset
  navigation), External Libraries for the runtime and packages, doc pages for built-ins
- ludic-lsp: go to definition for imports, document links, hover with inferred types,
  type definition, signature help with parameters, docs from docs/language
- `import "dir"` resolves a barrel `dir/index.ludic`
- package.ludic `entry`, `script` and `hook before|after <command>`; `ludic <script>`,
  `ludic script`, `ludic scripts`
- `ludic test --verbose` and `--test NAME`; the test runner filters by name

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-09-16 16:00:28 +03:00
parent da57b20156
commit f92d7f89c6
75 changed files with 46802 additions and 40343 deletions

View file

@ -0,0 +1,253 @@
# lsp/docs.ludic — the language reference, indexed for hover and go-to.
#
# docs/language/**.md (shipped in the install root) holds one page per symbol,
# with front matter:
#
# id: screen-fill_circle -> <DOCS_URL><id>.html
# tokens: Screen.fill_circle the source spellings it documents
# sig: / tip: / kind: / ns:
#
# Built-ins, built-in types and namespace methods therefore get a signature, a
# one-line summary and a link in hover, and go-to-definition opens the page.
# Loaded once, on first use; a toolchain without docs simply has no entries.
var DOCS_URL: pointer = "https://workshopsoft.pages.workshopsoft.io/ludic/"
var g_docs_loaded: bool = false
var g_dx_token: []pointer
var g_dx_file: []pointer
var g_dx_id: []pointer
var g_dx_sig: []pointer
var g_dx_tip: []pointer
var g_dx_kind: []pointer
# namespace name -> its section page
var g_dxn_name: []pointer
var g_dxn_file: []pointer
var g_dxn_id: []pointer
# the value of `key: …` in a front-matter block, or ""
function fm_get(fm: pointer, key: pointer) -> pointer {
let n = cstr_len(fm)
let kl = cstr_len(key)
var i = 0
while i < n {
var e = i
while e < n and fm[e] != '\n' { e += 1 }
if e - i > kl and fm[i..i + kl] == key and fm[i + kl] == ':' {
var s = i + kl + 1
while s < e and fm[s] == ' ' { s += 1 }
var t = e
while t > s and (fm[t - 1] == ' ' or fm[t - 1] == '\r') { t -= 1 }
return fm[s..t]
}
i = e + 1
}
return ""
}
# the front matter of a page (between the leading `---` lines), or ""
function fm_of(text: pointer) -> pointer {
if not starts_with(text, "---\n") { return "" }
let n = cstr_len(text)
var i = 4
while i < n {
if text[i] == '-' and text[i - 1] == '\n' and i + 3 <= n and text[i..i + 3] == "---" { return text[4..i] }
i += 1
}
return ""
}
# the body after the front matter
function fm_body(text: pointer) -> pointer {
let fm = fm_of(text)
if (fm == "") { return text }
var i = 4 + cstr_len(fm) + 3
while text[i] == '\n' or text[i] == '\r' { i += 1 }
return text[i..cstr_len(text)]
}
function docs_add(tok: pointer, file: pointer, fm: pointer) -> void {
push(g_dx_token, tok)
push(g_dx_file, file)
push(g_dx_id, fm_get(fm, "id"))
push(g_dx_sig, fm_get(fm, "sig"))
push(g_dx_tip, fm_get(fm, "tip"))
push(g_dx_kind, fm_get(fm, "kind"))
}
function docs_load() -> void {
if g_docs_loaded { return }
g_docs_loaded = true
g_dx_token = new []pointer; g_dx_file = new []pointer; g_dx_id = new []pointer
g_dx_sig = new []pointer; g_dx_tip = new []pointer; g_dx_kind = new []pointer
g_dxn_name = new []pointer; g_dxn_file = new []pointer; g_dxn_id = new []pointer
let root = lsp_home() + "docs/language"
if not Fs.is_dir(root) { return }
let listfile = `{Os.temp_dir()}/ludic_lsp_docs_{Os.pid()}.txt`
run(`find '{root}' -name '*.md' > '{listfile}' 2>/dev/null`)
let list = read_file(listfile)
run(`rm -f '{listfile}'`)
if (list == null) { return }
var i = 0
var start = 0
while list[i] != 0 {
if list[i] == '\n' {
if i > start { docs_index_file(list[start..i]) }
start = i + 1
}
i += 1
}
if i > start { docs_index_file(list[start..i]) }
}
function docs_index_file(path: pointer) -> void {
if ends_with(path, "/_section.md") { return }
let text = read_file(path)
if (text == null) { return }
let fm = fm_of(text)
if (fm == "") { return }
let toks = fm_get(fm, "tokens")
var i = 0
let n = cstr_len(toks)
while i < n {
while i < n and toks[i] == ' ' { i += 1 }
let s = i
while i < n and toks[i] != ' ' { i += 1 }
if i > s { docs_add(toks[s..i], path, fm) }
}
# a namespace method also tells us where its namespace's section page is
let ns = fm_get(fm, "ns")
if not (ns == "") and docs_ns_index(ns) < 0 {
let sec = dirname(path) + "/_section.md"
if Fs.exists(sec) {
push(g_dxn_name, ns)
push(g_dxn_file, sec)
push(g_dxn_id, "ns-" + lower_str(ns))
}
}
}
function lower_str(s: pointer) -> pointer {
let n = cstr_len(s)
let b = bytes(n + 1)
var i = 0
while i < n { b[i] = lower(s[i]); i += 1 }
b[n] = 0
return b
}
function docs_index(tok: pointer) -> int {
docs_load()
var i = 0
while i < len(g_dx_token) { if (g_dx_token[i] == tok) { return i }; i += 1 }
return -1
}
function docs_ns_index(ns: pointer) -> int {
var i = 0
while i < len(g_dxn_name) { if (g_dxn_name[i] == ns) { return i }; i += 1 }
return -1
}
function docs_url(i: int) -> pointer { return DOCS_URL + g_dx_id[i] + ".html" }
# the spelling a token is documented under: `Screen.fill_circle` for the
# method name in `Screen.fill_circle(…)`, else the word itself
function doc_key_at(D: Doc, tok: int) -> pointer {
let name = ttext(D, tok)
let dot = prev_sig(D, tok)
if dot >= 0 and tis(D, dot, ".") {
let recv = prev_sig(D, dot)
if recv >= 0 { return ttext(D, recv) + "." + name }
}
return name
}
# a hover for a documented name, or null
function docs_hover(D: Doc, tok: int) -> pointer {
let key = doc_key_at(D, tok)
let i = docs_index(key)
if i >= 0 {
var sig = g_dx_sig[i]
if (sig == "") { sig = key }
let m = buf_new()
buf_puts(m, g_dx_tip[i])
let body = docs_first_paragraph(g_dx_file[i])
if not (body == "") and not (body == g_dx_tip[i]) { buf_puts(m, "\n\n"); buf_puts(m, body) }
buf_puts(m, "\n\n[Documentation](")
buf_puts(m, docs_url(i))
buf_puts(m, ")")
let o = buf_new()
hover_md(o, sig, buf_str(m))
return buf_str(o)
}
let n = docs_ns_index(ttext(D, tok))
if n >= 0 {
let o = buf_new()
let body = docs_first_paragraph(g_dxn_file[n])
hover_md(o, "namespace " + g_dxn_name[n], body + "\n\n[Documentation](" + DOCS_URL + g_dxn_id[n] + ".html)")
return buf_str(o)
}
return null
}
# go-to-definition for a documented name: its page
function docs_definition(D: Doc, tok: int) -> pointer {
var file: pointer = null
let i = docs_index(doc_key_at(D, tok))
if i >= 0 { file = g_dx_file[i] }
else {
let n = docs_ns_index(ttext(D, tok))
if n >= 0 { file = g_dxn_file[n] }
}
if (file == null) { return null }
let o = buf_new()
buf_puts(o, "{\"uri\":")
jstr_out(o, path_to_uri(file))
buf_puts(o, ",\"range\":{\"start\":{\"line\":0,\"character\":0},\"end\":{\"line\":0,\"character\":0}}}")
return buf_str(o)
}
# the first prose paragraph of a page, markup kept (the editor renders markdown)
function docs_first_paragraph(path: pointer) -> pointer {
let text = read_file(path)
if (text == null) { return "" }
let body = fm_body(text)
let n = cstr_len(body)
var i = 0
while i < n and (body[i] == '\n' or body[i] == ' ') { i += 1 }
if i + 2 < n and body[i..i + 3] == "```" { return "" }
let s = i
while i < n {
if body[i] == '\n' and i + 1 < n and body[i + 1] == '\n' { break }
i += 1
}
var p = body[s..i]
if cstr_len(p) > 600 { p = p[0..600] + "…" }
return p
}
# the documented signature of a namespace method or builtin, or null
function docs_sig(key: pointer) -> pointer {
let i = docs_index(key)
if i < 0 { return null }
if (g_dx_sig[i] == "") { return null }
return g_dx_sig[i]
}
function docs_tip(key: pointer) -> pointer {
let i = docs_index(key)
if i < 0 { return "" }
return g_dx_tip[i]
}
# every documented member of a namespace: `Screen` -> fill_circle, show, …
function docs_ns_members(ns: pointer, out: []int) -> void {
docs_load()
let pre = ns + "."
var i = 0
while i < len(g_dx_token) {
if starts_with(g_dx_token[i], pre) { push(out, i) }
i += 1
}
}

View file

@ -0,0 +1,215 @@
# lsp/imports.ludic — where an `import "…"` string points, resolved the way the
# compiler's do_import (selfhost/frontend/parse.ludic) resolves it:
#
# 1. relative to the importing file import "camp/save.ludic"
# 2. the package's ludic_modules/ (or $LUDIC_MODULES) third-party packages
# 3. the install root: runtime/… and packages/… import "ludic.render3d/r3d.ludic"
#
# `dir/*.ludic` imports every .ludic in the directory, sorted by name.
var g_home: pointer = null
# the install root with a trailing '/': $LUDIC_HOME, else the directory above
# this binary's bin/ (a checkout's bin/ludic-lsp gives the checkout itself)
function lsp_home() -> pointer {
if (g_home != null) { return g_home }
var h = Os.env_or("LUDIC_HOME", "")
if not (h == "") {
if not ends_with(h, "/") { h = h + "/" }
g_home = h
return h
}
let me = arg(0)
var i = cstr_len(me)
while i > 0 and me[i - 1] != '/' { i -= 1 }
var d = ""
if i > 0 { d = me[0..i] }
else { d = path_dir_holding(me) } # started by name, from $PATH
if not (d == "") and d[0] != '/' { # started by a relative path
let cwd = Os.env_or("PWD", "")
if not (cwd == "") { d = cwd + "/" + d }
}
if not (d == "") { d = normpath(d) + "/" }
if ends_with(d, "/bin/") { d = d[0..len(d) - 4] }
g_home = d
return d
}
# the first $PATH directory holding `name`, with a trailing '/', or ""
function path_dir_holding(name: pointer) -> pointer {
let p = Os.env_or("PATH", "")
let n = cstr_len(p)
var a = 0
while a < n {
var b = a
while b < n and p[b] != ':' { b += 1 }
if b > a {
let dir = p[a..b]
if Fs.exists(dir + "/" + name) { return dir + "/" }
}
a = b + 1
}
return ""
}
# the nearest directory at or above dir holding a package.ludic, or null
function package_root_of(dir: pointer) -> pointer {
var d = dir
var guard = 0
while guard < 64 {
if Fs.exists(d + "/package.ludic") { return d }
if (d == "/") or (d == ".") or (d == "") { return null }
d = dirname(d)
guard += 1
}
return null
}
function modules_dir_for(from: pointer) -> pointer {
let env = Os.env_or("LUDIC_MODULES", "")
if not (env == "") {
if env[0] == '/' { return env }
let r = package_root_of(dirname(from))
if (r != null) { return join_path(r, env) }
return null
}
let root = package_root_of(dirname(from))
if (root == null) { return null }
return root + "/ludic_modules"
}
# one file an import names, or null when it cannot be found anywhere
function resolve_import(from: pointer, rel: pointer) -> pointer {
if (rel == "") { return null }
# `import "camp"`: the directory's barrel
if not ends_with(rel, ".ludic") {
if ends_with(rel, "/") { return resolve_import(from, rel + "index.ludic") }
return resolve_import(from, rel + "/index.ludic")
}
let direct = join_path(dirname(from), rel)
if Fs.exists(direct) and not Fs.is_dir(direct) { return direct }
if rel[0] == '/' { return null }
let mods = modules_dir_for(from)
if (mods != null) {
let m = join_path(mods, rel)
if Fs.exists(m) and not Fs.is_dir(m) { return m }
}
let home = lsp_home()
if not (home == "") {
if starts_with(rel, "runtime/") {
let rt = join_path(home, rel)
if Fs.exists(rt) { return rt }
}
let pk = join_path(home + "packages", rel)
if Fs.exists(pk) and not Fs.is_dir(pk) { return pk }
}
return null
}
function is_glob_import(rel: pointer) -> bool { return ends_with(rel, "/*.ludic") }
# every file an import names: one for a plain import, the sorted directory
# listing for `dir/*.ludic`
function resolve_import_all(from: pointer, rel: pointer) -> []pointer {
let out = new []pointer
if not is_glob_import(rel) {
let one = resolve_import(from, rel)
if (one != null) { push(out, one) }
return out
}
let dirrel = rel[0..len(rel) - 8]
var dir = join_path(dirname(from), dirrel)
if not Fs.is_dir(dir) {
let home = lsp_home()
dir = join_path(home + "packages", dirrel)
if not Fs.is_dir(dir) { return out }
}
let names = Fs.list(dir)
if (names == null) { return out }
var i = 0
while i < len(names) {
var best = i
var j = i + 1
while j < len(names) { if str_before(names[j], names[best]) { best = j }; j += 1 }
let t = names[i]; names[i] = names[best]; names[best] = t
i += 1
}
i = 0
while i < len(names) {
if ends_with(names[i], ".ludic") { push(out, join_path(dir, names[i])) }
i += 1
}
return out
}
function str_before(a: pointer, b: pointer) -> bool {
var i = 0
while a[i] != 0 and b[i] != 0 {
if a[i] != b[i] { return a[i] < b[i] }
i += 1
}
return a[i] == 0 and b[i] != 0
}
# the text inside an import's string token (no quotes)
function import_rel_of(D: Doc, tok: int) -> pointer {
var n = tlen(D, tok) - 2
if n < 0 { n = 0 }
return D.src[D.tk_start[tok] + 1..D.tk_start[tok] + 1 + n]
}
# is tok the string of an `import "…"`?
function is_import_string(D: Doc, tok: int) -> bool {
if tok < 0 or D.tk_kind[tok] != LT_STR { return false }
let p = prev_sig(D, tok)
return p >= 0 and tis(D, p, "import")
}
# textDocument/documentLink: every import string links to its file, so the
# editor underlines it and Cmd/Ctrl-click opens it
function on_document_link(msg: JVal, id: JVal) -> void {
let D = doc_of(msg)
if (D == null) { send_result(id, "[]"); return }
let o = buf_new()
buf_putc(o, '[')
var first = true
var t = 0
while t < ntok(D) {
if is_import_string(D, t) {
let files = resolve_import_all(D.path, import_rel_of(D, t))
if len(files) > 0 and tlen(D, t) > 2 {
if not first { buf_putc(o, ',') }
first = false
buf_puts(o, "{\"range\":")
put_range(o, D, D.tk_start[t] + 1, D.tk_end[t] - 1)
buf_puts(o, ",\"target\":")
jstr_out(o, path_to_uri(files[0]))
buf_puts(o, ",\"tooltip\":")
jstr_out(o, files[0])
buf_putc(o, '}')
}
}
t += 1
}
buf_putc(o, ']')
send_result(id, buf_str(o))
}
# go-to-definition on an import string: the file (every file, for a glob)
function import_definition(D: Doc, tok: int) -> pointer {
let files = resolve_import_all(D.path, import_rel_of(D, tok))
if len(files) == 0 { return null }
let o = buf_new()
buf_putc(o, '[')
var i = 0
while i < len(files) {
let T = ensure_doc(files[i])
if (T != null) {
if o.len > 1 { buf_putc(o, ',') }
put_location(o, T, 0, 0)
}
i += 1
}
buf_putc(o, ']')
return buf_str(o)
}

View file

@ -0,0 +1,226 @@
# lsp/sighelp.ludic — the call the cursor is inside: signature help with
# per-parameter ranges (so the editor bolds the active one), and named-argument
# completion (`Screen.fill_circle(x: …, radius: …)`).
# the call around off: sets g_call_* and returns true when there is one
var g_call_callee: int = -1 # token of the function name
var g_call_open: int = -1 # token of its `(`
var g_call_arg: int = 0 # which argument (0-based) the cursor is in
var g_call_named: pointer = "" # `name:` written at the start of that argument, if any
var g_call_used: []pointer # names already given in earlier arguments
function find_call_at(D: Doc, off: int) -> bool {
g_call_callee = -1; g_call_open = -1; g_call_arg = 0; g_call_named = ""
g_call_used = new []pointer
var depth = 0
var i = ntok(D) - 1
while i >= 0 and D.tk_start[i] >= off { i -= 1 }
while i >= 0 {
if D.tk_kind[i] == LT_OP and tlen(D, i) == 1 {
let c = D.src[D.tk_start[i]]
if c == ')' or c == ']' { depth += 1 }
else if c == '[' { if depth == 0 { return false }; depth -= 1 }
else if c == '{' or c == '}' { if depth == 0 { return false } }
else if c == '(' {
if depth == 0 {
let callee = prev_sig(D, i)
if callee < 0 or D.tk_kind[callee] != LT_ID { return false }
g_call_callee = callee
g_call_open = i
call_args_before(D, i, off)
return true
}
depth -= 1
}
}
i -= 1
}
return false
}
# count the arguments before off and collect `name:` labels
function call_args_before(D: Doc, open: int, off: int) -> void {
var depth = 0
var arg_start = open + 1
var k = open + 1
while k < ntok(D) and D.tk_start[k] < off {
if D.tk_kind[k] == LT_OP and tlen(D, k) == 1 {
let c = D.src[D.tk_start[k]]
if c == '(' or c == '[' or c == '{' { depth += 1 }
else if c == ')' or c == ']' or c == '}' { depth -= 1 }
else if c == ',' and depth == 0 {
let lbl = arg_label(D, arg_start)
if not (lbl == "") { push(g_call_used, lbl) }
g_call_arg += 1
arg_start = k + 1
}
}
k += 1
}
let here = arg_label(D, arg_start)
if not (here == "") and D.tk_end[arg_start] <= off { g_call_named = here }
}
# `name:` at the start of an argument -> name, else ""
function arg_label(D: Doc, t: int) -> pointer {
var s = t
while s < ntok(D) and (D.tk_kind[s] == LT_NL or D.tk_kind[s] == LT_COMMENT) { s += 1 }
if s >= ntok(D) or D.tk_kind[s] != LT_ID { return "" }
let c = next_sig(D, s)
if c >= 0 and tis(D, c, ":") { return ttext(D, s) }
return ""
}
# the signature label and documentation for the call's callee
var g_call_doc: pointer = ""
function call_signature(D: Doc) -> pointer {
g_call_doc = ""
let name = ttext(D, g_call_callee)
let dot = prev_sig(D, g_call_callee)
if dot >= 0 and tis(D, dot, ".") {
let nsi = prev_sig(D, dot)
if nsi >= 0 and D.tk_kind[nsi] == LT_ID {
let key = ttext(D, nsi) + "." + name
let ds = docs_sig(key)
if (ds != null) { g_call_doc = docs_tip(key); return ds }
let ns = ns_method_sig(ttext(D, nsi), name)
if (ns != null) { return ns }
}
return null
}
let t = find_top(D, name)
if t >= 0 and (g_owner.syms[t].kind == LS_FN or g_owner.syms[t].kind == LS_EXTERN) {
g_call_doc = g_owner.syms[t].doc
return g_owner.syms[t].detail
}
let ds = docs_sig(name)
if (ds != null) { g_call_doc = docs_tip(name); return ds }
return builtin_sig(name)
}
# [start, end) of each parameter inside the label's outermost ( )
var g_ps: []int
var g_pe: []int
function split_params(label: pointer) -> void {
g_ps = new []int; g_pe = new []int
let n = cstr_len(label)
var open = 0
while open < n and label[open] != '(' { open += 1 }
if open >= n { return }
var depth = 0
var s = open + 1
var i = open + 1
while i < n {
let c = label[i]
if c == '(' or c == '[' { depth += 1 }
else if (c == ')' and depth == 0) or (c == ',' and depth == 0) {
var a = s
while a < i and label[a] == ' ' { a += 1 }
var b = i
while b > a and label[b - 1] == ' ' { b -= 1 }
if b > a { push(g_ps, a); push(g_pe, b) }
if c == ')' { return }
s = i + 1
}
else if c == ')' or c == ']' { depth -= 1 }
i += 1
}
}
# the parameter name in label[a..b): `radius`, `sk: Skin` -> sk
function param_name(label: pointer, a: int, b: int) -> pointer {
var e = a
while e < b and label[e] != ':' and label[e] != ' ' and label[e] != '=' { e += 1 }
return label[a..e]
}
function param_type(label: pointer, a: int, b: int) -> pointer {
var c = a
while c < b and label[c] != ':' { c += 1 }
if c >= b { return "" }
c += 1
while c < b and label[c] == ' ' { c += 1 }
return label[c..b]
}
function on_signature_help2(msg: JVal, id: JVal) -> void {
let D = doc_of(msg)
if (D == null) { send_result(id, "null"); return }
let off = off_of(msg, D, "params.position")
if not find_call_at(D, off) { send_result(id, "null"); return }
let sig = call_signature(D)
if (sig == null) { send_result(id, "null"); return }
split_params(sig)
var active = g_call_arg
if not (g_call_named == "") {
var p = 0
while p < len(g_ps) { if (param_name(sig, g_ps[p], g_pe[p]) == g_call_named) { active = p }; p += 1 }
}
let o = buf_new()
buf_puts(o, "{\"signatures\":[{\"label\":")
jstr_out(o, sig)
if (g_call_doc != null) and not (g_call_doc == "") {
buf_puts(o, ",\"documentation\":{\"kind\":\"markdown\",\"value\":")
jstr_out(o, g_call_doc)
buf_putc(o, '}')
}
buf_puts(o, ",\"parameters\":[")
var i = 0
while i < len(g_ps) {
if i > 0 { buf_putc(o, ',') }
buf_puts(o, "{\"label\":[")
buf_puti(o, utf16_len(sig, 0, g_ps[i]))
buf_putc(o, ',')
buf_puti(o, utf16_len(sig, 0, g_pe[i]))
buf_puts(o, "]")
let ty = param_type(sig, g_ps[i], g_pe[i])
if not (ty == "") { buf_puts(o, ",\"documentation\":"); jstr_out(o, ty) }
buf_putc(o, '}')
i += 1
}
buf_puts(o, "]}],\"activeSignature\":0,\"activeParameter\":")
buf_puti(o, active)
buf_putc(o, '}')
send_result(id, buf_str(o))
}
# completion inside a call's argument list: `name: ` for every parameter not
# given yet, ranked first. Returns true when it added anything.
function comp_call_args(o: Buf, D: Doc, off: int) -> bool {
if not find_call_at(D, off) { return false }
if not (g_call_named == "") { return false }
let sig = call_signature(D)
if (sig == null) { return false }
split_params(sig)
var added = false
var i = 0
while i < len(g_ps) {
let pn = param_name(sig, g_ps[i], g_pe[i])
var used = false
var u = 0
while u < len(g_call_used) { if (g_call_used[u] == pn) { used = true }; u += 1 }
if not used and cstr_len(pn) > 0 {
comp_item_full(o, pn + ":", 5, param_type(sig, g_ps[i], g_pe[i]), "0" + itoa(i + 10), pn + ": ")
added = true
}
i += 1
}
return added
}
# completion for `Namespace.`: its documented members
function comp_ns_members(o: Buf, ns: pointer) -> bool {
let idx = new []int
docs_ns_members(ns, idx)
var i = 0
while i < len(idx) {
let k = idx[i]
let full = g_dx_token[k]
let member = full[cstr_len(ns) + 1..cstr_len(full)]
var sig = g_dx_sig[k]
if (sig == "") { sig = g_dx_tip[k] }
comp_item_full(o, member, 2, sig, "0" + member, "")
i += 1
}
return len(idx) > 0
}

View file

@ -0,0 +1,277 @@
# lsp/types.ludic — what type a name has, for hover and go-to-type.
#
# Declared types are taken as written (`var hp: int`, `sk: Skin`). An
# undeclared one (`let i = skin_joint(sk, "hip")`) is inferred from its
# initializer, shallowly: literals, `new T`, calls to functions and builtins
# with a known return type, other names, fields, and comparisons. Anything
# cleverer is the compiler's job; an unknown type is simply left out.
# the return type written after the last `->` of a signature label, or ""
function sig_return(sig: pointer) -> pointer {
if (sig == null) { return "" }
let n = cstr_len(sig)
var at = -1
var i = 0
while i + 1 < n {
if sig[i] == '-' and sig[i + 1] == '>' { at = i }
i += 1
}
if at < 0 { return "" }
var s = at + 2
while s < n and sig[s] == ' ' { s += 1 }
var e = n
while e > s and sig[e - 1] == ' ' { e -= 1 }
return sig[s..e]
}
function is_compare_op(D: Doc, t: int) -> bool {
return tis(D, t, "==") or tis(D, t, "!=") or tis(D, t, "<") or tis(D, t, ">") or tis(D, t, "<=") or tis(D, t, ">=") or tis(D, t, "and") or tis(D, t, "or")
}
# does the expression starting at t contain a top-level comparison before it ends?
function expr_is_boolean(D: Doc, t: int) -> bool {
var depth = 0
var k = t
let line = D.tk_line[t]
while k >= 0 and k < ntok(D) {
let kind = D.tk_kind[k]
if kind == LT_NL or kind == LT_EOF or kind == LT_COMMENT { return false }
if tis(D, k, "(") or tis(D, k, "[") { depth += 1 }
else if tis(D, k, ")") or tis(D, k, "]") { if depth == 0 { return false }; depth -= 1 }
else if tis(D, k, "{") or tis(D, k, "}") or tis(D, k, ";") or tis(D, k, ",") { if depth == 0 { return false } }
else if depth == 0 and is_compare_op(D, k) { return true }
if D.tk_line[k] != line { return false }
k += 1
}
return false
}
# the declared or inferred type of symbol y (which lives in doc O), or ""
function sym_type(O: Doc, si: int, depth: int) -> pointer {
let y = O.syms[si]
if (y.ty != null) and not (y.ty == "") { return y.ty }
if y.init >= 0 and depth < 8 { return infer_type(O, y.init, depth + 1) }
return ""
}
# the type of the expression whose first token is t, or ""
function infer_type(D: Doc, t: int, depth: int) -> pointer {
if t < 0 or t >= ntok(D) or depth > 8 { return "" }
if expr_is_boolean(D, t) { return "bool" }
let k = D.tk_kind[t]
if k == LT_INT { return "int" }
if k == LT_FLOAT { return "fixed" }
if k == LT_STR { return "string" }
if k == LT_CHAR { return "int" }
if k == LT_BOOL { return "bool" }
if tis(D, t, "not") { return "bool" }
if tis(D, t, "(") { return infer_type(D, t + 1, depth + 1) }
if tis(D, t, "-") { return infer_type(D, next_sig(D, t), depth + 1) }
if tis(D, t, "new") {
let n = next_sig(D, t)
if n < 0 { return "" }
if tis(D, n, "[") {
let close = D.tmatch[n]
if close >= 0 { let el = next_sig(D, close); if el >= 0 { return "[]" + ttext(D, el) } }
return ""
}
return ttext(D, n)
}
if tis(D, t, "[") {
let first = next_sig(D, t)
if first >= 0 and not tis(D, first, "]") { let et = infer_type(D, first, depth + 1); if not (et == "") { return "[]" + et } }
return ""
}
if k != LT_ID and k != LT_TYPE { return "" }
let name = ttext(D, t)
let nx = next_sig(D, t)
# a call: its return type
if nx >= 0 and tis(D, nx, "(") {
if k == LT_TYPE { return name } # int(x), string(n)
let top = find_top(D, name)
if top >= 0 {
let ow = g_owner
if ow.syms[top].kind == LS_FN or ow.syms[top].kind == LS_EXTERN {
let r = ow.syms[top].ty
if (r == "") { return "void" }
return r
}
}
let r2 = sig_return(docs_sig(name))
if not (r2 == "") { return r2 }
return sig_return(builtin_sig(name))
}
# Name.member: a namespace call, or a field of a typed receiver
if nx >= 0 and tis(D, nx, ".") {
let m = next_sig(D, nx)
if m < 0 { return "" }
let mn = next_sig(D, m)
if mn >= 0 and tis(D, mn, "(") {
let r = sig_return(docs_sig(name + "." + ttext(D, m)))
if not (r == "") { return r }
return sig_return(ns_method_sig(name, ttext(D, m)))
}
let comp = receiver_component(D, nx)
if comp >= 0 and (g_owner != null) {
let co = g_owner
var f = 0
while f < len(co.syms) {
if co.syms[f].kind == LS_FIELD and co.syms[f].parent == comp and (co.syms[f].name == ttext(D, m)) { return co.syms[f].ty }
f += 1
}
}
return ""
}
# a record literal: `Pos { x: 1 }`
if nx >= 0 and tis(D, nx, "{") and name[0] >= 'A' and name[0] <= 'Z' { return name }
if k == LT_TYPE { return name }
# another name
let l = find_local(D, name, D.tk_start[t])
if l >= 0 {
if D.syms[l].init == t { return "" }
return sym_type(D, l, depth + 1)
}
let top = find_top(D, name)
if top >= 0 {
let ow = g_owner
let yk = ow.syms[top].kind
if yk == LS_VAR or yk == LS_CONST { return sym_type(ow, top, depth + 1) }
if yk == LS_FN or yk == LS_EXTERN { return ow.syms[top].detail }
}
return ""
}
# the hover code line for a symbol: `var hp: int`, `param sk: Skin`, …
function sym_hover_code(O: Doc, si: int) -> pointer {
let y = O.syms[si]
let k = y.kind
if k == LS_QUERYVAR and not (y.detail == "") { return y.detail }
if k == LS_LOCAL or k == LS_PARAM or k == LS_VAR or k == LS_CONST or k == LS_QUERYVAR {
let ty = sym_type(O, si, 0)
var head = "let "
if k == LS_LOCAL and y.mutable == 1 { head = "var " }
if k == LS_PARAM { head = "param " }
if k == LS_VAR { head = "var " }
if k == LS_CONST { head = "const " }
if k == LS_QUERYVAR { head = "query " }
if (ty == "") { return head + y.name }
return head + y.name + ": " + ty
}
if k == LS_COMPONENT or k == LS_ARCHETYPE { return record_hover_code(O, si) }
if (y.detail == "") { return y.name }
return y.detail
}
# `property Actor {` with one field per line, the way it is written
function record_hover_code(O: Doc, si: int) -> pointer {
let y = O.syms[si]
let b = buf_new()
if y.kind == LS_COMPONENT { buf_puts(b, "property ") } else { buf_puts(b, "model ") }
buf_puts(b, y.name)
buf_puts(b, " {")
var n = 0
var f = 0
while f < len(O.syms) {
let fy = O.syms[f]
if fy.kind == LS_FIELD and fy.parent == si {
buf_puts(b, "\n ")
buf_puts(b, fy.name)
if not (fy.ty == "") { buf_puts(b, ": "); buf_puts(b, fy.ty) }
n += 1
}
f += 1
}
if n == 0 { buf_puts(b, " }") } else { buf_puts(b, "\n}") }
return buf_str(b)
}
# the declaration of a symbol's type: a model/property in the workspace, else
# the reference page of a built-in type. Location JSON or null.
function type_definition_of(O: Doc, si: int, from: Doc) -> pointer {
var ty = sym_type(O, si, 0)
if (ty == "") { return null }
while starts_with(ty, "[]") { ty = ty[2..cstr_len(ty)] }
let top = find_top(from, ty)
if top >= 0 {
let ow = g_owner
let o = buf_new()
put_location(o, ow, ow.syms[top].start, ow.syms[top].end)
return buf_str(o)
}
let i = docs_index(ty)
if i < 0 { return null }
let o = buf_new()
buf_puts(o, "{\"uri\":")
jstr_out(o, path_to_uri(g_dx_file[i]))
buf_puts(o, ",\"range\":{\"start\":{\"line\":0,\"character\":0},\"end\":{\"line\":0,\"character\":0}}}")
return buf_str(o)
}
function on_type_definition(msg: JVal, id: JVal) -> void {
let D = doc_of(msg)
if (D == null) { send_result(id, "null"); return }
let off = off_of(msg, D, "params.position")
resolve_at(D, off)
if (g_owner == null) or g_symidx < 0 { on_definition(msg, id); return }
let r = type_definition_of(g_owner, g_symidx, D)
if (r == null) { on_definition(msg, id); return }
send_result(id, r)
}
# ---- contextual keywords ------------------------------------------------------
# The compiler reserves its declaration words only as function names
# (is_reserved_word in selfhost/frontend/parse.ludic): `var model = …`,
# `model: Model` and `a.model` are all ordinary names. A word is a keyword only
# where it could start or continue a declaration.
function is_contextual_word(w: pointer) -> bool {
if (w == "program") or (w == "import") or (w == "property") or (w == "model") or (w == "enum") or (w == "ui") { return true }
if (w == "function") or (w == "extern") or (w == "handler") or (w == "entry") or (w == "event") or (w == "scene") { return true }
if (w == "phase") or (w == "query") or (w == "on") or (w == "cancellable") or (w == "public") or (w == "layer") or (w == "start") { return true }
if (w == "state") or (w == "machine") or (w == "prefab") or (w == "namespace") or (w == "test") { return true }
return false
}
function demote_contextual_keywords(D: Doc) -> void {
let N = ntok(D)
var i = 0
while i < N {
if D.tk_kind[i] == LT_KW and is_contextual_word(ttext(D, i)) and used_as_name(D, i) { D.tk_kind[i] = LT_ID }
i += 1
}
}
function used_as_name(D: Doc, i: int) -> bool {
let p = prev_sig(D, i)
if p >= 0 {
if tis(D, p, ".") or tis(D, p, "var") or tis(D, p, "let") or tis(D, p, "const") or tis(D, p, "return") { return true }
if D.tk_kind[p] == LT_OP and not (tis(D, p, "{") or tis(D, p, "}") or tis(D, p, ";") or tis(D, p, ")") or tis(D, p, "]")) {
# `import` after `(` never happens; `query` after `in` is not an op
return true
}
}
let n = next_sig(D, i)
if n >= 0 and D.tk_kind[n] == LT_OP {
if tis(D, n, ":") or tis(D, n, ".") or tis(D, n, "=") or tis(D, n, ")") or tis(D, n, ",") or tis(D, n, "]") { return true }
if tis(D, n, "+=") or tis(D, n, "-=") or tis(D, n, "==") or tis(D, n, "!=") { return true }
}
return false
}
# is this a documented standard-library namespace (Screen, Color, Math, …)?
function is_namespace_name(w: pointer) -> bool {
if cstr_len(w) == 0 or w[0] < 'A' or w[0] > 'Z' { return false }
docs_load()
if docs_ns_index(w) >= 0 { return true }
return ns_method_sig(w, "") != null
}
# SEM_VENDOR when a symbol lives in a package (the toolchain's or ludic_modules/)
function vendor_mod(O: Doc) -> int {
if (O == null) { return 0 }
if Text.contains(O.path, "/ludic_modules/") { return SEM_VENDOR }
let home = lsp_home()
if not (home == "") and starts_with(O.path, home + "packages/") { return SEM_VENDOR }
if not (home == "") and starts_with(O.path, home + "runtime/") { return SEM_DEFAULT_LIBRARY }
return 0
}