# tools.ludic — the Ludic editor toolchain: building ludic-fmt / ludic-lsp and # their regression suite. Replaces tools/build-tools.sh and tools/test-tools.sh. # # The formatter and language server are themselves written in Ludic # (tools/ludic-tools/*.ludic) and compiled by ludicc; no C is compiled. The LSP # protocol test, the TextMate grammar test and the vocabulary check are still # driven through python3/node (they are not project shell scripts), invoked here. # refresh one editor-copy of a shared grammar file if it has drifted function sync_one(shared: pointer, dst: pointer) -> void { if not shq(`cmp -s {shared} {dst}`) { shell(`cp {shared} {dst}`) print(`sync: {shared} -> {dst}`) } } # VS Code needs the grammar inside its own extension directory, so it gets a # copy that build refreshes rather than trusts (test-tools asserts they match). function sync_vscode_grammar() -> void { if not shq("test -d tools/editors/vscode") { return } shell("mkdir -p tools/editors/vscode/syntaxes") sync_one("tools/editors/shared/ludic.tmLanguage.json", "tools/editors/vscode/syntaxes/ludic.tmLanguage.json") sync_one("tools/editors/shared/ludic.markdown-injection.json", "tools/editors/vscode/syntaxes/ludic.markdown-injection.json") sync_one("tools/editors/shared/language-configuration.json", "tools/editors/vscode/language-configuration.json") } # is `flag` present anywhere in argv? function has_flag(flag: pointer) -> bool { var i = 2 while i < arg_count() { if (arg(i) == flag) { return true }; i += 1 } return false } # ---- tools: build the editor toolchain -------------------------------------- # usage: ludic-dev tools [--install] [--test] function cmd_tools() -> int { ensure_ludicc() print("ludicc: tools/ludic-tools/fmt.ludic -> bin/ludic-fmt (Ludic -> LLVM IR -> binary, no C)") if not build_tool("ludic-fmt", "tools/ludic-tools/fmt.ludic") { return 1 } print("ludicc: tools/ludic-tools/lsp.ludic -> bin/ludic-lsp (Ludic -> LLVM IR -> binary, no C)") if not build_tool("ludic-lsp", "tools/ludic-tools/lsp.ludic") { return 1 } sync_vscode_grammar() if has_flag("--install") { shell("mkdir -p \"$HOME/.local/bin\"") shell("ln -sf \"$PWD/bin/ludic-fmt\" \"$HOME/.local/bin/ludic-fmt\"") shell("ln -sf \"$PWD/bin/ludic-lsp\" \"$HOME/.local/bin/ludic-lsp\"") print("linked into ~/.local/bin (make sure it is on your PATH)") } if has_flag("--test") { return cmd_test_tools() } print("done. editors: see tools/editors/README.md") return 0 } # ---- test-tools: the editor-toolchain regression suite ----------------------- function cmd_test_tools() -> int { PASS = 0 FAIL = 0 let fmt = "bin/ludic-fmt" if not is_exec(fmt) { err("bin/ludic-fmt missing — run: ludic-dev tools\n"); return 2 } if not is_exec("bin/ludic-lsp") { err("bin/ludic-lsp missing — run: ludic-dev tools\n"); return 2 } print("formatter") # --- idempotence: formatting twice equals formatting once ------------------ let nsrc = capture_line("find examples runtime -name '*.ludic' | wc -l") if shq(`fail=0; for f in $(find examples runtime -name '*.ludic' | sort); do {fmt} "$f" > {tmp_dir()}/a 2>/dev/null; {fmt} {tmp_dir()}/a > {tmp_dir()}/b 2>/dev/null; cmp -s {tmp_dir()}/a {tmp_dir()}/b || fail=1; done; test $fail -eq 0`) { ok(`idempotent on {nsrc} files`) } else { bad("idempotent on the source tree") } # --- meaning preserved: reformatting a formatted file must be a no-op ------- if shq(`fail=0; for f in examples/ecs/hello.ludic examples/games/snake.ludic examples/games/menu.ludic examples/lang/qdecl.ludic examples/lang/scenes.ludic; do test -f "$f" || continue; {fmt} "$f" > {tmp_dir()}/f1 2>/dev/null; {fmt} {tmp_dir()}/f1 > {tmp_dir()}/f2 2>/dev/null; cmp -s {tmp_dir()}/f1 {tmp_dir()}/f2 || fail=1; done; test $fail -eq 0`) { ok("formatting is idempotent (fmt(fmt(x)) == fmt(x))") } else { bad("formatting is not idempotent") } # --- the strongest invariant: only whitespace ever changes ----------------- # every token is re-emitted in order, so the input and output must be # byte-identical once all whitespace is stripped. if shq(`fail=0; for f in $(find examples runtime -name '*.ludic') README.md LANGUAGE.md COMPILING.md; do test -f "$f" || continue; a=$(tr -d ' \t\n' < "$f" | shasum | cut -d' ' -f1); b=$({fmt} "$f" 2>/dev/null | tr -d ' \t\n' | shasum | cut -d' ' -f1); test "$a" = "$b" || fail=1; done; test $fail -eq 0`) { ok("only whitespace changes (byte-identical modulo spacing)") } else { bad("formatter altered non-whitespace bytes") } # --- multi-byte characters must survive intact ----------------------------- write_file(`{tmp_dir()}/utf8.ludic`, "program E {\n # … ellipsis in a comment\n const S: string = \"café → naïve\"\n}\n") if shq(`{fmt} {tmp_dir()}/utf8.ludic > {tmp_dir()}/utf8.out 2>/dev/null; cmp -s {tmp_dir()}/utf8.ludic {tmp_dir()}/utf8.out`) { ok("UTF-8 preserved byte for byte") } else { bad("UTF-8 mangled") } write_file(`{tmp_dir()}/utf8b.ludic`, "program F {\nfn f() -> int { return … }\n}\n") let ua = capture_line(`tr -d ' \t\n' < {tmp_dir()}/utf8b.ludic | shasum | cut -d' ' -f1`) let ub = capture_line(`{fmt} {tmp_dir()}/utf8b.ludic | tr -d ' \t\n' | shasum | cut -d' ' -f1`) check("unrecognised characters pass through whole", ub, ua) # --- comments survive, the whole reason this is not `ludicc --fmt` ---------- write_file(`{tmp_dir()}/c.ludic`, "program C {\n # keep me\n property P { x: int = 0 } # and me\n}\n") let ncmt = capture_line(`{fmt} {tmp_dir()}/c.ludic | grep -c '#'`) check("comments preserved", ncmt, "2") # --- messy input is normalised --------------------------------------------- write_file(`{tmp_dir()}/m.ludic`, "program M{\nproperty P{x:int=0}\nhandler S phase Update{let a=1+2\nif a== -1 {a=-a}}\n}\n") shell(`{fmt} {tmp_dir()}/m.ludic > {tmp_dir()}/m.out 2>/dev/null`) if shq(`grep -q '^ property P {{ x: int = 0 }}$' {tmp_dir()}/m.out`) { ok("reindents and respaces") } else { bad("reindents and respaces") } if shq(`grep -q 'a = -a' {tmp_dir()}/m.out`) { ok("unary minus not spaced") } else { bad("unary minus not spaced") } # --- language conventions are respected, not overwritten ------------------- write_file(`{tmp_dir()}/u.ludic`, "program U {\n ui M {\n panel id: Root w: 10 {\n button id: Go text: \"go\"\n }\n }\n}\n") if shq(`{fmt} {tmp_dir()}/u.ludic | grep -q 'panel id: Root w: 10 {{'`) { ok("widget props keep their `key: value` spacing") } else { bad("widget props keep their `key: value` spacing") } write_file(`{tmp_dir()}/q.ludic`, "program Q {\n handler S phase Update {\n for (p) in query [Pos, {Foe}] { }\n }\n property Pos { x: int = 0 }\n property Foe { }\n}\n") if shq(`{fmt} {tmp_dir()}/q.ludic | grep -q '\\[Pos, {{Foe}}\\]'`) { ok("query tags stay tight ({Foe})") } else { bad("query tags stay tight") } write_file(`{tmp_dir()}/al.ludic`, "program A {\n const R_A: int = 0 # one\n const R_BBB: int = 1 # two\n}\n") if shq(`{fmt} {tmp_dir()}/al.ludic | grep -q 'R_A: int = 0 # one'`) { ok("hand alignment preserved") } else { bad("hand alignment preserved") } write_file(`{tmp_dir()}/gen.ludic`, "program G {\n function f(p: Pool, m: Map) -> Pool> {\n let ok = a < b and c > d\n return null\n }\n}\n") if shq(`{fmt} {tmp_dir()}/gen.ludic | cmp -s - {tmp_dir()}/gen.ludic`) { ok("type arguments stay together, comparisons keep their spaces (L5)") } else { bad("type arguments stay together (L5)") } # --- the style rules a project states (L10) -------------------------------- let lp = `{tmp_dir()}/lintproj` let lfmt = `{capture_line("pwd")}/{fmt}` # the tests below run from inside the project shell(`rm -rf {lp} && mkdir -p {lp}/src`) write_file(`{lp}/package.ludic`, "package \"t\"\nlint one_statement\nlint max_file_lines 20\nlint max_function_lines 5\nlint max_comment_lines 2\nlint max_header_lines 3\nlint paths \"src\"\nlint baseline \"lint-baseline.txt\"\n") write_file(`{lp}/src/a.ludic`, "# a.ludic - a file\nfunction f() -> int {\n # one\n # two\n # three\n let a = 1; return a\n}\n") if not shq(`cd {lp} && {lfmt} --lint > out.txt 2>&1`) and shq(`grep -q 'src/a.ludic:3: max_comment_lines' {lp}/out.txt`) and shq(`grep -q 'src/a.ludic:6: one_statement' {lp}/out.txt`) { ok("--lint fails a comment over the limit and two statements on a line, at their lines") } else { bad("--lint fails a comment over the limit and two statements on a line") } if shq(`cd {lp} && {lfmt} --init-baseline > /dev/null && {lfmt} --lint > /dev/null`) { ok("--init-baseline allows what is there") } else { bad("--init-baseline allows what is there") } write_file(`{lp}/src/b.ludic`, "function g() -> int {\n let b = 2; return b\n}\n") if not shq(`cd {lp} && {lfmt} --lint > /dev/null 2>&1`) { ok("a new violation fails, whatever the baseline allows elsewhere") } else { bad("a new violation fails") } write_file(`{lp}/src/b.ludic`, "function g() -> int { return 2 }\n") write_file(`{lp}/src/a.ludic`, "# a.ludic - a file\nfunction f() -> int {\n let a = 1; return a\n}\n") if shq(`cd {lp} && {lfmt} --lint > /dev/null && ! grep -q max_comment_lines lint-baseline.txt`) { ok("fixing one lowers the baseline, and it stays lowered") } else { bad("fixing one lowers the baseline") } write_file(`{lp}/src/c.ludic`, "function h() -> string { return \"a; b\" }\n") if shq(`cd {lp} && {lfmt} --lint > /dev/null`) { ok("a ; inside a string is not a statement") } else { bad("a ; inside a string is not a statement") } # --- for editors (R9): --lint --json, and a buffer on stdin ----------------- write_file(`{lp}/src/d.ludic`, "function d() -> int {\n let a = 1; let b = \"q\\\"\"; return a\n}\n") let lj = `{lp}/lint.json` if not shq(`cd {lp} && {lfmt} --lint --json > {lj} 2> {lp}/lint.err`) and shq(`python3 -m json.tool {lj} > /dev/null`) and shq(`grep -q '"file": "src/d.ludic", "line": 2, "col": 15, "rule": "one_statement"' {lj}`) and not shq(`grep -q 'lint:' {lj}`) { ok("--lint --json: one JSON array on stdout, with the column, the summary on stderr") } else { bad2("--lint --json", capture_line(`cat {lj}`)) } shell(`cd {lp} && {lfmt} --init-baseline > /dev/null`) write_file(`{lp}/src/d.ludic`, "function d() -> int { return 1 }\n") let pinned = capture_line(`cat {lp}/lint-baseline.txt`) shell(`cd {lp} && {lfmt} --lint --json > /dev/null 2>&1`) check("--lint --json never lowers the baseline (plain --lint does)", capture_line(`cat {lp}/lint-baseline.txt`), pinned) write_file(`{lp}/buf.txt`, "function g() -> int {\n let b = 2; return b\n}\n") let sj = capture(`cd {lp} && {lfmt} - --lint --json --stdin-name src/new.ludic < buf.txt`) if s_contains(sj, `"file": "src/new.ludic", "line": 2`) { ok("- --lint --json: a buffer judged as the file it stands for") } else { bad2("- --lint --json", sj) } check("a buffer outside the lint paths breaks no rule", s_trim(capture(`cd {lp} && {lfmt} - --lint --json --stdin-name notes/new.ludic < buf.txt 2>/dev/null`)), "[]") check("- formats stdin to stdout", capture_line(`printf 'function f() -> int{{return 1}}\n' | {fmt} -`), "function f() -> int { return 1 }") let bad_rc = sh(`printf 'function f() -> int {{\n let s = (1]\n}}\n' | {fmt} - --stdin-name x.ludic > {tmp_dir()}/bad.out 2> {tmp_dir()}/bad.err`) if bad_rc == 2 and shq(`grep -q '^x.ludic:2:13: error:' {tmp_dir()}/bad.err`) and shq(`test ! -s {tmp_dir()}/bad.out`) { ok("- refuses a buffer with a bracket closed by the wrong one: exit 2, where on stderr, nothing on stdout") } else { bad2("- refuses an unreadable buffer", capture_line(`cat {tmp_dir()}/bad.err`)) } check("- refuses an unclosed string", string(sh(`printf 'let s = "abc\n' | {fmt} - > /dev/null 2>&1`)), "2") # --- --check exit status, for CI and pre-commit hooks ---------------------- let chk1 = sh(`{fmt} --check -q {tmp_dir()}/m.ludic`) check("--check exits 1 on unformatted input", string(chk1), "1") shell(`{fmt} -w {tmp_dir()}/m.ludic`) let chk2 = sh(`{fmt} --check -q {tmp_dir()}/m.ludic`) check("--check exits 0 after -w", string(chk2), "0") # --- markdown: fences formatted, prose untouched --------------------------- write_file(`{tmp_dir()}/d.md`, "# T\n\nprose with spaces\n\n```ludic\nprogram D{property P{x:int=0}}\n```\n\n```python\nz = 1\n```\n") shell(`{fmt} {tmp_dir()}/d.md > {tmp_dir()}/d.out 2>/dev/null`) if shq(`grep -q 'prose with spaces' {tmp_dir()}/d.out`) { ok("markdown prose untouched") } else { bad("markdown prose untouched") } if shq(`grep -q 'z = 1' {tmp_dir()}/d.out`) { ok("non-ludic fences untouched") } else { bad("non-ludic fences untouched") } if shq(`grep -q 'program D {{ property P {{ x: int = 0 }} }}' {tmp_dir()}/d.out`) { ok("ludic fences formatted") } else { bad("ludic fences formatted") } print("language server") # The LSP indexes the workspace by walking directories, which reads the BSD # dirent layout; off Darwin that scan comes up empty and the cross-file # features (go-to-def, find-usages, workspace symbols) can't resolve. Gate the # end-to-end protocol test on Darwin until the directory walk is portable. if is_darwin() { let lrc = sh("bin/ludic-dev test-lsp") if lrc == 0 { ok("language server protocol") } else if lrc == 2 { skip("language server protocol (bin/ludic-lsp not built)") } else { bad("language server protocol") } } else { skip("language server protocol (workspace scan uses the macOS/BSD dirent layout)") } print("") print("editor assets") test_json("tools/editors/shared/ludic.tmLanguage.json") test_json("tools/editors/shared/ludic.markdown-injection.json") test_json("tools/editors/shared/language-configuration.json") test_json("tools/editors/vscode/package.json") test_json("tools/editors/vscode/snippets/ludic.json") test_xml("tools/editors/jetbrains/src/main/resources/META-INF/plugin.xml") test_xml("tools/editors/jetbrains/src/main/resources/META-INF/ludic-markdown.xml") if shq("command -v node >/dev/null 2>&1") { if shq("node --check tools/editors/vscode/src/extension.js 2>/dev/null") { ok("extension.js parses") } else { bad("extension.js parses") } print("textmate grammar") # exit 2 = the TextMate engine is not installed: a skip, never a silent pass let grc = sh("node tools/test-grammar.js") if grc == 0 { ok("textmate grammar") } else if grc == 2 { skip("textmate grammar (npm i vscode-textmate vscode-oniguruma, or set LUDIC_NODE_MODULES)") } else { bad("textmate grammar") } } else { print(" skip extension.js and grammar tests (node not installed)") } # VS Code carries its own copy of the shared grammar; catch an edit to the copy if shq("cmp -s tools/editors/shared/ludic.tmLanguage.json tools/editors/vscode/syntaxes/ludic.tmLanguage.json") and shq("cmp -s tools/editors/shared/ludic.markdown-injection.json tools/editors/vscode/syntaxes/ludic.markdown-injection.json") and shq("cmp -s tools/editors/shared/language-configuration.json tools/editors/vscode/language-configuration.json") { ok("vscode grammar copies match tools/editors/shared") } else { bad2("vscode grammar copies have drifted", "run: ludic-dev tools to resync") } # the JetBrains plugin is off by default (its first build pulls a gigabyte of # IntelliJ SDK). One env var away: LUDIC_TEST_JETBRAINS=1 ludic-dev test-tools if (getenv_or("LUDIC_TEST_JETBRAINS", "0") == "1") { if shq("test -x tools/editors/jetbrains/gradlew") { if shq("cd tools/editors/jetbrains && ./gradlew buildPlugin verifyPluginStructure --console=plain -q >/dev/null 2>&1 && test -f build/distributions/Ludic-1.6.0.zip") { ok("jetbrains plugin builds and verifies") } else { bad("jetbrains plugin build") } if shq("cd tools/editors/jetbrains && ./gradlew test --console=plain -q >/dev/null 2>&1") { ok("jetbrains platform tests (lexer, file type, PSI, folding)") } else { bad("jetbrains platform tests") } } else { print(" skip jetbrains plugin (no gradle wrapper)") } } else { print(" skip jetbrains plugin build (set LUDIC_TEST_JETBRAINS=1)") } # the vocabulary lives in one place; drift between it and its copies (the # TextMate grammar, the Kotlin lexer) is the failure mode this layout prevents. # The check is written in Ludic (tools/ludic-cli/checks.ludic) and runs through x — no # Python in the loop. if shq("bin/ludic-dev check-vocabulary 2>/dev/null") { ok("vocabulary in sync across grammar/lexer/header") } else { bad("vocabulary drifted") } # every feature the compiler actually implements — namespace methods, keywords, # types, phases — must have a docs/language page. This reads the implementation # (emit_ns_call + ludic_syntax.h), so shipping a feature without docs fails here. # Ported to Ludic; runs through x. if shq("bin/ludic-dev check-impl > /dev/null 2>&1") { ok("docs cover every implemented feature") } else { bad("docs drifted from the implementation") } # every ```ludic fence in the docs must parse (or be marked skip/expect-error), # so a documented snippet can't drift from what the compiler accepts. if shq("bin/ludic-dev check-docs > /dev/null 2>&1") { ok("every doc fence parses") } else { bad2("a doc fence drifted", "run: ludic-dev check-docs") } return report() } # JSON/XML validity, checked by the Ludic validators in checks.ludic (no Python). function test_json(path: pointer) -> void { let bn = capture_line(`basename {path}`) if shq(`bin/ludic-dev lint-asset {path}`) { ok(`valid JSON: {bn}`) } else { bad(`invalid JSON: {path}`) } } function test_xml(path: pointer) -> void { let bn = capture_line(`basename {path}`) if shq(`bin/ludic-dev lint-asset {path}`) { ok(`valid XML: {bn}`) } else { bad(`invalid XML: {path}`) } }