ludic/tools/ludic-tools/ludic_lsp.c
Orkuncakilkaya 69fe39bff1 Phase 6a (cont.): reconcile editor toolchain with the rename
Updated the LSP indexer (ludic_index.h), completion snippets (ludic_lsp.c),
check-docs wrapper, and the fmt/LSP test fixtures (test-tools.sh, test-lsp.py)
to the new keywords. Fixed two pre-existing bugs surfaced along the way:
build-tools.sh had a set -e bug (a failing find|while pipeline in a command
substitution) that silently prevented ALL rebuilds since the first build, so the
editor binaries were stale; and an LSP test offset was calibrated to the old
keyword length. test.sh 14/14, test-tools 28/0, check-docs + check-vocabulary
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 18:34:57 +03:00

1265 lines
55 KiB
C

/* ============================================================================
* ludic-lsp — the Ludic language server.
*
* Speaks LSP 3.17 over stdio, so it is the IDE-agnostic half of this toolchain:
* VS Code, JetBrains, Neovim, Helix, Emacs, Sublime and Zed all drive the same
* binary, and an editor nobody has written a plugin for yet still gets
* completion, diagnostics, hover and formatting by pointing at it.
*
* It answers structural questions itself (from ludic_index.h, which keeps
* parsing after an error the way a compiler must not), and defers questions of
* correctness to `ludicc` — the compiler stays the single source of truth about
* whether a program is valid.
*
* Markdown is a first-class input: a ```ludic fence in a .md gets the same
* highlighting, hover and diagnostics as a .ludic file.
* ==========================================================================*/
#define _GNU_SOURCE
#include "ludic_workspace.h"
#include "ludic_fmt.h"
#include "ludic_json.h"
#include <signal.h>
/* ---------- server state --------------------------------------------------*/
static LIndex X;
static char* g_ludicc = 0; /* path to the compiler, if we found one */
static int g_indent = 2;
static int g_compiler_diags = 1;
static int g_shutdown = 0;
static int g_utf8_positions = 0; /* the client accepted utf-8 offsets */
static FILE* g_log = 0;
static void lg(const char* fmt, ...){
if (!g_log) return;
va_list ap; va_start(ap, fmt); vfprintf(g_log, fmt, ap); va_end(ap);
fputc('\n', g_log); fflush(g_log);
}
/* ---------- framing -------------------------------------------------------*/
static void send_raw(const char* body){
printf("Content-Length: %zu\r\n\r\n%s", strlen(body), body);
fflush(stdout);
}
static void send_result(JVal* id, const char* result_json){
JSB s = {0};
jsb_puts(&s, "{\"jsonrpc\":\"2.0\",\"id\":");
if (!id) jsb_puts(&s, "null");
else if (id->t == JSTR) jsb_str(&s, id->s);
else jsb_putf(&s, "%ld", (long)id->num);
jsb_puts(&s, ",\"result\":");
jsb_puts(&s, result_json && *result_json ? result_json : "null");
jsb_puts(&s, "}");
send_raw(s.b); free(s.b);
}
static void send_notify(const char* method, const char* params_json){
JSB s = {0};
jsb_puts(&s, "{\"jsonrpc\":\"2.0\",\"method\":");
jsb_str(&s, method);
jsb_puts(&s, ",\"params\":");
jsb_puts(&s, params_json ? params_json : "null");
jsb_puts(&s, "}");
send_raw(s.b); free(s.b);
}
/* Read one Content-Length framed message. NULL at EOF. */
static char* read_message(void){
char line[8192];
long len = -1;
for (;;){
if (!fgets(line, sizeof(line), stdin)) return 0;
if (line[0] == '\r' || line[0] == '\n') break;
if (!strncasecmp(line, "Content-Length:", 15)) len = strtol(line + 15, 0, 10);
}
if (len <= 0) return 0;
char* buf = malloc(len + 1);
long got = 0;
while (got < len){
size_t r = fread(buf + got, 1, len - got, stdin);
if (!r) break;
got += r;
}
buf[got] = 0;
return buf;
}
/* ---------- position helpers ---------------------------------------------*/
static int pos_char(LDoc* D, int off){
if (g_utf8_positions){
int line = ldoc_line_of(D, off);
return off - D->lex.linestart[line];
}
return ldoc_col_of(D, off);
}
static void put_pos(JSB* s, LDoc* D, int off){
jsb_putf(s, "{\"line\":%d,\"character\":%d}", ldoc_line_of(D, off), pos_char(D, off));
}
static void put_range(JSB* s, LDoc* D, int a, int b){
jsb_puts(s, "{\"start\":"); put_pos(s, D, a);
jsb_puts(s, ",\"end\":"); put_pos(s, D, b);
jsb_puts(s, "}");
}
static void put_location(JSB* s, LDoc* D, int a, int b){
jsb_puts(s, "{\"uri\":"); jsb_str(s, D->uri);
jsb_puts(s, ",\"range\":"); put_range(s, D, a, b);
jsb_puts(s, "}");
}
/* ---------- diagnostics ---------------------------------------------------*/
typedef struct { LDoc* doc; LDiag* v; int n, cap; } DiagSet;
static void diag_add(DiagSet* ds, LDoc* D, int line, int col, int endline, int endcol, int sev, const char* msg){
if (ds->n >= ds->cap){ ds->cap = ds->cap ? ds->cap * 2 : 16; ds->v = realloc(ds->v, ds->cap * sizeof(LDiag)); }
LDiag* d = &ds->v[ds->n++];
memset(d, 0, sizeof(*d));
d->line = line; d->col = col; d->endline = endline; d->endcol = endcol; d->severity = sev;
snprintf(d->msg, sizeof(d->msg), "%s", msg);
snprintf(d->path, sizeof(d->path), "%s", D->path);
}
/* Structural problems we can see without the compiler, cheaply, on every
* keystroke: unbalanced delimiters and unterminated literals. */
static void diag_structural(LDoc* D, DiagSet* ds){
int stack[512], top = 0;
for (int i = 0; i < D->lex.n; i++){
const LTok* t = &D->lex.v[i];
if (t->bad){
const char* m = t->kind == LT_STR ? "unterminated string literal"
: t->kind == LT_CHAR ? "unterminated character literal"
: "unexpected character";
diag_add(ds, D, ldoc_line_of(D, t->start), pos_char(D, t->start),
ldoc_line_of(D, t->end), pos_char(D, t->end), 1, m);
continue;
}
if (t->kind != LT_OP || ltok_len(t) != 1) continue;
char c = D->text[t->start];
if (c == '{' || c == '(' || c == '['){ if (top < 512) stack[top++] = i; }
else if (c == '}' || c == ')' || c == ']'){
if (!top){
char m[64]; snprintf(m, sizeof(m), "unmatched '%c'", c);
diag_add(ds, D, ldoc_line_of(D, t->start), pos_char(D, t->start),
ldoc_line_of(D, t->end), pos_char(D, t->end), 1, m);
} else {
int o = stack[--top];
char oc = D->text[D->lex.v[o].start];
char want = oc == '{' ? '}' : oc == '(' ? ')' : ']';
if (want != c){
char m[80]; snprintf(m, sizeof(m), "expected '%c' to close '%c', found '%c'", want, oc, c);
diag_add(ds, D, ldoc_line_of(D, t->start), pos_char(D, t->start),
ldoc_line_of(D, t->end), pos_char(D, t->end), 1, m);
}
}
}
}
for (int k = 0; k < top; k++){
const LTok* t = &D->lex.v[stack[k]];
char m[64]; snprintf(m, sizeof(m), "unclosed '%c'", D->text[t->start]);
diag_add(ds, D, ldoc_line_of(D, t->start), pos_char(D, t->start),
ldoc_line_of(D, t->end), pos_char(D, t->end), 1, m);
}
}
/* Ask the compiler. `ludicc --fmt` runs lex + parse + validate and stops before
* codegen, which is exactly the checking pass we want and costs milliseconds.
* A fragment (an imported file with no `game` block) is not a program on its
* own, so we check the unit root that pulls it in and let the file paths in the
* compiler's own output route each error back to the right buffer. */
static void diag_compiler(LDoc* D, DiagSet* ds){
if (!g_ludicc || !g_compiler_diags) return;
/* A .md is Ludic only inside its fences; the file as a whole is prose, and
* handing it to the compiler yields errors about the prose. The fences are
* still checked structurally, which is as far as a fragment can be checked
* anyway — most of them are deliberately incomplete snippets. */
if (D->is_markdown) return;
LDoc* root = lw_unit_root(&X, D);
if (!root) root = D->is_unit ? D : 0;
if (!root) return;
/* Paths reach us from URIs the editor sends, and they end up in a shell
* command. A quote in a path would let a filename run something; refuse
* rather than escape, since no real Ludic file needs one. */
if (strchr(root->path, '\'') || strchr(g_ludicc, '\'')) return;
/* the compiler reads from disk; an unsaved buffer would be checked stale */
char cmd[PATH_MAX * 2 + 64];
snprintf(cmd, sizeof(cmd), "'%s' '%s' --fmt -o /dev/null 2>&1", g_ludicc, root->path);
FILE* p = popen(cmd, "r");
if (!p) return;
char line[4096];
char msg[512]; msg[0] = 0;
int have = 0, lineno = 0;
while (fgets(line, sizeof(line), p)){
size_t n = strlen(line);
while (n && (line[n-1] == '\n' || line[n-1] == '\r')) line[--n] = 0;
int ln;
char rest[1024];
if (sscanf(line, "ludicc: error: line %d: %1023[^\n]", &ln, rest) == 2){
if (have){ diag_add(ds, root, lineno - 1, 0, lineno - 1, 200, 1, msg); }
lineno = ln; snprintf(msg, sizeof(msg), "%s", rest); have = 1;
continue;
}
/* the follow-up line names the file the error is really in */
char path[1024];
if (have && sscanf(line, " %1023[^:]:%d |", path, &ln) == 2){
LDoc* target = lw_ensure(&X, path);
if (!target) target = root;
int l0 = ln - 1;
int end = 200;
if (l0 >= 0 && l0 < target->lex.nline){
int s0 = target->lex.linestart[l0];
int e0 = (l0 + 1 < target->lex.nline) ? target->lex.linestart[l0+1] - 1 : (int)strlen(target->text);
end = pos_char(target, e0 > s0 ? e0 : s0);
}
diag_add(ds, target, l0, 0, l0, end, 1, msg);
have = 0; msg[0] = 0;
continue;
}
if (!have && !strncmp(line, "ludicc: ", 8) && !strstr(line, "error: line")){
diag_add(ds, root, 0, 0, 0, 0, 1, line + 8);
}
}
if (have) diag_add(ds, root, lineno - 1, 0, lineno - 1, 200, 1, msg);
pclose(p);
}
/* Publish for every document that got a diagnostic, and clear the rest.
*
* Markdown gets none. A fence in prose is a snippet — `system Move { … }` with
* an elision mark, or a body shown without its enclosing `game` block — so
* almost every "error" found there is the documentation doing its job. The
* useful half of the server still applies to fences: highlighting, hover,
* completion, go-to-definition and formatting are never wrong on a snippet. */
static void publish_diagnostics(LDoc* D){
DiagSet ds = {0};
if (!D->is_markdown) diag_structural(D, &ds);
/* only bother the compiler when the buffer itself parses — otherwise we are
* reporting the same brace twice, in two different dialects */
int structural = ds.n;
if (!structural) diag_compiler(D, &ds);
/* Every document that has a diagnostic now, plus every document that had
* one last time — otherwise fixing an error in an imported file leaves its
* marker on screen forever, because nothing would ever publish an empty
* list for that file again. */
LDoc* touched[128]; int nt = 0;
touched[nt++] = D;
for (int i = 0; i < ds.n; i++){
LDoc* T = lw_by_path(&X, ds.v[i].path);
if (!T) continue;
int seen = 0; for (int k = 0; k < nt; k++) if (touched[k] == T) seen = 1;
if (!seen && nt < 128) touched[nt++] = T;
}
for (int i = 0; i < X.n && nt < 128; i++){
if (!X.d[i]->had_diags) continue;
int seen = 0; for (int k = 0; k < nt; k++) if (touched[k] == X.d[i]) seen = 1;
if (!seen) touched[nt++] = X.d[i];
}
for (int k = 0; k < nt; k++){
LDoc* T = touched[k];
T->had_diags = 0;
JSB s = {0};
jsb_puts(&s, "{\"uri\":"); jsb_str(&s, T->uri);
jsb_puts(&s, ",\"diagnostics\":[");
int first = 1;
for (int i = 0; i < ds.n; i++){
if (strcmp(ds.v[i].path, T->path)) continue;
if (!first) jsb_putc(&s, ',');
first = 0;
T->had_diags = 1;
jsb_putf(&s, "{\"range\":{\"start\":{\"line\":%d,\"character\":%d},\"end\":{\"line\":%d,\"character\":%d}}",
ds.v[i].line < 0 ? 0 : ds.v[i].line, ds.v[i].col,
ds.v[i].endline < 0 ? 0 : ds.v[i].endline, ds.v[i].endcol);
jsb_putf(&s, ",\"severity\":%d,\"source\":\"ludic\",\"message\":", ds.v[i].severity);
jsb_str(&s, ds.v[i].msg);
jsb_putc(&s, '}');
}
jsb_puts(&s, "]}");
send_notify("textDocument/publishDiagnostics", s.b);
free(s.b);
}
free(ds.v);
}
/* ---------- semantic tokens ----------------------------------------------*/
/* The legend, in the order the client will index into. */
static const char* STOK_TYPES[] = {
"keyword","type","struct","class","namespace","function","parameter",
"variable","property","enumMember","number","string","comment","operator",
"decorator", 0
};
enum { STM_DECL = 1, STM_READONLY = 2, STM_DEFLIB = 4 };
static const char* STOK_MODS[] = { "declaration","readonly","defaultLibrary", 0 };
static int sem_type_of(int cls, int* mods){
*mods = 0;
switch (cls){
case SC_KEYWORD: return 0;
case SC_TYPE: *mods = STM_DEFLIB; return 1;
case SC_COMPONENT: return 2;
case SC_ARCHETYPE: return 3;
case SC_SCENE: return 4;
case SC_LAYER: return 4;
case SC_UI: return 3;
case SC_WIDGET: *mods = STM_DEFLIB; return 3;
case SC_SYSTEM: return 5;
case SC_FUNCTION: return 5;
case SC_BUILTIN: *mods = STM_DEFLIB; return 5;
case SC_PARAM: return 6;
case SC_VARIABLE: return 7;
case SC_MODVAR: return 7;
case SC_CONST: *mods = STM_READONLY; return 7;
case SC_PROP: return 8;
case SC_FIELD: return 8;
case SC_PHASE: *mods = STM_READONLY | STM_DEFLIB; return 9;
case SC_NUMBER: return 10;
case SC_STRING: return 11;
case SC_COMMENT: return 12;
case SC_OPERATOR: return 13;
case SC_ANNOTATION: return 14;
default: return -1;
}
}
static char* semantic_tokens(LDoc* D, int from_line, int to_line){
lw_classify(&X, D);
JSB s = {0};
jsb_puts(&s, "{\"data\":[");
int prev_line = 0, prev_char = 0, first = 1;
for (int i = 0; i < D->lex.n; i++){
const LTok* t = &D->lex.v[i];
if (t->kind == LT_NL || t->kind == LT_EOF) continue;
if (t->line < from_line || (to_line >= 0 && t->line > to_line)) continue;
int mods; int ty = sem_type_of(D->cls[i], &mods);
if (ty < 0) continue;
int line = t->line;
int ch = pos_char(D, t->start);
int len = g_utf8_positions ? ltok_len(t) : lutf16_len(D->text + t->start, ltok_len(t));
if (len <= 0) continue;
int dl = line - prev_line;
int dc = dl ? ch : ch - prev_char;
if (!first) jsb_putc(&s, ',');
first = 0;
jsb_putf(&s, "%d,%d,%d,%d,%d", dl, dc, len, ty, mods);
prev_line = line; prev_char = ch;
}
jsb_puts(&s, "]}");
return s.b;
}
/* ---------- symbol presentation ------------------------------------------*/
static int lsp_symbol_kind(int k){
switch (k){ /* LSP SymbolKind numbers */
case LS_UNIT: return 2; /* Module */
case LS_COMPONENT: return 23; /* Struct */
case LS_FIELD: return 8; /* Field */
case LS_ARCHETYPE: return 5; /* Class */
case LS_CONST: return 14; /* Constant */
case LS_VAR: return 13; /* Variable */
case LS_FN: return 12; /* Function */
case LS_EXTERN: return 12;
case LS_SYSTEM: return 6; /* Method */
case LS_UI: return 5;
case LS_WIDGET: return 14;
case LS_SCENE: return 3; /* Namespace */
case LS_LAYER: return 3;
case LS_PARAM: return 13;
case LS_LOCAL: return 13;
case LS_QUERYVAR: return 13;
case LS_STATE: return 22; /* EnumMember */
case LS_IMPORT: return 2;
default: return 13;
}
}
static int sym_is_outline(int k){
switch (k){
case LS_UNIT: case LS_COMPONENT: case LS_FIELD: case LS_ARCHETYPE:
case LS_CONST: case LS_VAR: case LS_FN: case LS_EXTERN: case LS_SYSTEM:
case LS_UI: case LS_WIDGET: case LS_SCENE: case LS_LAYER:
return 1;
default: return 0;
}
}
/* DocumentSymbol is a tree; emit children of `parent` recursively. */
static void put_doc_symbols(JSB* s, LDoc* D, int parent){
int first = 1;
jsb_putc(s, '[');
for (int i = 0; i < D->nsym; i++){
LSym* y = &D->sym[i];
if (y->parent != parent || !sym_is_outline(y->kind) || !y->name[0]) continue;
if (!first) jsb_putc(s, ',');
first = 0;
jsb_puts(s, "{\"name\":"); jsb_str(s, y->name);
jsb_puts(s, ",\"detail\":"); jsb_str(s, y->detail);
jsb_putf(s, ",\"kind\":%d,\"range\":", lsp_symbol_kind(y->kind));
int a = y->start, b = y->end;
if (y->body_end > y->end && y->body_start >= y->start){ a = y->start; b = y->body_end; }
put_range(s, D, a, b);
jsb_puts(s, ",\"selectionRange\":"); put_range(s, D, y->start, y->end);
jsb_puts(s, ",\"children\":");
put_doc_symbols(s, D, i);
jsb_putc(s, '}');
}
jsb_putc(s, ']');
}
/* ---------- hover ---------------------------------------------------------*/
static void hover_markdown(JSB* s, const char* code, const char* doc){
JSB m = {0};
jsb_puts(&m, "```ludic\n");
jsb_puts(&m, code);
jsb_puts(&m, "\n```");
if (doc && *doc){ jsb_puts(&m, "\n\n"); jsb_puts(&m, doc); }
jsb_puts(s, "{\"contents\":{\"kind\":\"markdown\",\"value\":");
jsb_str(s, m.b);
jsb_puts(s, "}}");
free(m.b);
}
/* ---------- completion ----------------------------------------------------*/
enum { CI_TEXT = 1, CI_METHOD = 2, CI_FUNCTION = 3, CI_FIELD = 5, CI_VARIABLE = 6,
CI_CLASS = 7, CI_PROPERTY = 10, CI_UNIT = 11, CI_VALUE = 12, CI_ENUM = 13,
CI_KEYWORD = 14, CI_SNIPPET = 15, CI_CONSTANT = 21, CI_STRUCT = 22, CI_EVENT = 23 };
typedef struct { JSB* s; int first; } CompOut;
static void comp_item(CompOut* c, const char* label, int kind, const char* detail, const char* doc, const char* insert){
if (!c->first) jsb_putc(c->s, ',');
c->first = 0;
jsb_puts(c->s, "{\"label\":"); jsb_str(c->s, label);
jsb_putf(c->s, ",\"kind\":%d", kind);
if (detail && *detail){ jsb_puts(c->s, ",\"detail\":"); jsb_str(c->s, detail); }
if (doc && *doc){
jsb_puts(c->s, ",\"documentation\":{\"kind\":\"markdown\",\"value\":");
jsb_str(c->s, doc);
jsb_putc(c->s, '}');
}
if (insert && *insert){
jsb_puts(c->s, ",\"insertText\":"); jsb_str(c->s, insert);
jsb_puts(c->s, ",\"insertTextFormat\":2");
}
jsb_putc(c->s, '}');
}
static void comp_words(CompOut* c, const char** words, int kind, const char* detail){
for (int i = 0; words[i]; i++) comp_item(c, words[i], kind, detail, 0, 0);
}
static void comp_builtins(CompOut* c){
for (int i = 0; LUDIC_BUILTINS[i].name; i++)
comp_item(c, LUDIC_BUILTINS[i].name, CI_FUNCTION, LUDIC_BUILTINS[i].sig, LUDIC_BUILTINS[i].doc, 0);
for (int i = 0; LUDIC_INTRINSICS[i].name; i++)
comp_item(c, LUDIC_INTRINSICS[i].name, CI_FUNCTION, LUDIC_INTRINSICS[i].sig, LUDIC_INTRINSICS[i].doc, 0);
}
static int comp_kind_of(int symkind){
switch (symkind){
case LS_COMPONENT: return CI_STRUCT;
case LS_ARCHETYPE: return CI_CLASS;
case LS_CONST: return CI_CONSTANT;
case LS_VAR: return CI_VARIABLE;
case LS_FN: case LS_EXTERN: return CI_FUNCTION;
case LS_SYSTEM: return CI_EVENT;
case LS_UI: return CI_CLASS;
case LS_WIDGET: return CI_CONSTANT;
case LS_SCENE: return CI_UNIT;
case LS_LAYER: return CI_UNIT;
case LS_FIELD: return CI_FIELD;
default: return CI_VARIABLE;
}
}
/* Everything the compilation unit declares, plus the locals in scope here.
* `types_only` is for the positions where nothing but a component or archetype
* can appear — a query's term list, a `spawn`, a type annotation — and there a
* local variable is not a candidate at all. */
static void comp_scope(CompOut* c, LDoc* D, int off, int types_only){
if (!types_only)
for (int i = 0; i < D->nsym; i++){
LSym* y = &D->sym[i];
if (!lsym_is_local(y->kind)) continue;
if (off < y->body_start || off > y->body_end) continue;
comp_item(c, y->name, CI_VARIABLE, y->detail[0] ? y->detail : y->type, 0, 0);
}
LDoc* rel[128]; int n = lw_related(&X, D, rel, 128);
for (int i = 0; i < n; i++)
for (int k = 0; k < rel[i]->nsym; k++){
LSym* y = &rel[i]->sym[k];
if (!lsym_is_toplevel(y->kind) || y->kind == LS_UNIT) continue;
if (types_only && !(y->kind == LS_COMPONENT || y->kind == LS_ARCHETYPE)) continue;
comp_item(c, y->name, comp_kind_of(y->kind), y->detail, y->doc, 0);
}
}
/* ---------- URI/doc plumbing ---------------------------------------------*/
static LDoc* doc_of(JVal* msg){
const char* uri = jstr(jpath(msg, "params.textDocument.uri"), 0);
if (!uri) return 0;
LDoc* D = lw_by_uri(&X, uri);
if (D) return D;
char* path = lw_uri_to_path(uri);
D = lw_ensure(&X, path);
free(path);
return D;
}
static int off_of(JVal* msg, LDoc* D, const char* base){
char kl[128], kc[128];
snprintf(kl, sizeof(kl), "%s.line", base);
snprintf(kc, sizeof(kc), "%s.character", base);
int line = jint(jpath(msg, kl), 0), ch = jint(jpath(msg, kc), 0);
if (g_utf8_positions){
if (line < 0 || line >= D->lex.nline) return 0;
int s0 = D->lex.linestart[line];
int e0 = (line + 1 < D->lex.nline) ? D->lex.linestart[line + 1] : (int)strlen(D->text);
int off = s0 + ch;
return off > e0 ? e0 : off;
}
return ldoc_offset_of(D, line, ch);
}
/* ---------- request handlers ---------------------------------------------*/
static void on_initialize(JVal* msg, JVal* id){
JVal* root = jpath(msg, "params.rootUri");
if (root && root->t == JSTR) X.root = lw_uri_to_path(root->s);
else {
JVal* folders = jpath(msg, "params.workspaceFolders");
JVal* f0 = jat(folders, 0);
const char* u = jstr(jget(f0, "uri"), 0);
if (u) X.root = lw_uri_to_path(u);
}
/* the client may prefer plain byte offsets; take it if offered, it is both
* cheaper and exactly what our lexer already produces */
JVal* enc = jpath(msg, "params.capabilities.general.positionEncodings");
if (enc) for (int i = 0; i < enc->n; i++){
const char* e = jstr(jat(enc, i), "");
if (!strcmp(e, "utf-8")){ g_utf8_positions = 1; break; }
}
JVal* init = jpath(msg, "params.initializationOptions");
const char* cc = jstr(jget(init, "compilerPath"), 0);
if (cc && *cc){ free(g_ludicc); g_ludicc = strdup(cc); }
if (jget(init, "indentSize")) g_indent = jint(jget(init, "indentSize"), 2);
if (jget(init, "compilerDiagnostics")) g_compiler_diags = jbool(jget(init, "compilerDiagnostics"), 1);
/* find the compiler if nobody told us where it is */
if (!g_ludicc && X.root){
char p[PATH_MAX];
snprintf(p, sizeof(p), "%s/build/ludicc", X.root);
if (!access(p, X_OK)) g_ludicc = strdup(p);
}
if (!g_ludicc && !access("build/ludicc", X_OK)) g_ludicc = strdup("build/ludicc");
if (X.root) lw_scan(&X, X.root, 0);
lg("initialize root=%s compiler=%s docs=%d", X.root ? X.root : "-", g_ludicc ? g_ludicc : "-", X.n);
JSB s = {0};
jsb_puts(&s, "{\"capabilities\":{");
jsb_puts(&s, "\"positionEncoding\":");
jsb_str(&s, g_utf8_positions ? "utf-8" : "utf-16");
jsb_puts(&s, ",\"textDocumentSync\":{\"openClose\":true,\"change\":1,\"save\":{\"includeText\":false}}");
jsb_puts(&s, ",\"documentSymbolProvider\":true");
jsb_puts(&s, ",\"workspaceSymbolProvider\":true");
jsb_puts(&s, ",\"definitionProvider\":true");
jsb_puts(&s, ",\"declarationProvider\":true");
jsb_puts(&s, ",\"referencesProvider\":true");
jsb_puts(&s, ",\"documentHighlightProvider\":true");
jsb_puts(&s, ",\"hoverProvider\":true");
jsb_puts(&s, ",\"renameProvider\":{\"prepareProvider\":true}");
jsb_puts(&s, ",\"documentFormattingProvider\":true");
jsb_puts(&s, ",\"documentRangeFormattingProvider\":true");
jsb_puts(&s, ",\"foldingRangeProvider\":true");
jsb_puts(&s, ",\"documentLinkProvider\":{\"resolveProvider\":false}");
jsb_puts(&s, ",\"inlayHintProvider\":true");
jsb_puts(&s, ",\"completionProvider\":{\"triggerCharacters\":[\".\",\"@\",\"[\",\" \"],\"resolveProvider\":false}");
jsb_puts(&s, ",\"signatureHelpProvider\":{\"triggerCharacters\":[\"(\",\",\"]}");
jsb_puts(&s, ",\"semanticTokensProvider\":{\"legend\":{\"tokenTypes\":[");
for (int i = 0; STOK_TYPES[i]; i++){ if (i) jsb_putc(&s, ','); jsb_str(&s, STOK_TYPES[i]); }
jsb_puts(&s, "],\"tokenModifiers\":[");
for (int i = 0; STOK_MODS[i]; i++){ if (i) jsb_putc(&s, ','); jsb_str(&s, STOK_MODS[i]); }
jsb_puts(&s, "]},\"full\":true,\"range\":true}");
jsb_puts(&s, "},\"serverInfo\":{\"name\":\"ludic-lsp\",\"version\":\"1.0.0\"}}");
send_result(id, s.b);
free(s.b);
}
static void on_document_symbol(JVal* msg, JVal* id){
LDoc* D = doc_of(msg);
if (!D){ send_result(id, "[]"); return; }
JSB s = {0};
put_doc_symbols(&s, D, -1);
send_result(id, s.b);
free(s.b);
}
static void on_workspace_symbol(JVal* msg, JVal* id){
const char* q = jstr(jpath(msg, "params.query"), "");
JSB s = {0}; jsb_putc(&s, '[');
int first = 1;
for (int i = 0; i < X.n; i++){
LDoc* D = X.d[i];
for (int k = 0; k < D->nsym; k++){
LSym* y = &D->sym[k];
if (!sym_is_outline(y->kind) || !y->name[0]) continue;
if (*q && !strcasestr(y->name, q)) continue;
if (!first) jsb_putc(&s, ',');
first = 0;
jsb_puts(&s, "{\"name\":"); jsb_str(&s, y->name);
jsb_putf(&s, ",\"kind\":%d,\"location\":", lsp_symbol_kind(y->kind));
put_location(&s, D, y->start, y->end);
if (D->unit[0]){ jsb_puts(&s, ",\"containerName\":"); jsb_str(&s, D->unit); }
jsb_putc(&s, '}');
}
}
jsb_putc(&s, ']');
send_result(id, s.b);
free(s.b);
}
/* What does this exact token refer to? Every feature that needs to know two
* occurrences are the same thing — go-to-definition, find-usages, rename,
* highlight — goes through here, so they cannot disagree with each other. */
static int resolve_tok(LDoc* D, int tok, LDoc** owner, int* symidx){
*owner = 0; *symidx = -1;
if (tok < 0) return -1;
if (D->lex.v[tok].kind != LT_ID && D->lex.v[tok].kind != LT_STR) return -1;
char name[96]; ltok_text(&D->lex, tok, name, sizeof(name));
/* The token may BE a declaration's name — a component, a field, a
* parameter, a query binding. Those resolve to themselves, which is what
* makes hover work on the left-hand side of `for (a, b) in query [...]`
* where the binding is not yet inside its own scope. */
for (int i = 0; i < D->nsym; i++)
if (D->sym[i].tok == tok){ *owner = D; *symidx = i; return tok; }
/* a member: resolve through the receiver's component */
int prev = ltok_prev_sig(&D->lex, tok);
if (prev >= 0 && ltok_is(&D->lex, prev, ".")){
LDoc* co = 0;
int comp = lw_receiver_component(&X, D, prev, &co);
if (comp >= 0 && co){
for (int i = 0; i < co->nsym; i++)
if (co->sym[i].kind == LS_FIELD && co->sym[i].parent == comp && !strcmp(co->sym[i].name, name)){
*owner = co; *symidx = i; return tok;
}
}
return tok;
}
int l = lw_find_local(D, name, D->lex.v[tok].start);
if (l >= 0){ *owner = D; *symidx = l; return tok; }
LDoc* o = 0; int t = lw_find_top(&X, D, name, &o);
if (t >= 0){ *owner = o; *symidx = t; return tok; }
return tok;
}
static int resolve_at(LDoc* D, int off, LDoc** owner, int* symidx){
return resolve_tok(D, ldoc_tok_at(D, off), owner, symidx);
}
/* Occurrences of one symbol, found by resolving each candidate token rather
* than by matching text. It is the difference between renaming a component
* field `x` and renaming every `x` in the project. */
typedef void (*OccFn)(void* ctx, LDoc* T, int tok);
static void for_each_occurrence(LDoc* from, LDoc* target_doc, int target_sym, OccFn fn, void* ctx){
char want[96];
lcpy(want, sizeof(want), target_doc->text + target_doc->sym[target_sym].start,
target_doc->sym[target_sym].end - target_doc->sym[target_sym].start);
/* A local binding cannot be referred to from another file, so there is no
* reason to walk the unit for one. */
LDoc* single[1] = { from };
LDoc* rel[128];
int n;
if (lsym_is_local(target_doc->sym[target_sym].kind)){ rel[0] = single[0]; n = 1; }
else n = lw_related(&X, from, rel, 128);
for (int i = 0; i < n; i++){
LDoc* T = rel[i];
for (int k = 0; k < T->lex.n; k++){
if (T->lex.v[k].kind != LT_ID) continue;
char w[96]; ltok_text(&T->lex, k, w, sizeof(w));
if (strcmp(w, want)) continue; /* cheap filter first */
LDoc* o = 0; int sy = -1;
resolve_tok(T, k, &o, &sy);
if (o == target_doc && sy == target_sym) fn(ctx, T, k);
}
}
}
static void on_definition(JVal* msg, JVal* id){
LDoc* D = doc_of(msg);
if (!D){ send_result(id, "null"); return; }
int off = off_of(msg, D, "params.position");
/* an import string jumps to the file */
int tok = ldoc_tok_at(D, off);
if (tok >= 0 && D->lex.v[tok].kind == LT_STR){
int p = ltok_prev_sig(&D->lex, tok);
if (p >= 0 && ltok_is(&D->lex, p, "import")){
char rel[512]; ltok_text(&D->lex, tok, rel, sizeof(rel));
int n = (int)strlen(rel); if (n >= 2){ memmove(rel, rel + 1, n - 2); rel[n-2] = 0; }
char* dir = lw_dirname(D->path);
char* full = lw_join(dir, rel); free(dir);
LDoc* T = lw_ensure(&X, full); free(full);
if (T){ JSB s = {0}; put_location(&s, T, 0, 0); send_result(id, s.b); free(s.b); return; }
}
}
LDoc* owner = 0; int sym = -1;
resolve_at(D, off, &owner, &sym);
if (!owner || sym < 0){ send_result(id, "null"); return; }
JSB s = {0};
put_location(&s, owner, owner->sym[sym].start, owner->sym[sym].end);
send_result(id, s.b);
free(s.b);
}
static void on_hover(JVal* msg, JVal* id){
LDoc* D = doc_of(msg);
if (!D){ send_result(id, "null"); return; }
int off = off_of(msg, D, "params.position");
int tok = ldoc_tok_at(D, off);
if (tok < 0){ send_result(id, "null"); return; }
char name[128]; ltok_text(&D->lex, tok, name, sizeof(name));
LDoc* owner = 0; int sym = -1;
resolve_at(D, off, &owner, &sym);
JSB s = {0};
if (owner && sym >= 0){
LSym* y = &owner->sym[sym];
char code[320];
snprintf(code, sizeof(code), "%s", y->detail[0] ? y->detail : y->name);
char doc[768];
snprintf(doc, sizeof(doc), "%s", y->doc);
if (owner != D){
size_t n = strlen(doc);
snprintf(doc + n, sizeof(doc) - n, "%s_declared in %s_", n ? "\n\n" : "", strrchr(owner->path, '/') ? strrchr(owner->path, '/') + 1 : owner->path);
}
hover_markdown(&s, code, doc);
send_result(id, s.b); free(s.b); return;
}
const LBuiltin* bi = lud_lookup(LUDIC_BUILTINS, name);
const char* origin = "runtime builtin — provided by `rt_` in runtime/native";
if (!bi){ bi = lud_lookup(LUDIC_INTRINSICS, name); origin = "compiler intrinsic — lowers straight to libc / the OS"; }
if (bi){
char doc[768]; snprintf(doc, sizeof(doc), "%s\n\n_%s_", bi->doc, origin);
hover_markdown(&s, bi->sig, doc);
send_result(id, s.b); free(s.b); return;
}
if (D->lex.v[tok].kind == LT_PHASE){
hover_markdown(&s, name, "A system phase. Each frame runs `Input` -> `FixedUpdate` -> `Update` -> `LateUpdate` -> `Render`; `Start` runs once at boot.");
send_result(id, s.b); free(s.b); return;
}
if (D->lex.v[tok].kind == LT_TYPE){
static const struct { const char* n; const char* d; } TY[] = {
{"int","32-bit integer."},
{"fixed","Q16.16 fixed-point. `fx(i)` widens an int, `flr(f)` truncates back."},
{"bool","Boolean."},
{"entity","An entity handle. `self()` gives the one the current query is on."},
{"str","A string literal."},
{"ptr","A raw pointer — the runtime's floor, not for game code."},
{"void","No value."},
{0,0}
};
for (int i = 0; TY[i].n; i++) if (!strcmp(TY[i].n, name)){
hover_markdown(&s, name, TY[i].d);
send_result(id, s.b); free(s.b); return;
}
}
free(s.b);
send_result(id, "null");
}
/* ---------- references, highlight, rename --------------------------------*/
typedef struct { JSB* s; int first; int mode; const char* newname; LDoc* cur; } OccOut;
enum { OCC_LOCATION, OCC_HIGHLIGHT };
static void occ_emit(void* ctx, LDoc* T, int tok){
OccOut* o = (OccOut*)ctx;
if (o->mode == OCC_HIGHLIGHT && T != o->cur) return;
if (!o->first) jsb_putc(o->s, ',');
o->first = 0;
if (o->mode == OCC_HIGHLIGHT){
jsb_puts(o->s, "{\"range\":");
put_range(o->s, T, T->lex.v[tok].start, T->lex.v[tok].end);
jsb_puts(o->s, ",\"kind\":1}");
} else {
put_location(o->s, T, T->lex.v[tok].start, T->lex.v[tok].end);
}
}
static void refs_common(JVal* msg, JVal* id, int mode){
LDoc* D = doc_of(msg);
if (!D){ send_result(id, "[]"); return; }
int off = off_of(msg, D, "params.position");
LDoc* owner = 0; int sym = -1;
int tok = resolve_at(D, off, &owner, &sym);
if (tok < 0 || !owner || sym < 0){ send_result(id, "[]"); return; }
JSB s = {0}; jsb_putc(&s, '[');
OccOut o = { &s, 1, mode, 0, D };
for_each_occurrence(D, owner, sym, occ_emit, &o);
jsb_putc(&s, ']');
send_result(id, s.b); free(s.b);
}
static void on_references(JVal* msg, JVal* id){ refs_common(msg, id, OCC_LOCATION); }
static void on_document_highlight(JVal* msg, JVal* id){ refs_common(msg, id, OCC_HIGHLIGHT); }
static void on_prepare_rename(JVal* msg, JVal* id){
LDoc* D = doc_of(msg);
if (!D){ send_result(id, "null"); return; }
int off = off_of(msg, D, "params.position");
LDoc* owner = 0; int sym = -1;
int tok = resolve_at(D, off, &owner, &sym);
/* Refusing beats renaming the wrong thing: if we cannot say what a name
* refers to, we cannot say which occurrences of it mean the same name. */
if (tok < 0 || !owner || sym < 0 || D->lex.v[tok].kind != LT_ID){ send_result(id, "null"); return; }
JSB s = {0};
jsb_puts(&s, "{\"range\":");
put_range(&s, D, D->lex.v[tok].start, D->lex.v[tok].end);
jsb_puts(&s, ",\"placeholder\":");
{ char w[96]; ltok_text(&D->lex, tok, w, sizeof(w)); jsb_str(&s, w); }
jsb_putc(&s, '}');
send_result(id, s.b); free(s.b);
}
/* Rename collects per-file edit lists, because the LSP workspace edit is keyed
* by URI and a symbol can occur in several files of one unit. */
typedef struct { LDoc* doc; JSB edits; int first; } FileEdits;
typedef struct { FileEdits f[64]; int n; const char* newname; } RenameOut;
static void rename_collect(void* ctx, LDoc* T, int tok){
RenameOut* r = (RenameOut*)ctx;
FileEdits* fe = 0;
for (int i = 0; i < r->n; i++) if (r->f[i].doc == T) fe = &r->f[i];
if (!fe){
if (r->n >= 64) return;
fe = &r->f[r->n++];
memset(fe, 0, sizeof(*fe));
fe->doc = T; fe->first = 1;
}
if (!fe->first) jsb_putc(&fe->edits, ',');
fe->first = 0;
jsb_puts(&fe->edits, "{\"range\":");
put_range(&fe->edits, T, T->lex.v[tok].start, T->lex.v[tok].end);
jsb_puts(&fe->edits, ",\"newText\":");
jsb_str(&fe->edits, r->newname);
jsb_putc(&fe->edits, '}');
}
static void on_rename(JVal* msg, JVal* id){
LDoc* D = doc_of(msg);
const char* newname = jstr(jpath(msg, "params.newName"), 0);
if (!D || !newname || !*newname){ send_result(id, "null"); return; }
int off = off_of(msg, D, "params.position");
LDoc* owner = 0; int sym = -1;
int tok = resolve_at(D, off, &owner, &sym);
if (tok < 0 || !owner || sym < 0){ send_result(id, "null"); return; }
RenameOut r; memset(&r, 0, sizeof(r)); r.newname = newname;
for_each_occurrence(D, owner, sym, rename_collect, &r);
JSB s = {0};
jsb_puts(&s, "{\"changes\":{");
for (int i = 0; i < r.n; i++){
if (i) jsb_putc(&s, ',');
jsb_str(&s, r.f[i].doc->uri);
jsb_putc(&s, ':');
jsb_putc(&s, '[');
jsb_puts(&s, r.f[i].edits.b ? r.f[i].edits.b : "");
jsb_putc(&s, ']');
free(r.f[i].edits.b);
}
jsb_puts(&s, "}}");
send_result(id, s.b); free(s.b);
}
static void on_formatting(JVal* msg, JVal* id, int ranged){
LDoc* D = doc_of(msg);
if (!D){ send_result(id, "null"); return; }
int tab = jint(jpath(msg, "params.options.tabSize"), g_indent);
if (tab < 1 || tab > 8) tab = g_indent;
char* out = D->is_markdown ? ludic_format_markdown(D->raw, tab) : ludic_format(D->raw, tab);
/* A whole-document replacement is the honest edit: the formatter's unit of
* work is the file, and a partial rewrite could not fix indentation that
* depends on lines outside the range. Range formatting therefore formats
* the document too — every editor accepts that, and it never surprises. */
(void)ranged;
int nline = D->lex.nline;
int endline = nline > 0 ? nline - 1 : 0;
int endchar = 0;
{
int s0 = nline ? D->lex.linestart[endline] : 0;
int e0 = (int)strlen(D->text);
endchar = g_utf8_positions ? e0 - s0 : lutf16_len(D->text + s0, e0 - s0);
}
JSB s = {0};
jsb_putf(&s, "[{\"range\":{\"start\":{\"line\":0,\"character\":0},\"end\":{\"line\":%d,\"character\":%d}},\"newText\":", endline, endchar);
jsb_str(&s, out);
jsb_puts(&s, "}]");
send_result(id, s.b);
free(s.b); free(out);
}
static void on_folding(JVal* msg, JVal* id){
LDoc* D = doc_of(msg);
if (!D){ send_result(id, "[]"); return; }
JSB s = {0}; jsb_putc(&s, '['); int first = 1;
for (int i = 0; i < D->lex.n; i++){
const LTok* t = &D->lex.v[i];
if (t->kind != LT_OP || ltok_len(t) != 1 || D->text[t->start] != '{') continue;
int m = D->match[i];
if (m < 0) continue;
int a = t->line, b = D->lex.v[m].line;
if (b <= a) continue;
if (!first) jsb_putc(&s, ',');
first = 0;
jsb_putf(&s, "{\"startLine\":%d,\"endLine\":%d,\"kind\":\"region\"}", a, b - 1);
}
/* runs of comment lines fold as one block */
for (int i = 0; i < D->lex.n; ){
if (D->lex.v[i].kind != LT_COMMENT || !lw_first_on_line(D, i)){ i++; continue; }
int a = D->lex.v[i].line, last = a, j = i;
while (j < D->lex.n){
if (D->lex.v[j].kind == LT_NL){ j++; continue; }
if (D->lex.v[j].kind == LT_COMMENT && lw_first_on_line(D, j) && D->lex.v[j].line <= last + 1){ last = D->lex.v[j].line; j++; continue; }
break;
}
if (last > a){
if (!first) jsb_putc(&s, ',');
first = 0;
jsb_putf(&s, "{\"startLine\":%d,\"endLine\":%d,\"kind\":\"comment\"}", a, last);
}
i = j;
}
jsb_putc(&s, ']');
send_result(id, s.b); free(s.b);
}
static void on_document_link(JVal* msg, JVal* id){
LDoc* D = doc_of(msg);
if (!D){ send_result(id, "[]"); return; }
JSB s = {0}; jsb_putc(&s, '['); int first = 1;
for (int i = 0; i < D->lex.n; i++){
if (D->lex.v[i].kind != LT_STR) continue;
int p = ltok_prev_sig(&D->lex, i);
if (p < 0 || !ltok_is(&D->lex, p, "import")) continue;
char rel[512]; ltok_text(&D->lex, i, rel, sizeof(rel));
int n = (int)strlen(rel); if (n >= 2){ memmove(rel, rel + 1, n - 2); rel[n-2] = 0; }
char* dir = lw_dirname(D->path);
char* full = lw_join(dir, rel); free(dir);
char* uri = lw_path_to_uri(full);
if (!first) jsb_putc(&s, ',');
first = 0;
jsb_puts(&s, "{\"range\":");
put_range(&s, D, D->lex.v[i].start + 1, D->lex.v[i].end - 1);
jsb_puts(&s, ",\"target\":"); jsb_str(&s, uri); jsb_putc(&s, '}');
free(uri); free(full);
}
jsb_putc(&s, ']');
send_result(id, s.b); free(s.b);
}
/* Query bindings read `for (p, v) in query [Pos, Vel]` — the types are one
* lookup away but invisible at the use site, so show them inline. */
static void on_inlay_hint(JVal* msg, JVal* id){
LDoc* D = doc_of(msg);
if (!D){ send_result(id, "[]"); return; }
JSB s = {0}; jsb_putc(&s, '['); int first = 1;
for (int i = 0; i < D->nsym; i++){
LSym* y = &D->sym[i];
if (y->kind != LS_QUERYVAR || !y->type[0]) continue;
if (!first) jsb_putc(&s, ',');
first = 0;
char label[128]; snprintf(label, sizeof(label), ": %s", y->type);
jsb_puts(&s, "{\"position\":"); put_pos(&s, D, y->end);
jsb_puts(&s, ",\"label\":"); jsb_str(&s, label);
jsb_puts(&s, ",\"kind\":1,\"paddingLeft\":false}");
}
jsb_putc(&s, ']');
send_result(id, s.b); free(s.b);
}
static void on_signature_help(JVal* msg, JVal* id){
LDoc* D = doc_of(msg);
if (!D){ send_result(id, "null"); return; }
int off = off_of(msg, D, "params.position");
/* walk out to the call we are inside */
int depth = 0, callee = -1, arg = 0;
for (int i = D->lex.n - 1; i >= 0; i--){
const LTok* t = &D->lex.v[i];
if (t->start >= off) continue;
if (t->kind != LT_OP || ltok_len(t) != 1) continue;
char c = D->text[t->start];
if (c == ')') depth++;
else if (c == '('){
if (depth == 0){ callee = ltok_prev_sig(&D->lex, i);
for (int k = i + 1; k < D->lex.n && D->lex.v[k].start < off; k++)
if (ltok_is(&D->lex, k, ",")) arg++;
break; }
depth--;
}
}
if (callee < 0 || D->lex.v[callee].kind != LT_ID){ send_result(id, "null"); return; }
char name[128]; ltok_text(&D->lex, callee, name, sizeof(name));
const char* sig = 0; const char* doc = "";
const LBuiltin* bi = lud_lookup(LUDIC_BUILTINS, name);
if (!bi) bi = lud_lookup(LUDIC_INTRINSICS, name);
if (bi){ sig = bi->sig; doc = bi->doc; }
else {
LDoc* o = 0; int t = lw_find_top(&X, D, name, &o);
if (t >= 0 && (o->sym[t].kind == LS_FN || o->sym[t].kind == LS_EXTERN)){ sig = o->sym[t].detail; doc = o->sym[t].doc; }
}
if (!sig){ send_result(id, "null"); return; }
JSB s = {0};
jsb_puts(&s, "{\"signatures\":[{\"label\":"); jsb_str(&s, sig);
if (doc && *doc){ jsb_puts(&s, ",\"documentation\":"); jsb_str(&s, doc); }
jsb_putf(&s, "}],\"activeSignature\":0,\"activeParameter\":%d}", arg);
send_result(id, s.b); free(s.b);
}
static void on_completion(JVal* msg, JVal* id){
LDoc* D = doc_of(msg);
if (!D){ send_result(id, "[]"); return; }
int off = off_of(msg, D, "params.position");
JSB s = {0};
jsb_puts(&s, "{\"isIncomplete\":false,\"items\":[");
CompOut c = { &s, 1 };
/* what comes immediately before the cursor decides everything */
int tok = -1;
for (int i = D->lex.n - 1; i >= 0; i--){
int k = D->lex.v[i].kind;
if (k == LT_NL || k == LT_EOF || k == LT_COMMENT) continue;
if (D->lex.v[i].end <= off){ tok = i; break; }
}
/* skip the partial word being typed */
int anchor = tok;
if (anchor >= 0 && D->lex.v[anchor].kind == LT_ID && D->lex.v[anchor].end >= off) anchor = ltok_prev_sig(&D->lex, anchor);
/* Inside an unclosed `[` we are listing query terms, where the only things
* that can appear are components and archetypes — including after a comma,
* which is why this is a scan rather than a look at the previous token. */
int in_query_terms = 0;
{
int depth = 0;
for (int i = anchor; i >= 0; i--){
const LTok* t = &D->lex.v[i];
if (t->kind != LT_OP || ltok_len(t) != 1) continue;
char ch = D->text[t->start];
if (ch == ']') depth++;
else if (ch == '['){ if (!depth){ in_query_terms = 1; break; } depth--; }
else if (ch == '{' || ch == '}') break; /* left the clause */
}
}
int done = 0;
if (in_query_terms){ comp_scope(&c, D, off, 1); done = 1; }
else if (anchor >= 0){
/* member access: only that component's fields */
if (ltok_is(&D->lex, anchor, ".")){
LDoc* co = 0;
int comp = lw_receiver_component(&X, D, anchor, &co);
if (comp >= 0 && co){
for (int i = 0; i < co->nsym; i++)
if (co->sym[i].kind == LS_FIELD && co->sym[i].parent == comp)
comp_item(&c, co->sym[i].name, CI_FIELD, co->sym[i].detail, co->sym[i].doc, 0);
}
done = 1;
}
else if (ltok_is(&D->lex, anchor, "phase")){ comp_words(&c, LUDIC_PHASES, CI_ENUM, "system phase"); done = 1; }
else if (ltok_is(&D->lex, anchor, ":") || ltok_is(&D->lex, anchor, "->")){
comp_words(&c, LUDIC_TYPES, CI_KEYWORD, "built-in type");
comp_scope(&c, D, off, 1);
done = 1;
}
else if (ltok_is(&D->lex, anchor, "spawn")){ comp_scope(&c, D, off, 1); done = 1; }
else if (ltok_is(&D->lex, anchor, "enter")){
LDoc* rel[128]; int n = lw_related(&X, D, rel, 128);
for (int i = 0; i < n; i++)
for (int k = 0; k < rel[i]->nsym; k++)
if (rel[i]->sym[k].kind == LS_SCENE)
comp_item(&c, rel[i]->sym[k].name, CI_UNIT, rel[i]->sym[k].detail, rel[i]->sym[k].doc, 0);
done = 1;
}
else if (D->lex.v[anchor].kind == LT_OP && ltok_len(&D->lex.v[anchor]) == 1 && D->text[D->lex.v[anchor].start] == '@'){
comp_item(&c, "deterministic", CI_KEYWORD, "@deterministic", "Marks a system as reproducible given the same inputs.", 0);
done = 1;
}
}
if (!done && lw_in_ui_body(D, off)){
comp_words(&c, LUDIC_WIDGETS, CI_CLASS, "widget");
comp_words(&c, LUDIC_WIDGET_PROPS, CI_PROPERTY, "widget property");
}
if (!done){
comp_words(&c, LUDIC_KW_DECL, CI_KEYWORD, "declaration");
comp_words(&c, LUDIC_KW_STMT, CI_KEYWORD, "statement");
comp_words(&c, LUDIC_KW_CLAUSE, CI_KEYWORD, "clause");
comp_words(&c, LUDIC_TYPES, CI_KEYWORD, "built-in type");
comp_builtins(&c);
comp_scope(&c, D, off, 0);
/* the shapes people actually type */
comp_item(&c, "handler", CI_SNIPPET, "handler … phase … { }", "A system with a phase and a body.",
"system ${1:Name} phase ${2|Start,Input,FixedUpdate,Update,LateUpdate,Render|} {\n $0\n}");
comp_item(&c, "system+query", CI_SNIPPET, "handler … query … { }", "A system that runs once per matching entity.",
"system ${1:Name} phase ${2|Update,FixedUpdate,LateUpdate,Render,Input|}\n query (${3:p}) [${4:Pos}]\n{\n $0\n}");
comp_item(&c, "property", CI_SNIPPET, "property … { }", "A component with typed fields.",
"component ${1:Name} { ${2:x}: ${3:int} = ${4:0} }");
comp_item(&c, "model", CI_SNIPPET, "archetype … { }", "An entity kind bundling components.",
"archetype ${1:Name} {\n ${2:Pos}\n}");
comp_item(&c, "for-query", CI_SNIPPET, "for (…) in query […] { }", "Iterate matching entities.",
"for (${1:p}) in query [${2:Pos}] {\n $0\n}");
comp_item(&c, "scene", CI_SNIPPET, "scene … { layer … }", "A scene with one layer.",
"scene ${1:Name} {\n on enter { $2 }\n layer ${3:Main} {\n $0\n }\n}");
}
jsb_puts(&s, "]}");
send_result(id, s.b);
free(s.b);
}
/* ---------- document lifecycle -------------------------------------------*/
static void refresh(LDoc* D){ ldoc_reindex(D); lw_classify(&X, D); }
static void on_did_open(JVal* msg){
const char* uri = jstr(jpath(msg, "params.textDocument.uri"), 0);
const char* text = jstr(jpath(msg, "params.textDocument.text"), 0);
if (!uri) return;
char* path = lw_uri_to_path(uri);
LDoc* D = lw_by_path(&X, path);
if (!D){ D = lw_new_doc(path, strdup(text ? text : "")); lw_add(&X, D); }
else if (text) ldoc_settext(D, strdup(text));
free(path);
D->open = 1;
D->version = jint(jpath(msg, "params.textDocument.version"), 0);
refresh(D);
publish_diagnostics(D);
}
static void on_did_change(JVal* msg){
LDoc* D = doc_of(msg);
if (!D) return;
JVal* changes = jpath(msg, "params.contentChanges");
JVal* last = jat(changes, changes ? changes->n - 1 : 0);
const char* text = jstr(jget(last, "text"), 0);
if (!text) return;
ldoc_settext(D, strdup(text));
D->version = jint(jpath(msg, "params.textDocument.version"), D->version + 1);
refresh(D);
/* structural feedback only while typing; the compiler waits for a save,
* because it reads the file from disk and would otherwise report the
* previous save's errors against the current buffer */
DiagSet ds = {0};
if (!D->is_markdown) diag_structural(D, &ds);
JSB s = {0};
jsb_puts(&s, "{\"uri\":"); jsb_str(&s, D->uri);
jsb_puts(&s, ",\"diagnostics\":[");
D->had_diags = ds.n > 0;
for (int i = 0; i < ds.n; i++){
if (i) jsb_putc(&s, ',');
jsb_putf(&s, "{\"range\":{\"start\":{\"line\":%d,\"character\":%d},\"end\":{\"line\":%d,\"character\":%d}},\"severity\":%d,\"source\":\"ludic\",\"message\":",
ds.v[i].line, ds.v[i].col, ds.v[i].endline, ds.v[i].endcol, ds.v[i].severity);
jsb_str(&s, ds.v[i].msg);
jsb_putc(&s, '}');
}
jsb_puts(&s, "]}");
send_notify("textDocument/publishDiagnostics", s.b);
free(s.b); free(ds.v);
}
static void on_did_save(JVal* msg){
LDoc* D = doc_of(msg);
if (!D) return;
const char* text = jstr(jpath(msg, "params.text"), 0);
if (text) ldoc_settext(D, strdup(text));
else { char* disk = lw_readfile(D->path, 0); if (disk) ldoc_settext(D, disk); }
refresh(D);
publish_diagnostics(D);
}
static void on_did_close(JVal* msg){
LDoc* D = doc_of(msg);
if (!D) return;
D->open = 0;
char* disk = lw_readfile(D->path, 0);
if (disk){ ldoc_settext(D, disk); refresh(D); }
}
/* ---------- dispatch ------------------------------------------------------*/
static void handle(JVal* msg){
const char* method = jstr(jget(msg, "method"), 0);
JVal* id = jget(msg, "id");
if (!method) return;
lg("<- %s", method);
if (!strcmp(method, "initialize")){ on_initialize(msg, id); return; }
if (!strcmp(method, "initialized")) return;
if (!strcmp(method, "shutdown")){ g_shutdown = 1; send_result(id, "null"); return; }
if (!strcmp(method, "exit")){ exit(g_shutdown ? 0 : 1); }
if (!strcmp(method, "textDocument/didOpen")){ on_did_open(msg); return; }
if (!strcmp(method, "textDocument/didChange")){ on_did_change(msg); return; }
if (!strcmp(method, "textDocument/didSave")){ on_did_save(msg); return; }
if (!strcmp(method, "textDocument/didClose")){ on_did_close(msg); return; }
if (!strcmp(method, "workspace/didChangeConfiguration")){
JVal* cfg = jpath(msg, "params.settings.ludic");
const char* cc = jstr(jget(cfg, "compilerPath"), 0);
if (cc && *cc){ free(g_ludicc); g_ludicc = strdup(cc); }
if (jget(cfg, "indentSize")) g_indent = jint(jget(cfg, "indentSize"), g_indent);
if (jget(cfg, "compilerDiagnostics")) g_compiler_diags = jbool(jget(cfg, "compilerDiagnostics"), 1);
return;
}
if (!strcmp(method, "workspace/didChangeWatchedFiles")){
JVal* ch = jpath(msg, "params.changes");
for (int i = 0; ch && i < ch->n; i++){
const char* u = jstr(jget(jat(ch, i), "uri"), 0);
if (!u) continue;
char* p = lw_uri_to_path(u);
LDoc* D = lw_by_path(&X, p);
if (D && !D->open){ char* disk = lw_readfile(p, 0); if (disk){ ldoc_settext(D, disk); refresh(D); } }
else if (!D) lw_ensure(&X, p);
free(p);
}
return;
}
if (!strcmp(method, "textDocument/documentSymbol")){ on_document_symbol(msg, id); return; }
if (!strcmp(method, "workspace/symbol")){ on_workspace_symbol(msg, id); return; }
if (!strcmp(method, "textDocument/definition") ||
!strcmp(method, "textDocument/declaration") ||
!strcmp(method, "textDocument/typeDefinition")){ on_definition(msg, id); return; }
if (!strcmp(method, "textDocument/hover")){ on_hover(msg, id); return; }
if (!strcmp(method, "textDocument/references")){ on_references(msg, id); return; }
if (!strcmp(method, "textDocument/documentHighlight")){ on_document_highlight(msg, id); return; }
if (!strcmp(method, "textDocument/prepareRename")){ on_prepare_rename(msg, id); return; }
if (!strcmp(method, "textDocument/rename")){ on_rename(msg, id); return; }
if (!strcmp(method, "textDocument/formatting")){ on_formatting(msg, id, 0); return; }
if (!strcmp(method, "textDocument/rangeFormatting")){ on_formatting(msg, id, 1); return; }
if (!strcmp(method, "textDocument/foldingRange")){ on_folding(msg, id); return; }
if (!strcmp(method, "textDocument/documentLink")){ on_document_link(msg, id); return; }
if (!strcmp(method, "textDocument/inlayHint")){ on_inlay_hint(msg, id); return; }
if (!strcmp(method, "textDocument/signatureHelp")){ on_signature_help(msg, id); return; }
if (!strcmp(method, "textDocument/completion")){ on_completion(msg, id); return; }
if (!strcmp(method, "textDocument/semanticTokens/full") ||
!strcmp(method, "textDocument/semanticTokens/range")){
LDoc* D = doc_of(msg);
if (!D){ send_result(id, "null"); return; }
int from = 0, to = -1;
JVal* r = jpath(msg, "params.range");
if (r){ from = jint(jpath(msg, "params.range.start.line"), 0); to = jint(jpath(msg, "params.range.end.line"), -1); }
char* out = semantic_tokens(D, from, to);
send_result(id, out);
free(out);
return;
}
/* an unknown request still needs an answer, or the client hangs */
if (id) send_result(id, "null");
}
static const char* USAGE =
"ludic-lsp — the Ludic language server (LSP 3.17 over stdio)\n"
"\n"
"usage: ludic-lsp [--stdio] [--compiler PATH] [--log FILE] [--no-compiler-diagnostics]\n"
"\n"
"Editors normally launch this themselves. --stdio is accepted and ignored;\n"
"stdio is the only transport.\n";
int main(int argc, char** argv){
signal(SIGPIPE, SIG_IGN);
for (int i = 1; i < argc; i++){
if (!strcmp(argv[i], "--compiler") && i + 1 < argc) g_ludicc = strdup(argv[++i]);
else if (!strcmp(argv[i], "--log") && i + 1 < argc) g_log = fopen(argv[++i], "w");
else if (!strcmp(argv[i], "--no-compiler-diagnostics")) g_compiler_diags = 0;
else if (!strcmp(argv[i], "--indent") && i + 1 < argc) g_indent = atoi(argv[++i]);
else if (!strcmp(argv[i], "--stdio")) ;
else if (!strcmp(argv[i], "-h") || !strcmp(argv[i], "--help")){ fputs(USAGE, stdout); return 0; }
else if (!strcmp(argv[i], "--version")){ puts("ludic-lsp 1.0.0"); return 0; }
}
for (;;){
char* raw = read_message();
if (!raw) break;
JVal* msg = jparse(raw);
handle(msg);
jfree(msg);
free(raw);
}
return 0;
}