fluffos/tools/lpc-syntax/test.mjs
Yucong Sun 6b6f169952
lpc-syntax: wire formatter into vscode extension, fix tokenizer/formatter bugs (#1259)
* lpc-syntax: wire formatter into vscode extension, fix tokenizer/formatter bugs

- Register a DocumentFormattingEditProvider (Format Document / format-on-save)
  backed by format.mjs, gated by a new lpc.format.enabled setting; never lets
  a formatter error corrupt or block a save.
- Regenerate the grammar contract (grammar.y already had `ref` = '&' sugar
  that lpc-grammar.json/grammar.ebnf hadn't picked up) and make operator-list
  generation deterministic (secondary alphabetical sort key instead of
  relying on Python's randomized string-hash set ordering).
- tokenizer.mjs: fix template-interpolation brace scanning to skip nested
  strings/chars/comments/templates as opaque spans (a stray '}' inside e.g.
  `${ ch == '}' }` previously ended the interpolation early); fix char
  literals with variable-length \xHH/\NNN escapes being truncated.
- format.mjs: track array/mapping literal braces `({ ... })` separately from
  block braces so they don't affect indentation depth; force a flush after a
  trailing `//` comment so a second format pass can't swallow following code
  into it; fix an off-by-one that mis-indented every nested block; stop
  accumulating a blank line on re-format of a source that swallows to EOF.
- language-configuration.json: add onEnterRules for /** */ doc-comment
  continuation.
- Extend test.mjs with regression coverage for all of the above (59 checks).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUhzkBiuWxX2M9RX94BckT

* lpc-syntax: fix heredoc, mapping-literal, case-colon, and indexing spacing in formatter

Verified tokenizer/highlighter already model heredoc (@/@@ text blocks)
correctly per parseHeredoc() in lexer_utils.cc. Found and fixed four real
formatter bugs, all in format.mjs:

- Mapping literals `([ ... ])` never got the array-literal treatment
  ({ ... }) got last session -- only '{'/'}' was tracked, not '['/']'.
  Generalized the brace-tracking into one combined stack covering both,
  distinguishing array/mapping literals from blocks/indexing by whether
  the bracket is immediately preceded by '('.
- Both array and mapping literals collapsed onto a single line even when
  the source spread them across many lines, which mangles real mudlib
  data tables. Multi-line literals now preserve their line breaks and
  indent one level, while short single-line literals still collapse as
  before.
- `case`/`default` labels rendered as "case 1 :" (space before the
  colon) -- checked against testsuite convention (843:6 no-space vs
  space) and fixed; ternary/mapping colons are unaffected.
- `a[0]`/`b[1..2]` rendered as "a [0]" / "b [1 .. 2]" (space before '['
  and around the range operator) -- checked against testsuite
  convention (1603:15, 197:7) and fixed; varargs '...' spacing is
  unaffected.

Fixing the heredoc terminator to force a line break (matching the
documented @/@@ style, since the driver rescans trailing code after the
terminator on its own) exposed a latent bug: the ';'-triggered flush
computed paren depth over just the current line buffer, which goes
negative (never reaches the expected 0) once a forced mid-statement
flush leaves an unmatched ')' behind. Replaced it with a running
paren-nesting counter across the whole pass.

Re-verified via an independent 723-file sweep of testsuite/ (tokenizer
lossless reconstruction, formatter idempotency + literal-content
preservation, highlighter lossless reconstruction, lint false-positive
check on real files): all clean except one confirmed non-issue
(intentional trailing-whitespace trim on a directive line). Added 6
regression tests (65 total) and regenerated the vscode/lib/format.mjs copy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUhzkBiuWxX2M9RX94BckT

* lpc-syntax: fix highlighting gaps found by auditing against grammar.y and docs/lpc

Cross-referenced the highlighting pipeline (tokenizer.mjs kind classification,
highlight.mjs, generate_ebnf.py's TextMate grammar generation) against
lexer_utils.cc's reswords[] table and every page under docs/lpc/.

Verified already correct, no change: `inherited` is genuinely not a keyword
(any identifier before `::` is treated uniformly, matching docs/lpc/constructs/
inherit.md's own examples); the full type/modifier keyword lists match
reswords[] exactly; range/spread/optional-chaining/nullish operators already
have distinct scopes; `array` staying highlighted as a keyword despite
ARRAY_RESERVED_WORD being #undef'd by default is a pre-existing, low-impact
gap not worth a schema change to plumb through.

Real gaps fixed, all in generate_ebnf.py/highlight.mjs (never hand-edit the
generated lpc-grammar.json/lpc.tmLanguage.json themselves):

- "struct" was an undocumented reserved word (lexer_utils.cc maps both
  "class" and "struct" to L_CLASS, both gated on unconditionally-defined
  macros) but TOKEN_SPEC only listed "class" -- struct declarations
  highlighted as a plain identifier. Added the second spelling.
- class/struct are type-introducing keywords, not control flow -- split them
  out of keyword.control.lpc into their own storage.type.class.lpc scope,
  matching how other C-family TextMate grammars color struct/class.
- The function-call heuristic (identifier immediately before '(') had no
  guard against matching a reserved word, relying only on TextMate's
  same-position rule-order tie-break. Added an explicit negative lookahead
  over the full keyword/type/modifier set so `if (`/`new (`/etc. can never
  be misscoped as entity.name.function.lpc.
- $1/$2 closure params had no visual distinction in the HTML highlighter
  (they intentionally still tokenize as plain 'identifier', since format.mjs
  keys spacing off that kind) -- fixed at the highlight.mjs layer with a
  dedicated lpc-param class, matching the TextMate grammar's existing
  dollar-params rule.
- Illegal/unknown characters rendered with no visual flag in the HTML
  highlighter -- added an lpc-unknown class so invalid syntax is visible.

Re-verified via an independent 723-file sweep of testsuite/ (highlighter
lossless reconstruction: 0 crashes, 0 mismatches) and the full test suite
(70 checks, all passing). tokenizer.mjs, lint.mjs, and format.mjs are
untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XUhzkBiuWxX2M9RX94BckT

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-12 15:42:14 -04:00

255 lines
15 KiB
JavaScript

// Dependency-free test suite: node tools/lpc-syntax/test.mjs
import { tokenize, grammar } from './tokenizer.mjs';
import { highlightLPC } from './highlight.mjs';
import { formatLPC } from './format.mjs';
import { lintLPC } from './lint.mjs';
import { readFileSync as readF } from 'node:fs';
import { fileURLToPath as f2p } from 'node:url';
import { dirname as dirN, join as joinP } from 'node:path';
let failures = 0;
const check = (name, cond, detail = '') => {
if (cond) console.log(` OK ${name}`);
else { console.error(`FAIL ${name}${detail ? ' -- ' + detail : ''}`); failures++; }
};
const kinds = (src) => tokenize(src).filter((t) => t.kind !== 'whitespace')
.map((t) => `${t.kind}:${t.text}`);
// --- grammar contract sanity ------------------------------------------------
check('grammar has productions', grammar.productions.length > 200);
check('grammar keywords include control flow',
['if', 'foreach', 'inherit', 'catch'].every((k) => grammar.keywords.includes(k)));
check('operators longest-match ordered',
grammar.operators.indexOf('<<=') < grammar.operators.indexOf('<<'));
check('grammar keywords include "struct" (an alternate spelling of L_CLASS,'
+ ' both STRUCT_CLASS and STRUCT_STRUCT are on by default)',
grammar.keywords.includes('struct') && grammar.keywords.includes('class'));
// --- tokenizer ---------------------------------------------------------------
check('keywords vs identifiers',
kinds('if (foo) return bar;').join(',') ===
'keyword:if,punctuation:(,identifier:foo,punctuation:),keyword:return,identifier:bar,punctuation:;');
check('types and modifiers',
kinds('private int x;').join(',') ===
'modifier:private,type:int,identifier:x,punctuation:;');
check('numbers: hex/bin/underscore/real',
kinds('0xFF 0b10_1 1_000 3.14').join(',') ===
'number:0xFF,number:0b10_1,number:1_000,number:3.14');
check('numbers: trailing dot, exponent forms, .. stays a range',
kinds('1. 2.5e2 1e3 2E-5 1..5').join(',') ===
'number:1.,number:2.5e2,number:1e3,number:2E-5,number:1,operator:..,number:5');
check('numbers: bare "1e" is not an exponent',
kinds('1e').join(',') === 'number:1,identifier:e');
check('string with escapes stays one token',
kinds('"a\\"b\\n"').join(',') === 'string:"a\\"b\\n"');
check('adjacent strings are two tokens',
kinds('"a" "b"').length === 2);
check('char literal', kinds("'\\n'")[0].startsWith('char:'));
check('directive captured whole (with continuation)',
kinds('#define F(x) \\\n ((x)+1)\nint y;')[0].startsWith('directive:#define F(x)'));
check('directive only at line start',
kinds('int a; // #define X\nint b;').some((k) => k.startsWith('comment:')) &&
!kinds('int a; // no\nint b;').some((k) => k.startsWith('directive:')));
check('template fragments + interpolated expression tokens',
(() => {
const k = kinds('`v=${1 + x}!`');
return k[0] === 'template:`v=${' && k.includes('number:1') &&
k.includes('identifier:x') && k[k.length - 1] === 'template:}!`';
})());
check('functional open/close', kinds('(: foo :)').join(',') ===
'functional:(:,identifier:foo,functional::)');
check('optional chaining ops', kinds('m?.k m.?[0]').some((k) => k === 'operator:?.') &&
kinds('m?.k m.?[0]').some((k) => k === 'operator:.?'));
check('text block single', kinds('@END\nline one\nEND\n')[0].startsWith('textblock:@END'));
check('text block array', kinds('@@T\nx\nT\n')[0].startsWith('textblock:@@T'));
check('range vs ellipsis vs dot',
kinds('a[1..2] f(...) x.y').join(',').includes('operator:..') &&
kinds('f(...)').includes('operator:...'));
check('char literal: multi-digit hex/octal escapes are not truncated',
kinds("'\\x41' '\\101' '\\n'").join(',') ===
"char:'\\x41',char:'\\101',char:'\\n'");
check('template interpolation: brace inside a nested string/char/comment does not end it early',
(() => {
const k1 = kinds('`x=${ s == "}" }`');
const k2 = kinds("`c=${ ch == '}' }`");
const k3 = kinds('`r=${/* } */ x}`');
return k1[k1.length - 1] === 'template:}`' && k1.includes('string:"}"') &&
k2[k2.length - 1] === 'template:}`' && k2.includes("char:'}'") &&
k3[k3.length - 1] === 'template:}`' && k3.includes('comment:/* } */');
})());
// --- highlighter --------------------------------------------------------------
const html = highlightLPC('int f() { return "hi"; } // done');
check('highlight keyword span', html.includes('<span class="lpc-keyword">return</span>'));
check('highlight string span', html.includes('<span class="lpc-string">&quot;hi&quot;</span>'));
check('highlight comment span', html.includes('<span class="lpc-comment">// done</span>'));
check('html escaped', highlightLPC('if (a < b) x = "<&>";').includes('&lt;&amp;&gt;'));
check('highlight $N closure params get their own class, not lpc-identifier',
highlightLPC('(: $1 + $2 :)').includes('<span class="lpc-param">$1</span>') &&
highlightLPC('(: $1 + $2 :)').includes('<span class="lpc-param">$2</span>'));
check('highlight illegal character is flagged, not silently dropped',
highlightLPC('int x = 1 \u0001;').includes('<span class="lpc-unknown">'));
// --- formatter ----------------------------------------------------------------
const ugly = 'int f( int x ){if(x>0){return x;}else{return -x;}}';
const pretty = formatLPC(ugly);
check('formatter indents braces', pretty.includes('\n if (x > 0) {'));
check('formatter newline per statement', pretty.split('\n').filter(Boolean).length >= 5);
check('formatter idempotent', formatLPC(pretty) === pretty,
JSON.stringify({ once: pretty, twice: formatLPC(pretty) }));
check('nested blocks (if/else) indent one level per depth, not off-by-one',
pretty === 'int f(int x) {\n if (x > 0) {\n return x;\n } else {\n return - x;\n }\n}\n',
pretty);
check('switch/case body indents under the switch, not at column 0',
(() => {
const out = formatLPC('int f(int x) { switch (x) { case 1: return 1; default: return 0; } }\n');
return out.includes('\n switch (x) {') &&
out.split('\n').every((l) => l === '' || l === 'int f(int x) {' || l === '}' || l.startsWith(' '));
})());
const withDirective = formatLPC('#define X 1\n int f(){return X;}');
check('directives at column 0', withDirective.startsWith('#define X 1\n'));
const withComment = formatLPC('// header\nint g(){return 1;}');
check('standalone comment kept', withComment.startsWith('// header\n'));
check('strings verbatim through formatter',
formatLPC('string s="a b";').includes('"a b"'));
check('array literal braces stay inline and do not affect indent depth',
(() => {
const out = formatLPC('void f() { return ({ 1, 2, 3 }); }\n');
return out.includes('return ({1, 2, 3});') && formatLPC(out) === out;
})());
check('nested array literal in a call is idempotent',
(() => {
const src = 'mixed f() { ASSERT(catch(allocate(5, function(int i) { if (i == 2) error("boom"); return i; }))); }\n';
const once = formatLPC(src);
return formatLPC(once) === once;
})());
check('trailing "//" comment forces a line break before following code',
(() => {
const src = 'void f() { if (a) //note\n return b; else c(); }\n';
const once = formatLPC(src);
const twice = formatLPC(once);
return once === twice && !once.split('\n').some((l) => /\/\/note.+\S/.test(l));
})());
check('formatter is stable on a source that swallows to EOF (unterminated construct)',
(() => {
const once = formatLPC('void f() {\n "\n}\n');
return formatLPC(once) === once;
})());
check('case/default label colon has no leading space (unlike ternary/mapping colons)',
formatLPC('int f(int x) { switch (x) { case 1: return 1; default: return 0; } }\n')
.includes('case 1:') &&
formatLPC('int f(int x) { switch (x) { case 1: return 1; default: return 0; } }\n')
.includes('default:'));
check('heredoc (@/@@) terminator breaks trailing code onto its own line',
(() => {
const src1 = 'int help() {\n write( @ENDHELP\nhelp text\nENDHELP\n );\n return 1;\n}\n';
const once1 = formatLPC(src1);
const src2 = 'int help() {\n this_player()->more( @@ENDHELP\nhelp text\nENDHELP\n , 1);\n return 1;\n}\n';
const once2 = formatLPC(src2);
return once1 === formatLPC(once1) && once2 === formatLPC(once2) &&
once1.includes('ENDHELP\n );\n') && once2.includes('ENDHELP\n , 1);\n');
})());
check('multiline array/mapping literals preserve their line breaks instead of collapsing',
(() => {
const arr = formatLPC('mixed x = ({\n 1,\n 2,\n});\n');
const map = formatLPC('mapping x = ([\n "a": 1,\n "b": 2,\n]);\n');
return arr === 'mixed x = ({\n 1,\n 2,\n});\n' && formatLPC(arr) === arr &&
map === 'mapping x = ([\n "a" : 1,\n "b" : 2,\n]);\n' && formatLPC(map) === map;
})());
check('single-line array/mapping literals still collapse onto one line',
(() => {
const out = formatLPC('mixed x = ({ 1, 2, 3 }); mapping m = ([ "a":1, "b":2 ]);\n');
return out === 'mixed x = ({1, 2, 3});\nmapping m = (["a" : 1, "b" : 2]);\n';
})());
check('indexing and ranges stay tight; varargs/spread keep normal spacing',
(() => {
const out = formatLPC('int f() { return a[0] + b[1..2] + c()[0] + e[..<4]; }\n');
const va = formatLPC('mixed f(int a, ...) { return g(1, 2, ...); }\n');
return out.includes('a[0]') && out.includes('b[1..2]') && out.includes('c()[0]') &&
out.includes('e[..<4]') && va.includes('int a, ...') && va.includes('g(1, 2, ...)');
})());
check('indexing/mapping literals nested inside a multiline array literal do not corrupt bracket tracking',
(() => {
const src = 'mixed x = ({\n a[0],\n b[1..2],\n ([ "k": 1 ]),\n});\n';
const once = formatLPC(src);
return formatLPC(once) === once && once.includes('a[0],') && once.includes('b[1..2],') &&
once.includes('(["k" : 1]),');
})());
// --- lint ---------------------------------------------------------------------
const msgs = (src) => lintLPC(src).map((d) => d.message);
check('lint: clean file is clean',
lintLPC('int f() { return ({ 1, 2 })[0]; } // ok\n').length === 0);
check('lint: functionals do not unbalance',
lintLPC('mixed f() { return (: $1 + $2 :); }\n').length === 0);
check('lint: illegal character',
msgs('int x = 1 @;\n').length === 0 /* @ starts textblock probe */ ||
msgs('int x = 1 \u0001;\n').some((m) => m.includes('Illegal character')));
check('lint: unterminated string',
msgs('string s = "abc;\n').some((m) => m.includes('Unterminated string')));
check('lint: escaped quote not closing',
msgs('string s = "a\\\\";\n').length === 0 &&
msgs('string s = "a\\";\n').some((m) => m.includes('Unterminated string')));
check('lint: unterminated block comment',
msgs('/* never ends\nint x;\n').some((m) => m.includes('Unterminated block comment')));
check('lint: unterminated template',
msgs('string s = `abc;\n').some((m) => m.includes('Unterminated template')));
check('lint: unterminated text block',
msgs('string s = @END\nbody line\n').some((m) => m.includes('Text block not terminated')));
check('lint: terminated text block is clean',
lintLPC('string s = @END\nbody\nEND\n').length === 0);
check('lint: unclosed brace',
msgs('int f() { if (1) { return 1; }\n').some((m) => m.includes("Unclosed '{'")));
check('lint: unmatched close',
msgs('int f() { return 1; } }\n').some((m) => m.includes("Unmatched '}'")));
check('lint: mismatched bracket kind',
msgs('int f() { return (1]; }\n').some((m) => m.includes("Mismatched ']'")));
check('lint: missing endif',
msgs('#ifdef FOO\nint x;\n').some((m) => m.includes('Missing #endif')));
check('lint: unexpected endif/else',
msgs('int x;\n#endif\n').some((m) => m.includes('Unexpected #endif')) &&
msgs('int x;\n#else\n').some((m) => m.includes('Unexpected #else')));
check('lint: balanced conditionals are clean',
lintLPC('#ifdef A\nint x;\n#elif B\nint y;\n#else\nint z;\n#endif\n').length === 0);
check('lint: positions are 1-based',
(() => { const d = lintLPC('string s = "abc;\n')[0];
return d.line === 1 && d.col === 12; })());
// --- generated VS Code assets ---------------------------------------------------
const here2 = dirN(f2p(import.meta.url));
const tml = JSON.parse(readF(joinP(here2, 'vscode/syntaxes/lpc.tmLanguage.json'), 'utf8'));
check('tmLanguage: scope + language wiring',
tml.scopeName === 'source.lpc' && Array.isArray(tml.patterns));
check('tmLanguage: keywords from grammar contract',
tml.repository.keywords.match.includes('foreach') &&
tml.repository.types.match.includes('mapping'));
check('tmLanguage: class/struct get their own storage.type.class scope, not keyword.control',
tml.repository['class-keyword'].name === 'storage.type.class.lpc' &&
/\bclass\b/.test(tml.repository['class-keyword'].match) &&
/\bstruct\b/.test(tml.repository['class-keyword'].match) &&
!/\bclass\b/.test(tml.repository.keywords.match) &&
!/\bstruct\b/.test(tml.repository.keywords.match));
check('tmLanguage: function-call excludes reserved words (no "if (" misfire as entity.name.function)',
(() => {
const re = new RegExp(tml.repository['function-call'].match);
return !re.test('if (') && !re.test('while (') && !re.test('new (') &&
!re.test('catch(') && re.test('foo (') && re.test('bar(');
})());
check('tmLanguage: operators longest-match ordered',
(() => { const parts = tml.repository.operators.match.split('|');
return parts.indexOf('>>=') < parts.indexOf('>>'); })());
check('vscode lib copies exist and are marked generated',
readF(joinP(here2, 'vscode/lib/lint.mjs'), 'utf8').startsWith('// GENERATED COPY') &&
readF(joinP(here2, 'vscode/lib/tokenizer.mjs'), 'utf8').startsWith('// GENERATED COPY') &&
readF(joinP(here2, 'vscode/lib/format.mjs'), 'utf8').startsWith('// GENERATED COPY'));
check('extension.js wires up the formatter',
(() => { const src = readF(joinP(here2, 'vscode/extension.js'), 'utf8');
return src.includes('registerDocumentFormattingEditProvider') &&
src.includes('formatLPC'); })());
const langConfig = JSON.parse(readF(joinP(here2, 'vscode/language-configuration.json'), 'utf8'));
check('language-configuration: brackets + doc-comment continuation wired',
langConfig.brackets.length === 3 &&
Array.isArray(langConfig.onEnterRules) && langConfig.onEnterRules.length > 0);
console.log(failures === 0 ? '\nAll lpc-syntax tests passed.' : `\n${failures} FAILURES`);
process.exit(failures === 0 ? 0 : 1);