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>
197 lines
7.9 KiB
JavaScript
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;
|
|
}
|