fluffos/tools/lpc-syntax/format.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

197 lines
7.9 KiB
JavaScript

// Basic LPC formatter over the grammar-driven tokenizer: brace-depth
// reindentation, single-space operator normalization, directives at
// column 0, strings/comments/text blocks verbatim. Deterministic and
// idempotent (format(format(x)) === format(x) -- pinned by test.mjs).
import { tokenize } from './tokenizer.mjs';
const INDENT = ' ';
export function formatLPC(source) {
const toks = tokenize(source).filter((t) => t.kind !== 'whitespace');
const lines = [];
let cur = [];
let depth = 0;
let pendingDedent = 0;
// Running paren nesting across the whole pass (not just the current
// `cur` line buffer) -- a forced mid-statement flush (e.g. after a
// heredoc terminator, or a multiline-literal element break) can leave
// `cur` holding an unmatched ')' with no '(' of its own, which would
// make a per-cur-buffer depth count go negative and never reach 0.
let parenLevel = 0;
// '({' ... '})' is an array literal and '([' ... '])' a mapping literal,
// not blocks or indexing -- one combined LIFO stack (nesting order can
// interleave '{' and '[', e.g. `([ "a": ({ ... }) ])`) tags each open
// bracket by whether it's immediately preceded by '(': array/mapping if
// so, block/index (unchanged behavior) otherwise. A literal starts
// single-line (no depth change, no forced break, same as before); the
// first time an element is seen starting on a later source line than
// the previous token, it flips to 'multiline' -- from then on it gets
// one real indent level (via `depth`) like a block, so nested blocks
// inside it (e.g. a closure literal) indent correctly on top of it.
const litStack = [];
const flush = (extraDedent = 0) => {
if (cur.length === 0) return;
const d = Math.max(0, depth - extraDedent);
lines.push(INDENT.repeat(d) + renderLine(cur));
cur = [];
};
// Blank-line preservation: track source line gaps.
let lastLine = 0;
for (let idx = 0; idx < toks.length; idx++) {
const t = toks[idx];
if (t.line > 0 && lastLine > 0 && t.line - lastLine > 1 && cur.length === 0
&& lines.length > 0 && lines[lines.length - 1] !== '') {
lines.push('');
}
if (t.line > 0) lastLine = t.line + (t.text.match(/\n/g) || []).length;
if (t.kind === 'directive') {
flush();
lines.push(t.text.replace(/[ \t]+$/g, ''));
continue;
}
if (t.kind === 'comment') {
// standalone comment gets its own line; trailing comment joins.
// A '//' comment runs to end of physical line, so nothing may
// follow it on the same rendered line -- force a flush, or a
// second format pass would swallow the next tokens into it.
if (cur.length === 0) {
lines.push(INDENT.repeat(depth) + t.text);
} else {
cur.push(t);
if (t.text.startsWith('//')) flush();
}
continue;
}
if (t.kind === 'textblock') {
// A heredoc's terminator word ends the token mid-line (e.g. at
// "ENDHELP" in "ENDHELP);" -- the driver rescans anything after it
// on that line as ordinary code, see parseHeredoc() in
// lexer_utils.cc). Force a break so that trailing code renders on
// its own line, matching the documented @/@@ style, instead of
// gluing it onto the terminator (e.g. "ENDHELP, 1);").
cur.push(t);
flush();
continue;
}
if (t.text === '}' || t.text === ']') {
const top = litStack.pop();
if (top && (top.kind === 'array' || top.kind === 'mapping')) {
if (top.multiline) {
if (cur.length > 0) flush();
depth = Math.max(0, depth - 1);
}
cur.push(t);
continue;
}
if (top && top.kind === 'index') {
cur.push(t);
continue;
}
// block close ('}' -- '[' never opens a block)
flush();
depth = Math.max(0, depth - 1);
cur.push(t);
// '}' followed by else/while(do)/';'/',' stays open on the line
const nxt = toks[idx + 1];
if (!nxt || !(nxt.kind === 'keyword' && (nxt.text === 'else' || nxt.text === 'while'))
&& !(nxt && nxt.kind === 'punctuation' && (nxt.text === ';' || nxt.text === ','))) {
flush(0);
}
continue;
}
// Inside a literal that's already gone multiline (or is starting to),
// a token beginning on a later source line than the previous one
// preserves that break -- one element per line, matching how the
// source laid out a large mapping/array table instead of smashing it
// onto a single line.
if (litStack.length) {
const top = litStack[litStack.length - 1];
if ((top.kind === 'array' || top.kind === 'mapping') && cur.length > 0 &&
t.line > 0 && cur[cur.length - 1].line > 0 && t.line > cur[cur.length - 1].line) {
flush();
if (!top.multiline) { top.multiline = true; depth++; }
}
}
cur.push(t);
if (t.text === '(') parenLevel++;
else if (t.text === ')') parenLevel = Math.max(0, parenLevel - 1);
if (t.text === '{' || t.text === '[') {
const prev = prevNonComment(toks, idx);
const isLiteral = !!prev && prev.text === '(';
if (t.text === '{') {
litStack.push({ kind: isLiteral ? 'array' : 'block', multiline: false });
if (isLiteral) continue;
flush(); // the '{' line itself sits at the CURRENT depth --
// depth only increments for what comes after it
depth++;
continue;
}
litStack.push({ kind: isLiteral ? 'mapping' : 'index', multiline: false });
continue;
}
if (t.text === ';' && parenLevel === 0) {
flush();
continue;
}
if (t.text === ':' && cur.length >= 2 &&
(cur[0].text === 'case' || cur[0].text === 'default')) {
flush(0);
continue;
}
}
flush();
while (lines.length && lines[lines.length - 1] === '') lines.pop();
// A line entry can itself embed raw newlines (an unterminated string/
// comment/text block token swallows to EOF, trailing newline included)
// -- don't add a second one, or each re-format grows another blank line.
const joined = lines.join('\n');
return joined.endsWith('\n') ? joined : joined + '\n';
}
function prevNonComment(toks, idx) {
for (let k = idx - 1; k >= 0; k--) {
if (toks[k].kind !== 'comment') return toks[k];
}
return null;
}
// '[' opens indexing (`a[0]`) or, right after '(', a mapping literal
// (`([...])`) -- neither ever wants a space before it. '..' is the range
// operator (`a[0..2]`) and stays tight on both sides, unlike '...'
// varargs/spread which keeps normal spacing (`f(int a, ...)`).
const NO_SPACE_BEFORE = new Set([';', ',', ')', ']', '}', '.', '::', '->', '?.', '.?', '++', '--', ':)', '[', '..']);
const NO_SPACE_AFTER = new Set(['(', '[', '{', '.', '::', '->', '?.', '.?', '!', '~', '(:', '..']);
function renderLine(toks) {
let out = '';
for (let i = 0; i < toks.length; i++) {
const t = toks[i];
const prev = toks[i - 1];
let sep = ' ';
if (i === 0) sep = '';
else if (NO_SPACE_BEFORE.has(t.text)) sep = '';
// `case X:` / `default:` -- no space before the label colon (near-
// universal house style; unlike the ternary and mapping-literal ':',
// which do get a leading space).
else if (t.text === ':' && (toks[0].text === 'case' || toks[0].text === 'default')) sep = '';
// `..<` -- the exclusive range-bound marker (`a[..<j]`) -- stays tight
// on its far side too; bare '<' (less-than) elsewhere keeps its space.
else if (prev && prev.text === '<' && toks[i - 2] && toks[i - 2].text === '..') sep = '';
else if (prev && NO_SPACE_AFTER.has(prev.text)) sep = '';
else if (prev && (prev.kind === 'identifier' || prev.kind === 'type' || prev.kind === 'keyword')
&& t.text === '(') sep = prev.kind === 'keyword' && prev.text !== 'catch' ? ' ' : '';
else if (prev && prev.kind === 'operator' && prev.text === '++') sep = '';
else if (prev && prev.kind === 'operator' && prev.text === '--') sep = '';
out += sep + t.text;
}
return out;
}