mirror of
https://github.com/fluffos/fluffos
synced 2026-08-12 18:26:06 -04:00
* 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>
255 lines
15 KiB
JavaScript
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">"hi"</span>'));
|
|
check('highlight comment span', html.includes('<span class="lpc-comment">// done</span>'));
|
|
check('html escaped', highlightLPC('if (a < b) x = "<&>";').includes('<&>'));
|
|
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);
|