#!/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 'program 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 'program 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 'program C {\n # keep me\n property 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 'program M{\nproperty P{x:int=0}\nhandler 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 '^ property 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 'program 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 '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' > "$TMP/q.ludic" "$FMT" "$TMP/q.ludic" | grep -q '\[Pos, {Foe}\]' && ok "query tags stay tight ({Foe})" || bad "query tags stay tight" printf 'program 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\nprogram D{property 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 'program D { property 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 ]