Baseline: Ludic compiler + toolchain, Phase 1 syntax fixes complete

Self-hosted compiler (selfhost/*.ludic), runtime, examples, editor tooling,
and docs. Phase 1 of the syntax-redesign cohesion pass has landed:
edge-system fix, signature-query, when-alias, and the documentation truth-pass.
Suite green (14/14), C-free bootstrap fixpoint holds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Orkun ÇAKILKAYA 2026-08-27 15:15:35 +03:00
commit 985f9ad8f2
418 changed files with 39065 additions and 0 deletions

56
tools/atlas.ludic Normal file
View file

@ -0,0 +1,56 @@
# ============================================================================
# atlas.ludic — render a Kenney tile pack as one index-labeled sheet, so you can
# see which sprite id is which before referring to it in a game.
#
# ludicc tools/atlas.ludic --headless -o build/atlas
# ./build/atlas && sips -s format png out.ppm --out atlas.png
#
# Replaces the old tools/atlas.c, which used the C runtime that no longer
# exists. It is the same program, in the language it is a tool for.
# ============================================================================
game Atlas {
const DIR: str = "assets/kenney/tiny-dungeon/Tiles"
const N: int = 132
const COLS: int = 12
const CELL: int = 24
system Sheet phase Start {
clear(0x101018)
for i in 0 .. N {
let col = i % COLS
let row = i / COLS
let x = col * CELL
let y = row * CELL
# tile_0000.png … tile_0131.png — the id is the file's number
let id = load_png(tile_path(i))
if id >= 0 {
draw_sprite(id, x + 4, y + 2)
}
text_int(x + 1, y + 18, i, 0x8090b0, 1)
}
present()
quit()
}
# build "<DIR>/tile_%04d.png" without a formatter: the runtime's string
# helpers write into a scratch buffer we own
fn tile_path(i: int) -> str {
let buf = atlas_buf()
let n = rt_put_str(buf, 0, DIR)
n = rt_put_str(buf, n, "/tile_")
if i < 1000 { poke8(buf, n, 48) n = n + 1 }
if i < 100 { poke8(buf, n, 48) n = n + 1 }
if i < 10 { poke8(buf, n, 48) n = n + 1 }
n = rt_put_int(buf, n, i)
n = rt_put_str(buf, n, ".png")
poke8(buf, n, 0)
return buf
}
var atlas_scratch: ptr = ptr_null()
fn atlas_buf() -> ptr {
if ptr_is_null(atlas_scratch) { atlas_scratch = mem_alloc(512) }
return atlas_scratch
}
}

72
tools/build-tools.sh Executable file
View file

@ -0,0 +1,72 @@
#!/bin/bash
# build-tools.sh — build the Ludic editor toolchain.
#
# ./tools/build-tools.sh -> build/ludic-fmt, build/ludic-lsp
# ./tools/build-tools.sh --test build, then run tools/test-tools.sh
# ./tools/build-tools.sh --install also symlink both into ~/.local/bin
#
# Both binaries are plain C with no dependencies, same as the compiler. They
# share the lexer and vocabulary in tools/ludic-tools/ludic_syntax.h, so a
# keyword added there reaches every editor at once.
set -e
cd "$(dirname "$0")/.."
CC="${LUDIC_CC:-clang}"
CFLAGS="-O2 -Wall -Wno-unused-function"
SRC=tools/ludic-tools
OUT=build
mkdir -p "$OUT"
build_one() {
local name="$1" main="$2"
local newest
# rebuild when any header or the entry point is newer than the binary
if [ -x "$OUT/$name" ]; then
newest=$(find "$SRC" -name '*.h' -o -name "$(basename "$main")" | while read -r f; do
[ "$f" -nt "$OUT/$name" ] && echo new
done)
[ -z "$newest" ] && { echo "$name: up to date"; return; }
fi
echo "cc: $main -> $OUT/$name"
$CC $CFLAGS "$main" -o "$OUT/$name"
}
build_one ludic-fmt "$SRC/ludic_fmt_main.c"
build_one ludic-lsp "$SRC/ludic_lsp.c"
# The language server shells out to the compiler for real diagnostics, so having
# it around is most of the value.
if [ ! -x "$OUT/ludicc" ]; then
echo "note: $OUT/ludicc not built yet — run ./build.sh for compiler diagnostics"
fi
# VS Code needs the grammar inside its own extension directory, so it gets a
# copy. A copy that can drift is the whole failure mode this toolchain is built
# to avoid, so it is refreshed here rather than maintained by hand, and
# test-tools.sh asserts the two are identical.
SHARED=tools/editors/shared
VSC=tools/editors/vscode
if [ -d "$VSC" ]; then
mkdir -p "$VSC/syntaxes"
for f in ludic.tmLanguage.json ludic.markdown-injection.json; do
cmp -s "$SHARED/$f" "$VSC/syntaxes/$f" || { cp "$SHARED/$f" "$VSC/syntaxes/$f"; echo "sync: $f -> $VSC/syntaxes/"; }
done
cmp -s "$SHARED/language-configuration.json" "$VSC/language-configuration.json" || {
cp "$SHARED/language-configuration.json" "$VSC/language-configuration.json"
echo "sync: language-configuration.json -> $VSC/"
}
fi
if [ "$1" = "--install" ] || [ "$2" = "--install" ]; then
mkdir -p "$HOME/.local/bin"
ln -sf "$PWD/$OUT/ludic-fmt" "$HOME/.local/bin/ludic-fmt"
ln -sf "$PWD/$OUT/ludic-lsp" "$HOME/.local/bin/ludic-lsp"
echo "linked into ~/.local/bin (make sure it is on your PATH)"
fi
if [ "$1" = "--test" ] || [ "$2" = "--test" ]; then
exec ./tools/test-tools.sh
fi
echo "done. editors: see tools/editors/README.md"

78
tools/check-docs.py Executable file
View file

@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""check-docs.py — every ```ludic fence in the docs is checked against the
compiler, so documentation cannot drift away from the language.
A fence declares its own intent with an in-fence comment (`#` is a Ludic
comment, so the marker is valid code and visible to a reader):
# doc-check: skip illustrative or pseudo-syntax; not checked
# doc-check: expect-error must FAIL to compile (error demonstrations)
Anything else must PARSE. The gate is `ludicc --fmt`, not a full build: it runs
lex + parse, which is what catches syntax drift, but does not resolve
identifiers — so an excerpt may reference components and registers that live in
the surrounding program it was lifted from, and still be checked.
A bare fragment is wrapped first: a declaration list goes inside
`game DocCheck { ... }`, loose statements inside a Start system.
"""
import re, subprocess, sys, os, tempfile
LC = './build/ludicc'
DECL = ('game','module','component','archetype','system','edge','fn','pure',
'extern','const','var','ui','scene','import')
def classify(body):
first = next((l.strip() for l in body.split('\n')
if l.strip() and not l.strip().startswith('#')), '')
head = first.split('(')[0].split()[0] if first else ''
if head in ('game','module'): return 'whole'
return 'decls' if head in DECL else 'stmts'
def wraps(body, kind):
"""Candidate framings, best guess first. An excerpt often mixes declarations
with loose statements, so both are tried and either parsing counts."""
if kind == 'whole': return [body]
as_decls = 'game DocCheck {\n' + body + '\n}\n'
as_stmts = 'game DocCheck {\n system DocS phase Start {\n' + body + '\n }\n}\n'
return [as_decls, as_stmts] if kind == 'decls' else [as_stmts, as_decls]
def fences(path):
t = open(path).read()
for m in re.finditer(r'^```ludic\n(.*?)^```', t, re.S | re.M):
yield t[:m.start()].count('\n') + 1, m.group(1)
def main(argv):
report = '--report' in argv
docs = [p for p in argv if p.endswith('.md')]
ok = bad = skipped = 0
failures = []
for path in docs:
for line, body in fences(path):
if '# doc-check: skip' in body:
skipped += 1; continue
expect_error = '# doc-check: expect-error' in body
kind = classify(body)
parsed, first_err = False, None
for src in wraps(body, kind):
with tempfile.NamedTemporaryFile('w', suffix='.ludic', delete=False) as f:
f.write(src); tmp = f.name
# bytes, not text: a diagnostic may echo a partial multibyte char
r = subprocess.run([LC, tmp, '--fmt'], capture_output=True)
os.unlink(tmp)
if r.returncode == 0: parsed = True; break
# keep the best-guess framing's error; later ones are fallbacks
if first_err is None: first_err = r.stderr
if parsed != (not expect_error):
bad += 1
want = 'be rejected' if expect_error else 'parse'
err = (first_err or b'').decode('utf-8','replace').strip().split('\n')[0]
failures.append(f"{path}:{line} expected to {want}: {err[:90]}")
else:
ok += 1
print(f" doc fences: {ok} as documented, {bad} drifted, {skipped} skipped")
for f in failures: print(f" {f}")
if report: return 0
return 1 if bad else 0
sys.exit(main(sys.argv[1:]))

150
tools/check-vocabulary.py Executable file
View file

@ -0,0 +1,150 @@
#!/usr/bin/env python3
"""Guard against the failure mode this whole layout exists to prevent.
The Ludic vocabulary — keywords, types, phases, builtins, intrinsics — is
written down in five places that cannot include each other:
compiler/**/*.c BUILTINS[] the language itself
compiler/**/*.c INTRINSICS[] the language itself
tools/ludic-tools/ ludic_syntax.h the toolchain's source of truth
tools/editors/shared/ the TextMate grammar (JSON, no includes)
tools/editors/jetbrains/ LudicVocabulary (Kotlin, no includes)
Adding a builtin to the compiler and forgetting the rest is silent: the editor
just stops colouring it, and nobody notices for months. So it is checked.
"""
import json
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
problems = []
def read(*parts):
with open(os.path.join(ROOT, *parts)) as fh:
return fh.read()
def c_string_list(text, name):
"""Names in `static const char* NAME[] = { "a", "b", 0 };`"""
m = re.search(r"\b" + name + r"\s*\[\s*\]\s*=\s*\{(.*?)\}\s*;", text, re.S)
if not m:
problems.append(f"{name}: not found")
return set()
return set(re.findall(r'"([^"]+)"', m.group(1)))
def c_table_names(text, name):
"""First string of each row in a `{ "name", ... }` table."""
m = re.search(r"\b" + name + r"\s*\[\s*\]\s*=\s*\{(.*?)\n\s*\}\s*;", text, re.S)
if not m:
problems.append(f"{name}: not found")
return set()
return set(re.findall(r'\{\s*"([^"]+)"', m.group(1)))
def kotlin_set(text, name):
"""Names in `val NAME = setOf(...)`, found by matching the parentheses —
a non-greedy regex silently runs past a one-line setOf into the next one."""
m = re.search(r"\bval\s+" + name + r"\s*=\s*setOf\(", text)
if not m:
problems.append(f"Kotlin {name}: not found")
return set()
i = m.end()
depth = 1
while i < len(text) and depth:
if text[i] == "(":
depth += 1
elif text[i] == ")":
depth -= 1
i += 1
body = re.sub(r"//[^\n]*", "", text[m.end():i - 1])
return set(re.findall(r'"([^"]+)"', body))
def grammar_alternation(grammar, path, marker):
"""Names in a `\\b(a|b|c)\\b` match inside the TextMate grammar."""
node = grammar
for key in path:
node = node[key]
for pat in node:
rx = pat.get("match", "")
if marker in rx:
inner = re.search(r"\\b\((.*?)\)\\b", rx)
if inner:
return set(inner.group(1).split("|"))
problems.append(f"grammar: no rule containing {marker!r}")
return set()
def compare(label, reference, other, other_label, ignore=frozenset()):
missing = (reference - other) - ignore
extra = (other - reference) - ignore
if missing:
problems.append(f"{other_label} is missing {label}: {', '.join(sorted(missing))}")
if extra:
problems.append(f"{other_label} has unknown {label}: {', '.join(sorted(extra))}")
syntax_h = read("tools", "ludic-tools", "ludic_syntax.h")
def read_table_owner(table):
"""Find whichever compiler translation unit declares a table.
The compiler is split by concern and files move; hardcoding a path here
turns a routine refactor into a spurious vocabulary failure.
"""
import glob
for path in sorted(glob.glob(os.path.join(ROOT, "compiler", "**", "*.c"), recursive=True)):
text = open(path, encoding="utf-8").read()
if re.search(r"\b%s\s*\[\s*\]" % re.escape(table), text):
return text
return None # the compiler is now written in Ludic (selfhost/); no C table
ludicc_c = read_table_owner("BUILTINS")
native_c = read_table_owner("INTRINSICS")
kotlin = read("tools", "editors", "jetbrains", "src", "main", "kotlin", "io", "ludic", "ide", "LudicTokens.kt")
grammar = json.loads(read("tools", "editors", "shared", "ludic.tmLanguage.json"))
# --- the toolchain header is the reference ---------------------------------
h_decl = c_string_list(syntax_h, "LUDIC_KW_DECL")
h_clause = c_string_list(syntax_h, "LUDIC_KW_CLAUSE")
h_stmt = c_string_list(syntax_h, "LUDIC_KW_STMT")
h_types = c_string_list(syntax_h, "LUDIC_TYPES")
h_phases = c_string_list(syntax_h, "LUDIC_PHASES")
h_widgets = c_string_list(syntax_h, "LUDIC_WIDGETS")
h_builtins = c_table_names(syntax_h, "LUDIC_BUILTINS")
h_intrinsics = c_table_names(syntax_h, "LUDIC_INTRINSICS")
# --- against the compiler's builtin/intrinsic tables, when a C compiler is
# present. The compiler is now written in Ludic (selfhost/), so this
# cross-check is skipped; the editor tools remain checked against each other.
if ludicc_c is not None:
compare("builtins", c_table_names(ludicc_c, "BUILTINS"), h_builtins, "ludic_syntax.h")
if native_c is not None:
compare("intrinsics", c_table_names(native_c, "INTRINSICS"), h_intrinsics, "ludic_syntax.h")
# --- against the JetBrains lexer -------------------------------------------
compare("declaration keywords", h_decl, kotlin_set(kotlin, "DECL"), "LudicTokens.kt")
compare("clause keywords", h_clause, kotlin_set(kotlin, "CLAUSE"), "LudicTokens.kt")
compare("statement keywords", h_stmt, kotlin_set(kotlin, "STMT"), "LudicTokens.kt")
compare("primitive types", h_types, kotlin_set(kotlin, "PRIMITIVES"), "LudicTokens.kt")
compare("phases", h_phases, kotlin_set(kotlin, "PHASES"), "LudicTokens.kt")
compare("widgets", h_widgets, kotlin_set(kotlin, "WIDGETS"), "LudicTokens.kt")
compare("builtins", h_builtins | h_intrinsics, kotlin_set(kotlin, "BUILTINS"), "LudicTokens.kt")
# --- against the TextMate grammar ------------------------------------------
gpath = ["repository", "builtin", "patterns"]
compare("builtins", h_builtins, grammar_alternation(grammar, gpath, "rng_chance"), "ludic.tmLanguage.json")
compare("intrinsics", h_intrinsics, grammar_alternation(grammar, gpath, "mem_alloc"), "ludic.tmLanguage.json")
kpath = ["repository", "keyword", "patterns"]
compare("phases", h_phases, grammar_alternation(grammar, kpath, "FixedUpdate"), "ludic.tmLanguage.json")
compare("primitive types", h_types, grammar_alternation(grammar, kpath, "fixed"), "ludic.tmLanguage.json")
if problems:
print("vocabulary drift:", file=sys.stderr)
for p in problems:
print(" -", p, file=sys.stderr)
sys.exit(1)
sys.exit(0)

148
tools/editors/README.md Normal file
View file

@ -0,0 +1,148 @@
# Ludic in your editor
Everything here is built on one idea: **write the language knowledge once, in C,
and let every editor talk to it.** There is one lexer, one vocabulary, one
formatter and one language server. VS Code and JetBrains get first-class plugins
because those are the two that were asked for; every other editor gets the same
capabilities by pointing at the same binary.
```
tools/ludic-tools/ the actual language knowledge, in C, no dependencies
ludic_syntax.h lexer + the vocabulary — the source of truth
ludic_fmt.h comment-preserving formatter
ludic_index.h error-tolerant reader: declarations, bindings, scopes
ludic_workspace.h multi-file model: imports, compilation units, Markdown
ludic_json.h just enough JSON for LSP
ludic_fmt_main.c -> build/ludic-fmt
ludic_lsp.c -> build/ludic-lsp
tools/editors/
shared/ TextMate grammar + Markdown injection + language config
vscode/ VS Code extension
jetbrains/ IntelliJ Platform plugin (Community editions included)
neovim/ helix/ emacs/ sublime/ zed/ configuration, no plugin needed
```
## Build it
```bash
./tools/build-tools.sh
```
Produces `build/ludic-fmt` and `build/ludic-lsp`. Add `--install` to symlink both
into `~/.local/bin`, `--test` to run `tools/test-tools.sh` afterwards.
## What you get, in any editor
| Feature | How |
|---|---|
| Syntax highlighting | LSP semantic tokens, or the TextMate grammar with no server at all |
| Diagnostics | structural errors as you type; `ludicc`'s own errors on save |
| Completion | context-aware — see below |
| Hover | signature + docs for builtins, intrinsics, and everything you declared |
| Go to definition, find usages, rename | across the whole compilation unit, following `import` — by resolving each occurrence, not by matching text, so renaming a component's `x` leaves every other `x` alone |
| Formatting | whole-file, comment-preserving, idempotent |
| Outline, folding, inlay hints, signature help, document links | |
| ` ```ludic ` in Markdown | highlighted, checked and formatted |
Completion knows where the caret is: fields after `.`, components inside
`query [...]` and after `spawn`, phase names after `phase`, scene names after
`enter`, widget types and props inside a `ui` block, types after `:` and `->`,
and otherwise keywords, builtins, and everything in scope.
## Per-editor setup
- **VS Code** — [`vscode/`](vscode/). `npm install && npx @vscode/vsce package`, then
install the `.vsix`. Finds the binaries under `build/` on its own.
- **JetBrains** — [`jetbrains/`](jetbrains/). `./gradlew buildPlugin`, then install the
zip from disk. Uses LSP4IJ rather than the paid-IDE LSP API, so it works in
Community editions too.
- **Neovim** — `require('ludic').setup()` from [`neovim/`](neovim/lua/ludic.lua).
- **Helix** — merge [`helix/languages.toml`](helix/languages.toml) into your config.
- **Emacs** — [`emacs/ludic-mode.el`](emacs/ludic-mode.el); eglot is wired up in one line.
- **Sublime** — [`sublime/README.md`](sublime/README.md); the TextMate grammar loads as-is.
- **Zed** — [`zed/README.md`](zed/README.md); LSP-only, no grammar to build.
- **Anything else** that speaks LSP:
```
command: build/ludic-lsp --stdio
languages: ludic, markdown
initializationOptions: { "compilerPath": "build/ludicc", "compilerDiagnostics": true, "indentSize": 2 }
```
## Markdown
Markdown fenced-code highlighting is not one standard — it is three mechanisms,
and this ships all three so that a ` ```ludic ` block works wherever you write
prose:
1. **TextMate injection** (`shared/ludic.markdown-injection.json`) — what VS Code,
Sublime and other TextMate-based editors use. It is a plain grammar file with
an `injectionSelector`, so it is portable rather than VS Code-specific.
2. **Language-ID resolution** — JetBrains' Markdown support matches a fence's
info string against registered language IDs, so the plugin registering as
`Ludic` is all that is needed; no Markdown-specific code exists in the plugin.
3. **The language server** — `ludic-lsp` accepts Markdown documents directly. It
copies the buffer, blanks every byte outside a ` ```ludic ` fence, and
analyses the result; offsets still line up with the real file, so semantic
highlighting, hover, completion and go-to-definition all work inside fences
with no editor-side support at all. This is the fallback that works even
where the other two do not.
It reports **no diagnostics** in Markdown, deliberately. A fence is a
snippet — an elision mark, or a body shown without its enclosing `game`
block — so nearly every error found there would be the documentation doing
its job. `ludic-fmt --check` still keeps the fences formatted, which is the
part of "is this doc correct" that can be answered without guessing.
The formatter handles Markdown too, which keeps documentation honest:
```bash
build/ludic-fmt --check LANGUAGE.md README.md # CI: fail if a fence is unformatted
build/ludic-fmt -w LANGUAGE.md # reformat the fences, leave the prose
```
## Keeping a tree formatted
```bash
ln -sf ../../tools/git-hooks/pre-commit .git/hooks/pre-commit
```
The hook runs `ludic-fmt --check` over the staged `.ludic` and `.md` files only,
and does nothing at all when `build/ludic-fmt` has not been built — so it never
blocks a commit on a machine that has not run `build-tools.sh`.
## Why the formatter is not `ludicc --fmt`
The compiler has a canonical printer, and it is the right tool for seeing what
the compiler parsed. It is the wrong tool for an editor: it walks the AST *after*
import splicing, so it drops every comment and inlines every imported file into
whichever file you pointed it at. Running it as format-on-save on
`chronorift/combat.ludic` would replace that file with the whole game.
`ludic-fmt` works on the token stream instead. Nothing is dropped or reordered —
every token is re-emitted in order, and the only freedom taken is the whitespace
between them. Lines are re-indented and respaced but never joined or split, so
you keep control of line structure.
Two carve-outs keep the output idiomatic rather than merely uniform, because
both spellings are what the language documents and uses:
- runs of two or more spaces are preserved, so hand-aligned columns
(`const R_DIR: int = 0 # 0 up`) survive a save;
- `id=Root` inside a `ui` block and `{Enemy}` inside a query stay tight.
`tools/test-tools.sh` checks the property that matters: formatting every file in
the tree and re-running the *compiler's* canonical dump produces byte-identical
output. The formatter cannot change what a program means.
## Keeping it honest
The vocabulary is written down in five places that cannot include each other —
the compiler's two tables, `ludic_syntax.h`, the TextMate grammar (JSON), and the
JetBrains lexer (Kotlin). Adding a builtin and forgetting the rest is silent
failure, so `tools/check-vocabulary.py` compares all five and
`tools/test-tools.sh` runs it.
When you add a keyword or builtin: put it in `ludic_syntax.h`, then run
`./tools/test-tools.sh` and let it tell you which copies still need it.

View file

@ -0,0 +1,70 @@
;;; ludic-mode.el --- Major mode for the Ludic game language -*- lexical-binding: t; -*-
;; Highlighting here is a deliberate minimum: enough for a file to be readable
;; with nothing installed. Everything real -- completion, diagnostics, hover,
;; formatting -- comes from ludic-lsp via eglot (built into Emacs 29) or lsp-mode.
;;
;; Usage:
;; (add-to-list 'load-path "/path/to/gpp/tools/editors/emacs")
;; (require 'ludic-mode)
;;; Code:
(defconst ludic--declaration-keywords
'("game" "module" "import" "component" "archetype" "ui" "scene" "layer"
"const" "var" "export" "fn" "pure" "extern" "system" "edge" "start" "on"))
(defconst ludic--clause-keywords
'("phase" "query" "reads" "writes" "needs" "uses"
"requires" "ensures" "invariant" "effects"))
(defconst ludic--statement-keywords
'("let" "return" "if" "else" "when" "while" "for" "in" "spawn" "despawn"
"match" "machine" "state" "become" "enter" "where" "and" "or"))
(defconst ludic--types '("int" "fixed" "bool" "entity" "str" "ptr" "void"))
(defconst ludic--phases '("Start" "Input" "FixedUpdate" "Update" "LateUpdate" "Render"))
(defconst ludic-font-lock-keywords
(list
(cons (regexp-opt ludic--declaration-keywords 'symbols) 'font-lock-keyword-face)
(cons (regexp-opt ludic--clause-keywords 'symbols) 'font-lock-preprocessor-face)
(cons (regexp-opt ludic--statement-keywords 'symbols) 'font-lock-keyword-face)
(cons (regexp-opt ludic--types 'symbols) 'font-lock-type-face)
(cons (regexp-opt ludic--phases 'symbols) 'font-lock-builtin-face)
(cons "\\_<\\(true\\|false\\)\\_>" 'font-lock-constant-face)
(cons "@[A-Za-z_][A-Za-z0-9_]*" 'font-lock-preprocessor-face)
;; components, archetypes, scenes and UI handles are Capitalised by convention
(cons "\\_<[A-Z][A-Za-z0-9_]*\\_>" 'font-lock-type-face)
(cons "\\_<\\([a-z_][A-Za-z0-9_]*\\)\\s-*(" '(1 font-lock-function-name-face))))
(defvar ludic-mode-syntax-table
(let ((table (make-syntax-table)))
(modify-syntax-entry ?# "<" table)
(modify-syntax-entry ?\n ">" table)
(modify-syntax-entry ?_ "_" table)
(modify-syntax-entry ?\" "\"" table)
table)
"Syntax table for `ludic-mode'.")
;;;###autoload
(define-derived-mode ludic-mode prog-mode "Ludic"
"Major mode for editing Ludic source."
:syntax-table ludic-mode-syntax-table
(setq-local font-lock-defaults '(ludic-font-lock-keywords))
(setq-local comment-start "# ")
(setq-local comment-end "")
(setq-local indent-tabs-mode nil)
(setq-local tab-width 2)
(setq-local standard-indent 2))
;;;###autoload
(add-to-list 'auto-mode-alist '("\\.ludic\\'" . ludic-mode))
;; eglot: one line, and the server does the rest. Markdown is registered too so
;; that ```ludic fences are analysed in prose files.
(with-eval-after-load 'eglot
(add-to-list 'eglot-server-programs '(ludic-mode . ("ludic-lsp" "--stdio"))))
(provide 'ludic-mode)
;;; ludic-mode.el ends here

View file

@ -0,0 +1,24 @@
# Ludic for Helix. Append to ~/.config/helix/languages.toml, adjusting the two
# paths. Highlighting comes from the server's semantic tokens; Helix applies
# them with no grammar to build.
[[language]]
name = "ludic"
scope = "source.ludic"
file-types = ["ludic"]
comment-token = "#"
indent = { tab-width = 2, unit = " " }
language-servers = ["ludic-lsp"]
auto-format = true
roots = ["build.sh", "LANGUAGE.md", ".git"]
[language-server.ludic-lsp]
command = "ludic-lsp"
args = ["--stdio"]
# Ludic fences inside Markdown: point Helix's markdown at the same server so
# `# ```ludic` blocks get diagnostics too. Merge this with your existing
# markdown entry rather than adding a second one.
# [[language]]
# name = "markdown"
# language-servers = ["marksman", "ludic-lsp"]

View file

@ -0,0 +1,58 @@
# Ludic for JetBrains IDEs
Works in IntelliJ IDEA (Community and Ultimate), CLion, GoLand, PyCharm, Rider,
WebStorm — anything on the IntelliJ Platform 2023.2 through 2026.2
(`since-build` 232, `until-build` 262.*).
`until-build` 262 is deliberately ahead of the newest released platform — 2025.3
is build **253**, and no 262 IDE exists yet. That headroom is the point: an
`until-build` pinned to today's IDE gets the plugin auto-disabled the moment a
user upgrades.
Two things are machine-checked rather than assumed, because a plugin that
packages cleanly can still fail to load:
- `./gradlew verifyPlugin` runs JetBrains' own Plugin Verifier over the built
artifact against 2025.3 and reports **Compatible**, with no deprecated or
scheduled-for-removal API usages.
- `./gradlew runIde` boots a real IDEA 2025.3 with the plugin installed. The
sandbox log line to look for is
`Loaded custom plugins: LSP4IJ (0.20.1), Ludic (1.0.0)` in
`.intellijPlatform/sandbox/Ludic/IC-2025.3/log/idea.log`.
**If the plugin does not appear in Settings -> Plugins after installing**, the
cause is almost always that the IDE's build number is outside
`since-build`..`until-build`. IntelliJ installs the declared dependency (LSP4IJ)
from Marketplace first and only then rejects the incompatible plugin, so the
symptom is "LSP4IJ got installed, mine did not". Check **Help -> About** for the
build number and widen `pluginUntilBuild` in `gradle.properties` to match.
## Building
Gradle must run on **JDK 17–21**. A newer JDK fails with a bare version number
as the error message, which is not obvious the first time you see it:
```bash
export JAVA_HOME=$(/usr/libexec/java_home -v 21) # macOS
cd tools/editors/jetbrains
./gradlew buildPlugin # -> build/distributions/Ludic-1.0.0.zip
./gradlew runIde # try it in a sandbox IDE
```
The first build downloads an IntelliJ IDEA Community distribution (over 1 GB),
so expect it to take a while; later builds take seconds. Once the cache is warm,
```bash
LUDIC_TEST_JETBRAINS=1 ./tools/test-tools.sh
```
includes the plugin build in the toolchain's own test run.
Install the zip with **Settings -> Plugins -> ⚙ -> Install Plugin from Disk**.
LSP4IJ is a required dependency; the IDE offers to install it for you.
## Settings
**Settings -> Languages & Frameworks -> Ludic** — the path to `ludic-lsp` and to
`ludicc`. Both default to `build/` under the project root, which is where
`tools/build-tools.sh` puts them.

View file

@ -0,0 +1,96 @@
import org.jetbrains.intellij.platform.gradle.TestFrameworkType
import org.jetbrains.intellij.platform.gradle.IntelliJPlatformType
plugins {
id("java")
id("org.jetbrains.kotlin.jvm") version "2.4.10"
// The IntelliJ Platform Gradle Plugin 2.x. The 1.x line (org.jetbrains.intellij)
// is superseded and does not run on current Gradle.
id("org.jetbrains.intellij.platform") version "2.18.1"
}
group = providers.gradleProperty("pluginGroup").get()
version = providers.gradleProperty("pluginVersion").get()
repositories {
mavenCentral()
intellijPlatform {
defaultRepositories()
}
}
dependencies {
testImplementation("junit:junit:4.13.2")
intellijPlatform {
// Community, deliberately: the plugin must work in the free IDEs, which
// is also why the language server is wired up through LSP4IJ rather
// than com.intellij.platform.lsp (Ultimate and friends only).
// useInstaller = false takes the Maven artifact (com.jetbrains.intellij.idea:ideaIC)
// instead of the .dmg/.tar.gz installer. Since 2025.x the Community installer is
// published as `idea-<version>`, not `ideaIC-<version>`, and the installer path
// cannot find it; the Maven coordinate is unaffected by that rename.
intellijIdeaCommunity(providers.gradleProperty("platformVersion").get()) {
useInstaller = false
}
bundledPlugin("org.intellij.plugins.markdown")
plugin("com.redhat.devtools.lsp4ij", providers.gradleProperty("lsp4ijVersion").get())
// JetBrains' Plugin Verifier CLI, used by the verifyPlugin task below
pluginVerifier()
// headless fixtures for the platform tests in src/test
testFramework(TestFrameworkType.Platform)
}
}
kotlin {
jvmToolchain(17)
}
intellijPlatform {
pluginConfiguration {
name = providers.gradleProperty("pluginName")
version = providers.gradleProperty("pluginVersion")
ideaVersion {
sinceBuild = providers.gradleProperty("pluginSinceBuild")
untilBuild = providers.gradleProperty("pluginUntilBuild")
}
}
// Compatibility is claimed up to pluginUntilBuild, so it gets checked
// against that IDE rather than assumed: `./gradlew verifyPlugin` runs
// JetBrains' own Plugin Verifier over the built artifact.
pluginVerification {
ides {
// The platform this is compiled against, which is also the newest
// that exists (2025.3 = build 253). until-build claims 262, which
// no released IDE reaches yet.
create(IntelliJPlatformType.IntellijIdeaCommunity, providers.gradleProperty("platformVersion").get()) {
useInstaller = false
}
}
}
buildSearchableOptions = false
// The platform's bytecode instrumenter only adds @NotNull assertions and
// compiles .form files. This plugin is Kotlin with no GUI forms, so it has
// nothing to instrument — and skipping it avoids the instrumenter's JBR
// probing, which fails on a plain JDK layout.
instrumentCode = false
}
// `./gradlew runIde` opens the Ludic repository itself, so the sandbox has real
// .ludic files to look at and build/ludic-lsp sitting where the plugin expects.
// Three levels up: jetbrains -> editors -> tools -> repository root. Getting
// this wrong points the IDE at tools/, where no build/ludic-lsp exists, and the
// language server then cannot find itself.
val repoRoot: File = rootProject.projectDir.parentFile.parentFile.parentFile
tasks.named<JavaExec>("runIde") {
args = listOf(repoRoot.absolutePath)
}
tasks.test {
useJUnit()
testLogging {
events("passed", "failed", "skipped")
showStandardStreams = false
}
}

View file

@ -0,0 +1,26 @@
pluginGroup = io.ludic.ide
pluginName = Ludic
pluginVersion = 1.0.0
# 2023.2 is the first build with the platform APIs this plugin uses. LSP4IJ (not
# the paid-IDE LSP API) supplies the language-server bridge, so Community
# editions are supported as well as Ultimate.
pluginSinceBuild = 232
# Deliberately ahead of the newest released platform (2025.3 is build 253): an
# until-build that stops at today's IDE gets the plugin auto-disabled the moment
# a user upgrades. 262 buys roughly three release cycles of headroom. The APIs
# this plugin uses are the stable ones (LexerBase, SyntaxHighlighter,
# ParserDefinition, FoldingBuilder), and everything project-wide is delegated to
# ludic-lsp over a versioned protocol rather than to platform internals.
pluginUntilBuild = 262.*
platformVersion = 2025.3
lsp4ijVersion = 0.20.1
# Gradle itself must run on JDK 17-21; a newer JDK reports only its version
# number as the failure. The Kotlin toolchain below is pinned separately.
# The wrapper is Gradle 9.x because IntelliJ Platform Gradle Plugin 2.13+
# requires it.
org.gradle.jvmargs = -Xmx2g
org.gradle.caching = true
kotlin.stdlib.default.dependency = false

Binary file not shown.

View file

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
tools/editors/jetbrains/gradlew vendored Executable file
View file

@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

93
tools/editors/jetbrains/gradlew.bat vendored Normal file
View file

@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -0,0 +1,2 @@
// The distribution is named after this, so it is the plugin name, not a slug.
rootProject.name = "Ludic"

View file

@ -0,0 +1,83 @@
package io.ludic.ide
import com.intellij.openapi.options.Configurable
import com.intellij.openapi.project.Project
import com.intellij.openapi.ui.TextFieldWithBrowseButton
import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory
import com.intellij.ui.components.JBCheckBox
import com.intellij.ui.components.JBLabel
import com.intellij.util.ui.FormBuilder
import javax.swing.JComponent
import javax.swing.JPanel
import javax.swing.JSpinner
import javax.swing.SpinnerNumberModel
/** Settings -> Languages and Frameworks -> Ludic */
class LudicConfigurable(private val project: Project) : Configurable {
private val serverField = TextFieldWithBrowseButton()
private val compilerField = TextFieldWithBrowseButton()
private val diagnostics = JBCheckBox("Report compiler errors on save")
private val indent = JSpinner(SpinnerNumberModel(2, 1, 8, 1))
private var panel: JPanel? = null
override fun getDisplayName() = "Ludic"
override fun createComponent(): JComponent {
// The title travels on the descriptor now; the four-argument overload
// that took it separately is scheduled for removal.
serverField.addBrowseFolderListener(
project,
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle("Path to ludic-lsp")
)
compilerField.addBrowseFolderListener(
project,
FileChooserDescriptorFactory.createSingleFileNoJarsDescriptor().withTitle("Path to ludicc")
)
panel = FormBuilder.createFormBuilder()
.addLabeledComponent(JBLabel("Language server (ludic-lsp):"), serverField, 1, false)
.addComponentToRightColumn(
JBLabel("Leave empty to use build/ludic-lsp under the project, then PATH.")
)
.addLabeledComponent(JBLabel("Compiler (ludicc):"), compilerField, 1, false)
.addComponentToRightColumn(
JBLabel("Used for authoritative diagnostics. Empty means build/ludicc.")
)
.addComponent(diagnostics)
.addLabeledComponent(JBLabel("Formatter indent:"), indent, 1, false)
.addComponentFillVertically(JPanel(), 0)
.panel
reset()
return panel!!
}
private fun settings() = LudicSettings.of(project)
override fun isModified(): Boolean {
val s = settings()
return serverField.text != s.serverPath ||
compilerField.text != s.compilerPath ||
diagnostics.isSelected != s.compilerDiagnostics ||
(indent.value as Int) != s.indentSize
}
override fun apply() {
val s = settings()
s.serverPath = serverField.text.trim()
s.compilerPath = compilerField.text.trim()
s.compilerDiagnostics = diagnostics.isSelected
s.indentSize = indent.value as Int
}
override fun reset() {
val s = settings()
serverField.text = s.serverPath
compilerField.text = s.compilerPath
diagnostics.isSelected = s.compilerDiagnostics
indent.value = s.indentSize
}
override fun disposeUIResources() {
panel = null
}
}

View file

@ -0,0 +1,85 @@
package io.ludic.ide
import com.intellij.lang.BracePair
import com.intellij.lang.Commenter
import com.intellij.lang.PairedBraceMatcher
import com.intellij.lang.folding.FoldingBuilderEx
import com.intellij.lang.folding.FoldingDescriptor
import com.intellij.openapi.editor.Document
import com.intellij.openapi.project.DumbAware
import com.intellij.openapi.util.TextRange
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import com.intellij.psi.tree.IElementType
/**
* The small editor mechanics the platform expects a language to provide: how to
* comment a line, which delimiters pair up, and what can be folded. None of
* this needs the language server, so none of it waits for one.
*/
class LudicCommenter : Commenter {
override fun getLineCommentPrefix() = "# "
override fun getBlockCommentPrefix(): String? = null
override fun getBlockCommentSuffix(): String? = null
override fun getCommentedBlockCommentPrefix(): String? = null
override fun getCommentedBlockCommentSuffix(): String? = null
}
class LudicBraceMatcher : PairedBraceMatcher {
override fun getPairs(): Array<BracePair> = PAIRS
override fun isPairedBracesAllowedBeforeType(lbraceType: IElementType, next: IElementType?): Boolean = true
override fun getCodeConstructStart(file: PsiFile?, openingBraceOffset: Int): Int = openingBraceOffset
private companion object {
// Structural braces only: a record literal shares the token type, so
// marking them all structural would make the "matched brace" gutter
// noisy inside `spawn Hero { Pos = { x = 1 } }`.
val PAIRS = arrayOf(
BracePair(LudicTokens.BRACE, LudicTokens.BRACE, true),
BracePair(LudicTokens.BRACKET, LudicTokens.BRACKET, false),
BracePair(LudicTokens.PAREN, LudicTokens.PAREN, false)
)
}
}
/**
* Folds every braced block. Working off the lexer's token stream rather than a
* PSI tree keeps this correct for a flat PSI, and it degrades gracefully on a
* file with unbalanced braces, which is most files while they are being typed.
*/
class LudicFoldingBuilder : FoldingBuilderEx(), DumbAware {
override fun buildFoldRegions(root: PsiElement, document: Document, quick: Boolean): Array<FoldingDescriptor> {
val text = root.containingFile?.text ?: return emptyArray()
val lexer = LudicLexer()
lexer.start(text, 0, text.length, 0)
val opens = ArrayDeque<Int>()
val regions = mutableListOf<FoldingDescriptor>()
val node = root.node ?: return emptyArray()
while (lexer.tokenType != null) {
if (lexer.tokenType == LudicTokens.BRACE) {
when (text[lexer.tokenStart]) {
'{' -> opens.addLast(lexer.tokenStart)
'}' -> {
val open = opens.removeLastOrNull()
if (open != null) {
val end = lexer.tokenEnd
// only worth folding when it actually spans lines
if (document.getLineNumber(end) > document.getLineNumber(open)) {
regions += FoldingDescriptor(node, TextRange(open, end))
}
}
}
}
}
lexer.advance()
}
return regions.toTypedArray()
}
override fun getPlaceholderText(node: com.intellij.lang.ASTNode) = "{...}"
override fun isCollapsedByDefault(node: com.intellij.lang.ASTNode) = false
}

View file

@ -0,0 +1,32 @@
package io.ludic.ide
import com.intellij.lang.Language
import com.intellij.openapi.fileTypes.LanguageFileType
import com.intellij.openapi.util.IconLoader
import com.intellij.openapi.vfs.VirtualFile
import javax.swing.Icon
/**
* The language ID matters more than it looks: the IDE's Markdown support
* resolves a fenced block's info string ("```ludic") against registered
* language IDs, so registering as "Ludic" is what makes fences in .md files
* highlight, with no Markdown-specific code on our side.
*/
object LudicLanguage : Language("Ludic") {
private fun readResolve(): Any = LudicLanguage
override fun getDisplayName() = "Ludic"
override fun isCaseSensitive() = true
}
object LudicIcons {
val FILE: Icon = IconLoader.getIcon("/icons/ludic.svg", LudicIcons::class.java)
}
object LudicFileType : LanguageFileType(LudicLanguage) {
private fun readResolve(): Any = LudicFileType
override fun getName() = "Ludic"
override fun getDescription() = "Ludic source"
override fun getDefaultExtension() = "ludic"
override fun getIcon(): Icon = LudicIcons.FILE
override fun getCharset(file: VirtualFile, content: ByteArray): String = "UTF-8"
}

View file

@ -0,0 +1,148 @@
package io.ludic.ide
import com.intellij.lexer.LexerBase
import com.intellij.psi.TokenType
import com.intellij.psi.tree.IElementType
/**
* A hand-written lexer matching tools/ludic-tools/ludic_syntax.h token for
* token. Hand-written rather than generated because the grammar is small, the
* rules are the ones the C lexer already implements, and keeping the two
* readable side by side is worth more here than a JFlex spec.
*
* It must never throw and never fail to advance: the IDE lexes half-typed text
* constantly, so anything unrecognised becomes one BAD character and the scan
* carries on.
*/
class LudicLexer : LexerBase() {
private var buffer: CharSequence = ""
private var startOffset = 0
private var endOffset = 0
private var tokenStart = 0
private var tokenEnd = 0
private var tokenType: IElementType? = null
override fun start(buffer: CharSequence, startOffset: Int, endOffset: Int, initialState: Int) {
this.buffer = buffer
this.startOffset = startOffset
this.endOffset = endOffset
this.tokenStart = startOffset
this.tokenEnd = startOffset
advance()
}
override fun getState() = 0
override fun getTokenType(): IElementType? = tokenType
override fun getTokenStart() = tokenStart
override fun getTokenEnd() = tokenEnd
override fun getBufferSequence() = buffer
override fun getBufferEnd() = endOffset
private fun at(i: Int): Char = if (i in startOffset until endOffset) buffer[i] else ' '
private fun isIdStart(c: Char) = c.isLetter() || c == '_'
private fun isIdPart(c: Char) = c.isLetterOrDigit() || c == '_'
private fun isSpace(c: Char) = c == ' ' || c == '\t' || c == '\r' || c == '\n'
override fun advance() {
tokenStart = tokenEnd
if (tokenStart >= endOffset) {
tokenType = null
return
}
var i = tokenStart
val c = at(i)
if (isSpace(c)) {
while (i < endOffset && isSpace(at(i))) i++
emit(i, TokenType.WHITE_SPACE)
return
}
if (c == '#') {
while (i < endOffset && at(i) != '\n') i++
emit(i, LudicTokens.COMMENT)
return
}
if (c == '"') {
i++
while (i < endOffset && at(i) != '"' && at(i) != '\n') {
if (at(i) == '\\' && i + 1 < endOffset) i += 2 else i++
}
if (i < endOffset && at(i) == '"') i++
emit(i, LudicTokens.STRING)
return
}
if (c == '\'') {
i++
if (at(i) == '\\' && i + 1 < endOffset) i += 2 else if (i < endOffset && at(i) != '\n') i++
if (i < endOffset && at(i) == '\'') i++
emit(i, LudicTokens.CHAR)
return
}
if (c.isDigit()) {
if (c == '0' && (at(i + 1) == 'x' || at(i + 1) == 'X')) {
i += 2
while (i < endOffset && (at(i).isDigit() || at(i) in 'a'..'f' || at(i) in 'A'..'F')) i++
} else {
while (i < endOffset && at(i).isDigit()) i++
if (at(i) == '.' && at(i + 1).isDigit()) {
i++
while (i < endOffset && at(i).isDigit()) i++
}
}
emit(i, LudicTokens.NUMBER)
return
}
// @deterministic is one token: the sigil never stands alone
if (c == '@' && isIdStart(at(i + 1))) {
i++
while (i < endOffset && isIdPart(at(i))) i++
emit(i, LudicTokens.ANNOTATION)
return
}
if (isIdStart(c)) {
while (i < endOffset && isIdPart(at(i))) i++
val word = buffer.subSequence(tokenStart, i).toString()
val type = when {
word == "true" || word == "false" -> LudicTokens.BOOLEAN
word in LudicVocabulary.PRIMITIVES -> LudicTokens.PRIMITIVE
word in LudicVocabulary.PHASES -> LudicTokens.PHASE
word in LudicVocabulary.DECL -> LudicTokens.DECL_KEYWORD
word in LudicVocabulary.CLAUSE -> LudicTokens.CLAUSE
word in LudicVocabulary.STMT -> LudicTokens.KEYWORD
word in LudicVocabulary.BUILTINS -> LudicTokens.BUILTIN
word[0].isUpperCase() -> LudicTokens.TYPE_NAME
else -> LudicTokens.IDENTIFIER
}
emit(i, type)
return
}
// two-character operators before one-character ones
val two = if (i + 1 < endOffset) buffer.subSequence(i, i + 2).toString() else ""
if (two in TWO_CHAR_OPS) {
emit(i + 2, LudicTokens.OPERATOR)
return
}
when (c) {
'{', '}' -> { emit(i + 1, LudicTokens.BRACE); return }
'[', ']' -> { emit(i + 1, LudicTokens.BRACKET); return }
'(', ')' -> { emit(i + 1, LudicTokens.PAREN); return }
',' -> { emit(i + 1, LudicTokens.COMMA); return }
}
if (c in SINGLE_CHAR_OPS) {
emit(i + 1, LudicTokens.OPERATOR)
return
}
emit(i + 1, LudicTokens.BAD)
}
private fun emit(end: Int, type: IElementType) {
tokenEnd = end
tokenType = type
}
private companion object {
val TWO_CHAR_OPS = setOf("->", "+=", "-=", "*=", "/=", "==", "!=", "<=", ">=", "&&", "||", "..", "=>")
const val SINGLE_CHAR_OPS = "+-*/%<>=:.!@;"
}
}

View file

@ -0,0 +1,52 @@
package io.ludic.ide
import com.intellij.lang.ASTNode
import com.intellij.lang.ParserDefinition
import com.intellij.lang.PsiParser
import com.intellij.lexer.Lexer
import com.intellij.openapi.project.Project
import com.intellij.psi.FileViewProvider
import com.intellij.psi.PsiElement
import com.intellij.psi.PsiFile
import com.intellij.psi.TokenType
import com.intellij.psi.tree.IFileElementType
import com.intellij.psi.tree.TokenSet
import com.intellij.extapi.psi.ASTWrapperPsiElement
import com.intellij.extapi.psi.PsiFileBase
/**
* A flat PSI: every token becomes a leaf under the file node, with no tree
* structure above it.
*
* That is enough for the two things the platform genuinely needs a parser for —
* injecting Ludic into Markdown fences, and letting the editor treat a .ludic
* file as a real language rather than plain text — while all actual analysis
* stays in ludic-lsp, where it is shared with every other editor. Building a
* second, divergent parser in Kotlin would mean two grammars to keep honest
* against one compiler.
*/
class LudicParserDefinition : ParserDefinition {
override fun createLexer(project: Project?): Lexer = LudicLexer()
override fun createParser(project: Project?): PsiParser = PsiParser { root, builder ->
val mark = builder.mark()
while (!builder.eof()) builder.advanceLexer()
mark.done(root)
builder.treeBuilt
}
override fun getFileNodeType(): IFileElementType = LudicTokens.FILE
override fun getCommentTokens(): TokenSet = TokenSet.create(LudicTokens.COMMENT)
override fun getStringLiteralElements(): TokenSet = TokenSet.create(LudicTokens.STRING, LudicTokens.CHAR)
override fun getWhitespaceTokens(): TokenSet = TokenSet.create(TokenType.WHITE_SPACE)
// Only ever reached for composite nodes, and this grammar marks none beyond
// the file itself — but the platform requires an answer, so give it a real
// PSI element rather than something that would surprise it later.
override fun createElement(node: ASTNode): PsiElement = ASTWrapperPsiElement(node)
override fun createFile(viewProvider: FileViewProvider): PsiFile = LudicPsiFile(viewProvider)
}
class LudicPsiFile(viewProvider: FileViewProvider) : PsiFileBase(viewProvider, LudicLanguage) {
override fun getFileType() = LudicFileType
override fun toString() = "Ludic file"
}

View file

@ -0,0 +1,158 @@
package io.ludic.ide
import com.intellij.openapi.components.PersistentStateComponent
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.State
import com.intellij.openapi.components.Storage
import com.intellij.openapi.components.service
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.util.xmlb.XmlSerializerUtil
import com.redhat.devtools.lsp4ij.LanguageServerFactory
import com.redhat.devtools.lsp4ij.client.LanguageClientImpl
import com.redhat.devtools.lsp4ij.server.CannotStartProcessException
import com.redhat.devtools.lsp4ij.server.ProcessStreamConnectionProvider
import com.redhat.devtools.lsp4ij.server.StreamConnectionProvider
import java.io.File
/**
* Where to find the toolchain. Both paths may be left blank, in which case the
* plugin looks under the project root for what tools/build-tools.sh produces —
* which is the case for anyone working in the Ludic repository itself.
*/
@Service(Service.Level.PROJECT)
@State(name = "LudicSettings", storages = [Storage("ludic.xml")])
class LudicSettings : PersistentStateComponent<LudicSettings> {
var serverPath: String = ""
var compilerPath: String = ""
var compilerDiagnostics: Boolean = true
var indentSize: Int = 2
override fun getState(): LudicSettings = this
override fun loadState(state: LudicSettings) = XmlSerializerUtil.copyBean(state, this)
companion object {
fun of(project: Project): LudicSettings = project.service()
}
}
/**
* First existing executable among the usual places, or null.
*
* Takes the project root as a plain path rather than a Project so it can be
* tested without booting an IDE — this lookup silently returning null is the
* difference between a working language server and one that never starts.
*/
internal fun findExecutable(root: String?, configured: String, names: List<String>, subdirs: List<String>): String? {
if (configured.isNotBlank()) {
val f = File(expandHome(configured))
return if (f.canExecute()) f.absolutePath else null
}
if (root == null) return searchPath(names)
for (dir in subdirs) {
for (name in names) {
val f = File(File(root, dir), name)
if (f.isFile && f.canExecute()) return f.absolutePath
}
}
return searchPath(names)
}
/** Fall back to PATH, so a system-wide install works with no configuration. */
private fun searchPath(names: List<String>): String? {
val path = System.getenv("PATH") ?: return null
for (dir in path.split(File.pathSeparator)) {
for (name in names) {
val f = File(dir, name)
if (f.isFile && f.canExecute()) return f.absolutePath
}
}
return null
}
private fun expandHome(p: String) =
if (p.startsWith("~")) System.getProperty("user.home") + p.substring(1) else p
class LudicConnectionProvider(private val project: Project) : ProcessStreamConnectionProvider() {
/**
* Resolve at start(), not at construction.
*
* The provider is built when the project opens, which is usually before the
* toolchain has been compiled. Resolving in an `init` block froze that
* answer forever: build ludic-lsp afterwards and the server stayed dead
* until the IDE was restarted, with `commands=[]` as the only clue.
*/
override fun start() {
val settings = LudicSettings.of(project)
val root = project.basePath
setCommands(serverCommand(root, settings.serverPath)
?: throw CannotStartProcessException(notFoundMessage(root, settings.serverPath)))
root?.let { setWorkingDirectory(it) }
super.start()
}
/**
* What went wrong, in terms of the user's filesystem. "commands=[]" tells
* nobody anything; this names every path that was tried.
*/
private fun notFoundMessage(root: String?, configured: String): String = buildString {
if (configured.isNotBlank()) {
append("The Ludic language server configured at '")
append(configured)
append("' is missing or not executable.")
} else {
append("Could not find the 'ludic-lsp' executable. Looked in:\n")
SERVER_SUBDIRS.forEach { d ->
append(" ")
append(if (root != null) File(File(root, d), "ludic-lsp").path else "<no project root>/$d/ludic-lsp")
append('\n')
}
append(" and on PATH.\n")
}
append("\nBuild it with ./tools/build-tools.sh from the repository root, ")
append("or set the path in Settings | Languages & Frameworks | Ludic. ")
append("Syntax highlighting works without it; completion and diagnostics do not.")
}
/**
* Passed to the server's `initialize`. The server can find `ludicc` on its
* own, but telling it explicitly means the IDE's setting wins.
*/
override fun getInitializationOptions(rootUri: VirtualFile?): Any {
val settings = LudicSettings.of(project)
val compiler = findExecutable(
project.basePath, settings.compilerPath,
listOf("ludicc", "ludicc.exe"),
COMPILER_SUBDIRS
) ?: ""
return mapOf(
"compilerPath" to compiler,
"compilerDiagnostics" to settings.compilerDiagnostics,
"indentSize" to settings.indentSize
)
}
}
/**
* The exact argv the language server is launched with, or null when the binary
* cannot be found. Split out from start() so the command line itself is
* testable — an empty or wrong command here is invisible until LSP4IJ reports
* `commands=[]`, which says nothing about why.
*/
internal fun serverCommand(root: String?, configured: String): List<String>? {
val exe = findExecutable(root, configured, listOf("ludic-lsp", "ludic-lsp.exe"), SERVER_SUBDIRS)
return exe?.let { listOf(it, "--stdio") }
}
/** Where the toolchain is looked for, relative to the project root. */
internal val SERVER_SUBDIRS = listOf("build", "tools/build", "bin", ".")
internal val COMPILER_SUBDIRS = listOf("build", "bin", ".")
class LudicLanguageServerFactory : LanguageServerFactory {
override fun createConnectionProvider(project: Project): StreamConnectionProvider =
LudicConnectionProvider(project)
override fun createLanguageClient(project: Project): LanguageClientImpl =
LanguageClientImpl(project)
}

View file

@ -0,0 +1,76 @@
package io.ludic.ide
import com.intellij.lexer.Lexer
import com.intellij.openapi.editor.DefaultLanguageHighlighterColors as D
import com.intellij.openapi.editor.colors.TextAttributesKey
import com.intellij.openapi.editor.colors.TextAttributesKey.createTextAttributesKey
import com.intellij.openapi.editor.HighlighterColors
import com.intellij.openapi.fileTypes.SyntaxHighlighter
import com.intellij.openapi.fileTypes.SyntaxHighlighterBase
import com.intellij.openapi.fileTypes.SyntaxHighlighterFactory
import com.intellij.openapi.project.Project
import com.intellij.openapi.vfs.VirtualFile
import com.intellij.psi.tree.IElementType
/**
* Colours are all derived from the IDE's own semantic keys rather than hard
* coded, so Ludic looks native in whatever theme the user has, dark or light,
* without shipping a colour scheme.
*/
object LudicColors {
val COMMENT = key("LUDIC_COMMENT", D.LINE_COMMENT)
val STRING = key("LUDIC_STRING", D.STRING)
val NUMBER = key("LUDIC_NUMBER", D.NUMBER)
val KEYWORD = key("LUDIC_KEYWORD", D.KEYWORD)
val DECL_KEYWORD = key("LUDIC_DECL_KEYWORD", D.KEYWORD)
val CLAUSE = key("LUDIC_CLAUSE", D.METADATA)
val PRIMITIVE = key("LUDIC_PRIMITIVE", D.KEYWORD)
val PHASE = key("LUDIC_PHASE", D.STATIC_FIELD)
val BOOLEAN = key("LUDIC_BOOLEAN", D.KEYWORD)
val BUILTIN = key("LUDIC_BUILTIN", D.PREDEFINED_SYMBOL)
val TYPE_NAME = key("LUDIC_TYPE", D.CLASS_NAME)
val IDENTIFIER = key("LUDIC_IDENTIFIER", D.IDENTIFIER)
val ANNOTATION = key("LUDIC_ANNOTATION", D.METADATA)
val OPERATOR = key("LUDIC_OPERATOR", D.OPERATION_SIGN)
val BRACE = key("LUDIC_BRACE", D.BRACES)
val BRACKET = key("LUDIC_BRACKET", D.BRACKETS)
val PAREN = key("LUDIC_PAREN", D.PARENTHESES)
val COMMA = key("LUDIC_COMMA", D.COMMA)
val BAD = createTextAttributesKey("LUDIC_BAD_CHARACTER", HighlighterColors.BAD_CHARACTER)
private fun key(name: String, base: TextAttributesKey) = createTextAttributesKey(name, base)
}
class LudicSyntaxHighlighter : SyntaxHighlighterBase() {
override fun getHighlightingLexer(): Lexer = LudicLexer()
override fun getTokenHighlights(tokenType: IElementType): Array<TextAttributesKey> = pack(
when (tokenType) {
LudicTokens.COMMENT -> LudicColors.COMMENT
LudicTokens.STRING, LudicTokens.CHAR -> LudicColors.STRING
LudicTokens.NUMBER -> LudicColors.NUMBER
LudicTokens.KEYWORD -> LudicColors.KEYWORD
LudicTokens.DECL_KEYWORD -> LudicColors.DECL_KEYWORD
LudicTokens.CLAUSE -> LudicColors.CLAUSE
LudicTokens.PRIMITIVE -> LudicColors.PRIMITIVE
LudicTokens.PHASE -> LudicColors.PHASE
LudicTokens.BOOLEAN -> LudicColors.BOOLEAN
LudicTokens.BUILTIN -> LudicColors.BUILTIN
LudicTokens.TYPE_NAME -> LudicColors.TYPE_NAME
LudicTokens.IDENTIFIER -> LudicColors.IDENTIFIER
LudicTokens.ANNOTATION -> LudicColors.ANNOTATION
LudicTokens.OPERATOR -> LudicColors.OPERATOR
LudicTokens.BRACE -> LudicColors.BRACE
LudicTokens.BRACKET -> LudicColors.BRACKET
LudicTokens.PAREN -> LudicColors.PAREN
LudicTokens.COMMA -> LudicColors.COMMA
LudicTokens.BAD -> LudicColors.BAD
else -> null
}
)
}
class LudicSyntaxHighlighterFactory : SyntaxHighlighterFactory() {
override fun getSyntaxHighlighter(project: Project?, virtualFile: VirtualFile?): SyntaxHighlighter =
LudicSyntaxHighlighter()
}

View file

@ -0,0 +1,81 @@
package io.ludic.ide
import com.intellij.psi.tree.IElementType
import com.intellij.psi.tree.IFileElementType
/**
* One token type per thing the highlighter needs to tell apart. This is a
* lexer-only language on the JetBrains side: the token set is the whole grammar
* the platform sees, and ludic-lsp supplies everything structural.
*/
class LudicTokenType(debugName: String) : IElementType(debugName, LudicLanguage)
object LudicTokens {
val FILE = IFileElementType(LudicLanguage)
val COMMENT = LudicTokenType("COMMENT")
val STRING = LudicTokenType("STRING")
val CHAR = LudicTokenType("CHAR")
val NUMBER = LudicTokenType("NUMBER")
val IDENTIFIER = LudicTokenType("IDENTIFIER")
val TYPE_NAME = LudicTokenType("TYPE_NAME") // Capitalised: component, archetype, scene
val KEYWORD = LudicTokenType("KEYWORD")
val DECL_KEYWORD = LudicTokenType("DECL_KEYWORD") // game, component, system, fn, ...
val CLAUSE = LudicTokenType("CLAUSE") // phase, query, reads, writes, ...
val PRIMITIVE = LudicTokenType("PRIMITIVE") // int, fixed, bool, entity, str, ptr, void
val PHASE = LudicTokenType("PHASE") // Start, Update, Render, ...
val BOOLEAN = LudicTokenType("BOOLEAN")
val BUILTIN = LudicTokenType("BUILTIN") // runtime surface + compiler intrinsics
val ANNOTATION = LudicTokenType("ANNOTATION") // @deterministic
val OPERATOR = LudicTokenType("OPERATOR")
val BRACE = LudicTokenType("BRACE")
val BRACKET = LudicTokenType("BRACKET")
val PAREN = LudicTokenType("PAREN")
val COMMA = LudicTokenType("COMMA")
val BAD = LudicTokenType("BAD_CHARACTER")
}
/**
* The vocabulary, mirroring tools/ludic-tools/ludic_syntax.h. When the language
* grows a keyword both sides change together; that header is the reference.
*/
object LudicVocabulary {
val DECL = setOf(
"game", "module", "import", "component", "struct", "archetype", "ui", "scene", "layer",
"const", "var", "export", "fn", "pure", "extern", "system", "edge", "start", "on"
)
val CLAUSE = setOf(
"phase", "query", "reads", "writes", "needs", "uses",
"requires", "ensures", "invariant", "effects"
)
val STMT = setOf(
"let", "return", "if", "else", "when", "while", "for", "in", "spawn", "despawn",
"match", "machine", "state", "become", "enter", "where", "and", "or", "not",
"break", "continue", "new"
)
val PRIMITIVES = setOf("int", "fixed", "bool", "entity", "str", "ptr", "void")
val PHASES = setOf("Start", "Input", "FixedUpdate", "Update", "LateUpdate", "Render")
val WIDGETS = setOf("panel", "col", "row", "label", "button", "image", "spacer")
val BUILTINS = setOf(
"min", "max", "abs", "clamp", "seed", "rng_range", "rng_chance", "fx", "flr",
"map_size", "map_row", "tile", "clear", "present", "fill_rect", "frame_rect",
"put_px", "text", "text_int", "font_load", "text_ttf", "text_w", "text_h",
"image_load", "draw_image", "draw_image_scaled", "draw_9slice", "load_png",
"load_sprites", "draw_sprite", "draw_sprite_scaled", "ui_build", "ui_open",
"ui_tick", "ui_render", "ui_clicked", "ui_set_text", "ui_set_int", "ui_focus",
"ui_focused", "ui_visible", "key", "reg", "setreg", "self", "save", "load",
"status", "print_int", "quit",
// compiler intrinsics: the floor the Ludic-written runtime stands on
"mem_alloc", "mem_realloc", "mem_free", "mem_copy", "mem_set", "peek8", "poke8", "peek32",
"os_argc", "os_arg", "file_stderr",
"poke32", "peekp", "pokep", "peekf", "pokef", "ptr_add", "ptr_null",
"ptr_is_null", "as_fixed", "as_int", "file_open", "file_read", "file_write",
"file_seek", "file_tell", "file_close", "read_byte", "write_byte", "print_str",
"str_len", "shl", "shr", "band", "bor", "bxor", "bnot", "os_exit", "os_time",
"is_windowed", "game_title", "win_open", "win_poll", "win_present",
"win_running", "win_close"
)
fun isKeyword(w: String) = w in DECL || w in CLAUSE || w in STMT
}

View file

@ -0,0 +1,14 @@
<idea-plugin>
<!--
Loaded only when the Markdown plugin is present.
Nothing needs to be declared for ```ludic fences to highlight: the
Markdown plugin resolves a fence's info string against registered language
IDs, and io.ludic.ide.LudicLanguage registers as "Ludic". This file exists
so that the dependency is expressed honestly — the feature does depend on
the Markdown plugin — and as the place to hang Markdown-specific behaviour
if the fence syntax ever needs more than the default.
-->
<extensions defaultExtensionNs="com.intellij">
</extensions>
</idea-plugin>

View file

@ -0,0 +1,78 @@
<idea-plugin>
<id>io.ludic.ide</id>
<name>Ludic</name>
<vendor>Ludic</vendor>
<description><![CDATA[
<p>Language support for <b>Ludic</b>, the AI-first, ECS-native game language.</p>
<ul>
<li>Syntax highlighting, brace matching, commenting and folding, implemented natively so a
<code>.ludic</code> file is coloured with no external process.</li>
<li>Completion, diagnostics, hover, go-to-definition, find usages, rename and formatting via
the <code>ludic-lsp</code> language server, through LSP4IJ, so this works in Community
editions as well as the paid IDEs.</li>
<li>Errors from the Ludic compiler itself, reported on save.</li>
<li>Highlighting of <code>```ludic</code> fenced blocks in Markdown.</li>
</ul>
<p>Set the paths under <b>Settings &rarr; Languages &amp; Frameworks &rarr; Ludic</b>, or leave
them blank to use <code>build/ludic-lsp</code> and <code>build/ludicc</code> under the
project root.</p>
]]></description>
<depends>com.intellij.modules.platform</depends>
<depends>com.redhat.devtools.lsp4ij</depends>
<!-- Optional: without it everything still works, we just do not get the
Markdown fence injection that the platform's own Markdown plugin does
for any registered language. -->
<depends optional="true" config-file="ludic-markdown.xml">org.intellij.plugins.markdown</depends>
<extensions defaultExtensionNs="com.intellij">
<fileType
name="Ludic"
implementationClass="io.ludic.ide.LudicFileType"
fieldName="INSTANCE"
language="Ludic"
extensions="ludic"/>
<lang.parserDefinition
language="Ludic"
implementationClass="io.ludic.ide.LudicParserDefinition"/>
<lang.syntaxHighlighterFactory
language="Ludic"
implementationClass="io.ludic.ide.LudicSyntaxHighlighterFactory"/>
<lang.commenter
language="Ludic"
implementationClass="io.ludic.ide.LudicCommenter"/>
<lang.braceMatcher
language="Ludic"
implementationClass="io.ludic.ide.LudicBraceMatcher"/>
<lang.foldingBuilder
language="Ludic"
implementationClass="io.ludic.ide.LudicFoldingBuilder"/>
<projectConfigurable
parentId="language"
instance="io.ludic.ide.LudicConfigurable"
id="io.ludic.ide.settings"
displayName="Ludic"/>
</extensions>
<extensions defaultExtensionNs="com.redhat.devtools.lsp4ij">
<server
id="ludic"
name="Ludic"
factoryClass="io.ludic.ide.LudicLanguageServerFactory">
<description><![CDATA[
The Ludic language server. Built by <code>tools/build-tools.sh</code>.
]]></description>
</server>
<languageMapping language="Ludic" serverId="ludic"/>
<!-- Markdown is mapped too: the server understands ```ludic fences in a
.md and reports diagnostics and semantic tokens for them. -->
<languageMapping language="Markdown" serverId="ludic"/>
</extensions>
</idea-plugin>

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect width="32" height="32" rx="6" fill="#1a1a2c"/>
<rect x="7" y="7" width="8" height="8" rx="1.5" fill="#ffe060"/>
<rect x="17" y="7" width="8" height="8" rx="1.5" fill="#4a4a72"/>
<rect x="7" y="17" width="8" height="8" rx="1.5" fill="#4a4a72"/>
<rect x="17" y="17" width="8" height="8" rx="1.5" fill="#9aa0c8"/>
</svg>

After

Width:  |  Height:  |  Size: 418 B

View file

@ -0,0 +1,155 @@
package io.ludic.ide
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
/**
* Locating `ludic-lsp` is the single point where the whole language server can
* fail silently: if this returns null the provider starts with an empty command
* list, LSP4IJ reports `commands=[]`, and nothing in that message points at the
* cause. It shipped broken once for exactly that reason, so it gets tests.
*/
class FindExecutableTest {
@get:Rule
val tmp = TemporaryFolder()
private val names = listOf("ludic-lsp", "ludic-lsp.exe")
private fun executableAt(vararg segments: String): File {
val f = File(tmp.root, segments.joinToString(File.separator))
f.parentFile.mkdirs()
f.writeText("#!/bin/sh\n")
check(f.setExecutable(true))
return f
}
@Test
fun `finds the binary that build-tools_sh produces`() {
val exe = executableAt("build", "ludic-lsp")
assertEquals(exe.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
}
@Test
fun `a project root that is a subdirectory of the repo finds nothing`() {
// The bug that shipped: the IDE was opened on <repo>/tools, so the
// lookup searched <repo>/tools/build and came back empty while
// <repo>/build/ludic-lsp sat there the whole time.
executableAt("build", "ludic-lsp")
val subdir = File(tmp.root, "tools").apply { mkdirs() }
assertNull(
"must not silently resolve from an unrelated root",
findExecutable(subdir.path, "", names, SERVER_SUBDIRS)
)
}
@Test
fun `search order prefers build over the other candidates`() {
executableAt("bin", "ludic-lsp")
val preferred = executableAt("build", "ludic-lsp")
assertEquals(preferred.absolutePath, findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
}
@Test
fun `every documented subdirectory is actually searched`() {
for (dir in SERVER_SUBDIRS) {
val fresh = TemporaryFolder().apply { create() }
val f = File(fresh.root, "$dir${File.separator}ludic-lsp")
f.parentFile.mkdirs()
f.writeText("#!/bin/sh\n")
f.setExecutable(true)
assertEquals(
"subdir '$dir' is advertised but not searched",
f.canonicalPath,
File(findExecutable(fresh.root.path, "", names, SERVER_SUBDIRS)!!).canonicalPath
)
fresh.delete()
}
}
@Test
fun `an explicitly configured path wins over anything found by search`() {
executableAt("build", "ludic-lsp")
val custom = executableAt("elsewhere", "my-lsp")
assertEquals(custom.absolutePath, findExecutable(tmp.root.path, custom.absolutePath, names, SERVER_SUBDIRS))
}
@Test
fun `a configured path that does not exist fails instead of falling back`() {
// Silently ignoring the setting and using a different binary would be
// worse than failing: the user asked for a specific server.
executableAt("build", "ludic-lsp")
assertNull(findExecutable(tmp.root.path, File(tmp.root, "nope").path, names, SERVER_SUBDIRS))
}
@Test
fun `a non-executable file is not accepted`() {
val f = File(tmp.root, "build/ludic-lsp")
f.parentFile.mkdirs()
f.writeText("not executable")
f.setExecutable(false)
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
}
@Test
fun `a directory named like the binary is not accepted`() {
File(tmp.root, "build/ludic-lsp").mkdirs()
assertNull(findExecutable(tmp.root.path, "", names, SERVER_SUBDIRS))
}
@Test
fun `a null project root still allows a PATH lookup`() {
// No exception, no crash: the default project has no basePath.
findExecutable(null, "", names, SERVER_SUBDIRS)
}
@Test
fun `the real repository layout resolves`() {
// Guards the arrangement the plugin actually ships against: run
// ./tools/build-tools.sh and opening the repo root must just work.
val repoRoot = File(System.getProperty("user.dir")).parentFile.parentFile.parentFile
val built = File(repoRoot, "build/ludic-lsp")
if (!built.isFile) return // toolchain not built in this checkout
val found = findExecutable(repoRoot.path, "", names, SERVER_SUBDIRS)
assertTrue("repo root must resolve to build/ludic-lsp", found != null)
assertEquals(built.canonicalPath, File(found!!).canonicalPath)
}
}
/**
* The launch command itself. LSP4IJ only ever reports `commands=[...]`, so if
* this list is wrong the failure surfaces with no explanation at all.
*/
class ServerCommandTest {
private val repoRoot: File =
File(System.getProperty("user.dir")).parentFile.parentFile.parentFile
@Test
fun `the repository root produces a runnable stdio command`() {
val built = File(repoRoot, "build/ludic-lsp")
if (!built.isFile) return // toolchain not built in this checkout
val cmd = serverCommand(repoRoot.path, "")
assertEquals(listOf(built.canonicalPath, "--stdio"), cmd!!.let { listOf(File(it[0]).canonicalPath, it[1]) })
// and it must actually be executable, not merely present
val proc = ProcessBuilder(cmd[0], "--version").redirectErrorStream(true).start()
val out = proc.inputStream.bufferedReader().readText().trim()
proc.waitFor()
assertTrue("expected a version banner, got '$out'", out.startsWith("ludic-lsp"))
}
@Test
fun `a root without the toolchain yields no command rather than an empty one`() {
// The distinction that matters: null lets start() throw an explanatory
// error; an empty list is what produced the useless `commands=[]`.
val cmd = serverCommand(File(repoRoot, "tools/editors").path, "")
assertTrue("must be null, not an empty list", cmd == null || cmd.isNotEmpty())
}
}

View file

@ -0,0 +1,169 @@
package io.ludic.ide
import com.intellij.psi.TokenType
import com.intellij.psi.tree.IElementType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Syntax highlighting is the lexer plus a colour map, so this is where a
* highlighting regression actually shows up. These are plain JUnit tests: the
* lexer has no platform dependencies, so they run in milliseconds without
* booting an IDE.
*/
class LudicLexerTest {
private fun lex(text: String): List<Pair<IElementType, String>> {
val lexer = LudicLexer()
lexer.start(text, 0, text.length, 0)
val out = mutableListOf<Pair<IElementType, String>>()
while (true) {
val t = lexer.tokenType ?: break
if (t != TokenType.WHITE_SPACE) {
out += t to text.substring(lexer.tokenStart, lexer.tokenEnd)
}
lexer.advance()
}
return out
}
private fun typesOf(text: String) = lex(text).map { it.first }
@Test
fun `declaration keywords are distinct from statement keywords`() {
assertEquals(
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.BRACE),
typesOf("game Hello {")
)
assertEquals(LudicTokens.KEYWORD, typesOf("if x { }").first())
}
@Test
fun `clause keywords are their own class`() {
// `phase` and `query` colour differently from `if`/`let`, which is the
// whole reason a system signature reads as a signature.
assertEquals(
listOf(LudicTokens.DECL_KEYWORD, LudicTokens.TYPE_NAME, LudicTokens.CLAUSE, LudicTokens.PHASE),
typesOf("system Move phase FixedUpdate")
)
}
@Test
fun `primitive types and phases are not plain identifiers`() {
assertEquals(LudicTokens.PRIMITIVE, typesOf("int").single())
assertEquals(LudicTokens.PRIMITIVE, typesOf("fixed").single())
assertEquals(LudicTokens.PHASE, typesOf("LateUpdate").single())
assertEquals(LudicTokens.BOOLEAN, typesOf("true").single())
}
@Test
fun `builtins are recognised, unknown calls are not`() {
assertEquals(LudicTokens.BUILTIN, typesOf("rng_chance").single())
assertEquals(LudicTokens.BUILTIN, typesOf("peek32").single()) // intrinsic
assertEquals(LudicTokens.IDENTIFIER, typesOf("my_helper").single())
}
@Test
fun `capitalised words read as type names`() {
assertEquals(LudicTokens.TYPE_NAME, typesOf("Pos").single())
assertEquals(LudicTokens.IDENTIFIER, typesOf("pos").single())
}
@Test
fun `comments run to end of line and keep their text`() {
val toks = lex("let a = 1 # keep me\nlet b = 2")
val comment = toks.first { it.first == LudicTokens.COMMENT }
assertEquals("# keep me", comment.second)
// the newline ends it: the next line is code again
assertEquals(LudicTokens.KEYWORD, toks[toks.indexOf(comment) + 1].first)
}
@Test
fun `strings handle escapes and do not swallow the rest of the file`() {
val toks = lex(""""a\"b" 42""")
assertEquals(LudicTokens.STRING, toks[0].first)
assertEquals(""""a\"b"""", toks[0].second)
assertEquals(LudicTokens.NUMBER, toks[1].first)
}
@Test
fun `an unterminated string stops at the newline`() {
// A half-typed string must not colour the whole rest of the file.
val toks = lex("let s = \"oops\nlet t = 1")
assertEquals(LudicTokens.STRING, toks[3].first)
assertEquals("\"oops", toks[3].second)
assertEquals(LudicTokens.KEYWORD, toks[4].first)
}
@Test
fun `numbers cover hex, fixed-point and the range operator`() {
assertEquals(LudicTokens.NUMBER, typesOf("0x1affff").single())
assertEquals(LudicTokens.NUMBER, typesOf("1.5").single())
// `0..10` is two ints and a range op, not a malformed float
assertEquals(
listOf(LudicTokens.NUMBER, LudicTokens.OPERATOR, LudicTokens.NUMBER),
typesOf("0..10")
)
assertEquals("..", lex("0..10")[1].second)
}
@Test
fun `annotations lex as one token including the sigil`() {
val toks = lex("@deterministic")
assertEquals(LudicTokens.ANNOTATION, toks.single().first)
assertEquals("@deterministic", toks.single().second)
}
@Test
fun `two-character operators beat one-character ones`() {
assertEquals(listOf("->"), lex("->").map { it.second })
assertEquals(listOf("=>"), lex("=>").map { it.second })
assertEquals(listOf("!="), lex("!=").map { it.second })
assertEquals(listOf("<="), lex("<=").map { it.second })
}
@Test
fun `the lexer always terminates and covers every character`() {
// The IDE lexes half-typed text constantly; a lexer that stalls hangs
// the editor, and one that skips bytes corrupts every offset after it.
val nasty = "game X { component ¿ { a: int = ' } # é\n query [{Tag}] \"unclosed"
val lexer = LudicLexer()
lexer.start(nasty, 0, nasty.length, 0)
var covered = 0
var guard = 0
while (lexer.tokenType != null) {
assertTrue("token must advance", lexer.tokenEnd > lexer.tokenStart)
assertEquals("no gaps between tokens", covered, lexer.tokenStart)
covered = lexer.tokenEnd
lexer.advance()
check(guard++ < 10_000) { "lexer did not terminate" }
}
assertEquals("every character consumed", nasty.length, covered)
}
@Test
fun `a real source file lexes with no bad characters`() {
val src = """
game Hello {
component Pos { x: int = 0, y: int = 0 }
archetype Mob { Pos }
system Move phase FixedUpdate
query (p) [Pos, {Player}] where p.x > 0
{
p.x = p.x + 1
for i in 0 .. 10 { print_int(i) }
}
ui Menu {
panel id=Root w=288 pad=16 {
button id=Go text="Go" size=16
}
}
}
""".trimIndent()
val bad = lex(src).filter { it.first == LudicTokens.BAD }
assertTrue("unexpected bad characters: ${bad.map { it.second }}", bad.isEmpty())
}
}

View file

@ -0,0 +1,124 @@
package io.ludic.ide
import com.intellij.lang.LanguageParserDefinitions
import com.intellij.openapi.fileTypes.FileTypeManager
import com.intellij.psi.PsiFile
import com.intellij.testFramework.fixtures.BasePlatformTestCase
/**
* The IDE-side wiring, exercised in a real (headless) platform fixture. These
* cover the things that make a file feel like a supported language rather than
* plain text: the extension is claimed, a PSI tree is built (which is what
* indexing and Markdown injection both stand on), folds appear, braces pair up
* and Ctrl+/ comments.
*/
class LudicPlatformTest : BasePlatformTestCase() {
private val sample = """
game Hello {
component Pos { x: int = 0, y: int = 0 }
archetype Mob { Pos }
# move everything that has a position
system Move phase FixedUpdate
query (p) [Pos, {Player}]
{
p.x = p.x + 1
}
}
""".trimIndent()
private fun ludicFile(text: String = sample): PsiFile =
myFixture.configureByText("sample.ludic", text)
fun testExtensionIsClaimedByTheLudicFileType() {
val type = FileTypeManager.getInstance().getFileTypeByExtension("ludic")
assertEquals("Ludic", type.name)
assertEquals(LudicFileType, type)
}
fun testFileTypeIsNotBinaryAndUsesUtf8() {
assertFalse(LudicFileType.isBinary)
assertEquals("ludic", LudicFileType.defaultExtension)
}
fun testParserDefinitionIsRegisteredForTheLanguage() {
// Markdown fence injection needs a PSI tree, so this registration is
// what makes ```ludic blocks highlight in a .md file.
val def = LanguageParserDefinitions.INSTANCE.forLanguage(LudicLanguage)
assertNotNull("no ParserDefinition registered for Ludic", def)
}
fun testPsiTreeIsBuiltAndCoversTheWholeFile() {
val file = ludicFile()
assertEquals(LudicLanguage, file.language)
assertEquals(sample, file.text)
assertTrue("expected a non-empty PSI tree", file.children.isNotEmpty())
// every character must be reachable through the tree, or offsets drift
assertEquals(sample.length, file.textLength)
}
fun testHighlighterAssignsColoursToEveryMeaningfulToken() {
val highlighter = LudicSyntaxHighlighter()
val coloured = listOf(
LudicTokens.DECL_KEYWORD, LudicTokens.KEYWORD, LudicTokens.CLAUSE,
LudicTokens.PRIMITIVE, LudicTokens.PHASE, LudicTokens.BOOLEAN,
LudicTokens.BUILTIN, LudicTokens.TYPE_NAME, LudicTokens.STRING,
LudicTokens.NUMBER, LudicTokens.COMMENT, LudicTokens.ANNOTATION,
LudicTokens.OPERATOR, LudicTokens.BRACE, LudicTokens.BRACKET,
LudicTokens.PAREN, LudicTokens.COMMA, LudicTokens.IDENTIFIER
)
for (token in coloured) {
val keys = highlighter.getTokenHighlights(token)
assertTrue("no colour mapped for $token", keys.isNotEmpty())
}
}
fun testFoldingCollapsesMultiLineBlocksOnly() {
val file = ludicFile()
val regions = LudicFoldingBuilder().buildFoldRegions(file.node, file.viewProvider.document!!)
// The sample has exactly two blocks that span lines: the `game` body and
// the `system` body. `component Pos { … }`, `archetype Mob { … }` and the
// `{Player}` query filter all fit on one line, and folding a single line
// gains nothing, so they are deliberately skipped.
assertEquals("multi-line blocks only", 2, regions.size)
val outermost = regions.maxByOrNull { it.range.length }!!
assertEquals("outermost fold starts at the game block's brace",
sample.indexOf('{'), outermost.range.startOffset)
assertEquals("outermost fold ends at the last brace",
sample.length, outermost.range.endOffset)
for (r in regions) {
assertTrue("fold region must be non-empty", r.range.length > 0)
assertEquals("{...}", LudicFoldingBuilder().getPlaceholderText(file.node))
}
}
fun testCommenterUsesHashWithASpace() {
// The trailing space is deliberate: Ctrl+/ should produce "# code",
// which is how every comment in the runtime and examples is written.
val commenter = LudicCommenter()
assertEquals("# ", commenter.lineCommentPrefix)
assertNull("Ludic has no block comments", commenter.blockCommentPrefix)
}
fun testBraceMatcherPairsAllThreeBracketKinds() {
val matcher = LudicBraceMatcher()
val pairs = matcher.pairs
val open = pairs.map { it.leftBraceType }.toSet()
assertTrue(open.contains(LudicTokens.BRACE))
assertTrue(open.contains(LudicTokens.BRACKET))
assertTrue(open.contains(LudicTokens.PAREN))
}
fun testHalfTypedFileStillProducesAPsiTree() {
// The IDE must survive what a file looks like mid-keystroke; a parser
// that throws here takes highlighting and indexing down with it.
val broken = "game X {\n component P { x: int =\n system"
val file = myFixture.configureByText("broken.ludic", broken)
assertEquals(broken.length, file.textLength)
assertEquals(LudicLanguage, file.language)
}
}

View file

@ -0,0 +1,86 @@
-- Ludic support for Neovim (0.9+), with no plugin manager and no dependencies.
--
-- :lua require('ludic').setup()
-- -- or, from init.lua:
-- vim.opt.runtimepath:append('/path/to/gpp/tools/editors/neovim')
-- require('ludic').setup({ server = '/path/to/gpp/build/ludic-lsp' })
--
-- Highlighting here comes from the language server's semantic tokens, which
-- Neovim applies natively -- there is no tree-sitter grammar to install and no
-- second copy of the vocabulary to keep in sync.
local M = {}
local function find_executable(names, roots, subdirs)
for _, root in ipairs(roots) do
for _, sub in ipairs(subdirs) do
for _, name in ipairs(names) do
local p = root .. '/' .. sub .. '/' .. name
if vim.fn.executable(p) == 1 then return p end
end
end
end
for _, name in ipairs(names) do
if vim.fn.executable(name) == 1 then return name end
end
return nil
end
function M.setup(opts)
opts = opts or {}
-- .ludic files are Ludic
vim.filetype.add({ extension = { ludic = 'ludic' } })
vim.api.nvim_create_autocmd('FileType', {
pattern = 'ludic',
callback = function()
vim.bo.commentstring = '# %s'
vim.bo.comments = ':#'
vim.bo.expandtab = true
vim.bo.shiftwidth = opts.indent or 2
vim.bo.tabstop = opts.indent or 2
end,
})
local root_markers = { 'build.sh', 'LANGUAGE.md', '.git' }
local function root_dir(fname)
local found = vim.fs.find(root_markers, { path = vim.fs.dirname(fname), upward = true })[1]
return found and vim.fs.dirname(found) or vim.fs.dirname(fname)
end
vim.api.nvim_create_autocmd('FileType', {
-- markdown is included on purpose: ```ludic fences get diagnostics and
-- semantic highlighting like any source file
pattern = { 'ludic', 'markdown' },
callback = function(args)
local root = root_dir(vim.api.nvim_buf_get_name(args.buf))
local server = opts.server or find_executable(
{ 'ludic-lsp' }, { root }, { 'build', 'tools/build', 'bin', '.' })
if not server then return end
local compiler = opts.compiler or find_executable(
{ 'ludicc' }, { root }, { 'build', 'bin', '.' }) or ''
vim.lsp.start({
name = 'ludic-lsp',
cmd = { server, '--stdio' },
root_dir = root,
init_options = {
compilerPath = compiler,
compilerDiagnostics = opts.compiler_diagnostics ~= false,
indentSize = opts.indent or 2,
},
}, { bufnr = args.buf })
end,
})
if opts.format_on_save ~= false then
vim.api.nvim_create_autocmd('BufWritePre', {
pattern = { '*.ludic' },
callback = function() vim.lsp.buf.format({ async = false, timeout_ms = 2000 }) end,
})
end
end
return M

View file

@ -0,0 +1,31 @@
{
"comment": "Editor mechanics that are not highlighting and not the language server: comments, bracket pairs, auto-closing, indentation triggers. VS Code reads this file directly; the JetBrains plugin implements the same rules natively; other editors can transcribe it.",
"comments": {
"lineComment": "#"
},
"brackets": [["{", "}"], ["[", "]"], ["(", ")"]],
"autoClosingPairs": [
{ "open": "{", "close": "}" },
{ "open": "[", "close": "]" },
{ "open": "(", "close": ")" },
{ "open": "\"", "close": "\"", "notIn": ["string", "comment"] }
],
"surroundingPairs": [["{", "}"], ["[", "]"], ["(", ")"], ["\"", "\""]],
"indentationRules": {
"increaseIndentPattern": "^((?!//).)*(\\{[^}\"']*|\\([^)\"']*|\\[[^\\]\"']*)$",
"decreaseIndentPattern": "^\\s*[}\\])].*$"
},
"onEnterRules": [
{
"beforeText": "^\\s*#.*$",
"action": { "indent": "none", "appendText": "# " }
}
],
"wordPattern": "[A-Za-z_][A-Za-z0-9_]*",
"folding": {
"markers": {
"start": "^\\s*#\\s*region\\b",
"end": "^\\s*#\\s*endregion\\b"
}
}
}

View file

@ -0,0 +1,27 @@
{
"scopeName": "markdown.ludic.codeblock",
"comment": "Injects Ludic highlighting into ```ludic fences in Markdown. Any TextMate-based editor (VS Code, Sublime, JetBrains' TextMate bundles, Zed) picks this up; JetBrains' native Markdown support gets the same thing for free from the registered `Ludic` language ID, and ludic-lsp will also serve semantic tokens and diagnostics for the fence bodies in a .md file.",
"injectionSelector": "L:text.html.markdown",
"patterns": [{ "include": "#ludic-code-block" }],
"repository": {
"ludic-code-block": {
"begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(ludic)((\\s+|:|,|\\{|\\?)[^`~]*)?)$",
"name": "markup.fenced_code.block.markdown",
"end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$",
"beginCaptures": {
"3": { "name": "punctuation.definition.markdown" },
"4": { "name": "fenced_code.block.language.markdown" },
"5": { "name": "fenced_code.block.language.attributes.markdown" }
},
"endCaptures": { "3": { "name": "punctuation.definition.markdown" } },
"patterns": [
{
"begin": "(^|\\G)(\\s*)(.*)",
"while": "(^|\\G)(?!\\s*([`~]{3,})\\s*$)",
"contentName": "meta.embedded.block.ludic",
"patterns": [{ "include": "source.ludic" }]
}
]
}
}
}

View file

@ -0,0 +1,241 @@
{
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
"name": "Ludic",
"scopeName": "source.ludic",
"fileTypes": ["ludic"],
"comment": "Static grammar for Ludic. It is the fallback: when ludic-lsp is running, its semantic tokens refine everything here with real name resolution. Generated shapes are kept in sync with tools/ludic-tools/ludic_syntax.h.",
"patterns": [
{ "include": "#comment" },
{ "include": "#declaration" },
{ "include": "#query" },
{ "include": "#string" },
{ "include": "#number" },
{ "include": "#annotation" },
{ "include": "#keyword" },
{ "include": "#widget" },
{ "include": "#builtin" },
{ "include": "#member" },
{ "include": "#call" },
{ "include": "#typename" },
{ "include": "#operator" }
],
"repository": {
"comment": {
"match": "#.*$",
"name": "comment.line.number-sign.ludic"
},
"string": {
"patterns": [
{
"name": "string.quoted.double.ludic",
"begin": "\"",
"end": "\"",
"beginCaptures": { "0": { "name": "punctuation.definition.string.begin.ludic" } },
"endCaptures": { "0": { "name": "punctuation.definition.string.end.ludic" } },
"patterns": [{ "name": "constant.character.escape.ludic", "match": "\\\\." }]
},
{
"name": "string.quoted.single.ludic",
"match": "'(\\\\.|[^'\\\\])'"
}
]
},
"number": {
"patterns": [
{ "name": "constant.numeric.hex.ludic", "match": "\\b0[xX][0-9a-fA-F]+\\b" },
{ "name": "constant.numeric.fixed.ludic", "match": "\\b[0-9]+\\.[0-9]+\\b" },
{ "name": "constant.numeric.integer.ludic", "match": "\\b[0-9]+\\b" }
]
},
"annotation": {
"name": "entity.name.function.decorator.ludic",
"match": "@[A-Za-z_][A-Za-z0-9_]*"
},
"declaration": {
"patterns": [
{
"match": "\\b(game|module)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.unit.ludic" },
"2": { "name": "entity.name.type.unit.ludic" }
}
},
{
"match": "\\b(component)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.component.ludic" },
"2": { "name": "entity.name.type.component.ludic" }
}
},
{
"match": "\\b(archetype)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.archetype.ludic" },
"2": { "name": "entity.name.type.archetype.ludic" }
}
},
{
"match": "\\b(scene)\\s+([A-Za-z_][A-Za-z0-9_]*)(\\s+start)?",
"captures": {
"1": { "name": "storage.type.scene.ludic" },
"2": { "name": "entity.name.type.scene.ludic" },
"3": { "name": "keyword.other.start.ludic" }
}
},
{
"match": "\\b(layer|ui)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.ludic" },
"2": { "name": "entity.name.type.ludic" }
}
},
{
"match": "\\b(edge\\s+)?(system)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.modifier.edge.ludic" },
"2": { "name": "storage.type.system.ludic" },
"3": { "name": "entity.name.function.system.ludic" }
}
},
{
"match": "\\b(export\\s+|pure\\s+)?(extern\\s+)?(fn)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.modifier.ludic" },
"2": { "name": "storage.modifier.extern.ludic" },
"3": { "name": "storage.type.function.ludic" },
"4": { "name": "entity.name.function.ludic" }
}
},
{
"match": "\\b(const|var|let)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.ludic" },
"2": { "name": "variable.other.definition.ludic" }
}
},
{
"match": "\\b(state)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.state.ludic" },
"2": { "name": "entity.name.constant.state.ludic" }
}
},
{
"match": "\\b(import)\\s+(?=\")",
"captures": { "1": { "name": "keyword.control.import.ludic" } }
},
{
"match": "\\b(phase)\\s+(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b",
"captures": {
"1": { "name": "keyword.other.clause.ludic" },
"2": { "name": "constant.language.phase.ludic" }
}
},
{
"match": "\\b(enter)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.enter.ludic" },
"2": { "name": "entity.name.type.scene.ludic" }
}
},
{
"match": "\\b(spawn)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.spawn.ludic" },
"2": { "name": "entity.name.type.archetype.ludic" }
}
}
]
},
"query": {
"comment": "Inside a query's brackets a bare word names a component and {Word} is a filter that does not bind.",
"begin": "\\b(query)\\s*(\\[)",
"beginCaptures": {
"1": { "name": "keyword.other.query.ludic" },
"2": { "name": "punctuation.section.brackets.begin.ludic" }
},
"end": "(\\])",
"endCaptures": { "1": { "name": "punctuation.section.brackets.end.ludic" } },
"patterns": [
{
"match": "(\\{)\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*(\\})",
"captures": {
"1": { "name": "punctuation.section.braces.begin.ludic" },
"2": { "name": "entity.name.type.tag.ludic" },
"3": { "name": "punctuation.section.braces.end.ludic" }
}
},
{ "name": "entity.name.type.component.ludic", "match": "\\b[A-Za-z_][A-Za-z0-9_]*\\b" },
{ "name": "punctuation.separator.ludic", "match": "," }
]
},
"keyword": {
"patterns": [
{ "name": "keyword.control.ludic", "match": "\\b(if|else|when|while|for|in|match|machine|become|enter|return|spawn|despawn|where|break|continue|new)\\b" },
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|reads|writes|needs|uses|requires|ensures|invariant|effects|on)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|export|pure|extern|start|edge)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(game|module|component|archetype|ui|scene|layer|const|var|let|fn|system|state)\\b" },
{ "name": "support.type.primitive.ludic", "match": "\\b(int|fixed|bool|entity|str|ptr|void)\\b" },
{ "name": "constant.language.boolean.ludic", "match": "\\b(true|false)\\b" },
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" }
]
},
"widget": {
"patterns": [
{
"comment": "widget types, recognised by the props or the child block that follow them",
"name": "support.class.widget.ludic",
"match": "\\b(panel|col|row|label|button|image|spacer)\\b(?=\\s*(\\{|[a-z][A-Za-z0-9_]*\\s*=|$))"
},
{
"comment": "widget props are written tight (id=Root); an assignment is written x = 1",
"match": "\\b([a-z][A-Za-z0-9_]*)(=)(?!=)",
"captures": {
"1": { "name": "variable.parameter.widget.ludic" },
"2": { "name": "keyword.operator.assignment.ludic" }
}
}
]
},
"builtin": {
"patterns": [
{
"comment": "the runtime surface — every name here resolves to rt_<name> in runtime/native",
"name": "support.function.builtin.ludic",
"match": "\\b(min|max|abs|clamp|seed|rng_range|rng_chance|fx|flr|map_size|map_row|tile|clear|present|fill_rect|frame_rect|put_px|text|text_int|font_load|text_ttf|text_w|text_h|image_load|draw_image|draw_image_scaled|draw_9slice|load_png|load_sprites|draw_sprite|draw_sprite_scaled|ui_build|ui_open|ui_tick|ui_render|ui_clicked|ui_set_text|ui_set_int|ui_focus|ui_focused|ui_visible|key|reg|setreg|self|save|load|status|print_int|quit)\\b(?=\\s*\\()"
},
{
"comment": "compiler intrinsics — these lower straight to libc or the OS",
"name": "support.function.intrinsic.ludic",
"match": "\\b(mem_alloc|mem_realloc|mem_free|mem_copy|mem_set|peek8|poke8|peek32|poke32|peekp|pokep|peekf|pokef|ptr_add|ptr_null|ptr_is_null|as_fixed|as_int|file_open|file_read|file_write|file_seek|file_tell|file_close|read_byte|write_byte|print_str|str_len|shl|shr|band|bor|bxor|bnot|os_exit|os_time|os_argc|os_arg|file_stderr|is_windowed|game_title|win_open|win_poll|win_present|win_running|win_close)\\b(?=\\s*\\()"
}
]
},
"member": {
"match": "(\\.)([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "punctuation.accessor.ludic" },
"2": { "name": "variable.other.property.ludic" }
}
},
"call": {
"match": "\\b([A-Za-z_][A-Za-z0-9_]*)\\s*(?=\\()",
"name": "entity.name.function.call.ludic"
},
"typename": {
"comment": "components, archetypes, scenes and UI handles are Capitalised by convention",
"name": "entity.name.type.ludic",
"match": "\\b[A-Z][A-Za-z0-9_]*\\b"
},
"operator": {
"patterns": [
{ "name": "keyword.operator.arrow.ludic", "match": "->|=>" },
{ "name": "keyword.operator.range.ludic", "match": "\\.\\." },
{ "name": "keyword.operator.comparison.ludic", "match": "==|!=|<=|>=|<|>" },
{ "name": "keyword.operator.assignment.ludic", "match": "\\+=|-=|\\*=|/=|=" },
{ "name": "keyword.operator.arithmetic.ludic", "match": "[+\\-*/%]" }
]
}
}
}

View file

@ -0,0 +1,35 @@
# Ludic for Sublime Text
Sublime reads TextMate grammars directly, so the shared grammar works as-is.
1. **Highlighting.** Copy `tools/editors/shared/ludic.tmLanguage.json` into your
`Packages/User/` directory, renamed to `Ludic.tmLanguage.json`. Sublime picks
up `.ludic` from the grammar's `fileTypes`.
2. **Markdown fences.** Copy
`tools/editors/shared/ludic.markdown-injection.json` alongside it as
`Ludic Markdown.tmLanguage.json`. Sublime honours `injectionSelector`, so
` ```ludic ` blocks in Markdown are highlighted with no further setup.
3. **Everything else** comes from the language server. Install
[LSP](https://packagecontrol.io/packages/LSP), then add to
`Preferences -> Package Settings -> LSP -> Settings`:
```json
{
"clients": {
"ludic": {
"enabled": true,
"command": ["/path/to/gpp/build/ludic-lsp", "--stdio"],
"selector": "source.ludic | text.html.markdown",
"initializationOptions": {
"compilerPath": "/path/to/gpp/build/ludicc",
"compilerDiagnostics": true,
"indentSize": 2
}
}
}
}
```
Format on save with `LSP-format-on-save`, or bind `lsp_format_document`.

View file

@ -0,0 +1,12 @@
.vscode/**
node_modules/**
!node_modules/vscode-languageclient/**
!node_modules/vscode-jsonrpc/**
!node_modules/vscode-languageserver-protocol/**
!node_modules/vscode-languageserver-types/**
!node_modules/semver/**
!node_modules/balanced-match/**
!node_modules/minimatch/**
!node_modules/brace-expansion/**
!node_modules/concat-map/**
**/*.map

View file

@ -0,0 +1,54 @@
# Ludic for VS Code
Syntax highlighting, formatting, and full language-server support for **Ludic**,
the AI-first, ECS-native game language. See `LANGUAGE.md` in the Ludic repository
for the language reference.
## What you get
| | Source |
|---|---|
| Syntax highlighting | TextMate grammar, works with no build |
| Semantic highlighting | `ludic-lsp` — real name resolution, so a component reads differently from a local |
| Diagnostics | structural errors as you type; `ludicc`'s own errors on save |
| Completion | context-aware: fields after `.`, components inside `query [...]`, phases after `phase`, widgets inside `ui` |
| Hover | signatures and docs for builtins, intrinsics, and everything you declared |
| Go to definition / references / rename | across every file in the compilation unit, following `import` |
| Formatting | format-on-save, comment-preserving |
| Outline, folding, inlay hints, signature help | |
| ` ```ludic ` fences in Markdown | highlighted, checked, and formatted |
## Setup
Build the toolchain once, from the repository root:
```bash
./tools/build-tools.sh
```
That produces `build/ludic-lsp` and `build/ludic-fmt`, which the extension finds
on its own. If you keep them elsewhere, set `ludic.server.path`.
Format on save:
```json
"[ludic]": { "editor.formatOnSave": true }
```
## Settings
- `ludic.server.path` — where `ludic-lsp` lives. Empty = search `build/`, then `PATH`.
- `ludic.server.enabled` — turn the server off and keep only static highlighting.
- `ludic.compilerPath` — where `ludicc` lives, for on-save diagnostics.
- `ludic.compilerDiagnostics` — whether to run the compiler at all.
- `ludic.indentSize` — spaces per level for the formatter.
- `ludic.trace.server` — log LSP traffic (`Ludic: Show Language Server Log`).
## Installing without publishing
```bash
cd tools/editors/vscode
npm install
npx @vscode/vsce package
code --install-extension ludic-1.0.0.vsix
```

View file

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect width="32" height="32" rx="6" fill="#1a1a2c"/>
<rect x="7" y="7" width="8" height="8" rx="1.5" fill="#ffe060"/>
<rect x="17" y="7" width="8" height="8" rx="1.5" fill="#4a4a72"/>
<rect x="7" y="17" width="8" height="8" rx="1.5" fill="#4a4a72"/>
<rect x="17" y="17" width="8" height="8" rx="1.5" fill="#9aa0c8"/>
</svg>

After

Width:  |  Height:  |  Size: 418 B

View file

@ -0,0 +1,31 @@
{
"comment": "Editor mechanics that are not highlighting and not the language server: comments, bracket pairs, auto-closing, indentation triggers. VS Code reads this file directly; the JetBrains plugin implements the same rules natively; other editors can transcribe it.",
"comments": {
"lineComment": "#"
},
"brackets": [["{", "}"], ["[", "]"], ["(", ")"]],
"autoClosingPairs": [
{ "open": "{", "close": "}" },
{ "open": "[", "close": "]" },
{ "open": "(", "close": ")" },
{ "open": "\"", "close": "\"", "notIn": ["string", "comment"] }
],
"surroundingPairs": [["{", "}"], ["[", "]"], ["(", ")"], ["\"", "\""]],
"indentationRules": {
"increaseIndentPattern": "^((?!//).)*(\\{[^}\"']*|\\([^)\"']*|\\[[^\\]\"']*)$",
"decreaseIndentPattern": "^\\s*[}\\])].*$"
},
"onEnterRules": [
{
"beforeText": "^\\s*#.*$",
"action": { "indent": "none", "appendText": "# " }
}
],
"wordPattern": "[A-Za-z_][A-Za-z0-9_]*",
"folding": {
"markers": {
"start": "^\\s*#\\s*region\\b",
"end": "^\\s*#\\s*endregion\\b"
}
}
}

97
tools/editors/vscode/package-lock.json generated Normal file
View file

@ -0,0 +1,97 @@
{
"name": "ludic",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ludic",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"vscode-languageclient": "^9.0.1"
},
"engines": {
"vscode": "^1.75.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
"integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/vscode-languageclient": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/vscode-languageclient/-/vscode-languageclient-9.0.1.tgz",
"integrity": "sha512-JZiimVdvimEuHh5olxhxkht09m3JzUGwggb5eRUkzzJhZ2KjCN0nh55VfiED9oez9DyF8/fz1g1iBV3h+0Z2EA==",
"license": "MIT",
"dependencies": {
"minimatch": "^5.1.0",
"semver": "^7.3.7",
"vscode-languageserver-protocol": "3.17.5"
},
"engines": {
"vscode": "^1.82.0"
}
},
"node_modules/vscode-languageserver-protocol": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz",
"integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==",
"license": "MIT",
"dependencies": {
"vscode-jsonrpc": "8.2.0",
"vscode-languageserver-types": "3.17.5"
}
},
"node_modules/vscode-languageserver-types": {
"version": "3.17.5",
"resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz",
"integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==",
"license": "MIT"
}
}
}

View file

@ -0,0 +1,158 @@
{
"name": "ludic",
"displayName": "Ludic",
"description": "Ludic language support: syntax highlighting, formatting, and the ludic-lsp language server. Also highlights ```ludic fences in Markdown.",
"version": "1.0.0",
"publisher": "ludic",
"license": "MIT",
"engines": {
"vscode": "^1.75.0"
},
"categories": [
"Programming Languages",
"Formatters",
"Linters"
],
"keywords": [
"ludic",
"ecs",
"gamedev"
],
"main": "./src/extension.js",
"activationEvents": [
"onLanguage:ludic",
"onLanguage:markdown",
"workspaceContains:**/*.ludic"
],
"contributes": {
"languages": [
{
"id": "ludic",
"aliases": [
"Ludic",
"ludic"
],
"extensions": [
".ludic"
],
"configuration": "./language-configuration.json",
"icon": {
"light": "./icons/ludic.svg",
"dark": "./icons/ludic.svg"
}
}
],
"grammars": [
{
"language": "ludic",
"scopeName": "source.ludic",
"path": "./syntaxes/ludic.tmLanguage.json"
},
{
"scopeName": "markdown.ludic.codeblock",
"path": "./syntaxes/ludic.markdown-injection.json",
"injectTo": [
"text.html.markdown"
],
"embeddedLanguages": {
"meta.embedded.block.ludic": "ludic"
}
}
],
"semanticTokenScopes": [
{
"language": "ludic",
"scopes": {
"struct": [
"entity.name.type.component.ludic"
],
"class": [
"entity.name.type.archetype.ludic"
],
"namespace": [
"entity.name.type.scene.ludic"
],
"function.defaultLibrary": [
"support.function.builtin.ludic"
],
"enumMember.readonly.defaultLibrary": [
"constant.language.phase.ludic"
],
"decorator": [
"entity.name.function.decorator.ludic"
]
}
}
],
"configuration": {
"title": "Ludic",
"properties": {
"ludic.server.path": {
"type": "string",
"default": "",
"markdownDescription": "Path to the `ludic-lsp` binary. Empty means: look in `build/ludic-lsp` under each workspace folder, then on `PATH`."
},
"ludic.server.enabled": {
"type": "boolean",
"default": true,
"description": "Run the Ludic language server. Turn this off to keep only the static syntax highlighting."
},
"ludic.compilerPath": {
"type": "string",
"default": "",
"markdownDescription": "Path to `ludicc`. The server shells out to it for authoritative diagnostics on save. Empty means `build/ludicc` under the workspace."
},
"ludic.compilerDiagnostics": {
"type": "boolean",
"default": true,
"description": "Report errors from the Ludic compiler on save, in addition to the server's own structural checks."
},
"ludic.indentSize": {
"type": "number",
"default": 2,
"description": "Spaces per indent level used by the formatter when the editor does not say otherwise."
},
"ludic.trace.server": {
"type": "string",
"enum": [
"off",
"messages",
"verbose"
],
"default": "off",
"description": "Log the traffic between VS Code and the Ludic language server."
}
}
},
"commands": [
{
"command": "ludic.restartServer",
"title": "Ludic: Restart Language Server"
},
{
"command": "ludic.showOutput",
"title": "Ludic: Show Language Server Log"
}
],
"configurationDefaults": {
"[ludic]": {
"editor.insertSpaces": true,
"editor.tabSize": 2,
"editor.semanticHighlighting.enabled": true
}
},
"snippets": [
{
"language": "ludic",
"path": "./snippets/ludic.json"
}
]
},
"dependencies": {
"vscode-languageclient": "^9.0.1"
},
"repository": {
"type": "git",
"url": "https://example.invalid/ludic"
}
}

View file

@ -0,0 +1,96 @@
{
"game": {
"prefix": "game",
"body": ["game ${1:Name} {", "\t$0", "}"],
"description": "A game unit — compiles to an executable."
},
"module": {
"prefix": "module",
"body": ["module ${1:Name} {", "\t$0", "}"],
"description": "A library unit — compiles to a shared library, no frame loop."
},
"component": {
"prefix": "component",
"body": ["component ${1:Name} { ${2:x}: ${3:int} = ${4:0} }"],
"description": "A component: typed per-entity fields."
},
"archetype": {
"prefix": "archetype",
"body": ["archetype ${1:Name} {", "\t${2:Pos}", "}"],
"description": "An entity kind bundling a fixed set of components."
},
"system": {
"prefix": "system",
"body": [
"system ${1:Name} phase ${2|Start,Input,FixedUpdate,Update,LateUpdate,Render|} {",
"\t$0",
"}"
],
"description": "A system that runs once per tick in its phase."
},
"system with query": {
"prefix": "systemq",
"body": [
"system ${1:Name} phase ${2|Update,FixedUpdate,LateUpdate,Render,Input|}",
" query (${3:p}) [${4:Pos}]",
"{",
"\t$0",
"}"
],
"description": "A system whose body runs once per matching entity."
},
"for query": {
"prefix": "forq",
"body": ["for (${1:p}) in query [${2:Pos}] {", "\t$0", "}"],
"description": "Iterate every entity matching a query."
},
"spawn": {
"prefix": "spawn",
"body": ["spawn ${1:Kind} {", "\t${2:Pos} = { ${3:x} = ${4:0} }", "}"],
"description": "Create an entity of an archetype."
},
"scene": {
"prefix": "scene",
"body": [
"scene ${1:Name} {",
"\ton enter { $2 }",
"\tlayer ${3:Main} {",
"\t\t$0",
"\t}",
"}"
],
"description": "A scene with lifecycle hooks and one layer of systems."
},
"ui": {
"prefix": "ui",
"body": [
"ui ${1:Name} {",
"\tpanel id=${2:Root} pad=16 gap=6 align=center {",
"\t\t$0",
"\t}",
"}"
],
"description": "A retained UI tree."
},
"match": {
"prefix": "match",
"body": ["match ${1:x} {", "\t${2:0} => { $3 }", "\t_ => { $0 }", "}"],
"description": "Pattern match."
},
"machine": {
"prefix": "machine",
"body": [
"machine reg(${1:R_PHASE}) {",
"\tstate ${2:Idle} = 0 {",
"\t\t$0",
"\t}",
"}"
],
"description": "A state machine over a register."
},
"extern fn": {
"prefix": "extern",
"body": ["extern fn ${1:name}(${2:a}: ${3:int}) -> ${4:int} = \"${5:c_symbol}\""],
"description": "Bind a C symbol through the FFI."
}
}

View file

@ -0,0 +1,149 @@
/* ============================================================================
* The VS Code half of Ludic support.
*
* There is deliberately very little here. Highlighting comes from the TextMate
* grammar in syntaxes/ (shared, unmodified, with every other editor), and
* everything else — completion, hover, diagnostics, formatting, symbols —
* comes from `ludic-lsp`, the same binary JetBrains and Neovim talk to. This
* file's whole job is finding that binary and keeping it alive.
* ==========================================================================*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const vscode = require('vscode');
const { LanguageClient, TransportKind } = require('vscode-languageclient/node');
let client = null;
let output = null;
/** Where the language server might be, in the order we should look. */
function resolveServer(folders) {
const configured = vscode.workspace.getConfiguration('ludic').get('server.path');
if (configured) return expand(configured);
const names = ['ludic-lsp', 'ludic-lsp.exe'];
for (const folder of folders) {
const root = folder.uri.fsPath;
for (const rel of ['build', 'tools/build', 'bin', '.']) {
for (const name of names) {
const p = path.join(root, rel, name);
if (isExecutable(p)) return p;
}
}
}
// Fall back to PATH; spawning by bare name lets the OS resolve it.
for (const dir of (process.env.PATH || '').split(path.delimiter)) {
for (const name of names) {
const p = path.join(dir, name);
if (isExecutable(p)) return p;
}
}
return null;
}
function resolveCompiler(folders) {
const configured = vscode.workspace.getConfiguration('ludic').get('compilerPath');
if (configured) return expand(configured);
for (const folder of folders) {
const p = path.join(folder.uri.fsPath, 'build', 'ludicc');
if (isExecutable(p)) return p;
}
return '';
}
function expand(p) {
return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
}
function isExecutable(p) {
try {
fs.accessSync(p, fs.constants.X_OK);
return fs.statSync(p).isFile();
} catch {
return false;
}
}
async function start(context) {
const cfg = vscode.workspace.getConfiguration('ludic');
if (!cfg.get('server.enabled')) return;
const folders = vscode.workspace.workspaceFolders || [];
const server = resolveServer(folders);
if (!server) {
// Not an error worth a modal: the grammar still highlights, and plenty of
// people open a .ludic file without having built the toolchain.
output.appendLine(
'ludic-lsp not found. Build it with tools/build-tools.sh, or set "ludic.server.path". ' +
'Syntax highlighting works without it; completion and diagnostics do not.'
);
return;
}
output.appendLine(`starting ${server}`);
const serverOptions = {
run: { command: server, args: ['--stdio'], transport: TransportKind.stdio },
debug: { command: server, args: ['--stdio'], transport: TransportKind.stdio }
};
const clientOptions = {
// Markdown is included on purpose: ```ludic fences in a .md get the same
// diagnostics, hover and semantic highlighting as a real source file.
documentSelector: [
{ scheme: 'file', language: 'ludic' },
{ scheme: 'file', language: 'markdown' }
],
synchronize: {
fileEvents: vscode.workspace.createFileSystemWatcher('**/*.ludic'),
configurationSection: 'ludic'
},
outputChannel: output,
initializationOptions: {
compilerPath: resolveCompiler(folders),
compilerDiagnostics: cfg.get('compilerDiagnostics'),
indentSize: cfg.get('indentSize')
}
};
client = new LanguageClient('ludic', 'Ludic Language Server', serverOptions, clientOptions);
await client.start();
context.subscriptions.push(client);
}
async function stop() {
if (!client) return;
const c = client;
client = null;
await c.stop();
}
async function activate(context) {
output = vscode.window.createOutputChannel('Ludic');
context.subscriptions.push(output);
context.subscriptions.push(
vscode.commands.registerCommand('ludic.restartServer', async () => {
await stop();
await start(context);
vscode.window.setStatusBarMessage('Ludic: language server restarted', 3000);
}),
vscode.commands.registerCommand('ludic.showOutput', () => output.show())
);
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration(async (e) => {
if (e.affectsConfiguration('ludic.server')) {
await stop();
await start(context);
}
})
);
await start(context);
}
function deactivate() {
return stop();
}
module.exports = { activate, deactivate };

View file

@ -0,0 +1,27 @@
{
"scopeName": "markdown.ludic.codeblock",
"comment": "Injects Ludic highlighting into ```ludic fences in Markdown. Any TextMate-based editor (VS Code, Sublime, JetBrains' TextMate bundles, Zed) picks this up; JetBrains' native Markdown support gets the same thing for free from the registered `Ludic` language ID, and ludic-lsp will also serve semantic tokens and diagnostics for the fence bodies in a .md file.",
"injectionSelector": "L:text.html.markdown",
"patterns": [{ "include": "#ludic-code-block" }],
"repository": {
"ludic-code-block": {
"begin": "(^|\\G)(\\s*)(`{3,}|~{3,})\\s*(?i:(ludic)((\\s+|:|,|\\{|\\?)[^`~]*)?)$",
"name": "markup.fenced_code.block.markdown",
"end": "(^|\\G)(\\2|\\s{0,3})(\\3)\\s*$",
"beginCaptures": {
"3": { "name": "punctuation.definition.markdown" },
"4": { "name": "fenced_code.block.language.markdown" },
"5": { "name": "fenced_code.block.language.attributes.markdown" }
},
"endCaptures": { "3": { "name": "punctuation.definition.markdown" } },
"patterns": [
{
"begin": "(^|\\G)(\\s*)(.*)",
"while": "(^|\\G)(?!\\s*([`~]{3,})\\s*$)",
"contentName": "meta.embedded.block.ludic",
"patterns": [{ "include": "source.ludic" }]
}
]
}
}
}

View file

@ -0,0 +1,241 @@
{
"$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json",
"name": "Ludic",
"scopeName": "source.ludic",
"fileTypes": ["ludic"],
"comment": "Static grammar for Ludic. It is the fallback: when ludic-lsp is running, its semantic tokens refine everything here with real name resolution. Generated shapes are kept in sync with tools/ludic-tools/ludic_syntax.h.",
"patterns": [
{ "include": "#comment" },
{ "include": "#declaration" },
{ "include": "#query" },
{ "include": "#string" },
{ "include": "#number" },
{ "include": "#annotation" },
{ "include": "#keyword" },
{ "include": "#widget" },
{ "include": "#builtin" },
{ "include": "#member" },
{ "include": "#call" },
{ "include": "#typename" },
{ "include": "#operator" }
],
"repository": {
"comment": {
"match": "#.*$",
"name": "comment.line.number-sign.ludic"
},
"string": {
"patterns": [
{
"name": "string.quoted.double.ludic",
"begin": "\"",
"end": "\"",
"beginCaptures": { "0": { "name": "punctuation.definition.string.begin.ludic" } },
"endCaptures": { "0": { "name": "punctuation.definition.string.end.ludic" } },
"patterns": [{ "name": "constant.character.escape.ludic", "match": "\\\\." }]
},
{
"name": "string.quoted.single.ludic",
"match": "'(\\\\.|[^'\\\\])'"
}
]
},
"number": {
"patterns": [
{ "name": "constant.numeric.hex.ludic", "match": "\\b0[xX][0-9a-fA-F]+\\b" },
{ "name": "constant.numeric.fixed.ludic", "match": "\\b[0-9]+\\.[0-9]+\\b" },
{ "name": "constant.numeric.integer.ludic", "match": "\\b[0-9]+\\b" }
]
},
"annotation": {
"name": "entity.name.function.decorator.ludic",
"match": "@[A-Za-z_][A-Za-z0-9_]*"
},
"declaration": {
"patterns": [
{
"match": "\\b(game|module)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.unit.ludic" },
"2": { "name": "entity.name.type.unit.ludic" }
}
},
{
"match": "\\b(component)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.component.ludic" },
"2": { "name": "entity.name.type.component.ludic" }
}
},
{
"match": "\\b(archetype)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.archetype.ludic" },
"2": { "name": "entity.name.type.archetype.ludic" }
}
},
{
"match": "\\b(scene)\\s+([A-Za-z_][A-Za-z0-9_]*)(\\s+start)?",
"captures": {
"1": { "name": "storage.type.scene.ludic" },
"2": { "name": "entity.name.type.scene.ludic" },
"3": { "name": "keyword.other.start.ludic" }
}
},
{
"match": "\\b(layer|ui)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.ludic" },
"2": { "name": "entity.name.type.ludic" }
}
},
{
"match": "\\b(edge\\s+)?(system)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.modifier.edge.ludic" },
"2": { "name": "storage.type.system.ludic" },
"3": { "name": "entity.name.function.system.ludic" }
}
},
{
"match": "\\b(export\\s+|pure\\s+)?(extern\\s+)?(fn)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.modifier.ludic" },
"2": { "name": "storage.modifier.extern.ludic" },
"3": { "name": "storage.type.function.ludic" },
"4": { "name": "entity.name.function.ludic" }
}
},
{
"match": "\\b(const|var|let)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "storage.type.ludic" },
"2": { "name": "variable.other.definition.ludic" }
}
},
{
"match": "\\b(state)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.state.ludic" },
"2": { "name": "entity.name.constant.state.ludic" }
}
},
{
"match": "\\b(import)\\s+(?=\")",
"captures": { "1": { "name": "keyword.control.import.ludic" } }
},
{
"match": "\\b(phase)\\s+(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b",
"captures": {
"1": { "name": "keyword.other.clause.ludic" },
"2": { "name": "constant.language.phase.ludic" }
}
},
{
"match": "\\b(enter)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.enter.ludic" },
"2": { "name": "entity.name.type.scene.ludic" }
}
},
{
"match": "\\b(spawn)\\s+([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "keyword.control.spawn.ludic" },
"2": { "name": "entity.name.type.archetype.ludic" }
}
}
]
},
"query": {
"comment": "Inside a query's brackets a bare word names a component and {Word} is a filter that does not bind.",
"begin": "\\b(query)\\s*(\\[)",
"beginCaptures": {
"1": { "name": "keyword.other.query.ludic" },
"2": { "name": "punctuation.section.brackets.begin.ludic" }
},
"end": "(\\])",
"endCaptures": { "1": { "name": "punctuation.section.brackets.end.ludic" } },
"patterns": [
{
"match": "(\\{)\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*(\\})",
"captures": {
"1": { "name": "punctuation.section.braces.begin.ludic" },
"2": { "name": "entity.name.type.tag.ludic" },
"3": { "name": "punctuation.section.braces.end.ludic" }
}
},
{ "name": "entity.name.type.component.ludic", "match": "\\b[A-Za-z_][A-Za-z0-9_]*\\b" },
{ "name": "punctuation.separator.ludic", "match": "," }
]
},
"keyword": {
"patterns": [
{ "name": "keyword.control.ludic", "match": "\\b(if|else|when|while|for|in|match|machine|become|enter|return|spawn|despawn|where|break|continue|new)\\b" },
{ "name": "keyword.operator.logical.ludic", "match": "\\b(and|or|not)\\b" },
{ "name": "keyword.other.clause.ludic", "match": "\\b(phase|query|reads|writes|needs|uses|requires|ensures|invariant|effects|on)\\b" },
{ "name": "keyword.other.ludic", "match": "\\b(import|export|pure|extern|start|edge)\\b" },
{ "name": "storage.type.ludic", "match": "\\b(game|module|component|archetype|ui|scene|layer|const|var|let|fn|system|state)\\b" },
{ "name": "support.type.primitive.ludic", "match": "\\b(int|fixed|bool|entity|str|ptr|void)\\b" },
{ "name": "constant.language.boolean.ludic", "match": "\\b(true|false)\\b" },
{ "name": "constant.language.phase.ludic", "match": "\\b(Start|Input|FixedUpdate|Update|LateUpdate|Render)\\b" }
]
},
"widget": {
"patterns": [
{
"comment": "widget types, recognised by the props or the child block that follow them",
"name": "support.class.widget.ludic",
"match": "\\b(panel|col|row|label|button|image|spacer)\\b(?=\\s*(\\{|[a-z][A-Za-z0-9_]*\\s*=|$))"
},
{
"comment": "widget props are written tight (id=Root); an assignment is written x = 1",
"match": "\\b([a-z][A-Za-z0-9_]*)(=)(?!=)",
"captures": {
"1": { "name": "variable.parameter.widget.ludic" },
"2": { "name": "keyword.operator.assignment.ludic" }
}
}
]
},
"builtin": {
"patterns": [
{
"comment": "the runtime surface — every name here resolves to rt_<name> in runtime/native",
"name": "support.function.builtin.ludic",
"match": "\\b(min|max|abs|clamp|seed|rng_range|rng_chance|fx|flr|map_size|map_row|tile|clear|present|fill_rect|frame_rect|put_px|text|text_int|font_load|text_ttf|text_w|text_h|image_load|draw_image|draw_image_scaled|draw_9slice|load_png|load_sprites|draw_sprite|draw_sprite_scaled|ui_build|ui_open|ui_tick|ui_render|ui_clicked|ui_set_text|ui_set_int|ui_focus|ui_focused|ui_visible|key|reg|setreg|self|save|load|status|print_int|quit)\\b(?=\\s*\\()"
},
{
"comment": "compiler intrinsics — these lower straight to libc or the OS",
"name": "support.function.intrinsic.ludic",
"match": "\\b(mem_alloc|mem_realloc|mem_free|mem_copy|mem_set|peek8|poke8|peek32|poke32|peekp|pokep|peekf|pokef|ptr_add|ptr_null|ptr_is_null|as_fixed|as_int|file_open|file_read|file_write|file_seek|file_tell|file_close|read_byte|write_byte|print_str|str_len|shl|shr|band|bor|bxor|bnot|os_exit|os_time|os_argc|os_arg|file_stderr|is_windowed|game_title|win_open|win_poll|win_present|win_running|win_close)\\b(?=\\s*\\()"
}
]
},
"member": {
"match": "(\\.)([A-Za-z_][A-Za-z0-9_]*)",
"captures": {
"1": { "name": "punctuation.accessor.ludic" },
"2": { "name": "variable.other.property.ludic" }
}
},
"call": {
"match": "\\b([A-Za-z_][A-Za-z0-9_]*)\\s*(?=\\()",
"name": "entity.name.function.call.ludic"
},
"typename": {
"comment": "components, archetypes, scenes and UI handles are Capitalised by convention",
"name": "entity.name.type.ludic",
"match": "\\b[A-Z][A-Za-z0-9_]*\\b"
},
"operator": {
"patterns": [
{ "name": "keyword.operator.arrow.ludic", "match": "->|=>" },
{ "name": "keyword.operator.range.ludic", "match": "\\.\\." },
{ "name": "keyword.operator.comparison.ludic", "match": "==|!=|<=|>=|<|>" },
{ "name": "keyword.operator.assignment.ludic", "match": "\\+=|-=|\\*=|/=|=" },
{ "name": "keyword.operator.arithmetic.ludic", "match": "[+\\-*/%]" }
]
}
}
}

View file

@ -0,0 +1,39 @@
# Ludic for Zed
Zed extensions want a Tree-sitter grammar for highlighting, which this toolchain
deliberately does not ship — the vocabulary lives in one C header and is exposed
through the language server's semantic tokens instead, so there is no second
grammar to keep honest.
That is enough for Zed: point it at `ludic-lsp` and highlighting, completion,
diagnostics and formatting all arrive over LSP.
Add to `~/.config/zed/settings.json`:
```json
{
"lsp": {
"ludic-lsp": {
"binary": { "path": "/path/to/gpp/build/ludic-lsp", "arguments": ["--stdio"] },
"initialization_options": {
"compilerPath": "/path/to/gpp/build/ludicc",
"compilerDiagnostics": true,
"indentSize": 2
}
}
},
"file_types": { "Ludic": ["ludic"] },
"languages": {
"Ludic": {
"language_servers": ["ludic-lsp"],
"tab_size": 2,
"hard_tabs": false,
"format_on_save": "on"
}
}
}
```
If you would rather have static highlighting too, the TextMate grammar in
`tools/editors/shared/ludic.tmLanguage.json` can be dropped into a Zed extension
that declares a `grammars` entry — but it is not required.

29
tools/git-hooks/pre-commit Executable file
View file

@ -0,0 +1,29 @@
#!/bin/sh
# Reject a commit that contains unformatted Ludic — in source or in the ```ludic
# fences of a Markdown file.
#
# ln -sf ../../tools/git-hooks/pre-commit .git/hooks/pre-commit
#
# Checks only the files being committed, so it stays fast on a big tree.
set -e
ROOT=$(git rev-parse --show-toplevel)
FMT="$ROOT/build/ludic-fmt"
[ -x "$FMT" ] || exit 0 # toolchain not built: do not block the commit
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(ludic|md|markdown)$' || true)
[ -z "$FILES" ] && exit 0
UNFORMATTED=""
for f in $FILES; do
[ -f "$ROOT/$f" ] || continue
"$FMT" --check -q "$ROOT/$f" >/dev/null 2>&1 || UNFORMATTED="$UNFORMATTED $f"
done
if [ -n "$UNFORMATTED" ]; then
echo "unformatted Ludic:"
for f in $UNFORMATTED; do echo " $f"; done
echo
echo "fix with: build/ludic-fmt -w$UNFORMATTED"
exit 1
fi

View file

@ -0,0 +1,339 @@
/* ============================================================================
* ludic_fmt.h — the canonical Ludic formatter.
*
* This is deliberately NOT `ludicc --fmt`. The compiler's printer walks the AST
* after import splicing, so it drops every comment and inlines every imported
* file into whichever file you pointed it at — fine for inspecting what the
* compiler saw, catastrophic as an editor's "format on save".
*
* This formatter works on the token stream instead:
* - comments and blank lines survive, because they are tokens;
* - nothing is ever dropped or reordered, because every token is re-emitted
* in order — the only freedom taken is the whitespace between them;
* - lines are re-indented and respaced but never joined or split, so the
* author keeps control of line structure and a format-on-save never
* rewrites a file out from under someone mid-edit.
* ==========================================================================*/
#ifndef LUDIC_FMT_H
#define LUDIC_FMT_H
#include "ludic_syntax.h"
typedef struct { char* b; size_t n, cap; } FSB;
static void fsb_ensure(FSB* s, size_t add){
if (s->n + add + 1 > s->cap){ s->cap = (s->n + add + 1) * 2; s->b = realloc(s->b, s->cap); }
}
static void fsb_add(FSB* s, const char* z, size_t l){ fsb_ensure(s, l); memcpy(s->b + s->n, z, l); s->n += l; s->b[s->n] = 0; }
static void fsb_puts(FSB* s, const char* z){ fsb_add(s, z, strlen(z)); }
static void fsb_putc(FSB* s, char c){ fsb_add(s, &c, 1); }
static void fsb_indent(FSB* s, int n){ for (int i = 0; i < n; i++) fsb_putc(s, ' '); }
/* Can this token be one side of a member access? */
static int lud_name_like(int kind){
return kind == LT_ID || kind == LT_KW || kind == LT_TYPE || kind == LT_PHASE || kind == LT_BOOL;
}
/* A '.' hugs an operand, a closing bracket, and another dot — the last so that
* a run of dots stays a run of dots instead of being spaced into pieces. */
static int lud_dot_tight(const LLex* L, const LTok* t){
if (lud_name_like(t->kind)) return 1;
if (t->kind != LT_OP) return 0;
int n = ltok_len(t);
char c0 = L->src[t->start];
if (n == 1 && (c0 == ')' || c0 == ']' || c0 == '.')) return 1;
if (n == 2 && c0 == '.' && L->src[t->start + 1] == '.') return 1;
return 0;
}
/* Is the token at index i a unary '-' / '!' rather than a binary operator?
* Unary iff nothing that can end an operand precedes it. */
static int fmt_is_unary(const LLex* L, int i){
const LTok* t = &L->v[i];
if (t->kind != LT_OP) return 0;
int n = ltok_len(t);
if (!(n == 1 && (L->src[t->start] == '-' || L->src[t->start] == '!'))) return 0;
int p = ltok_prev_sig(L, i);
if (p < 0) return 1;
const LTok* pt = &L->v[p];
switch (pt->kind){
case LT_ID: case LT_INT: case LT_FLOAT: case LT_STR: case LT_CHAR:
case LT_BOOL: case LT_TYPE: case LT_PHASE:
return 0;
case LT_OP: {
char c = L->src[pt->start];
/* a closing bracket ends an operand; every other operator does not */
return !(ltok_len(pt) == 1 && (c == ')' || c == ']' || c == '}'));
}
default: return 1; /* keyword, annotation, comment: operand starts here */
}
}
/* Whitespace between the previous emitted token (prev) and the current one. */
static int fmt_space_before(const LLex* L, int prev, int cur){
if (prev < 0) return 0;
const LTok* p = &L->v[prev];
const LTok* c = &L->v[cur];
const char* src = L->src;
int plen = ltok_len(p), clen = ltok_len(c);
char p0 = src[p->start], c0 = src[c->start];
int p1 = (plen == 1), c1 = (clen == 1);
/* An error token is bytes we did not understand. Whatever spacing it had
* is the only spacing we can justify, so it is preserved verbatim. */
if (p->kind == LT_ERR || c->kind == LT_ERR) return c->start - p->end;
/* nothing hugs a closer, a separator or a member dot from the left */
if (c1 && (c0 == ')' || c0 == ']' || c0 == ',' || c0 == ':' || c0 == ';')) return 0;
/* `.` binds tight only when it really is member access — `a.b`. A lone dot
* next to a brace is something else (or a typo) and gets normal spacing. */
if (c1 && c0 == '.' && c->kind == LT_OP && lud_dot_tight(L, p)) return 0;
if (p1 && p0 == '.' && p->kind == LT_OP && lud_dot_tight(L, c)) return 0;
/* nothing hugs an opener from the right */
if (p1 && (p0 == '(' || p0 == '[') && p->kind == LT_OP) return 0;
/* a unary sign binds to its operand */
if (p->kind == LT_OP && fmt_is_unary(L, prev)) return 0;
/* @anno(args) */
if (p->kind == LT_ANNO && c1 && c0 == '(') return 0;
if (c1 && c0 == '(' && c->kind == LT_OP){
/* `fn move(` and `clear(` hug; `if (`, `return (`, `x * (` do not */
switch (p->kind){
case LT_ID: case LT_TYPE: case LT_PHASE: return 0;
case LT_OP: return !(p1 && (p0 == ')' || p0 == ']'));
default: return 1;
}
}
return 1;
}
/* Clause keywords hang under the declaration they qualify: a system's
* `phase`/`query` lines and a function's contract lines are indented one level
* past the `system`/`fn` they belong to, with the body brace back at the
* declaration's own level. Every example in the tree is written that way. */
static int fmt_is_clause_word(const LLex* L, int i){
static const char* CLAUSES[] = { "phase","query","reads","writes","needs","uses",
"requires","ensures","invariant","effects", 0 };
if (i < 0 || i >= L->n || L->v[i].kind != LT_KW) return 0;
char b[32]; ltok_text(L, i, b, sizeof(b));
return lud_in(CLAUSES, b);
}
/* One pass over the token stream, re-emitting it with canonical whitespace.
*
* Two carve-outs keep the result idiomatic rather than merely uniform:
* - a run of two or more spaces is preserved verbatim, so hand-aligned
* columns (`const R_DIR: int = 0`) and deliberately set-off trailing
* comments survive a format-on-save;
* - `id=Root` inside a `ui` block and `{Enemy}` inside a query stay tight,
* because those are the spellings the language documents and uses.
*/
static char* ludic_format(const char* src, int indent_width){
LLex L; lud_lex(&L, src);
FSB o = {0};
/* One entry per open brace, holding the indent level to return to. Every
* brace opened on the same line shares that line's level, so a line like
* `if a { if b { if c {` steps in by ONE level, not three — and the line
* that closes them all lands back where it started. */
int stack[512]; int hang[512]; int sp = 0;
int cur = 0;
int open = 0; /* unclosed ( or [ : the author owns the alignment */
int brack = 0; /* unclosed [ only: query-term context */
int ui_depth = -1; /* brace depth just outside the innermost `ui` block */
int pending_blank = 0;
int wrote_any = 0;
int prev_line_had_comment = 0;
int i = 0;
while (i < L.n && L.v[i].kind != LT_EOF){
int a = i;
while (i < L.n && L.v[i].kind != LT_NL && L.v[i].kind != LT_EOF) i++;
int b = i; /* [a,b) are this line's tokens */
if (i < L.n && L.v[i].kind == LT_NL) i++;
if (a == b){ /* a blank line */
if (wrote_any) pending_blank = 1;
continue;
}
/* ---- where does this line start? --------------------------------- */
int line_level = cur;
{ /* leading closers belong to the level of the line that opened them */
int tsp = sp, t = a;
while (t < b && L.v[t].kind == LT_OP && ltok_len(&L.v[t]) == 1 && src[L.v[t].start] == '}'){
if (tsp > 0) line_level = stack[--tsp];
t++;
}
}
/* Original column of this line, for the cases where the author's
* alignment is the only sensible answer. */
int orig_ind = 0;
for (int k = L.linestart[L.v[a].line]; k < L.v[a].start; k++) orig_ind += (src[k] == '\t') ? 4 : 1;
/* A comment on its own line, indented past its block and following a
* line that itself ended in a comment, is the continuation of that
* comment — a column the author chose, not stray indentation. */
int comment_run = (L.v[a].kind == LT_COMMENT && b == a + 1 && prev_line_had_comment);
int ind;
if (open > 0 || (sp > 0 && hang[sp - 1])){
/* Inside an unclosed call, query, or a brace that was opened with
* content trailing it, the author is aligning to a column the
* formatter cannot see. Leave those lines exactly as written. */
int ls = L.linestart[L.v[a].line];
ind = 0;
for (int k = ls; k < L.v[a].start; k++) ind += (src[k] == '\t') ? 4 : 1;
} else {
ind = line_level * indent_width + (fmt_is_clause_word(&L, a) ? indent_width : 0);
if (comment_run && orig_ind > ind) ind = orig_ind;
}
if (pending_blank && wrote_any) fsb_putc(&o, '\n');
pending_blank = 0;
fsb_indent(&o, ind);
/* ---- the tokens --------------------------------------------------- */
int prev = -1;
int line_brack = brack, line_ui_open = (ui_depth >= 0 && sp > ui_depth);
for (int t = a; t < b; t++){
const LTok* tk = &L.v[t];
int gap = (prev >= 0) ? tk->start - L.v[prev].end : 0;
int want;
if (tk->kind == LT_COMMENT){
want = (prev >= 0) ? 2 : 0; /* set a trailing comment off */
} else {
want = fmt_space_before(&L, prev, t);
/* a query's {Tag} filter is one word, not a record literal */
if (line_brack > 0){
if (tk->kind == LT_OP && ltok_len(tk) == 1 && src[tk->start] == '}') want = 0;
if (prev >= 0 && L.v[prev].kind == LT_OP && ltok_len(&L.v[prev]) == 1 && src[L.v[prev].start] == '{') want = 0;
}
/* widget props are written k=v */
if (line_ui_open){
int eq_here = tk->kind == LT_OP && ltok_len(tk) == 1 && src[tk->start] == '=';
int eq_prev = prev >= 0 && L.v[prev].kind == LT_OP &&
ltok_len(&L.v[prev]) == 1 && src[L.v[prev].start] == '=';
if (eq_here || eq_prev) want = 0;
}
}
/* hand alignment wins over the canonical single space */
if (gap >= 2 && want >= 1){ if (gap > 60) gap = 60; want = gap; }
if (want < 0) want = 0;
for (int k = 0; k < want; k++) fsb_putc(&o, ' ');
fsb_add(&o, src + tk->start, ltok_len(tk));
prev = t;
if (tk->kind == LT_OP && ltok_len(tk) == 1){
char c = src[tk->start];
if (c == '[') line_brack++;
else if (c == ']'){ line_brack--; if (line_brack < 0) line_brack = 0; }
}
}
fsb_putc(&o, '\n');
wrote_any = 1;
prev_line_had_comment = (prev >= 0 && L.v[prev].kind == LT_COMMENT);
/* ---- carry the nesting into the next line ------------------------- */
if (ltok_is(&L, a, "ui") && L.v[a].kind == LT_KW && ui_depth < 0) ui_depth = sp;
{
int level = cur;
for (int t = a; t < b; t++){
const LTok* tk = &L.v[t];
if (tk->kind != LT_OP || ltok_len(tk) != 1) continue;
char c = src[tk->start];
if (c == '{'){
if (sp < 512){
int nxt = ltok_next_sig(&L, t);
stack[sp] = line_level;
hang[sp] = (nxt >= 0 && nxt < b); /* content follows on this line */
sp++;
}
level = line_level + 1;
}
else if (c == '}'){ if (sp > 0) level = stack[--sp]; }
else if (c == '(' || c == '['){ open++; if (c == '[') brack++; }
else if (c == ')' || c == ']'){ open--; if (open < 0) open = 0; if (c == ']'){ brack--; if (brack < 0) brack = 0; } }
}
cur = level;
}
if (ui_depth >= 0 && sp <= ui_depth) ui_depth = -1;
}
lud_lex_free(&L);
if (!o.b) { o.b = malloc(1); o.b[0] = 0; }
return o.b;
}
/* ---------- markdown ------------------------------------------------------
* Fenced Ludic in prose is still Ludic. This rewrites the body of every
* ```ludic fence in a Markdown document and leaves the prose untouched, so
* LANGUAGE.md and README.md can be kept honest by the same formatter as the
* source tree. Fence indentation (a fence inside a list item) is preserved. */
static int md_fence_at(const char* s, int i, int* fence_len, int* info_at, char* marker){
int j = i, n = 0;
while (s[j] == ' ') j++; /* leading indent */
char m = s[j];
if (m != '`' && m != '~') return 0;
while (s[j] == m){ j++; n++; }
if (n < 3) return 0;
*fence_len = n; *info_at = j; *marker = m;
return 1;
}
static int md_info_is_ludic(const char* s, int at){
while (s[at] == ' ' || s[at] == '\t') at++;
if (strncasecmp(s + at, "ludic", 5)) return 0;
char after = s[at + 5];
return after == 0 || after == '\n' || after == ' ' || after == '\t' || after == '\r';
}
static char* ludic_format_markdown(const char* src, int indent_width){
FSB o = {0};
int i = 0;
while (src[i]){
int ls = i; /* line start */
int le = ls; while (src[le] && src[le] != '\n') le++;
int flen, info, indent = 0; char marker;
while (src[ls + indent] == ' ') indent++;
if (md_fence_at(src, ls, &flen, &info, &marker) && md_info_is_ludic(src, info)){
/* copy the opening fence line verbatim */
fsb_add(&o, src + ls, le - ls + (src[le] ? 1 : 0));
i = src[le] ? le + 1 : le;
/* gather the body up to the closing fence */
int bs = i;
int be = bs;
for (;;){
if (!src[be]) break;
int ps = be, pe = ps; while (src[pe] && src[pe] != '\n') pe++;
int clen, cinfo; char cmark;
if (md_fence_at(src, ps, &clen, &cinfo, &cmark) && cmark == marker && clen >= flen){
int only = 1;
for (int k = cinfo; k < pe; k++) if (src[k] != ' ' && src[k] != '\r'){ only = 0; break; }
if (only) break;
}
be = src[pe] ? pe + 1 : pe;
}
/* de-indent the body, format it, re-indent it */
FSB body = {0};
for (int p = bs; p < be; ){
int q = p; while (src[q] && src[q] != '\n') q++;
int skip = 0; while (skip < indent && p + skip < q && src[p + skip] == ' ') skip++;
fsb_add(&body, src + p + skip, q - (p + skip));
fsb_putc(&body, '\n');
p = src[q] ? q + 1 : q;
}
char* f = ludic_format(body.b ? body.b : "", indent_width);
for (char* p = f; *p; ){
char* q = strchr(p, '\n'); if (!q) q = p + strlen(p);
if (q > p) fsb_indent(&o, indent);
fsb_add(&o, p, q - p);
fsb_putc(&o, '\n');
p = *q ? q + 1 : q;
}
free(f); free(body.b);
i = be;
continue;
}
fsb_add(&o, src + ls, le - ls + (src[le] ? 1 : 0));
i = src[le] ? le + 1 : le;
}
if (!o.b){ o.b = malloc(1); o.b[0] = 0; }
return o.b;
}
#endif /* LUDIC_FMT_H */

View file

@ -0,0 +1,118 @@
/* ============================================================================
* ludic-fmt — the Ludic formatter, as a plain CLI.
*
* Editors call this through the language server, but CI and pre-commit hooks
* want a binary they can run:
*
* ludic-fmt a.ludic b.ludic print the formatted text
* ludic-fmt -w examples rewrite every .ludic under examples/
* ludic-fmt --check . exit 1 if anything is unformatted
* ludic-fmt LANGUAGE.md -w reformat the ```ludic fences in a document
* cat x.ludic | ludic-fmt - filter mode, for editors without LSP
* ==========================================================================*/
#include "ludic_fmt.h"
#include <dirent.h>
#include <sys/stat.h>
#include <limits.h>
static int g_write = 0, g_check = 0, g_indent = 2, g_quiet = 0;
static int g_changed = 0, g_failed = 0;
static char* slurp(FILE* f){
FSB s = {0};
char buf[65536]; size_t n;
while ((n = fread(buf, 1, sizeof(buf), f)) > 0) fsb_add(&s, buf, n);
if (!s.b){ s.b = malloc(1); s.b[0] = 0; }
return s.b;
}
static int is_md(const char* p){
size_t n = strlen(p);
return (n > 3 && !strcmp(p + n - 3, ".md")) || (n > 9 && !strcmp(p + n - 9, ".markdown"));
}
static int is_ludic(const char* p){
size_t n = strlen(p);
return n > 6 && !strcmp(p + n - 6, ".ludic");
}
static void do_file(const char* path){
FILE* f = fopen(path, "rb");
if (!f){ fprintf(stderr, "ludic-fmt: cannot open %s\n", path); g_failed = 1; return; }
char* src = slurp(f); fclose(f);
char* out = is_md(path) ? ludic_format_markdown(src, g_indent) : ludic_format(src, g_indent);
int same = !strcmp(src, out);
if (g_check){
if (!same){ g_changed = 1; if (!g_quiet) printf("%s\n", path); }
} else if (g_write){
if (!same){
FILE* w = fopen(path, "wb");
if (!w){ fprintf(stderr, "ludic-fmt: cannot write %s\n", path); g_failed = 1; }
else { fwrite(out, 1, strlen(out), w); fclose(w); if (!g_quiet) fprintf(stderr, "formatted %s\n", path); }
g_changed = 1;
}
} else {
fwrite(out, 1, strlen(out), stdout);
}
free(src); free(out);
}
static void do_dir(const char* dir, int depth){
if (depth > 16) return;
DIR* d = opendir(dir);
if (!d){ fprintf(stderr, "ludic-fmt: cannot open %s\n", dir); g_failed = 1; return; }
struct dirent* e;
while ((e = readdir(d))){
if (e->d_name[0] == '.') continue;
if (!strcmp(e->d_name, "build") || !strcmp(e->d_name, "node_modules")) continue;
char p[PATH_MAX]; snprintf(p, sizeof(p), "%s/%s", dir, e->d_name);
struct stat st; if (stat(p, &st)) continue;
if (S_ISDIR(st.st_mode)) do_dir(p, depth + 1);
else if (is_ludic(p)) do_file(p);
}
closedir(d);
}
static const char* USAGE =
"ludic-fmt — format Ludic source\n"
"\n"
"usage: ludic-fmt [options] [file|dir ...]\n"
" ludic-fmt - read stdin, write stdout\n"
"\n"
"options:\n"
" -w, --write rewrite files in place\n"
" --check list unformatted files; exit 1 if any (implies no output)\n"
" --indent N spaces per level (default 2)\n"
" -q, --quiet no per-file chatter\n"
" -h, --help this text\n"
"\n"
"Directories are walked for *.ludic. A .md/.markdown file has the body of every\n"
"```ludic fence formatted and the prose left alone.\n";
int main(int argc, char** argv){
const char* files[4096]; int nf = 0;
for (int i = 1; i < argc; i++){
const char* a = argv[i];
if (!strcmp(a, "-w") || !strcmp(a, "--write")) g_write = 1;
else if (!strcmp(a, "--check") || !strcmp(a, "-l")) g_check = 1;
else if (!strcmp(a, "-q") || !strcmp(a, "--quiet")) g_quiet = 1;
else if (!strcmp(a, "--indent") && i + 1 < argc) g_indent = atoi(argv[++i]);
else if (!strcmp(a, "-h") || !strcmp(a, "--help")){ fputs(USAGE, stdout); return 0; }
else if (a[0] == '-' && a[1] && strcmp(a, "-")){ fprintf(stderr, "ludic-fmt: unknown option %s\n", a); return 2; }
else if (nf < 4096) files[nf++] = a;
}
if (g_indent < 1 || g_indent > 8) g_indent = 2;
if (nf == 0 || (nf == 1 && !strcmp(files[0], "-"))){
char* src = slurp(stdin);
char* out = ludic_format(src, g_indent);
fwrite(out, 1, strlen(out), stdout);
free(src); free(out);
return 0;
}
for (int i = 0; i < nf; i++){
struct stat st;
if (stat(files[i], &st)){ fprintf(stderr, "ludic-fmt: no such file %s\n", files[i]); g_failed = 1; continue; }
if (S_ISDIR(st.st_mode)) do_dir(files[i], 0);
else do_file(files[i]);
}
if (g_failed) return 2;
return (g_check && g_changed) ? 1 : 0;
}

View file

@ -0,0 +1,672 @@
/* ============================================================================
* ludic_index.h — an error-tolerant model of a Ludic workspace.
*
* The compiler's parser is the wrong tool for an editor: it stops at the first
* error and it splices imports into one flat program. A language server needs
* the opposite — keep going after a syntax error (a file being typed into is
* broken most of the time), keep every file separate, and remember where each
* name came from so it can be jumped to, renamed and completed.
*
* So this is a second, deliberately shallow reader of the same grammar. It
* recognises declarations and bindings and records their spans; it does not
* type-check. Ground-truth errors still come from `ludicc` itself (see
* ludic_lsp.c) — this layer supplies structure, not judgement.
* ==========================================================================*/
#ifndef LUDIC_INDEX_H
#define LUDIC_INDEX_H
#include "ludic_syntax.h"
#include <stdarg.h>
/* ---------- symbol kinds --------------------------------------------------*/
enum {
LS_UNIT, LS_COMPONENT, LS_FIELD, LS_ARCHETYPE, LS_CONST, LS_VAR,
LS_FN, LS_PARAM, LS_EXTERN, LS_SYSTEM, LS_UI, LS_WIDGET, LS_SCENE,
LS_LAYER, LS_LOCAL, LS_QUERYVAR, LS_STATE, LS_IMPORT
};
/* semantic classes, one per token — the source for semantic highlighting */
enum {
SC_NONE, SC_KEYWORD, SC_TYPE, SC_COMPONENT, SC_ARCHETYPE, SC_SCENE,
SC_LAYER, SC_UI, SC_WIDGET, SC_PROP, SC_FIELD, SC_SYSTEM, SC_FUNCTION,
SC_BUILTIN, SC_PARAM, SC_VARIABLE, SC_CONST, SC_MODVAR, SC_PHASE,
SC_NUMBER, SC_STRING, SC_COMMENT, SC_OPERATOR, SC_ANNOTATION, SC_UNKNOWN
};
typedef struct {
int kind;
char name[96];
char type[96]; /* declared type; for a query var, its component */
char detail[224]; /* one-line signature, shown in hover and outlines */
char doc[640]; /* the comment block sitting directly above */
int tok; /* token index of the NAME */
int start, end; /* byte span of the name */
int body_start, body_end; /* span the symbol governs (a block, or the
* whole file for a top-level declaration) */
int parent; /* enclosing symbol index, -1 at the top level */
int exported;
} LSym;
typedef struct {
int line, col, endline, endcol, severity; /* 1 = error, 2 = warning */
char msg[512];
char path[1024]; /* empty = this document */
} LDiag;
typedef struct LDoc {
char* path; /* filesystem path */
char* uri; /* file:// URI */
char* raw; /* the buffer exactly as the editor has it */
char* text; /* what the lexer sees: `raw`, or — for Markdown — a
* copy with every byte outside a ```ludic fence blanked
* out, so offsets still line up with the real file */
int version;
int open; /* the editor holds it (so `text` beats the disk) */
int is_markdown; /* a .md file: only its ```ludic fences are Ludic */
LLex lex;
int* match; /* per token: matching brace/bracket token, else -1 */
unsigned char* cls; /* per token: semantic class */
LSym* sym; int nsym, symcap;
char** imports; int nimport;
int had_diags; /* we published a non-empty list for it last time */
int is_unit; /* declares `game` or `module` */
int is_module;
char unit[96];
} LDoc;
typedef struct {
LDoc** d; int n, cap;
char* root; /* workspace root directory */
} LIndex;
/* ---------- small helpers -------------------------------------------------*/
static void lsym_reserve(LDoc* D){
if (D->nsym >= D->symcap){ D->symcap = D->symcap ? D->symcap * 2 : 128; D->sym = realloc(D->sym, D->symcap * sizeof(LSym)); }
}
static void lcpy(char* dst, int cap, const char* s, int n){
if (n >= cap) n = cap - 1; if (n < 0) n = 0;
memcpy(dst, s, n); dst[n] = 0;
}
static void lcatf(char* dst, int cap, const char* fmt, ...){
int used = (int)strlen(dst); if (used >= cap - 1) return;
va_list ap; va_start(ap, fmt); vsnprintf(dst + used, cap - used, fmt, ap); va_end(ap);
}
/* ---------- positions -----------------------------------------------------
* LSP counts characters in UTF-16 code units by default. Ludic source is UTF-8
* and string literals really do carry non-ASCII (the language ships a Unicode
* TrueType path), so the conversion is not optional. */
static int lutf16_len(const char* s, int nbytes){
int u = 0;
for (int i = 0; i < nbytes; ){
unsigned char c = (unsigned char)s[i];
if (c < 0x80){ i += 1; u += 1; }
else if (c < 0xE0){ i += 2; u += 1; }
else if (c < 0xF0){ i += 3; u += 1; }
else { i += 4; u += 2; } /* astral planes are a surrogate pair */
}
return u;
}
static int ldoc_line_of(const LDoc* D, int off){
int lo = 0, hi = D->lex.nline - 1;
while (lo < hi){ int mid = (lo + hi + 1) / 2; if (D->lex.linestart[mid] <= off) lo = mid; else hi = mid - 1; }
return lo;
}
static int ldoc_col_of(const LDoc* D, int off){
int line = ldoc_line_of(D, off);
return lutf16_len(D->text + D->lex.linestart[line], off - D->lex.linestart[line]);
}
static int ldoc_offset_of(const LDoc* D, int line, int character){
if (line < 0) return 0;
if (line >= D->lex.nline) return (int)strlen(D->text);
int off = D->lex.linestart[line];
int end = (line + 1 < D->lex.nline) ? D->lex.linestart[line + 1] : (int)strlen(D->text);
int u = 0;
while (off < end && u < character){
unsigned char c = (unsigned char)D->text[off];
if (c < 0x80){ off += 1; u += 1; }
else if (c < 0xE0){ off += 2; u += 1; }
else if (c < 0xF0){ off += 3; u += 1; }
else { off += 4; u += 2; }
}
return off;
}
/* The token the cursor is on. A caret sitting exactly between two tokens
* belongs to the one it is *inside*; only when it is inside none of them does
* the token ending there win — otherwise `st.|guard` resolves to the dot. */
static int ldoc_tok_at(const LDoc* D, int off){
int touching = -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 (off >= t->start && off < t->end) return i;
if (off == t->end && touching < 0) touching = i;
if (t->start > off) break;
}
return touching;
}
/* ---------- brace matching ------------------------------------------------*/
static void ldoc_match_braces(LDoc* D){
free(D->match);
D->match = malloc(sizeof(int) * (D->lex.n + 1));
for (int i = 0; i < D->lex.n; i++) D->match[i] = -1;
int stack[512], top = 0;
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) continue;
char c = D->text[t->start];
if (c == '{' || c == '(' || c == '['){ if (top < 512) stack[top++] = i; }
else if (c == '}' || c == ')' || c == ']'){
if (top > 0){ int o = stack[--top]; D->match[o] = i; D->match[i] = o; }
}
}
}
/* ---------- doc comments --------------------------------------------------
* The comment block immediately above a declaration is its documentation —
* the convention the runtime and examples already follow. */
static void ldoc_collect_doc(LDoc* D, int decl_tok, char* out, int cap){
out[0] = 0;
int line = D->lex.v[decl_tok].line;
/* walk backwards over comment-only lines */
int first = -1;
for (int i = decl_tok - 1; i >= 0; i--){
const LTok* t = &D->lex.v[i];
if (t->kind == LT_NL) continue;
if (t->kind != LT_COMMENT) break;
if (t->line >= line) break; /* trailing, not leading */
/* the comment must own its line */
int p = ltok_prev_sig(&D->lex, i);
if (p >= 0 && D->lex.v[p].line == t->line) break;
if (t->line < line - 1 && first >= 0) break;/* a blank line ends it */
if (first >= 0 && D->lex.v[first].line != t->line + 1) break;
first = i; line = t->line;
}
if (first < 0) return;
for (int i = first; i < decl_tok; i++){
const LTok* t = &D->lex.v[i];
if (t->kind != LT_COMMENT) continue;
int s = t->start + 1; /* skip '#' */
while (s < t->end && (D->text[s] == ' ' || D->text[s] == '\t' || D->text[s] == '#' || D->text[s] == '*')) s++;
int used = (int)strlen(out);
int n = t->end - s;
if (used + n + 2 >= cap) break;
memcpy(out + used, D->text + s, n); out[used + n] = '\n'; out[used + n + 1] = 0;
}
}
/* ---------- the shallow parser -------------------------------------------*/
typedef struct { LDoc* D; int i; } LP;
static int lp_kind(LP* p){ return p->i < p->D->lex.n ? p->D->lex.v[p->i].kind : LT_EOF; }
static int lp_is(LP* p, const char* s){ return ltok_is(&p->D->lex, p->i, s); }
static void lp_skipnl(LP* p){ while (p->i < p->D->lex.n && (lp_kind(p) == LT_NL || lp_kind(p) == LT_COMMENT)) p->i++; }
static void lp_adv(LP* p){ if (p->i < p->D->lex.n) p->i++; lp_skipnl(p); }
static char* lp_word(LP* p, char* buf, int cap){
buf[0] = 0;
if (p->i < p->D->lex.n) lcpy(buf, cap, p->D->text + p->D->lex.v[p->i].start, ltok_len(&p->D->lex.v[p->i]));
return buf;
}
/* Consume an identifier-ish token, returning its index (or -1). */
static int lp_name(LP* p){
int k = lp_kind(p);
if (k == LT_ID || k == LT_TYPE || k == LT_PHASE || k == LT_KW || k == LT_BOOL){ int t = p->i; lp_adv(p); return t; }
return -1;
}
static int lsym_add(LDoc* D, int kind, int nametok, int parent){
lsym_reserve(D);
LSym* s = &D->sym[D->nsym];
memset(s, 0, sizeof(*s));
s->kind = kind; s->tok = nametok; s->parent = parent;
if (nametok >= 0){
const LTok* t = &D->lex.v[nametok];
s->start = t->start; s->end = t->end;
lcpy(s->name, sizeof(s->name), D->text + t->start, ltok_len(t));
}
s->body_start = 0; s->body_end = (int)strlen(D->text);
return D->nsym++;
}
/* Skip a balanced group starting at the current opener; leaves p after it. */
static void lp_skip_group(LP* p){
int m = (p->i < p->D->lex.n) ? p->D->match[p->i] : -1;
if (m < 0){ lp_adv(p); return; }
p->i = m + 1; lp_skipnl(p);
}
/* Read `name: Type` pairs inside the parentheses at p->i, recording params. */
static void lp_params(LP* p, int owner, int kind, char* sig, int sigcap){
if (!lp_is(p, "(")) return;
int close = p->D->match[p->i];
lcatf(sig, sigcap, "(");
lp_adv(p);
int first = 1;
while (p->i < p->D->lex.n && (close < 0 || p->i < close)){
if (lp_is(p, ")")) break;
if (lp_is(p, ",")){ lp_adv(p); continue; }
int nt = lp_name(p); if (nt < 0){ lp_adv(p); continue; }
char ty[96]; ty[0] = 0;
if (lp_is(p, ":")){ lp_adv(p); int tt = lp_name(p); if (tt >= 0) lcpy(ty, sizeof(ty), p->D->text + p->D->lex.v[tt].start, ltok_len(&p->D->lex.v[tt])); }
int s = lsym_add(p->D, kind, nt, owner);
lcpy(p->D->sym[s].type, sizeof(p->D->sym[s].type), ty, (int)strlen(ty));
lcatf(sig, sigcap, "%s%s: %s", first ? "" : ", ", p->D->sym[s].name, ty[0] ? ty : "?");
first = 0;
}
if (close >= 0) p->i = close + 1;
lp_skipnl(p);
lcatf(sig, sigcap, ")");
}
/* `[Pos, Vel, {Enemy}]` — bind the listed components to the named variables in
* order, skipping {Tag} terms, which filter without binding. */
static void lp_query_terms(LP* p, int owner, int* vars, int nvars, int scope_start, int scope_end){
if (!lp_is(p, "[")) return;
int close = p->D->match[p->i];
lp_adv(p);
int bind = 0;
while (p->i < p->D->lex.n && (close < 0 || p->i < close)){
if (lp_is(p, "]")) break;
if (lp_is(p, ",")){ lp_adv(p); continue; }
if (lp_is(p, "{")){ /* a filter term */
int cb = p->D->match[p->i]; lp_adv(p);
int nt = lp_name(p); (void)nt;
if (cb >= 0) p->i = cb + 1; else lp_adv(p);
lp_skipnl(p); continue;
}
int nt = lp_name(p); if (nt < 0){ lp_adv(p); continue; }
if (bind < nvars && vars[bind] >= 0){
int s = lsym_add(p->D, LS_QUERYVAR, vars[bind], owner);
lcpy(p->D->sym[s].type, sizeof(p->D->sym[s].type), p->D->text + p->D->lex.v[nt].start, ltok_len(&p->D->lex.v[nt]));
p->D->sym[s].body_start = scope_start; p->D->sym[s].body_end = scope_end;
lcatf(p->D->sym[s].detail, sizeof(p->D->sym[s].detail), "%s: %s (query binding)", p->D->sym[s].name, p->D->sym[s].type);
}
bind++;
}
if (close >= 0) p->i = close + 1;
lp_skipnl(p);
if (lp_is(p, "where")) lp_adv(p); /* the condition is ordinary expression */
}
/* `(a, b)` variable list before `in query` / after the `query` clause. */
static int lp_varlist(LP* p, int* out, int max){
int n = 0;
if (!lp_is(p, "(")) return 0;
int close = p->D->match[p->i];
lp_adv(p);
while (p->i < p->D->lex.n && (close < 0 || p->i < close)){
if (lp_is(p, ")")) break;
if (lp_is(p, ",")){ lp_adv(p); continue; }
int nt = lp_name(p); if (nt < 0){ lp_adv(p); continue; }
if (n < max) out[n++] = nt;
}
if (close >= 0) p->i = close + 1;
lp_skipnl(p);
return n;
}
static void lp_block(LP* p, int owner, int scope_end);
/* statements — we only care about what BINDS a name or opens a scope */
static void lp_stmt(LP* p, int owner, int scope_end){
LDoc* D = p->D;
if (lp_is(p, "let")){
lp_adv(p); int nt = lp_name(p); if (nt < 0) return;
char ty[96]; ty[0] = 0;
if (lp_is(p, ":")){ lp_adv(p); int tt = lp_name(p); if (tt >= 0) lcpy(ty, sizeof(ty), D->text + D->lex.v[tt].start, ltok_len(&D->lex.v[tt])); }
int s = lsym_add(D, LS_LOCAL, nt, owner);
lcpy(D->sym[s].type, sizeof(D->sym[s].type), ty, (int)strlen(ty));
D->sym[s].body_start = D->lex.v[nt].start; D->sym[s].body_end = scope_end;
return;
}
if (lp_is(p, "for")){
lp_adv(p);
if (lp_is(p, "(")){ /* for (a, b) in query [...] */
int vars[16]; int nv = lp_varlist(p, vars, 16);
if (lp_is(p, "in")) lp_adv(p);
if (lp_is(p, "query")) lp_adv(p);
int body_end = scope_end;
/* the loop body is the next {...}; bindings live there */
lp_query_terms(p, owner, vars, nv, D->lex.v[p->i < D->lex.n ? p->i : D->lex.n - 1].start, body_end);
return;
}
int nt = lp_name(p); /* for i in a .. b */
if (nt >= 0){
int s = lsym_add(D, LS_LOCAL, nt, owner);
lcpy(D->sym[s].type, sizeof(D->sym[s].type), "int", 3);
D->sym[s].body_start = D->lex.v[nt].start; D->sym[s].body_end = scope_end;
}
return;
}
if (lp_is(p, "state")){ /* machine { state Idle = 0 {…} } */
lp_adv(p); int nt = lp_name(p);
if (nt >= 0) lsym_add(D, LS_STATE, nt, owner);
return;
}
lp_adv(p);
}
static void lp_block(LP* p, int owner, int scope_end){
if (!lp_is(p, "{")) return;
int close = p->D->match[p->i];
int end = close >= 0 ? p->D->lex.v[close].start : scope_end;
lp_adv(p);
while (p->i < p->D->lex.n && lp_kind(p) != LT_EOF){
if (close >= 0 && p->i >= close) break;
if (lp_is(p, "}")) break;
if (lp_is(p, "{")){ lp_block(p, owner, end); continue; }
int before = p->i;
lp_stmt(p, owner, end);
if (p->i == before) lp_adv(p);
}
if (close >= 0) p->i = close + 1; else lp_adv(p);
lp_skipnl(p);
}
/* `panel id=Root w=288 { … }` — each id= mints a UI_<Name> handle. */
static void lp_widget(LP* p, int owner, int uisym){
LDoc* D = p->D;
int type_tok = lp_name(p); if (type_tok < 0) return;
while (p->i < D->lex.n && lp_kind(p) == LT_ID && ltok_is(&D->lex, ltok_next_sig(&D->lex, p->i) < 0 ? p->i : ltok_next_sig(&D->lex, p->i), "=")){
int key = p->i; char kb[64]; lcpy(kb, sizeof(kb), D->text + D->lex.v[key].start, ltok_len(&D->lex.v[key]));
lp_adv(p); /* key */
if (lp_is(p, "=")) lp_adv(p); /* '=' */
if (!strcmp(kb, "id")){
int nt = lp_name(p);
if (nt >= 0){
int s = lsym_add(D, LS_WIDGET, nt, uisym);
snprintf(D->sym[s].name, sizeof(D->sym[s].name), "UI_%.*s",
ltok_len(&D->lex.v[nt]), D->text + D->lex.v[nt].start);
lcatf(D->sym[s].detail, sizeof(D->sym[s].detail), "%s widget handle",
(const char*)(D->text + D->lex.v[type_tok].start));
lcpy(D->sym[s].type, sizeof(D->sym[s].type), "int", 3);
}
} else {
/* skip one value expression: stop at the next `key=` or at a brace */
while (p->i < D->lex.n && lp_kind(p) != LT_EOF && !lp_is(p, "{") && !lp_is(p, "}")){
if (lp_is(p, "(") || lp_is(p, "[")){ lp_skip_group(p); continue; }
int nx = ltok_next_sig(&D->lex, p->i);
if (lp_kind(p) == LT_ID && nx >= 0 && ltok_is(&D->lex, nx, "=")) break;
lp_adv(p);
}
}
}
if (lp_is(p, "{")){
int close = D->match[p->i];
lp_adv(p);
while (p->i < D->lex.n && lp_kind(p) != LT_EOF && !lp_is(p, "}")){
if (close >= 0 && p->i >= close) break;
int before = p->i;
lp_widget(p, owner, uisym);
if (p->i == before) lp_adv(p);
}
if (close >= 0) p->i = close + 1; else lp_adv(p);
lp_skipnl(p);
}
}
/* one top-level declaration */
static void lp_decl(LP* p, int parent){
LDoc* D = p->D;
char w[96]; lp_word(p, w, sizeof(w));
int decl_tok = p->i;
if (!strcmp(w, "import")){
lp_adv(p);
if (lp_kind(p) == LT_STR){
const LTok* t = &D->lex.v[p->i];
int n = ltok_len(t) - 2; if (n < 0) n = 0;
char* rel = malloc(n + 1); memcpy(rel, D->text + t->start + 1, n); rel[n] = 0;
D->imports = realloc(D->imports, (D->nimport + 1) * sizeof(char*));
D->imports[D->nimport++] = rel;
int s = lsym_add(D, LS_IMPORT, p->i, parent);
lcpy(D->sym[s].name, sizeof(D->sym[s].name), rel, (int)strlen(rel));
lp_adv(p);
}
return;
}
if (!strcmp(w, "component") || !strcmp(w, "archetype")){
int is_comp = !strcmp(w, "component");
lp_adv(p); int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, is_comp ? LS_COMPONENT : LS_ARCHETYPE, nt, parent);
ldoc_collect_doc(D, decl_tok, D->sym[s].doc, sizeof(D->sym[s].doc));
while (lp_kind(p) == LT_ANNO) lp_adv(p);
snprintf(D->sym[s].detail, sizeof(D->sym[s].detail), "%s %s {",
is_comp ? "component" : "archetype", D->sym[s].name);
if (!lp_is(p, "{")) return;
int close = D->match[p->i];
D->sym[s].body_start = D->lex.v[p->i].start;
D->sym[s].body_end = close >= 0 ? D->lex.v[close].end : (int)strlen(D->text);
lp_adv(p);
int first = 1;
while (p->i < D->lex.n && lp_kind(p) != LT_EOF && !lp_is(p, "}")){
if (close >= 0 && p->i >= close) break;
if (lp_is(p, ",")){ lp_adv(p); continue; }
int ft = lp_name(p); if (ft < 0){ lp_adv(p); continue; }
if (is_comp){
int f = lsym_add(D, LS_FIELD, ft, s);
if (lp_is(p, ":")){
lp_adv(p); int tt = lp_name(p);
if (tt >= 0) lcpy(D->sym[f].type, sizeof(D->sym[f].type), D->text + D->lex.v[tt].start, ltok_len(&D->lex.v[tt]));
}
lcatf(D->sym[f].detail, sizeof(D->sym[f].detail), "%s.%s: %s", D->sym[s].name, D->sym[f].name, D->sym[f].type);
if (lp_is(p, "=")){ lp_adv(p); while (p->i < D->lex.n && !lp_is(p, ",") && !lp_is(p, "}") && lp_kind(p) != LT_EOF){ if (lp_is(p, "(") || lp_is(p, "{") || lp_is(p, "[")) lp_skip_group(p); else lp_adv(p); } }
lcatf(D->sym[s].detail, sizeof(D->sym[s].detail), "%s %s: %s", first ? "" : ",", D->sym[f].name, D->sym[f].type);
} else {
lcatf(D->sym[s].detail, sizeof(D->sym[s].detail), "%s %.*s", first ? "" : ",",
ltok_len(&D->lex.v[ft]), D->text + D->lex.v[ft].start);
}
first = 0;
}
lcatf(D->sym[s].detail, sizeof(D->sym[s].detail), " }");
if (close >= 0) p->i = close + 1; else lp_adv(p);
lp_skipnl(p);
return;
}
if (!strcmp(w, "const") || !strcmp(w, "var")){
int kind = !strcmp(w, "const") ? LS_CONST : LS_VAR;
lp_adv(p); int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, kind, nt, parent);
ldoc_collect_doc(D, decl_tok, D->sym[s].doc, sizeof(D->sym[s].doc));
if (lp_is(p, ":")){ lp_adv(p); int tt = lp_name(p); if (tt >= 0) lcpy(D->sym[s].type, sizeof(D->sym[s].type), D->text + D->lex.v[tt].start, ltok_len(&D->lex.v[tt])); }
lcatf(D->sym[s].detail, sizeof(D->sym[s].detail), "%s %s: %s", w, D->sym[s].name, D->sym[s].type);
while (p->i < D->lex.n && lp_kind(p) != LT_NL && lp_kind(p) != LT_EOF){
if (lp_is(p, "(") || lp_is(p, "{") || lp_is(p, "[")) lp_skip_group(p); else p->i++;
}
lp_skipnl(p);
return;
}
if (!strcmp(w, "extern")){
lp_adv(p); if (lp_is(p, "fn")) lp_adv(p);
int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, LS_EXTERN, nt, parent);
ldoc_collect_doc(D, decl_tok, D->sym[s].doc, sizeof(D->sym[s].doc));
char sig[224]; snprintf(sig, sizeof(sig), "extern fn %s", D->sym[s].name);
lp_params(p, s, LS_PARAM, sig, sizeof(sig));
if (lp_is(p, "->")){ lp_adv(p); int tt = lp_name(p); if (tt >= 0){ lcpy(D->sym[s].type, sizeof(D->sym[s].type), D->text + D->lex.v[tt].start, ltok_len(&D->lex.v[tt])); lcatf(sig, sizeof(sig), " -> %s", D->sym[s].type); } }
lcpy(D->sym[s].detail, sizeof(D->sym[s].detail), sig, (int)strlen(sig));
while (p->i < D->lex.n && lp_kind(p) != LT_NL && lp_kind(p) != LT_EOF) p->i++;
lp_skipnl(p);
return;
}
if (!strcmp(w, "fn") || !strcmp(w, "pure") || !strcmp(w, "export")){
int exported = !strcmp(w, "export");
if (!strcmp(w, "pure") || exported){ lp_adv(p); if (lp_is(p, "pure")) lp_adv(p); }
if (lp_is(p, "fn")) lp_adv(p);
int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, LS_FN, nt, parent);
D->sym[s].exported = exported;
ldoc_collect_doc(D, decl_tok, D->sym[s].doc, sizeof(D->sym[s].doc));
char sig[224]; snprintf(sig, sizeof(sig), "%sfn %s", exported ? "export " : "", D->sym[s].name);
lp_params(p, s, LS_PARAM, sig, sizeof(sig));
if (lp_is(p, "->")){ lp_adv(p); int tt = lp_name(p); if (tt >= 0) lcpy(D->sym[s].type, sizeof(D->sym[s].type), D->text + D->lex.v[tt].start, ltok_len(&D->lex.v[tt])); }
lcatf(sig, sizeof(sig), " -> %s", D->sym[s].type[0] ? D->sym[s].type : "void");
lcpy(D->sym[s].detail, sizeof(D->sym[s].detail), sig, (int)strlen(sig));
/* contracts sit between the signature and the body */
while (lp_is(p, "requires") || lp_is(p, "ensures") || lp_is(p, "invariant") || lp_is(p, "effects")){
lp_adv(p);
while (p->i < D->lex.n && lp_kind(p) != LT_NL && lp_kind(p) != LT_EOF && !lp_is(p, "{")){
if (lp_is(p, "(") || lp_is(p, "[")) lp_skip_group(p); else p->i++;
}
lp_skipnl(p);
}
if (lp_is(p, "{")){
int close = D->match[p->i];
D->sym[s].body_start = D->lex.v[p->i].start;
D->sym[s].body_end = close >= 0 ? D->lex.v[close].end : (int)strlen(D->text);
/* params are visible for the whole body */
for (int q = 0; q < D->nsym; q++) if (D->sym[q].parent == s && D->sym[q].kind == LS_PARAM){
D->sym[q].body_start = D->sym[s].body_start; D->sym[q].body_end = D->sym[s].body_end;
}
lp_block(p, s, D->sym[s].body_end);
}
return;
}
if (!strcmp(w, "system") || !strcmp(w, "edge")){
if (!strcmp(w, "edge")){ lp_adv(p); if (!lp_is(p, "system")) return; }
lp_adv(p);
int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, LS_SYSTEM, nt, parent);
ldoc_collect_doc(D, decl_tok, D->sym[s].doc, sizeof(D->sym[s].doc));
lcpy(D->sym[s].type, sizeof(D->sym[s].type), "Update", 6);
while (lp_kind(p) == LT_ANNO) lp_adv(p);
int vars[16]; int nv = 0; int have_query = 0;
for (;;){
if (lp_is(p, "phase")){ lp_adv(p); int tt = lp_name(p); if (tt >= 0) lcpy(D->sym[s].type, sizeof(D->sym[s].type), D->text + D->lex.v[tt].start, ltok_len(&D->lex.v[tt])); }
else if (lp_is(p, "query")){ lp_adv(p); have_query = 1; nv = lp_varlist(p, vars, 16); break; }
else if (lp_is(p, "reads") || lp_is(p, "writes") || lp_is(p, "uses") || lp_is(p, "effects")){ lp_adv(p); if (lp_is(p, "[")) lp_skip_group(p); }
else if (lp_is(p, "needs")){ lp_adv(p); lp_name(p); if (lp_is(p, "[")) lp_skip_group(p); }
else break;
lp_skipnl(p);
}
lcatf(D->sym[s].detail, sizeof(D->sym[s].detail), "system %s phase %s", D->sym[s].name, D->sym[s].type);
/* find the body first, so query bindings can be scoped to it */
int body_open = p->i;
if (have_query){
int save = p->i;
/* the query terms come before the body */
int scan = p->i;
while (scan < D->lex.n && !ltok_is(&D->lex, scan, "{") && D->lex.v[scan].kind != LT_EOF) scan++;
int close = (scan < D->lex.n) ? D->match[scan] : -1;
int bs = (scan < D->lex.n) ? D->lex.v[scan].start : 0;
int be = close >= 0 ? D->lex.v[close].end : (int)strlen(D->text);
p->i = save;
lp_query_terms(p, s, vars, nv, bs, be);
body_open = p->i;
}
(void)body_open;
if (lp_is(p, "{")){
int close = D->match[p->i];
D->sym[s].body_start = D->lex.v[p->i].start;
D->sym[s].body_end = close >= 0 ? D->lex.v[close].end : (int)strlen(D->text);
lp_block(p, s, D->sym[s].body_end);
}
return;
}
if (!strcmp(w, "ui")){
lp_adv(p); int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, LS_UI, nt, parent);
ldoc_collect_doc(D, decl_tok, D->sym[s].doc, sizeof(D->sym[s].doc));
lcatf(D->sym[s].detail, sizeof(D->sym[s].detail), "ui %s", D->sym[s].name);
/* the block name is a handle too */
{ int h = lsym_add(D, LS_WIDGET, nt, s);
snprintf(D->sym[h].name, sizeof(D->sym[h].name), "UI_%s", D->sym[s].name);
lcpy(D->sym[h].type, sizeof(D->sym[h].type), "int", 3);
lcatf(D->sym[h].detail, sizeof(D->sym[h].detail), "root handle of ui %s", D->sym[s].name); }
if (lp_is(p, "{")){
int close = D->match[p->i];
D->sym[s].body_start = D->lex.v[p->i].start;
D->sym[s].body_end = close >= 0 ? D->lex.v[close].end : (int)strlen(D->text);
lp_adv(p);
while (p->i < D->lex.n && lp_kind(p) != LT_EOF && !lp_is(p, "}")){
if (close >= 0 && p->i >= close) break;
int before = p->i;
lp_widget(p, s, s);
if (p->i == before) lp_adv(p);
}
if (close >= 0) p->i = close + 1; else lp_adv(p);
lp_skipnl(p);
}
return;
}
if (!strcmp(w, "scene")){
lp_adv(p); int nt = lp_name(p); if (nt < 0) return;
int s = lsym_add(D, LS_SCENE, nt, parent);
ldoc_collect_doc(D, decl_tok, D->sym[s].doc, sizeof(D->sym[s].doc));
int start = 0;
if (lp_is(p, "start")){ start = 1; lp_adv(p); }
lcatf(D->sym[s].detail, sizeof(D->sym[s].detail), "scene %s%s", D->sym[s].name, start ? " start" : "");
if (lp_is(p, "{")){
int close = D->match[p->i];
D->sym[s].body_start = D->lex.v[p->i].start;
D->sym[s].body_end = close >= 0 ? D->lex.v[close].end : (int)strlen(D->text);
lp_adv(p);
while (p->i < D->lex.n && lp_kind(p) != LT_EOF && !lp_is(p, "}")){
if (close >= 0 && p->i >= close) break;
int before = p->i;
if (lp_is(p, "on")){ lp_adv(p); lp_name(p); lp_block(p, s, D->sym[s].body_end); }
else if (lp_is(p, "layer")){
lp_adv(p); int lt = lp_name(p);
int ls = lt >= 0 ? lsym_add(D, LS_LAYER, lt, s) : -1;
if (ls >= 0) lcatf(D->sym[ls].detail, sizeof(D->sym[ls].detail), "layer %s of scene %s", D->sym[ls].name, D->sym[s].name);
if (lp_is(p, "{")){
int lc = D->match[p->i];
if (ls >= 0){ D->sym[ls].body_start = D->lex.v[p->i].start; D->sym[ls].body_end = lc >= 0 ? D->lex.v[lc].end : D->sym[s].body_end; }
lp_adv(p);
while (p->i < D->lex.n && lp_kind(p) != LT_EOF && !lp_is(p, "}")){
if (lc >= 0 && p->i >= lc) break;
int b2 = p->i; lp_decl(p, ls >= 0 ? ls : s);
if (p->i == b2) lp_adv(p);
}
if (lc >= 0) p->i = lc + 1; else lp_adv(p);
lp_skipnl(p);
}
} else lp_adv(p);
if (p->i == before) lp_adv(p);
}
if (close >= 0) p->i = close + 1; else lp_adv(p);
lp_skipnl(p);
}
return;
}
lp_adv(p);
}
static void ldoc_parse(LDoc* D){
D->nsym = 0;
for (int i = 0; i < D->nimport; i++) free(D->imports[i]);
free(D->imports); D->imports = 0; D->nimport = 0;
D->is_unit = 0; D->is_module = 0; D->unit[0] = 0;
LP p = { D, 0 };
lp_skipnl(&p);
while (lp_is(&p, "import")) { lp_decl(&p, -1); lp_skipnl(&p); }
int unit_parent = -1;
if (lp_is(&p, "game") || lp_is(&p, "module")){
D->is_module = lp_is(&p, "module");
D->is_unit = 1;
lp_adv(&p);
int nt = lp_name(&p);
if (nt >= 0){
unit_parent = lsym_add(D, LS_UNIT, nt, -1);
lcpy(D->unit, sizeof(D->unit), D->sym[unit_parent].name, (int)strlen(D->sym[unit_parent].name));
lcatf(D->sym[unit_parent].detail, sizeof(D->sym[unit_parent].detail), "%s %s", D->is_module ? "module" : "game", D->unit);
}
if (lp_is(&p, "{")){
int close = D->match[p.i];
if (unit_parent >= 0){
D->sym[unit_parent].body_start = D->lex.v[p.i].start;
D->sym[unit_parent].body_end = close >= 0 ? D->lex.v[close].end : (int)strlen(D->text);
}
lp_adv(&p);
while (p.i < D->lex.n && lp_kind(&p) != LT_EOF && !lp_is(&p, "}")){
if (close >= 0 && p.i >= close) break;
int before = p.i; lp_decl(&p, unit_parent);
if (p.i == before) lp_adv(&p);
}
}
return;
}
/* A fragment: an imported file is a bare list of declarations. */
while (p.i < D->lex.n && lp_kind(&p) != LT_EOF){
int before = p.i; lp_decl(&p, -1);
if (p.i == before) lp_adv(&p);
}
}
#endif /* LUDIC_INDEX_H */

View file

@ -0,0 +1,191 @@
/* ============================================================================
* ludic_json.h — just enough JSON for LSP, and no dependencies.
*
* The rest of this toolchain is C with no third-party libraries, and the
* language server should not be the thing that drags a package manager into a
* project whose whole premise is a self-contained native pipeline. This is a
* recursive-descent reader and an escaping writer; nothing more.
* ==========================================================================*/
#ifndef LUDIC_JSON_H
#define LUDIC_JSON_H
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdarg.h>
#include <math.h>
enum { JNULL, JBOOL, JNUM, JSTR, JARR, JOBJ };
typedef struct JVal JVal;
struct JVal {
int t;
double num;
int b;
char* s; /* JSTR: decoded UTF-8 */
JVal** kids; char** keys; int n, cap;
};
static JVal* jnew(int t){ JVal* v = calloc(1, sizeof(JVal)); v->t = t; return v; }
static void jfree(JVal* v){
if (!v) return;
for (int i = 0; i < v->n; i++){ jfree(v->kids[i]); free(v->keys ? v->keys[i] : 0); }
free(v->kids); free(v->keys); free(v->s); free(v);
}
static void jpush(JVal* v, const char* key, JVal* kid){
if (v->n >= v->cap){
v->cap = v->cap ? v->cap * 2 : 8;
v->kids = realloc(v->kids, v->cap * sizeof(JVal*));
v->keys = realloc(v->keys, v->cap * sizeof(char*));
}
v->keys[v->n] = key ? strdup(key) : 0;
v->kids[v->n++] = kid;
}
static void jskip(const char** p){ while (**p == ' ' || **p == '\t' || **p == '\n' || **p == '\r') (*p)++; }
static JVal* jparse_at(const char** p);
static void jutf8(char** o, unsigned cp){
if (cp < 0x80) *(*o)++ = (char)cp;
else if (cp < 0x800){ *(*o)++ = (char)(0xC0 | (cp >> 6)); *(*o)++ = (char)(0x80 | (cp & 63)); }
else if (cp < 0x10000){ *(*o)++ = (char)(0xE0 | (cp >> 12)); *(*o)++ = (char)(0x80 | ((cp >> 6) & 63)); *(*o)++ = (char)(0x80 | (cp & 63)); }
else { *(*o)++ = (char)(0xF0 | (cp >> 18)); *(*o)++ = (char)(0x80 | ((cp >> 12) & 63)); *(*o)++ = (char)(0x80 | ((cp >> 6) & 63)); *(*o)++ = (char)(0x80 | (cp & 63)); }
}
static char* jparse_string(const char** p){
if (**p != '"') return 0;
(*p)++;
const char* s = *p;
size_t cap = strlen(s) + 1;
char* out = malloc(cap); char* o = out;
while (**p && **p != '"'){
if (**p == '\\'){
(*p)++;
char c = **p; (*p)++;
switch (c){
case 'n': *o++ = '\n'; break;
case 't': *o++ = '\t'; break;
case 'r': *o++ = '\r'; break;
case 'b': *o++ = '\b'; break;
case 'f': *o++ = '\f'; break;
case 'u': {
unsigned cp = (unsigned)strtoul((char[5]){ (*p)[0], (*p)[1], (*p)[2], (*p)[3], 0 }, 0, 16);
*p += 4;
if (cp >= 0xD800 && cp < 0xDC00 && (*p)[0] == '\\' && (*p)[1] == 'u'){
unsigned lo = (unsigned)strtoul((char[5]){ (*p)[2], (*p)[3], (*p)[4], (*p)[5], 0 }, 0, 16);
*p += 6;
cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
}
jutf8(&o, cp);
break;
}
default: *o++ = c;
}
} else *o++ = *(*p)++;
}
if (**p == '"') (*p)++;
*o = 0;
return out;
}
static JVal* jparse_at(const char** p){
jskip(p);
char c = **p;
if (c == '{'){
(*p)++; JVal* v = jnew(JOBJ);
for (;;){
jskip(p);
if (**p == '}'){ (*p)++; break; }
char* k = jparse_string(p);
jskip(p); if (**p == ':') (*p)++;
JVal* kid = jparse_at(p);
jpush(v, k ? k : "", kid);
free(k);
jskip(p);
if (**p == ',') (*p)++;
else if (**p == '}'){ (*p)++; break; }
else if (!**p) break;
}
return v;
}
if (c == '['){
(*p)++; JVal* v = jnew(JARR);
for (;;){
jskip(p);
if (**p == ']'){ (*p)++; break; }
jpush(v, 0, jparse_at(p));
jskip(p);
if (**p == ',') (*p)++;
else if (**p == ']'){ (*p)++; break; }
else if (!**p) break;
}
return v;
}
if (c == '"'){ JVal* v = jnew(JSTR); v->s = jparse_string(p); return v; }
if (!strncmp(*p, "true", 4)){ *p += 4; JVal* v = jnew(JBOOL); v->b = 1; return v; }
if (!strncmp(*p, "false", 5)){ *p += 5; JVal* v = jnew(JBOOL); v->b = 0; return v; }
if (!strncmp(*p, "null", 4)){ *p += 4; return jnew(JNULL); }
{ char* end; double d = strtod(*p, &end); if (end != *p){ *p = end; JVal* v = jnew(JNUM); v->num = d; return v; } }
(*p)++; /* unparsable: step past it */
return jnew(JNULL);
}
static JVal* jparse(const char* text){ const char* p = text; return jparse_at(&p); }
static JVal* jget(JVal* v, const char* key){
if (!v || v->t != JOBJ) return 0;
for (int i = 0; i < v->n; i++) if (v->keys[i] && !strcmp(v->keys[i], key)) return v->kids[i];
return 0;
}
static JVal* jat(JVal* v, int i){ return (v && i >= 0 && i < v->n) ? v->kids[i] : 0; }
static const char* jstr(JVal* v, const char* def){ return (v && v->t == JSTR && v->s) ? v->s : def; }
static int jint(JVal* v, int def){ return (v && v->t == JNUM) ? (int)v->num : def; }
static int jbool(JVal* v, int def){ return v ? (v->t == JBOOL ? v->b : (v->t == JNUM ? v->num != 0 : def)) : def; }
/* dotted lookup: jpath(msg, "params.textDocument.uri") */
static JVal* jpath(JVal* v, const char* path){
char buf[256]; snprintf(buf, sizeof(buf), "%s", path);
char* save = 0;
for (char* tok = strtok_r(buf, ".", &save); tok; tok = strtok_r(0, ".", &save)){
v = jget(v, tok);
if (!v) return 0;
}
return v;
}
/* ---------- writing -------------------------------------------------------*/
typedef struct { char* b; size_t n, cap; } JSB;
static void jsb_ensure(JSB* s, size_t add){
if (s->n + add + 1 > s->cap){ s->cap = (s->n + add + 1) * 2; s->b = realloc(s->b, s->cap); }
}
static void jsb_add(JSB* s, const char* z, size_t l){ jsb_ensure(s, l); memcpy(s->b + s->n, z, l); s->n += l; s->b[s->n] = 0; }
static void jsb_puts(JSB* s, const char* z){ jsb_add(s, z, strlen(z)); }
static void jsb_putc(JSB* s, char c){ jsb_add(s, &c, 1); }
static void jsb_putf(JSB* s, const char* fmt, ...){
char tmp[1024];
va_list ap; va_start(ap, fmt);
int n = vsnprintf(tmp, sizeof(tmp), fmt, ap);
va_end(ap);
if (n < (int)sizeof(tmp)){ jsb_add(s, tmp, n); return; }
char* big = malloc(n + 1);
va_start(ap, fmt); vsnprintf(big, n + 1, fmt, ap); va_end(ap);
jsb_add(s, big, n); free(big);
}
/* Escape a UTF-8 string as a JSON string literal, quotes included. */
static void jsb_str(JSB* s, const char* z){
jsb_putc(s, '"');
if (!z) z = "";
for (const unsigned char* p = (const unsigned char*)z; *p; p++){
switch (*p){
case '"': jsb_puts(s, "\\\""); break;
case '\\': jsb_puts(s, "\\\\"); break;
case '\n': jsb_puts(s, "\\n"); break;
case '\r': jsb_puts(s, "\\r"); break;
case '\t': jsb_puts(s, "\\t"); break;
default:
if (*p < 0x20) jsb_putf(s, "\\u%04x", *p);
else jsb_putc(s, (char)*p);
}
}
jsb_putc(s, '"');
}
static void jsb_kv_str(JSB* s, const char* k, const char* v){ jsb_str(s, k); jsb_putc(s, ':'); jsb_str(s, v); }
static void jsb_kv_int(JSB* s, const char* k, long v){ jsb_str(s, k); jsb_putf(s, ":%ld", v); }
static void jsb_kv_bool(JSB* s, const char* k, int v){ jsb_str(s, k); jsb_puts(s, v ? ":true" : ":false"); }
#endif /* LUDIC_JSON_H */

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,321 @@
/* ============================================================================
* ludic_syntax.h — the lexical layer shared by every Ludic editor tool.
*
* One lexer, one vocabulary. `ludic-fmt`, `ludic-lsp` and the generated
* TextMate grammar all read their keyword/builtin/type tables from here, so an
* addition to the language shows up in every editor at once instead of drifting
* across six hand-maintained copies.
*
* Unlike the compiler's lexer (compiler/ludicc.c) this one is written for
* editors: it keeps comments, keeps newlines, records byte spans for every
* token, and never exits on bad input — a stray character becomes an LT_ERR
* token and lexing continues, because a file being typed into is malformed most
* of the time.
* ==========================================================================*/
#ifndef LUDIC_SYNTAX_H
#define LUDIC_SYNTAX_H
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
/* ---------- token kinds ---------------------------------------------------*/
enum {
LT_EOF, LT_NL, LT_COMMENT,
LT_ID, /* a plain identifier */
LT_KW, /* a reserved word (see KEYWORDS) */
LT_TYPE, /* a built-in type name: int fixed bool entity str ptr void */
LT_PHASE, /* Start Input FixedUpdate Update LateUpdate Render */
LT_BOOL, /* true false */
LT_INT, LT_FLOAT, LT_STR, LT_CHAR,
LT_ANNO, /* @deterministic — the '@' and the name as one token */
LT_OP,
LT_ERR
};
typedef struct {
int kind;
int start, end; /* byte offsets into the source buffer */
int line; /* 0-based */
int bad; /* set on an unterminated string / stray character */
} LTok;
typedef struct {
LTok* v; int n, cap;
const char* src;
int* linestart; int nline, caplin;
} LLex;
/* ---------- the vocabulary ------------------------------------------------*/
/* Reserved words, grouped so editors can colour them differently. The groups
* mirror the compiler's parser: anything parse_decl() dispatches on is a
* declaration keyword, anything stmt() dispatches on is a statement keyword. */
static const char* LUDIC_KW_DECL[] = {
"game","module","import","component","struct","archetype","ui","scene","layer",
"const","var","export","fn","pure","extern","system","edge","start","on", 0
};
static const char* LUDIC_KW_CLAUSE[] = {
"phase","query","reads","writes","needs","uses",
"requires","ensures","invariant","effects", 0
};
static const char* LUDIC_KW_STMT[] = {
"let","return","if","else","when","while","for","in","spawn","despawn",
"match","machine","state","become","enter","where","and","or","not",
"break","continue","new", 0
};
static const char* LUDIC_TYPES[] = {
"int","fixed","bool","entity","str","ptr","void", 0
};
static const char* LUDIC_PHASES[] = {
"Start","Input","FixedUpdate","Update","LateUpdate","Render", 0
};
static const char* LUDIC_WIDGETS[] = {
"panel","col","row","label","button","image","spacer", 0
};
static const char* LUDIC_WIDGET_PROPS[] = {
"id","text","skin","image","align","w","h","pad","gap","size","font",
"inset","grow","bg","fg","border","focus","x","y", 0
};
/* A builtin is a name the runtime provides (`clear(c)` -> rt_clear). An
* intrinsic is a name the compiler lowers directly to libc/OS. Editors treat
* both as "standard library", but the signatures differ, so they stay apart. */
typedef struct { const char* name; const char* sig; const char* doc; } LBuiltin;
static const LBuiltin LUDIC_BUILTINS[] = {
{"min","min(a: int, b: int) -> int","Smaller of two integers."},
{"max","max(a: int, b: int) -> int","Larger of two integers."},
{"abs","abs(a: int) -> int","Absolute value."},
{"clamp","clamp(v: int, lo: int, hi: int) -> int","Constrain v to [lo, hi]."},
{"seed","seed(i: int)","Seed the deterministic RNG."},
{"rng_range","rng_range(lo: int, hi: int) -> int","Deterministic integer in [lo, hi]."},
{"rng_chance","rng_chance(pct: int) -> bool","True pct% of the time, deterministically."},
{"fx","fx(i: int) -> fixed","Widen an int to Q16.16 fixed-point."},
{"flr","flr(f: fixed) -> int","Truncate a fixed-point value toward zero."},
{"map_size","map_size(w: int, h: int)","Set the tilemap dimensions."},
{"map_row","map_row(y: int, row: str)","Fill one tilemap row from a string."},
{"tile","tile(x: int, y: int) -> int","Tile code at a map cell."},
{"clear","clear(color: int)","Clear the framebuffer to a 0xRRGGBB colour."},
{"present","present()","Push the framebuffer to the window (or out.ppm when headless)."},
{"fill_rect","fill_rect(x: int, y: int, w: int, h: int, color: int)","Filled rectangle."},
{"frame_rect","frame_rect(x: int, y: int, w: int, h: int, color: int)","One-pixel rectangle outline."},
{"put_px","put_px(x: int, y: int, color: int)","Write a single pixel."},
{"text","text(x: int, y: int, s: str, color: int, scale: int)","Draw text with the built-in 5x7 bitmap font."},
{"text_int","text_int(x: int, y: int, n: int, color: int, scale: int)","Draw an integer with the 5x7 bitmap font."},
{"font_load","font_load(path: str) -> int","Load a TrueType .ttf/.ttc; returns a font id."},
{"text_ttf","text_ttf(font: int, x: int, y: int, utf8: str, color: int, px: int)","Draw UTF-8 text with a TrueType font."},
{"text_w","text_w(font: int, utf8: str, px: int) -> int","Advance width of the string, in pixels."},
{"text_h","text_h(font: int, px: int) -> int","Line height of the font, in pixels."},
{"image_load","image_load(path: str) -> int","Decode a PNG into an image id."},
{"draw_image","draw_image(id: int, x: int, y: int)","Blit an image at its natural size."},
{"draw_image_scaled","draw_image_scaled(id: int, x: int, y: int, w: int, h: int)","Blit an image stretched to w x h."},
{"draw_9slice","draw_9slice(id: int, x: int, y: int, w: int, h: int, inset: int)","Nine-slice an image across a w x h box."},
{"load_png","load_png(path: str) -> int","Decode a PNG as a 16x16 sprite sheet; returns the first sprite id."},
{"load_sprites","load_sprites(path: str)","Load the sprite sheet used by draw_sprite."},
{"draw_sprite","draw_sprite(id: int, x: int, y: int)","Blit a sprite."},
{"draw_sprite_scaled","draw_sprite_scaled(id: int, x: int, y: int, scale: int)","Blit a sprite at an integer scale."},
{"ui_build","ui_build()","Construct every declared `ui` tree (loads skins and images)."},
{"ui_open","ui_open(id: int)","Make a ui tree active and focus its first button."},
{"ui_tick","ui_tick(key: int)","Feed a key to the UI: w/s move focus, space/enter activate."},
{"ui_render","ui_render()","Lay out and draw the active ui tree."},
{"ui_clicked","ui_clicked(id: int) -> bool","True on the frame a widget was activated."},
{"ui_set_text","ui_set_text(id: int, s: str)","Replace a widget's text."},
{"ui_set_int","ui_set_int(id: int, n: int)","Replace a widget's text with a number."},
{"ui_focus","ui_focus(id: int)","Move keyboard focus to a widget."},
{"ui_focused","ui_focused() -> int","Id of the focused widget."},
{"ui_visible","ui_visible(id: int, on: bool)","Show or hide a widget subtree."},
{"key","key() -> int","Key code pressed this frame, 0 if none."},
{"reg","reg(i: int) -> int","Read one of the 64 integer resources shared by systems."},
{"setreg","setreg(i: int, v: int)","Write one of the 64 integer resources."},
{"self","self() -> entity","The entity of the innermost query loop."},
{"save","save()","Write a binary snapshot of the whole ECS world."},
{"load","load() -> bool","Restore the snapshot; false if there is none."},
{"status","status(s: str)","Set the one-line status message."},
{"print_int","print_int(i: int)","Print an integer to stdout."},
{"quit","quit()","Stop the frame loop and exit."},
{0,0,0}
};
static const LBuiltin LUDIC_INTRINSICS[] = {
{"mem_alloc","mem_alloc(n: int) -> ptr","Allocate n bytes (malloc)."},
{"mem_realloc","mem_realloc(p: ptr, n: int) -> ptr","Resize a block to n bytes, preserving its contents (realloc)."},
{"os_argc","os_argc() -> int","Number of command-line arguments, argv[0] included."},
{"os_arg","os_arg(i: int) -> str","The i-th command-line argument."},
{"file_stderr","file_stderr() -> ptr","The standard error stream, for file_write."},
{"mem_free","mem_free(p: ptr)","Release an allocation."},
{"mem_copy","mem_copy(dst: ptr, src: ptr, n: int)","memcpy."},
{"mem_set","mem_set(p: ptr, byte: int, n: int)","memset."},
{"peek8","peek8(p: ptr, off: int) -> int","Read one byte."},
{"poke8","poke8(p: ptr, off: int, v: int)","Write one byte."},
{"peek32","peek32(p: ptr, off: int) -> int","Read a 32-bit word."},
{"poke32","poke32(p: ptr, off: int, v: int)","Write a 32-bit word."},
{"peekp","peekp(p: ptr, off: int) -> ptr","Read a pointer-sized word."},
{"pokep","pokep(p: ptr, off: int, v: ptr)","Write a pointer-sized word."},
{"peekf","peekf(p: ptr, off: int) -> fixed","Read a fixed-point word."},
{"pokef","pokef(p: ptr, off: int, v: fixed)","Write a fixed-point word."},
{"ptr_add","ptr_add(p: ptr, off: int) -> ptr","Offset a pointer by bytes."},
{"ptr_null","ptr_null() -> ptr","The null pointer."},
{"ptr_is_null","ptr_is_null(p: ptr) -> bool","Null test."},
{"as_fixed","as_fixed(i: int) -> fixed","Reinterpret an int as fixed (no conversion)."},
{"as_int","as_int(f: fixed) -> int","Reinterpret a fixed as int (no conversion)."},
{"file_open","file_open(path: str, mode: str) -> ptr","fopen."},
{"file_read","file_read(f: ptr, buf: ptr, n: int) -> int","fread."},
{"file_write","file_write(f: ptr, buf: ptr, n: int) -> int","fwrite."},
{"file_seek","file_seek(f: ptr, off: int, whence: int) -> int","fseek."},
{"file_tell","file_tell(f: ptr) -> int","ftell."},
{"file_close","file_close(f: ptr)","fclose."},
{"read_byte","read_byte() -> int","Read one byte from stdin, -1 at EOF."},
{"write_byte","write_byte(b: int)","Write one byte to stdout."},
{"print_str","print_str(s: str)","Write a string to stdout."},
{"str_len","str_len(s: str) -> int","Length of a string in bytes."},
{"shl","shl(v: int, n: int) -> int","Shift left."},
{"shr","shr(v: int, n: int) -> int","Logical shift right."},
{"band","band(a: int, b: int) -> int","Bitwise and."},
{"bor","bor(a: int, b: int) -> int","Bitwise or."},
{"bxor","bxor(a: int, b: int) -> int","Bitwise xor."},
{"bnot","bnot(a: int) -> int","Bitwise not."},
{"os_exit","os_exit(code: int)","Terminate the process."},
{"os_time","os_time() -> int","Seconds since the epoch."},
{"is_windowed","is_windowed() -> bool","True when the build has a window."},
{"game_title","game_title() -> str","The name from the `game` declaration."},
{"win_open","win_open(title: str, w: int, h: int, scale: int)","Open the platform window."},
{"win_poll","win_poll() -> int","Pump the event queue; returns a key code."},
{"win_present","win_present(px: ptr, w: int, h: int)","Blit a framebuffer to the window."},
{"win_running","win_running() -> bool","False once the window has been closed."},
{"win_close","win_close()","Close the platform window."},
{0,0,0}
};
static int lud_in(const char** set, const char* s){
for (int i = 0; set[i]; i++) if (!strcmp(set[i], s)) return 1;
return 0;
}
static const LBuiltin* lud_lookup(const LBuiltin* t, const char* s){
for (int i = 0; t[i].name; i++) if (!strcmp(t[i].name, s)) return &t[i];
return 0;
}
static int lud_is_keyword(const char* s){
return lud_in(LUDIC_KW_DECL, s) || lud_in(LUDIC_KW_CLAUSE, s) || lud_in(LUDIC_KW_STMT, s);
}
/* ---------- lexing --------------------------------------------------------*/
static void ltok_push(LLex* L, int kind, int start, int end, int line, int bad){
if (L->n >= L->cap){ L->cap = L->cap ? L->cap * 2 : 512; L->v = realloc(L->v, L->cap * sizeof(LTok)); }
L->v[L->n++] = (LTok){ kind, start, end, line, bad };
}
static void lline_push(LLex* L, int off){
if (L->nline >= L->caplin){ L->caplin = L->caplin ? L->caplin * 2 : 256; L->linestart = realloc(L->linestart, L->caplin * sizeof(int)); }
L->linestart[L->nline++] = off;
}
static const char* LUDIC_OPS2[] = { "->","+=","-=","*=","/=","==","!=","<=",">=","&&","||","..","=>", 0 };
/* Lex the whole buffer. Comments and newlines are kept — the formatter needs
* both, and a highlighter needs the comments. */
static void lud_lex(LLex* L, const char* src){
memset(L, 0, sizeof(*L));
L->src = src;
lline_push(L, 0);
int i = 0, line = 0;
while (src[i]){
char c = src[i];
if (c == '\n'){ ltok_push(L, LT_NL, i, i + 1, line, 0); i++; line++; lline_push(L, i); continue; }
if (c == ' ' || c == '\t' || c == '\r'){ i++; continue; }
if (c == '#'){ int s = i; while (src[i] && src[i] != '\n') i++; ltok_push(L, LT_COMMENT, s, i, line, 0); continue; }
if (c == '"'){
int s = i; i++; int bad = 0;
while (src[i] && src[i] != '"' && src[i] != '\n'){ if (src[i] == '\\' && src[i+1]) i += 2; else i++; }
if (src[i] == '"') i++; else bad = 1;
ltok_push(L, LT_STR, s, i, line, bad); continue;
}
if (c == '\''){
int s = i; i++; int bad = 0;
if (src[i] == '\\' && src[i+1]) i += 2; else if (src[i] && src[i] != '\n') i++;
if (src[i] == '\'') i++; else bad = 1;
ltok_push(L, LT_CHAR, s, i, line, bad); continue;
}
if (isdigit((unsigned char)c)){
int s = i;
if (c == '0' && (src[i+1] == 'x' || src[i+1] == 'X')){
i += 2; while (isxdigit((unsigned char)src[i])) i++;
ltok_push(L, LT_INT, s, i, line, 0); continue;
}
while (isdigit((unsigned char)src[i])) i++;
if (src[i] == '.' && isdigit((unsigned char)src[i+1])){
i++; while (isdigit((unsigned char)src[i])) i++;
ltok_push(L, LT_FLOAT, s, i, line, 0); continue;
}
ltok_push(L, LT_INT, s, i, line, 0); continue;
}
/* @name is one token: an annotation reads as a unit, and the formatter
* must never put a space between the sigil and the name. */
if (c == '@' && (isalpha((unsigned char)src[i+1]) || src[i+1] == '_')){
int s = i; i++; while (isalnum((unsigned char)src[i]) || src[i] == '_') i++;
ltok_push(L, LT_ANNO, s, i, line, 0); continue;
}
if (isalpha((unsigned char)c) || c == '_'){
int s = i; while (isalnum((unsigned char)src[i]) || src[i] == '_') i++;
int len = i - s; char w[128];
if (len < (int)sizeof(w)){ memcpy(w, src + s, len); w[len] = 0; } else { w[0] = 0; }
int k = LT_ID;
if (!strcmp(w, "true") || !strcmp(w, "false")) k = LT_BOOL;
else if (lud_in(LUDIC_TYPES, w)) k = LT_TYPE;
else if (lud_in(LUDIC_PHASES, w)) k = LT_PHASE;
else if (lud_is_keyword(w)) k = LT_KW;
ltok_push(L, k, s, i, line, 0); continue;
}
{
int matched = 0;
for (int k = 0; LUDIC_OPS2[k]; k++)
if (src[i] == LUDIC_OPS2[k][0] && src[i+1] == LUDIC_OPS2[k][1]){
ltok_push(L, LT_OP, i, i + 2, line, 0); i += 2; matched = 1; break;
}
if (matched) continue;
}
if (c == ';'){ ltok_push(L, LT_OP, i, i + 1, line, 0); i++; continue; }
if (strchr("+-*/%<>=(){}[],:.!@", c)){ ltok_push(L, LT_OP, i, i + 1, line, 0); i++; continue; }
/* Anything else is an error token — but a whole UTF-8 character's worth
* at a time. Splitting a multi-byte character into one token per byte
* would let a consumer that re-emits tokens (the formatter) put spaces
* inside it and corrupt the file. */
{
unsigned char u = (unsigned char)c;
int len = u < 0x80 ? 1 : u < 0xE0 ? 2 : u < 0xF0 ? 3 : 4;
for (int k = 1; k < len; k++) if (!src[i + k] || ((unsigned char)src[i + k] & 0xC0) != 0x80){ len = k; break; }
ltok_push(L, LT_ERR, i, i + len, line, 1); i += len;
}
}
ltok_push(L, LT_EOF, i, i, line, 0);
}
static void lud_lex_free(LLex* L){ free(L->v); free(L->linestart); memset(L, 0, sizeof(*L)); }
/* ---------- helpers over the token stream --------------------------------*/
static int ltok_len(const LTok* t){ return t->end - t->start; }
static int ltok_is(const LLex* L, int i, const char* s){
if (i < 0 || i >= L->n) return 0;
const LTok* t = &L->v[i]; int n = ltok_len(t);
return (int)strlen(s) == n && !strncmp(L->src + t->start, s, n);
}
/* Copy a token's text into a caller buffer; returns buf. */
static char* ltok_text(const LLex* L, int i, char* buf, int cap){
const LTok* t = &L->v[i]; int n = ltok_len(t);
if (n >= cap) n = cap - 1;
memcpy(buf, L->src + t->start, n); buf[n] = 0; return buf;
}
/* Index of the next token that is not a newline or comment, or -1. */
static int ltok_next_sig(const LLex* L, int i){
for (int j = i + 1; j < L->n; j++){
int k = L->v[j].kind;
if (k != LT_NL && k != LT_COMMENT) return j;
}
return -1;
}
static int ltok_prev_sig(const LLex* L, int i){
for (int j = i - 1; j >= 0; j--){
int k = L->v[j].kind;
if (k != LT_NL && k != LT_COMMENT) return j;
}
return -1;
}
#endif /* LUDIC_SYNTAX_H */

View file

@ -0,0 +1,405 @@
/* ============================================================================
* ludic_workspace.h — many documents, one project.
*
* Ludic programs are multi-file: `import "rules.ludic"` splices a fragment into
* whichever `game`/`module` file pulled it in. An imported fragment is NOT a
* standalone program — it has no `game` block — so running the compiler on it
* directly is meaningless. This layer keeps that straight: it knows which files
* form a compilation unit, which file is its root, and it resolves a name
* against the whole unit rather than one buffer.
* ==========================================================================*/
#ifndef LUDIC_WORKSPACE_H
#define LUDIC_WORKSPACE_H
#include "ludic_index.h"
#include <dirent.h>
#include <limits.h>
#include <sys/stat.h>
#include <unistd.h>
/* ---------- paths & URIs --------------------------------------------------*/
static char* lw_readfile(const char* path, long* out_n){
FILE* f = fopen(path, "rb"); if (!f) return 0;
fseek(f, 0, SEEK_END); long n = ftell(f); fseek(f, 0, SEEK_SET);
char* b = malloc(n + 1);
if (fread(b, 1, n, f) != (size_t)n){ fclose(f); free(b); return 0; }
b[n] = 0; fclose(f);
if (out_n) *out_n = n;
return b;
}
static int lw_hex(int c){ return c >= '0' && c <= '9' ? c - '0' : (c | 32) >= 'a' && (c | 32) <= 'f' ? (c | 32) - 'a' + 10 : -1; }
static char* lw_uri_to_path(const char* uri){
if (strncmp(uri, "file://", 7)) return strdup(uri);
const char* s = uri + 7;
/* file://host/path is not something an editor sends for local files */
char* out = malloc(strlen(s) + 1); int j = 0;
for (int i = 0; s[i]; ){
if (s[i] == '%' && lw_hex(s[i+1]) >= 0 && lw_hex(s[i+2]) >= 0){
out[j++] = (char)(lw_hex(s[i+1]) * 16 + lw_hex(s[i+2])); i += 3;
} else out[j++] = s[i++];
}
out[j] = 0;
return out;
}
static char* lw_path_to_uri(const char* path){
static const char* safe = "-_.~/abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
size_t cap = strlen(path) * 3 + 16;
char* out = malloc(cap);
strcpy(out, "file://");
int j = 7;
for (int i = 0; path[i]; i++){
unsigned char c = (unsigned char)path[i];
if (strchr(safe, c)) out[j++] = c;
else j += sprintf(out + j, "%%%02X", c);
}
out[j] = 0;
return out;
}
static char* lw_dirname(const char* p){
char* d = strdup(p); char* s = strrchr(d, '/');
if (s) *s = 0; else { free(d); d = strdup("."); }
return d;
}
static char* lw_join(const char* dir, const char* rel){
if (rel[0] == '/') return strdup(rel);
size_t n = strlen(dir) + strlen(rel) + 2;
char* p = malloc(n); snprintf(p, n, "%s/%s", dir, rel);
char real[PATH_MAX];
if (realpath(p, real)){ free(p); return strdup(real); }
return p;
}
static int lw_ends(const char* s, const char* suf){
size_t a = strlen(s), b = strlen(suf);
return a >= b && !strcmp(s + a - b, suf);
}
/* ---------- Markdown ------------------------------------------------------
* Fenced Ludic inside prose is real Ludic and deserves the same treatment. The
* trick that makes it free: copy the document and blank out every byte that is
* not inside a ```ludic fence. Offsets, lines and columns still match the file
* on disk exactly, so every feature built on top — highlighting, hover,
* go-to-definition, diagnostics — works on a .md without knowing about it. */
static char* lw_scrub_markdown(const char* src){
char* out = strdup(src);
int i = 0, n = (int)strlen(src);
while (i < n){
int ls = i, le = i;
while (le < n && src[le] != '\n') le++;
int ind = 0; while (ls + ind < le && src[ls + ind] == ' ') ind++;
int j = ls + ind; char m = j < le ? src[j] : 0;
int run = 0; while (j + run < le && src[j + run] == m) run++;
int fenced = (m == '`' || m == '~') && run >= 3;
int info = j + run;
int is_ludic = 0;
if (fenced){
int k = info; while (k < le && (src[k] == ' ' || src[k] == '\t')) k++;
is_ludic = (le - k >= 5) && !strncasecmp(src + k, "ludic", 5) &&
(le - k == 5 || src[k+5] == ' ' || src[k+5] == '\t' || src[k+5] == '\r');
}
/* blank the fence line itself either way */
for (int k = ls; k < le; k++) out[k] = ' ';
i = le < n ? le + 1 : n;
if (!fenced) continue;
/* inside a fence: keep the body only when the info string says ludic */
while (i < n){
int bs = i, be = i;
while (be < n && src[be] != '\n') be++;
int bi = 0; while (bs + bi < be && src[bs + bi] == ' ') bi++;
int cj = bs + bi, crun = 0;
while (cj + crun < be && src[cj + crun] == m) crun++;
int only = 1;
for (int k = cj + crun; k < be; k++) if (src[k] != ' ' && src[k] != '\r'){ only = 0; break; }
if (crun >= run && only){
for (int k = bs; k < be; k++) out[k] = ' ';
i = be < n ? be + 1 : n;
break;
}
if (!is_ludic) for (int k = bs; k < be; k++) out[k] = ' ';
i = be < n ? be + 1 : n;
}
}
return out;
}
/* ---------- documents -----------------------------------------------------*/
static void ldoc_settext(LDoc* D, char* raw){
if (D->text != D->raw) free(D->text);
free(D->raw);
D->raw = raw;
D->text = D->is_markdown ? lw_scrub_markdown(raw) : raw;
}
static void ldoc_reindex(LDoc* D){
lud_lex_free(&D->lex);
lud_lex(&D->lex, D->text);
ldoc_match_braces(D);
ldoc_parse(D);
free(D->cls);
D->cls = calloc(D->lex.n + 1, 1);
}
static LDoc* lw_new_doc(const char* path, char* text){
LDoc* D = calloc(1, sizeof(LDoc));
char real[PATH_MAX];
D->path = realpath(path, real) ? strdup(real) : strdup(path);
D->uri = lw_path_to_uri(D->path);
D->is_markdown = lw_ends(D->path, ".md") || lw_ends(D->path, ".markdown");
ldoc_settext(D, text);
ldoc_reindex(D);
return D;
}
static void lw_add(LIndex* X, LDoc* D){
if (X->n >= X->cap){ X->cap = X->cap ? X->cap * 2 : 32; X->d = realloc(X->d, X->cap * sizeof(LDoc*)); }
X->d[X->n++] = D;
}
static LDoc* lw_by_path(LIndex* X, const char* path){
char real[PATH_MAX];
const char* p = realpath(path, real) ? real : path;
for (int i = 0; i < X->n; i++) if (!strcmp(X->d[i]->path, p)) return X->d[i];
return 0;
}
static LDoc* lw_by_uri(LIndex* X, const char* uri){
char* p = lw_uri_to_path(uri);
LDoc* D = lw_by_path(X, p);
if (!D) for (int i = 0; i < X->n; i++) if (!strcmp(X->d[i]->uri, uri)){ D = X->d[i]; break; }
free(p);
return D;
}
/* Load a file into the index if it is not there yet. */
static LDoc* lw_ensure(LIndex* X, const char* path){
LDoc* D = lw_by_path(X, path);
if (D) return D;
char* text = lw_readfile(path, 0);
if (!text) return 0;
D = lw_new_doc(path, text);
lw_add(X, D);
return D;
}
/* Walk the workspace once at startup so that go-to-definition works across
* files the editor has never opened. Skips the usual noise directories. */
static void lw_scan(LIndex* X, const char* dir, int depth){
if (depth > 8) return;
DIR* d = opendir(dir); if (!d) return;
struct dirent* e;
while ((e = readdir(d))){
if (e->d_name[0] == '.') continue;
if (!strcmp(e->d_name, "build") || !strcmp(e->d_name, "node_modules") ||
!strcmp(e->d_name, "target") || !strcmp(e->d_name, "out")) continue;
char p[PATH_MAX]; snprintf(p, sizeof(p), "%s/%s", dir, e->d_name);
struct stat st; if (stat(p, &st)) continue;
if (S_ISDIR(st.st_mode)) lw_scan(X, p, depth + 1);
else if (lw_ends(e->d_name, ".ludic")) lw_ensure(X, p);
}
closedir(d);
}
/* ---------- compilation units --------------------------------------------
* `related` = every document that shares a compilation unit with D: the unit
* root that (transitively) imports D, plus everything that root imports. */
static void lw_imports_of(LIndex* X, LDoc* D, LDoc** out, int* n, int max){
for (int i = 0; i < D->nimport; i++){
char* dir = lw_dirname(D->path);
char* full = lw_join(dir, D->imports[i]);
free(dir);
LDoc* I = lw_ensure(X, full);
free(full);
if (!I) continue;
int seen = 0; for (int k = 0; k < *n; k++) if (out[k] == I) seen = 1;
if (seen || *n >= max) continue;
out[(*n)++] = I;
lw_imports_of(X, I, out, n, max);
}
}
static int lw_related(LIndex* X, LDoc* D, LDoc** out, int max){
int n = 0;
if (!D) return 0;
out[n++] = D;
lw_imports_of(X, D, out, &n, max);
/* pull in any unit root that reaches D, and that root's other imports */
for (int i = 0; i < X->n && n < max; i++){
LDoc* R = X->d[i];
if (!R->is_unit || R == D) continue;
LDoc* reach[128]; int rn = 0;
lw_imports_of(X, R, reach, &rn, 128);
int hits = 0; for (int k = 0; k < rn; k++) if (reach[k] == D) hits = 1;
if (!hits) continue;
int seen = 0; for (int k = 0; k < n; k++) if (out[k] == R) seen = 1;
if (!seen && n < max) out[n++] = R;
for (int k = 0; k < rn && n < max; k++){
int s2 = 0; for (int q = 0; q < n; q++) if (out[q] == reach[k]) s2 = 1;
if (!s2) out[n++] = reach[k];
}
}
return n;
}
/* The file `ludicc` should actually be pointed at when checking D. */
static LDoc* lw_unit_root(LIndex* X, LDoc* D){
if (!D || D->is_markdown) return 0;
if (D->is_unit) return D;
LDoc* rel[128]; int n = lw_related(X, D, rel, 128);
/* a `game` beats a `module`: it is the one that actually compiles */
for (int i = 0; i < n; i++) if (rel[i]->is_unit && !rel[i]->is_module && !rel[i]->is_markdown) return rel[i];
for (int i = 0; i < n; i++) if (rel[i]->is_unit && !rel[i]->is_markdown) return rel[i];
return 0;
}
/* ---------- name resolution ----------------------------------------------*/
static int lsym_is_toplevel(int kind){
switch (kind){
case LS_COMPONENT: 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: case LS_UNIT:
return 1;
default: return 0;
}
}
static int lsym_is_local(int kind){
return kind == LS_LOCAL || kind == LS_PARAM || kind == LS_QUERYVAR || kind == LS_STATE;
}
/* The innermost local binding of `name` visible at byte offset `off`. */
static int lw_find_local(LDoc* D, const char* name, int off){
int best = -1;
for (int i = 0; i < D->nsym; i++){
LSym* s = &D->sym[i];
if (!lsym_is_local(s->kind) || strcmp(s->name, name)) continue;
if (off < s->body_start || off > s->body_end) continue;
if (best < 0 || s->body_start >= D->sym[best].body_start) best = i;
}
return best;
}
static int lw_find_top(LIndex* X, LDoc* D, const char* name, LDoc** owner){
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 (lsym_is_toplevel(rel[i]->sym[k].kind) && !strcmp(rel[i]->sym[k].name, name)){
*owner = rel[i]; return k;
}
/* fall back to the whole workspace — better a cross-unit jump than none */
for (int i = 0; i < X->n; i++)
for (int k = 0; k < X->d[i]->nsym; k++)
if (lsym_is_toplevel(X->d[i]->sym[k].kind) && !strcmp(X->d[i]->sym[k].name, name)){
*owner = X->d[i]; return k;
}
return -1;
}
/* The component a dotted receiver has, e.g. `p` in `p.x` when p came from a
* query binding or a typed parameter. Returns the component symbol or -1. */
static int lw_receiver_component(LIndex* X, LDoc* D, int dot_tok, LDoc** owner){
int recv = ltok_prev_sig(&D->lex, dot_tok);
if (recv < 0 || D->lex.v[recv].kind != LT_ID) return -1;
char name[96]; ltok_text(&D->lex, recv, name, sizeof(name));
int l = lw_find_local(D, name, D->lex.v[recv].start);
const char* ty = 0;
if (l >= 0) ty = D->sym[l].type;
else {
LDoc* o; int t = lw_find_top(X, D, name, &o);
if (t >= 0) ty = o->sym[t].type;
}
if (!ty || !ty[0]) return -1;
return lw_find_top(X, D, ty, owner);
}
/* ---------- semantic classification --------------------------------------*/
static int lw_first_on_line(LDoc* D, int tok){
int p = ltok_prev_sig(&D->lex, tok);
return p < 0 || D->lex.v[p].line != D->lex.v[tok].line;
}
static int lw_in_ui_body(LDoc* D, int off){
for (int i = 0; i < D->nsym; i++)
if (D->sym[i].kind == LS_UI && off > D->sym[i].body_start && off < D->sym[i].body_end) return 1;
return 0;
}
static void lw_classify(LIndex* X, LDoc* D){
if (!D->cls) D->cls = calloc(D->lex.n + 1, 1);
for (int i = 0; i < D->lex.n; i++){
const LTok* t = &D->lex.v[i];
int c = SC_NONE;
switch (t->kind){
case LT_COMMENT: c = SC_COMMENT; break;
case LT_STR: case LT_CHAR: c = SC_STRING; break;
case LT_INT: case LT_FLOAT: c = SC_NUMBER; break;
case LT_BOOL: case LT_KW: c = SC_KEYWORD; break;
case LT_TYPE: c = SC_TYPE; break;
case LT_PHASE: c = SC_PHASE; break;
case LT_ANNO: c = SC_ANNOTATION; break;
case LT_OP: c = SC_OPERATOR; break;
case LT_ID: c = SC_UNKNOWN; break;
default: c = SC_NONE; break;
}
if (t->kind != LT_ID){ D->cls[i] = (unsigned char)c; continue; }
char name[96]; ltok_text(&D->lex, i, name, sizeof(name));
/* 1. the token IS a declaration's name */
int decl = -1;
for (int s = 0; s < D->nsym; s++) if (D->sym[s].tok == i){ decl = s; break; }
if (decl >= 0){
switch (D->sym[decl].kind){
case LS_COMPONENT: c = SC_COMPONENT; break;
case LS_ARCHETYPE: c = SC_ARCHETYPE; break;
case LS_FIELD: c = SC_FIELD; break;
case LS_CONST: c = SC_CONST; break;
case LS_VAR: c = SC_MODVAR; break;
case LS_FN: case LS_EXTERN: c = SC_FUNCTION; break;
case LS_SYSTEM: c = SC_SYSTEM; break;
case LS_UI: c = SC_UI; break;
case LS_WIDGET: c = SC_CONST; break; /* UI_Foo is a handle */
case LS_SCENE: c = SC_SCENE; break;
case LS_LAYER: c = SC_LAYER; break;
case LS_PARAM: c = SC_PARAM; break;
case LS_QUERYVAR: case LS_LOCAL: case LS_STATE: c = SC_VARIABLE; break;
case LS_UNIT: c = SC_UI; break;
default: c = SC_UNKNOWN;
}
D->cls[i] = (unsigned char)c; continue;
}
/* 2. member access: `p.x` */
int prev = ltok_prev_sig(&D->lex, i);
if (prev >= 0 && ltok_is(&D->lex, prev, ".")){ D->cls[i] = SC_FIELD; continue; }
int next = ltok_next_sig(&D->lex, i);
int followed_by_eq = next >= 0 && ltok_is(&D->lex, next, "=");
/* 3. inside a `ui` block a bare word is a widget type and `k=` a prop —
* and those names (text, image, size) collide with builtins, so this
* has to be decided before the builtin table is consulted. */
if (lw_in_ui_body(D, t->start)){
if (followed_by_eq){ D->cls[i] = SC_PROP; continue; }
if (lw_first_on_line(D, i) || (prev >= 0 && (ltok_is(&D->lex, prev, "{") || ltok_is(&D->lex, prev, "}")))){
D->cls[i] = lud_in(LUDIC_WIDGETS, name) ? SC_WIDGET : SC_UI; continue;
}
}
/* 4. a local binding in scope */
int l = lw_find_local(D, name, t->start);
if (l >= 0){ D->cls[i] = D->sym[l].kind == LS_PARAM ? SC_PARAM : SC_VARIABLE; continue; }
/* 5. a declaration somewhere in the compilation unit */
LDoc* owner = 0; int top = lw_find_top(X, D, name, &owner);
if (top >= 0){
switch (owner->sym[top].kind){
case LS_COMPONENT: c = SC_COMPONENT; break;
case LS_ARCHETYPE: c = SC_ARCHETYPE; break;
case LS_CONST: c = SC_CONST; break;
case LS_VAR: c = SC_MODVAR; break;
case LS_FN: case LS_EXTERN: c = SC_FUNCTION; break;
case LS_SYSTEM: c = SC_SYSTEM; break;
case LS_UI: c = SC_UI; break;
case LS_WIDGET: c = SC_CONST; break;
case LS_SCENE: c = SC_SCENE; break;
case LS_LAYER: c = SC_LAYER; break;
default: c = SC_UNKNOWN;
}
D->cls[i] = (unsigned char)c; continue;
}
/* 6. the runtime surface */
if (lud_lookup(LUDIC_BUILTINS, name) || lud_lookup(LUDIC_INTRINSICS, name)){ D->cls[i] = SC_BUILTIN; continue; }
/* 7. a record field initialiser: `Pos = { x = 10 }` */
if (followed_by_eq){ D->cls[i] = SC_FIELD; continue; }
D->cls[i] = SC_UNKNOWN;
}
}
#endif /* LUDIC_WORKSPACE_H */

106
tools/ludic-web/run.mjs Normal file
View file

@ -0,0 +1,106 @@
// ============================================================================
// tools/ludic-web/run.mjs — run a headless Ludic wasm build under Node.
//
// node tools/ludic-web/run.mjs build/web/snake_headless.wasm --stdin=ddss
//
// The point of this runner is the regression it makes possible: the same game,
// the same input, compiled once for the host and once for wasm32, has to render
// the same frame. Ludic is fixed-point and its RNG is seeded, so "the same" here
// means byte-identical PPM — which is a much stronger check on the wasm backend
// than "it started".
//
// It shares runtime/web/platform.js's Host: files, stdin, stdout and the clock
// are identical services whichever host provides them. Only the window differs,
// and a headless build has none.
// ============================================================================
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { Host, ExitSignal } from '../../runtime/web/platform.js';
const args = process.argv.slice(2);
const modulePath = args.find((a) => !a.startsWith('--'));
const stdin = (args.find((a) => a.startsWith('--stdin=')) || '--stdin=').slice(8);
const maxFrames = Number((args.find((a) => a.startsWith('--frames=')) || '--frames=100000').slice(9));
if (!modulePath) {
console.error('usage: run.mjs <module.wasm> [--stdin=keys] [--frames=n]');
process.exit(2);
}
// Files, backed by the real filesystem: a headless build reads its assets and
// writes out.ppm exactly where the native headless binary does.
class NodeFiles {
constructor() { this.open = new Map(); this.next = 1; }
fopen(path, mode) {
const h = this.next++;
if (mode.includes('w') || mode.includes('a')) {
this.open.set(h, { path, write: true, chunks: [], size: 0, pos: 0 });
return h;
}
if (!existsSync(path)) return 0;
this.open.set(h, { path, write: false, data: new Uint8Array(readFileSync(path)), pos: 0 });
return h;
}
fread(h, dst, want) {
const f = this.open.get(h);
if (!f || f.write) return 0;
const n = Math.max(0, Math.min(want, f.data.length - f.pos));
dst.set(f.data.subarray(f.pos, f.pos + n));
f.pos += n;
return n;
}
fwrite(h, src, n) {
const f = this.open.get(h);
if (!f || !f.write) return 0;
f.chunks.push(src.slice(0, n));
f.size += n; f.pos += n;
return n;
}
fclose(h) {
const f = this.open.get(h);
if (!f) return;
if (f.write) {
const all = new Uint8Array(f.size);
let at = 0;
for (const c of f.chunks) { all.set(c, at); at += c.length; }
writeFileSync(f.path, all);
}
this.open.delete(h);
}
fseek(h, off, whence) {
const f = this.open.get(h);
if (!f) return -1;
const end = f.write ? f.size : f.data.length;
const base = whence === 1 ? f.pos : whence === 2 ? end : 0;
f.pos = Math.max(0, Math.min(end, base + off));
return 0;
}
ftell(h) { const f = this.open.get(h); return f ? f.pos : -1; }
}
const host = new Host({ files: new NodeFiles(), stdin });
const { instance } = await WebAssembly.instantiate(readFileSync(modulePath), { env: host.imports() });
host.bind(instance.exports.memory);
const { ludic_boot, ludic_frame, ludic_alive, ludic_teardown } = instance.exports;
let frames = 0;
try {
ludic_boot();
while (host.running && ludic_alive() && frames < maxFrames) { ludic_frame(); frames++; }
ludic_teardown();
} catch (e) {
if (!(e instanceof ExitSignal)) throw e;
}
host.flush();
if (frames >= maxFrames) {
console.error(`ludic-web: stopped after ${maxFrames} frames (the game never ended)`);
process.exit(1);
}
process.exit(host.exitCode ?? 0);

168
tools/test-grammar.js Normal file
View file

@ -0,0 +1,168 @@
/* ============================================================================
* test-grammar.js — tokenise Ludic with the real TextMate engine.
*
* A TextMate grammar is a pile of Oniguruma regexes in a JSON file: nothing
* validates it, and a broken pattern shows up as "that word stopped being
* coloured" months later. This runs the grammar through vscode-textmate, the
* same engine VS Code uses, and asserts that specific spans get specific
* scopes — including a ```ludic fence inside Markdown, which is the part most
* likely to break silently.
*
* node tools/test-grammar.js
*
* Needs vscode-textmate and vscode-oniguruma; skips cleanly when they are not
* installed, so it never blocks the suite on a machine without npm.
* ==========================================================================*/
const fs = require('fs');
const path = require('path');
const ROOT = path.dirname(__dirname);
const SHARED = path.join(ROOT, 'tools', 'editors', 'shared');
let vsctm, oniguruma;
try {
const local = path.join(ROOT, 'tools', 'editors', 'vscode', 'node_modules');
const paths = [local, '/tmp/tmgrammar/node_modules', ...module.paths];
vsctm = require(require.resolve('vscode-textmate', { paths }));
oniguruma = require(require.resolve('vscode-oniguruma', { paths }));
} catch (e) {
console.log(' skip TextMate grammar (npm i vscode-textmate vscode-oniguruma to run)');
process.exit(0);
}
let passed = 0;
let failed = 0;
function ok(name) { passed++; console.log(` ok ${name}`); }
function bad(name, detail) { failed++; console.log(` FAIL ${name}\n ${detail}`); }
async function main() {
const wasmPath = require.resolve('vscode-oniguruma/release/onig.wasm', {
paths: [path.join(ROOT, 'tools', 'editors', 'vscode', 'node_modules'), '/tmp/tmgrammar/node_modules']
});
await oniguruma.loadWASM(fs.readFileSync(wasmPath).buffer);
const onigLib = Promise.resolve({
createOnigScanner: (sources) => new oniguruma.OnigScanner(sources),
createOnigString: (s) => new oniguruma.OnigString(s)
});
const grammars = {
'source.ludic': path.join(SHARED, 'ludic.tmLanguage.json'),
'markdown.ludic.codeblock': path.join(SHARED, 'ludic.markdown-injection.json')
};
const registry = new vsctm.Registry({
onigLib,
loadGrammar: (scope) => {
const file = grammars[scope];
if (!file) return Promise.resolve(null);
return Promise.resolve(vsctm.parseRawGrammar(fs.readFileSync(file, 'utf8'), file));
},
getInjections: (scope) =>
scope === 'text.html.markdown' || scope.startsWith('text.html.markdown')
? ['markdown.ludic.codeblock']
: undefined
});
const grammar = await registry.loadGrammar('source.ludic');
if (!grammar) {
bad('grammar loads', 'registry returned null');
return report();
}
ok('grammar loads without a regex error');
// Tokenise a line and return the scopes covering the first occurrence of a
// substring, so assertions read in terms of the source rather than offsets.
function scopesFor(line, needle, state = vsctm.INITIAL) {
const at = line.indexOf(needle);
if (at < 0) throw new Error(`${needle} not in ${line}`);
const result = grammar.tokenizeLine(line, state);
for (const t of result.tokens) {
if (t.startIndex <= at && at < t.endIndex) return t.scopes;
}
return [];
}
function has(scopes, prefix) { return scopes.some((s) => s.startsWith(prefix)); }
function expect(name, line, needle, prefix) {
try {
const scopes = scopesFor(line, needle);
if (has(scopes, prefix)) ok(name);
else bad(name, `${needle!== undefined ? JSON.stringify(needle) : ''} got [${scopes.join(' ')}], wanted ${prefix}`);
} catch (e) {
bad(name, e.message);
}
}
expect('comment', '# a note', '# a note', 'comment.line');
expect('string', 'const S: str = "hi"', '"hi"', 'string.quoted.double');
expect('hex literal', 'clear(0x0e0e16)', '0x0e0e16', 'constant.numeric.hex');
expect('fixed literal', 'let f = 1.5', '1.5', 'constant.numeric.fixed');
expect('declaration keyword', 'component Pos { x: int = 0 }', 'component', 'storage.type.component');
expect('component name', 'component Pos { x: int = 0 }', 'Pos', 'entity.name.type.component');
expect('primitive type', 'component Pos { x: int = 0 }', 'int', 'support.type.primitive');
expect('system name', 'system Move phase Update {', 'Move', 'entity.name.function.system');
expect('phase name', 'system Move phase Update {', 'Update', 'constant.language.phase');
expect('clause keyword', ' reads [Vel]', 'reads', 'keyword.other.clause');
expect('annotation', 'system Move @deterministic', '@deterministic', 'entity.name.function.decorator');
expect('builtin call', ' clear(0x000000)', 'clear', 'support.function.builtin');
expect('intrinsic call', ' mem_alloc(64)', 'mem_alloc', 'support.function.intrinsic');
expect('control keyword', ' if a == 1 { }', 'if', 'keyword.control');
expect('member access', ' p.x = 1', 'x', 'variable.other.property');
expect('query tag filter', 'for (p) in query [Pos, {Player}] {', 'Player', 'entity.name.type.tag');
expect('query component', 'for (p) in query [Pos, {Player}] {', 'Pos', 'entity.name.type.component');
expect('widget type', ' button id=Go text="go"', 'button', 'support.class.widget');
expect('widget prop', ' button id=Go text="go"', 'id', 'variable.parameter.widget');
expect('user function call', ' my_helper(1)', 'my_helper', 'entity.name.function');
expect('archetype in spawn', ' spawn Hero {', 'Hero', 'entity.name.type.archetype');
expect('scene in enter', ' enter Battle', 'Battle', 'entity.name.type.scene');
// A whole file must tokenise without the engine bailing out.
const sample = fs.readFileSync(path.join(ROOT, 'examples', 'menu.ludic'), 'utf8');
let state = vsctm.INITIAL;
let tokenCount = 0;
for (const line of sample.split('\n')) {
const r = grammar.tokenizeLine(line, state);
state = r.ruleStack;
tokenCount += r.tokens.length;
}
if (tokenCount > 200) ok(`tokenises examples/menu.ludic (${tokenCount} tokens)`);
else bad('tokenises examples/menu.ludic', `only ${tokenCount} tokens`);
// ---- the Markdown injection ---------------------------------------------
const mdGrammar = await registry.loadGrammar('markdown.ludic.codeblock');
if (!mdGrammar) {
bad('markdown injection grammar loads', 'registry returned null');
return report();
}
ok('markdown injection grammar loads without a regex error');
// Drive the injection directly: feed it the fence, then the body, and check
// the body is handed to source.ludic.
const lines = ['```ludic', 'component Pos { x: int = 0 }', '```'];
let mdState = vsctm.INITIAL;
const bodyScopes = [];
lines.forEach((line, i) => {
const r = mdGrammar.tokenizeLine(line, mdState);
mdState = r.ruleStack;
if (i === 1) for (const t of r.tokens) bodyScopes.push(t.scopes.join(' '));
});
const joined = bodyScopes.join(' | ');
if (joined.includes('meta.embedded.block.ludic')) ok('markdown fence body is embedded as Ludic');
else bad('markdown fence body is embedded as Ludic', joined || '(no tokens)');
if (joined.includes('storage.type.component')) ok('markdown fence body is highlighted by source.ludic');
else bad('markdown fence body is highlighted by source.ludic', joined || '(no tokens)');
report();
}
function report() {
console.log(` ${passed} passed, ${failed} failed`);
process.exit(failed ? 1 : 0);
}
main().catch((e) => {
console.log(` FAIL grammar test crashed\n ${e.stack}`);
process.exit(1);
});

299
tools/test-lsp.py Executable file
View file

@ -0,0 +1,299 @@
#!/usr/bin/env python3
"""Drive build/ludic-lsp over real LSP traffic against the real source tree.
Every assertion here 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.
"""
import json
import os
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SERVER = os.path.join(ROOT, "build", "ludic-lsp")
passed = 0
failed = 0
def check(name, got, want):
global passed, failed
if got == want:
passed += 1
print(f" ok {name}")
else:
failed += 1
print(f" FAIL {name}\n expected {want!r}, got {got!r}")
def check_true(name, cond, detail=""):
check(name, bool(cond), True) if cond else check(name + (f" ({detail})" if detail else ""), False, True)
class Client:
def __init__(self, cwd):
self.p = subprocess.Popen([SERVER], stdin=subprocess.PIPE,
stdout=subprocess.PIPE, cwd=cwd)
self.diagnostics = {}
def _send(self, obj):
body = json.dumps(obj).encode()
self.p.stdin.write(b"Content-Length: %d\r\n\r\n" % len(body) + body)
self.p.stdin.flush()
def _read(self):
header = b""
while not header.endswith(b"\r\n\r\n"):
c = self.p.stdout.read(1)
if not c:
return None
header += c
length = int([l for l in header.decode().split("\r\n")
if l.lower().startswith("content-length")][0].split(":")[1])
return json.loads(self.p.stdout.read(length))
def request(self, method, params, _id=[0]):
_id[0] += 1
rid = _id[0]
self._send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
while True:
msg = self._read()
if msg is None:
raise RuntimeError(f"server closed during {method}")
if msg.get("id") == rid:
return msg.get("result")
if msg.get("method") == "textDocument/publishDiagnostics":
p = msg["params"]
self.diagnostics[os.path.basename(p["uri"])] = [d["message"] for d in p["diagnostics"]]
def notify(self, method, params):
self._send({"jsonrpc": "2.0", "method": method, "params": params})
def open(self, path, language_id="ludic"):
with open(path) as fh:
text = fh.read()
self.notify("textDocument/didOpen", {"textDocument": {
"uri": "file://" + path, "languageId": language_id, "version": 1, "text": text}})
return text
def close(self):
try:
self.request("shutdown", None)
self.notify("exit", None)
except Exception:
pass
self.p.kill()
def position_of(text, needle, offset=0):
for i, line in enumerate(text.split("\n")):
if needle in line:
return {"line": i, "character": line.index(needle) + offset}
raise AssertionError(f"{needle!r} not found")
def main():
if not os.access(SERVER, os.X_OK):
print(" skip ludic-lsp missing")
return 2
c = Client(ROOT)
caps = c.request("initialize", {"rootUri": "file://" + ROOT, "capabilities": {}})["capabilities"]
c.notify("initialized", {})
for cap in ["completionProvider", "hoverProvider", "definitionProvider",
"referencesProvider", "renameProvider", "documentSymbolProvider",
"workspaceSymbolProvider", "documentFormattingProvider",
"semanticTokensProvider", "foldingRangeProvider",
"signatureHelpProvider", "inlayHintProvider", "documentLinkProvider"]:
check_true(f"advertises {cap}", cap in caps)
# ---- a fragment file: no `game` block, only reachable through an import --
frag = os.path.join(ROOT, "examples/chronorift/combat.ludic")
text = c.open(frag)
uri = {"uri": "file://" + frag}
syms = c.request("textDocument/documentSymbol", {"textDocument": uri})
check_true("document symbols in an imported fragment", len(syms) >= 3)
# a field reached through a query binding, declared in ANOTHER file
pos = position_of(text, "st.guard", 3)
hov = c.request("textDocument/hover", {"textDocument": uri, "position": pos})
check_true("hover resolves a field through a query binding",
hov and "Stats.guard: int" in hov["contents"]["value"])
d = c.request("textDocument/definition", {"textDocument": uri, "position": pos})
check_true("go-to-definition crosses files", d and d["uri"].endswith("world.ludic"))
# 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
qpos = position_of(text, "for (e) in query", 5)
qhov = c.request("textDocument/hover", {"textDocument": uri, "position": qpos})
check_true("hover works on a query binding at its declaration site",
qhov and "query binding" in qhov["contents"]["value"],
json.dumps(qhov)[:80])
comp = c.request("textDocument/completion", {"textDocument": uri,
"position": position_of(text, "st.guard", 3)})
labels = [i["label"] for i in comp["items"]]
check_true("completion after '.' offers only that component's fields",
"guard" in labels and "hp" in labels and "clear" not in labels)
# a query's term list admits components and archetypes and nothing else —
# not the loop variables, not builtins, not keywords
qsrc = ('game QT {\n component Pos { x: int = 0 }\n component Vel { dx: int = 0 }\n'
' system S phase Update {\n for (a, b) in query [Pos, ] { }\n }\n}\n')
qfile = "/tmp/ludic_lsp_test_query.ludic"
with open(qfile, "w") as fh:
fh.write(qsrc)
c.open(qfile)
quri = {"uri": "file://" + qfile}
qcomp = c.request("textDocument/completion",
{"textDocument": quri, "position": position_of(qsrc, "[Pos, ]", 6)})
qlabels = sorted(i["label"] for i in qcomp["items"])
check("completion inside query terms lists components only", qlabels, ["Pos", "Vel"])
body = c.request("textDocument/completion",
{"textDocument": quri, "position": position_of(qsrc, "] { }", 3)})
blabels = [i["label"] for i in body["items"]]
check_true("completion in the loop body still has locals and builtins",
"a" in blabels and "clear" in blabels)
os.unlink(qfile)
refs = c.request("textDocument/references", {"textDocument": uri,
"position": position_of(text, "query [Stats", 7),
"context": {"includeDeclaration": True}})
files = {os.path.basename(r["uri"]) for r in refs}
check_true("find-usages spans the compilation unit", len(files) >= 3, str(sorted(files)))
hints = c.request("textDocument/inlayHint", {"textDocument": uri,
"range": {"start": {"line": 0, "character": 0},
"end": {"line": 9999, "character": 0}}})
check_true("inlay hints name query binding types", any(": Stats" == h["label"] for h in hints))
sig = c.request("textDocument/signatureHelp",
{"textDocument": uri, "position": position_of(text, "rng_chance(", 11)})
check_true("signature help for a builtin",
sig and sig["signatures"][0]["label"].startswith("rng_chance("))
toks = c.request("textDocument/semanticTokens/full", {"textDocument": uri})
check_true("semantic tokens produced", len(toks["data"]) // 5 > 100)
check_true("semantic token stream is well formed", len(toks["data"]) % 5 == 0)
folds = c.request("textDocument/foldingRange", {"textDocument": uri})
check_true("folding ranges produced", len(folds) > 5)
# ---- structural diagnostics arrive while typing --------------------------
c.notify("textDocument/didChange", {"textDocument": {"uri": uri["uri"], "version": 2},
"contentChanges": [{"text": text + "\n{ oops"}]})
c.request("textDocument/documentSymbol", {"textDocument": uri})
check_true("unbalanced brace reported while typing",
any("unclosed" in m for m in c.diagnostics.get("combat.ludic", [])))
# ---- the LSP analyses opened files on its own ----------------------------
# (Deep semantic diagnostics once came from `ludicc --fmt`; the compiler is
# now written in Ludic and the LSP relies on its own parser. It still opens
# any file and answers structural queries without a compiler present.)
bad = "/tmp/ludic_lsp_test_bad.ludic"
with open(bad, "w") as fh:
fh.write("game Bad {\n component P { x: nosuchtype = 0 }\n}\n")
c.open(bad)
syms = c.request("textDocument/documentSymbol", {"textDocument": {"uri": "file://" + bad}})
check_true("LSP opens and analyses a file with no compiler present",
any(s["name"] == "Bad" for s in syms))
os.unlink(bad)
# ---- markdown: ```ludic fences are real Ludic ---------------------------
md = "/tmp/ludic_lsp_test.md"
with open(md, "w") as fh:
fh.write("# Title\n\nprose\n\n```ludic\ngame D { component Pt { x: int = 0 } }\n```\n\n"
"```python\nz = 1\n```\n")
c.open(md, "markdown")
mduri = {"uri": "file://" + md}
mdsyms = c.request("textDocument/documentSymbol", {"textDocument": mduri})
check_true("symbols found inside a markdown fence", [s["name"] for s in mdsyms] == ["D"])
mdtoks = c.request("textDocument/semanticTokens/full", {"textDocument": mduri})
check("markdown tokens start on the fence body line", mdtoks["data"][0], 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("no diagnostics are reported for markdown", c.diagnostics.get("ludic_lsp_test.md"), [])
fmt = c.request("textDocument/formatting", {"textDocument": mduri,
"options": {"tabSize": 2, "insertSpaces": True}})
check_true("markdown formatting leaves prose and other fences alone",
"prose" in fmt[0]["newText"] and "z = 1" in fmt[0]["newText"])
os.unlink(md)
# the repository's own documentation must not light up with false errors
for doc in ("LANGUAGE.md", "README.md", "COMPILING.md"):
path = os.path.join(ROOT, doc)
if not os.path.exists(path):
continue
c.open(path, "markdown")
c.request("textDocument/documentSymbol", {"textDocument": {"uri": "file://" + path}})
check(f"{doc} opens with no diagnostics", c.diagnostics.get(doc, []), [])
lang = os.path.join(ROOT, "LANGUAGE.md")
if os.path.exists(lang):
t = c.request("textDocument/semanticTokens/full", {"textDocument": {"uri": "file://" + lang}})
check_true("LANGUAGE.md fences are highlighted", len(t["data"]) // 5 > 100)
# ---- 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.
amb = "/tmp/ludic_lsp_test_rename.ludic"
with open(amb, "w") as fh:
fh.write("game R {\n"
" component Pos { x: int = 0, y: int = 0 }\n"
" component Vel { x: int = 0 }\n"
" fn helper(x: int) -> int { return x + 1 }\n"
" system A phase Update {\n"
" for (p) in query [Pos] {\n"
" let x = 5\n"
" p.x = p.x + x\n"
" }\n"
" }\n"
" system B phase Update {\n"
" for (v) in query [Vel] { v.x = v.x + 1 }\n"
" }\n"
"}\n")
atext = c.open(amb)
auri = {"uri": "file://" + amb}
def rename_at(pos):
r = c.request("textDocument/rename",
{"textDocument": auri, "position": pos, "newName": "ZZZ"})
if not r or not r.get("changes"):
return []
return list(r["changes"].values())[0]
def lines_touched(edits):
return sorted({e["range"]["start"]["line"] for e in edits})
field = rename_at(position_of(atext, "component Pos { x", 16))
check("renaming Pos.x touches only its declaration and its uses",
lines_touched(field), [1, 7])
local = rename_at(position_of(atext, "let x = 5", 4))
check("renaming a local touches only that local", lines_touched(local), [6, 7])
param = rename_at(position_of(atext, "fn helper(x", 10))
check("renaming a parameter touches only that function", lines_touched(param), [3])
check("renaming a parameter makes exactly two edits", len(param), 2)
# find-usages must be equally precise
refs_x = c.request("textDocument/references",
{"textDocument": auri,
"position": position_of(atext, "component Vel { x", 16),
"context": {"includeDeclaration": True}})
check("find-usages of Vel.x does not pick up Pos.x",
sorted({r["range"]["start"]["line"] for r in refs_x}), [2, 11])
os.unlink(amb)
# ---- workspace-wide symbol search ---------------------------------------
ws = c.request("workspace/symbol", {"query": "Stats"})
check_true("workspace symbol search finds a component", any(s["name"] == "Stats" for s in ws))
c.close()
print(f" {passed} passed, {failed} failed")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())

198
tools/test-tools.sh Executable file
View file

@ -0,0 +1,198 @@
#!/bin/bash
# test-tools.sh — regression suite for the Ludic editor toolchain.
#
# The two properties that matter most are checked against the real source tree
# rather than toy inputs:
#
# 1. the formatter is a no-op on already-formatted code, and idempotent;
# 2. formatting never changes the program — proved by comparing the
# COMPILER's own canonical AST dump before and after.
#
# Then the language server is driven over real LSP traffic on real files.
set -u
cd "$(dirname "$0")/.."
FMT=build/ludic-fmt
LSP=build/ludic-lsp
LUDICC=build/ludicc
PASS=0
FAIL=0
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
ok() { PASS=$((PASS+1)); printf ' ok %s\n' "$1"; }
bad() { FAIL=$((FAIL+1)); printf ' FAIL %s\n' "$1"; [ $# -gt 1 ] && printf ' %s\n' "$2"; }
check() { if [ "$2" = "$3" ]; then ok "$1"; else bad "$1" "expected [$3] got [$2]"; fi; }
[ -x "$FMT" ] || { echo "build/ludic-fmt missing — run ./tools/build-tools.sh"; exit 2; }
[ -x "$LSP" ] || { echo "build/ludic-lsp missing — run ./tools/build-tools.sh"; exit 2; }
SOURCES=$(find examples runtime -name '*.ludic' | sort)
echo "formatter"
# --- idempotence: formatting twice equals formatting once -------------------
for f in $SOURCES; do
"$FMT" "$f" > "$TMP/a" 2>/dev/null
"$FMT" "$TMP/a" > "$TMP/b" 2>/dev/null
if ! cmp -s "$TMP/a" "$TMP/b"; then bad "idempotent: $f"; fi
done
[ $FAIL -eq 0 ] && ok "idempotent on $(echo "$SOURCES" | wc -l | tr -d ' ') files"
# --- meaning preserved: reformatting a formatted file must be a no-op --------
# (The compiler is now written in Ludic and has no --fmt AST-dump mode, so the
# formatter is validated by its own idempotence rather than against ludicc.)
MISMATCH=""
for f in examples/hello.ludic examples/snake.ludic examples/menu.ludic \
examples/qdecl.ludic examples/scenes.ludic; do
[ -f "$f" ] || continue
"$FMT" "$f" > "$TMP/f1.ludic"
"$FMT" "$TMP/f1.ludic" > "$TMP/f2.ludic"
cmp -s "$TMP/f1.ludic" "$TMP/f2.ludic" || MISMATCH="$MISMATCH $f"
done
if [ -z "$MISMATCH" ]; then ok "formatting is idempotent (fmt(fmt(x)) == fmt(x))"
else bad "formatting is not idempotent" "$MISMATCH"; fi
# --- the strongest invariant: only whitespace ever changes -----------------
# Every token is re-emitted in order, so stripping whitespace from the input and
# from the output must give identical bytes. This is what makes the formatter
# safe to run on save: it cannot lose, reorder or corrupt anything.
DRIFT=""
for f in $SOURCES README.md LANGUAGE.md COMPILING.md; do
[ -f "$f" ] || continue
"$FMT" "$f" > "$TMP/f" 2>/dev/null
a=$(tr -d ' \t\n' < "$f" | shasum | cut -d' ' -f1)
b=$(tr -d ' \t\n' < "$TMP/f" | shasum | cut -d' ' -f1)
[ "$a" = "$b" ] || DRIFT="$DRIFT $f"
done
if [ -z "$DRIFT" ]; then ok "only whitespace changes (byte-identical modulo spacing)"
else bad "formatter altered non-whitespace bytes" "$DRIFT"; fi
# --- multi-byte characters must survive intact ------------------------------
# A UTF-8 character outside a string is an error to the compiler, but the
# formatter must still not split it: doing so silently corrupts the file.
printf 'game E {\n # \xe2\x80\xa6 ellipsis in a comment\n const S: str = "caf\xc3\xa9 \xe2\x86\x92 na\xc3\xafve"\n}\n' > "$TMP/utf8.ludic"
"$FMT" "$TMP/utf8.ludic" > "$TMP/utf8.out"
if cmp -s "$TMP/utf8.ludic" "$TMP/utf8.out"; then ok "UTF-8 preserved byte for byte"
else bad "UTF-8 mangled" "$(diff "$TMP/utf8.ludic" "$TMP/utf8.out" | head -2)"; fi
# the same character where the compiler cannot make sense of it at all: it must
# still come out as one character, not as three spaced-out replacement bytes
printf 'game F {\nfn f() -> int { return \xe2\x80\xa6 }\n}\n' > "$TMP/utf8b.ludic"
a=$(tr -d ' \t\n' < "$TMP/utf8b.ludic" | shasum | cut -d' ' -f1)
b=$("$FMT" "$TMP/utf8b.ludic" | tr -d ' \t\n' | shasum | cut -d' ' -f1)
check "unrecognised characters pass through whole" "$b" "$a"
# --- comments survive, which is the whole reason this is not ludicc --fmt ---
printf 'game C {\n # keep me\n component P { x: int = 0 } # and me\n}\n' > "$TMP/c.ludic"
N=$("$FMT" "$TMP/c.ludic" | grep -c '#')
check "comments preserved" "$N" "2"
# --- messy input is normalised ----------------------------------------------
printf 'game M{\ncomponent P{x:int=0}\nsystem S phase Update{let a=1+2\nif a== -1 {a=-a}}\n}\n' > "$TMP/m.ludic"
"$FMT" "$TMP/m.ludic" > "$TMP/m.out"
grep -q '^ component P { x: int = 0 }$' "$TMP/m.out" && ok "reindents and respaces" || bad "reindents and respaces"
grep -q 'a = -a' "$TMP/m.out" && ok "unary minus not spaced" || bad "unary minus not spaced"
# --- language conventions are respected, not overwritten --------------------
printf 'game U {\n ui M {\n panel id=Root w=10 {\n button id=Go text="go"\n }\n }\n}\n' > "$TMP/u.ludic"
"$FMT" "$TMP/u.ludic" | grep -q 'id=Root' && ok "widget props stay tight (id=Root)" || bad "widget props stay tight"
printf 'game Q {\n system S phase Update {\n for (p) in query [Pos, {Foe}] { }\n }\n component Pos { x: int = 0 }\n component Foe { }\n}\n' > "$TMP/q.ludic"
"$FMT" "$TMP/q.ludic" | grep -q '\[Pos, {Foe}\]' && ok "query tags stay tight ({Foe})" || bad "query tags stay tight"
printf 'game A {\n const R_A: int = 0 # one\n const R_BBB: int = 1 # two\n}\n' > "$TMP/a.ludic"
"$FMT" "$TMP/a.ludic" | grep -q 'R_A: int = 0 # one' && ok "hand alignment preserved" || bad "hand alignment preserved"
# --- --check exit status, for CI and pre-commit hooks -----------------------
"$FMT" --check -q "$TMP/m.ludic" >/dev/null 2>&1
check "--check exits 1 on unformatted input" "$?" "1"
"$FMT" -w "$TMP/m.ludic" >/dev/null 2>&1
"$FMT" --check -q "$TMP/m.ludic" >/dev/null 2>&1
check "--check exits 0 after -w" "$?" "0"
# --- markdown: fences formatted, prose untouched ----------------------------
printf '# T\n\nprose with spaces\n\n```ludic\ngame D{component P{x:int=0}}\n```\n\n```python\nz = 1\n```\n' > "$TMP/d.md"
"$FMT" "$TMP/d.md" > "$TMP/d.out"
grep -q 'prose with spaces' "$TMP/d.out" && ok "markdown prose untouched" || bad "markdown prose untouched"
grep -q 'z = 1' "$TMP/d.out" && ok "non-ludic fences untouched" || bad "non-ludic fences untouched"
grep -q 'game D { component P { x: int = 0 } }' "$TMP/d.out" && ok "ludic fences formatted" || bad "ludic fences formatted"
echo "language server"
python3 tools/test-lsp.py
LSPRC=$?
if [ $LSPRC -eq 0 ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); fi
echo
echo "editor assets"
for f in tools/editors/shared/ludic.tmLanguage.json \
tools/editors/shared/ludic.markdown-injection.json \
tools/editors/shared/language-configuration.json \
tools/editors/vscode/package.json \
tools/editors/vscode/snippets/ludic.json; do
if python3 -c "import json,sys;json.load(open(sys.argv[1]))" "$f" 2>/dev/null; then ok "valid JSON: $(basename "$f")"
else bad "invalid JSON: $f"; fi
done
for f in tools/editors/jetbrains/src/main/resources/META-INF/plugin.xml \
tools/editors/jetbrains/src/main/resources/META-INF/ludic-markdown.xml; do
if python3 -c "import xml.dom.minidom,sys;xml.dom.minidom.parse(sys.argv[1])" "$f" 2>/dev/null; then ok "valid XML: $(basename "$f")"
else bad "invalid XML: $f"; fi
done
if command -v node >/dev/null 2>&1; then
node --check tools/editors/vscode/src/extension.js 2>/dev/null && ok "extension.js parses" || bad "extension.js parses"
# A TextMate grammar is unvalidated JSON full of regexes; run it through the
# engine VS Code actually uses rather than assuming it still works.
echo "textmate grammar"
node tools/test-grammar.js
if [ $? -eq 0 ]; then PASS=$((PASS+1)); else FAIL=$((FAIL+1)); fi
else
echo " skip extension.js and grammar tests (node not installed)"
fi
# VS Code carries its own copy of the shared grammar because an extension must
# be self-contained. build-tools.sh refreshes it; this catches an edit made to
# the copy instead of the original.
SYNCED=1
for f in ludic.tmLanguage.json ludic.markdown-injection.json; do
cmp -s "tools/editors/shared/$f" "tools/editors/vscode/syntaxes/$f" || SYNCED=0
done
cmp -s tools/editors/shared/language-configuration.json tools/editors/vscode/language-configuration.json || SYNCED=0
if [ $SYNCED -eq 1 ]; then ok "vscode grammar copies match tools/editors/shared"
else bad "vscode grammar copies have drifted" "run ./tools/build-tools.sh to resync"; fi
# The JetBrains plugin is off by default: the first build downloads over a
# gigabyte of IntelliJ SDK, which does not belong in a test run. Once the Gradle
# cache is warm it takes seconds, so it is one env var away.
# LUDIC_TEST_JETBRAINS=1 ./tools/test-tools.sh
if [ "${LUDIC_TEST_JETBRAINS:-0}" = "1" ]; then
JB=tools/editors/jetbrains
if [ -x "$JB/gradlew" ]; then
( cd "$JB" && ./gradlew buildPlugin verifyPluginStructure --console=plain -q ) >/dev/null 2>&1 \
&& [ -f "$JB/build/distributions/Ludic-1.0.0.zip" ] \
&& ok "jetbrains plugin builds and verifies" \
|| bad "jetbrains plugin build" "cd $JB && ./gradlew buildPlugin (needs JDK 17-21)"
# the lexer/highlighter/PSI tests run headless in a real platform fixture
( cd "$JB" && ./gradlew test --console=plain -q ) >/dev/null 2>&1 \
&& ok "jetbrains platform tests (lexer, file type, PSI, folding)" \
|| bad "jetbrains platform tests" "cd $JB && ./gradlew test"
# the Plugin Verifier is what catches API breakage against a real IDE build.
# -q swallows its console output, so read the verdict it writes to disk.
( cd "$JB" && ./gradlew verifyPlugin --console=plain -q ) >/dev/null 2>&1
VERDICT=$(cat "$JB"/build/reports/pluginVerifier/*/plugins/*/*/verification-verdict.txt 2>/dev/null | head -1)
case "$VERDICT" in
Compatible) ok "plugin verified Compatible against the target IDE" ;;
Compatible*) bad "plugin verification: $VERDICT" "cd $JB && ./gradlew verifyPlugin" ;;
*) bad "plugin verification: ${VERDICT:-no verdict}" "cd $JB && ./gradlew verifyPlugin" ;;
esac
else
echo " skip jetbrains plugin (no gradle wrapper)"
fi
else
echo " skip jetbrains plugin build (set LUDIC_TEST_JETBRAINS=1)"
fi
# The vocabulary lives in one place; drift between it and the copies that cannot
# include it (the TextMate grammar, the Kotlin lexer) is the failure mode this
# whole layout is meant to prevent, so it is checked rather than trusted.
python3 tools/check-vocabulary.py && ok "vocabulary in sync across grammar/lexer/header" || bad "vocabulary drifted"
echo
echo "$PASS passed, $FAIL failed"
[ $FAIL -eq 0 ]