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:
commit
985f9ad8f2
418 changed files with 39065 additions and 0 deletions
56
tools/atlas.ludic
Normal file
56
tools/atlas.ludic
Normal 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
72
tools/build-tools.sh
Executable 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
78
tools/check-docs.py
Executable 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
150
tools/check-vocabulary.py
Executable 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
148
tools/editors/README.md
Normal 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.
|
||||
70
tools/editors/emacs/ludic-mode.el
Normal file
70
tools/editors/emacs/ludic-mode.el
Normal 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
|
||||
24
tools/editors/helix/languages.toml
Normal file
24
tools/editors/helix/languages.toml
Normal 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"]
|
||||
58
tools/editors/jetbrains/README.md
Normal file
58
tools/editors/jetbrains/README.md
Normal 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.
|
||||
96
tools/editors/jetbrains/build.gradle.kts
Normal file
96
tools/editors/jetbrains/build.gradle.kts
Normal 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
|
||||
}
|
||||
}
|
||||
26
tools/editors/jetbrains/gradle.properties
Normal file
26
tools/editors/jetbrains/gradle.properties
Normal 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
|
||||
BIN
tools/editors/jetbrains/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
tools/editors/jetbrains/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
tools/editors/jetbrains/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
tools/editors/jetbrains/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
248
tools/editors/jetbrains/gradlew
vendored
Executable 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
93
tools/editors/jetbrains/gradlew.bat
vendored
Normal 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
|
||||
2
tools/editors/jetbrains/settings.gradle.kts
Normal file
2
tools/editors/jetbrains/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// The distribution is named after this, so it is the plugin name, not a slug.
|
||||
rootProject.name = "Ludic"
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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 = "+-*/%<>=:.!@;"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 → Languages & Frameworks → 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>
|
||||
|
|
@ -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 |
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
86
tools/editors/neovim/lua/ludic.lua
Normal file
86
tools/editors/neovim/lua/ludic.lua
Normal 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
|
||||
31
tools/editors/shared/language-configuration.json
Normal file
31
tools/editors/shared/language-configuration.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
27
tools/editors/shared/ludic.markdown-injection.json
Normal file
27
tools/editors/shared/ludic.markdown-injection.json
Normal 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" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
241
tools/editors/shared/ludic.tmLanguage.json
Normal file
241
tools/editors/shared/ludic.tmLanguage.json
Normal 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": "[+\\-*/%]" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
35
tools/editors/sublime/README.md
Normal file
35
tools/editors/sublime/README.md
Normal 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`.
|
||||
12
tools/editors/vscode/.vscodeignore
Normal file
12
tools/editors/vscode/.vscodeignore
Normal 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
|
||||
54
tools/editors/vscode/README.md
Normal file
54
tools/editors/vscode/README.md
Normal 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
|
||||
```
|
||||
7
tools/editors/vscode/icons/ludic.svg
Normal file
7
tools/editors/vscode/icons/ludic.svg
Normal 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 |
31
tools/editors/vscode/language-configuration.json
Normal file
31
tools/editors/vscode/language-configuration.json
Normal 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
97
tools/editors/vscode/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
158
tools/editors/vscode/package.json
Normal file
158
tools/editors/vscode/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
96
tools/editors/vscode/snippets/ludic.json
Normal file
96
tools/editors/vscode/snippets/ludic.json
Normal 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."
|
||||
}
|
||||
}
|
||||
149
tools/editors/vscode/src/extension.js
Normal file
149
tools/editors/vscode/src/extension.js
Normal 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 };
|
||||
27
tools/editors/vscode/syntaxes/ludic.markdown-injection.json
Normal file
27
tools/editors/vscode/syntaxes/ludic.markdown-injection.json
Normal 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" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
241
tools/editors/vscode/syntaxes/ludic.tmLanguage.json
Normal file
241
tools/editors/vscode/syntaxes/ludic.tmLanguage.json
Normal 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": "[+\\-*/%]" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
39
tools/editors/zed/README.md
Normal file
39
tools/editors/zed/README.md
Normal 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
29
tools/git-hooks/pre-commit
Executable 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
|
||||
339
tools/ludic-tools/ludic_fmt.h
Normal file
339
tools/ludic-tools/ludic_fmt.h
Normal 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 */
|
||||
118
tools/ludic-tools/ludic_fmt_main.c
Normal file
118
tools/ludic-tools/ludic_fmt_main.c
Normal 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;
|
||||
}
|
||||
672
tools/ludic-tools/ludic_index.h
Normal file
672
tools/ludic-tools/ludic_index.h
Normal 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 */
|
||||
191
tools/ludic-tools/ludic_json.h
Normal file
191
tools/ludic-tools/ludic_json.h
Normal 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 */
|
||||
1265
tools/ludic-tools/ludic_lsp.c
Normal file
1265
tools/ludic-tools/ludic_lsp.c
Normal file
File diff suppressed because it is too large
Load diff
321
tools/ludic-tools/ludic_syntax.h
Normal file
321
tools/ludic-tools/ludic_syntax.h
Normal 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 */
|
||||
405
tools/ludic-tools/ludic_workspace.h
Normal file
405
tools/ludic-tools/ludic_workspace.h
Normal 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
106
tools/ludic-web/run.mjs
Normal 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
168
tools/test-grammar.js
Normal 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
299
tools/test-lsp.py
Executable 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
198
tools/test-tools.sh
Executable 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 ]
|
||||
Loading…
Add table
Add a link
Reference in a new issue