ludic/tools/ludic-tools/lsp/imports.ludic
Orkuncakilkaya f92d7f89c6 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>
2026-09-16 16:13:31 +03:00

215 lines
6.4 KiB
Text

# 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)
}