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>
301 lines
9.3 KiB
JavaScript
301 lines
9.3 KiB
JavaScript
// LPC tokenizer driven by lpc-grammar.json (generated from grammar.y /
|
|
// lexer.l by tools/lpc-syntax/generate_ebnf.py -- regenerate with the
|
|
// generate_ebnf CMake target; never hand-edit the JSON).
|
|
//
|
|
// Token kinds: comment, directive, string, template, textblock, char,
|
|
// number, keyword, type, modifier, efunkw, identifier, operator,
|
|
// punctuation, functional, whitespace, unknown.
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
export const grammar = JSON.parse(readFileSync(join(here, 'lpc-grammar.json'), 'utf8'));
|
|
|
|
const KEYWORDS = new Set(grammar.keywords);
|
|
const TYPES = new Set(grammar.typeKeywords);
|
|
const MODIFIERS = new Set(grammar.modifierKeywords);
|
|
// Longest-match order comes pre-sorted from the generator.
|
|
const OPERATORS = grammar.operators;
|
|
const PUNCT = new Set(grammar.punctuation);
|
|
|
|
const isIdentStart = (c) => /[A-Za-z_]/.test(c);
|
|
const isIdentChar = (c) => /[A-Za-z0-9_]/.test(c);
|
|
const isDigit = (c) => /[0-9]/.test(c);
|
|
|
|
// Brace-depth scan for a template interpolation body: a raw '{'/'}' count
|
|
// is fooled by a '}' inside a nested string/char/comment/template (e.g.
|
|
// `${ ch == '}' }` or `${ s == "}" }`), so those spans must be skipped
|
|
// as opaque units rather than scanned character-by-character.
|
|
function skipStringSpan(src, i) {
|
|
let j = i + 1;
|
|
while (j < src.length && src[j] !== '"') {
|
|
if (src[j] === '\\') j++;
|
|
j++;
|
|
}
|
|
return Math.min(j + 1, src.length);
|
|
}
|
|
|
|
function skipCharSpan(src, i) {
|
|
let j = i + 1;
|
|
if (src[j] === '\\') j++;
|
|
j++;
|
|
if (src[j] === "'") j++;
|
|
return j;
|
|
}
|
|
|
|
function findInterpEnd(src, start) {
|
|
let depth = 1;
|
|
let k = start;
|
|
while (k < src.length && depth > 0) {
|
|
const c = src[k];
|
|
if (c === '"') { k = skipStringSpan(src, k); continue; }
|
|
if (c === "'") { k = skipCharSpan(src, k); continue; }
|
|
if (c === '/' && src[k + 1] === '/') {
|
|
const nl = src.indexOf('\n', k);
|
|
k = nl < 0 ? src.length : nl;
|
|
continue;
|
|
}
|
|
if (c === '/' && src[k + 1] === '*') {
|
|
const e = src.indexOf('*/', k + 2);
|
|
k = e < 0 ? src.length : e + 2;
|
|
continue;
|
|
}
|
|
if (c === '`') { k = skipTemplateSpan(src, k); continue; }
|
|
if (c === '{') { depth++; k++; continue; }
|
|
if (c === '}') { depth--; if (depth > 0) k++; continue; }
|
|
k++;
|
|
}
|
|
return k;
|
|
}
|
|
|
|
function skipTemplateSpan(src, i) {
|
|
let j = i + 1;
|
|
while (j < src.length) {
|
|
if (src[j] === '\\') { j += 2; continue; }
|
|
if (src[j] === '`') return j + 1;
|
|
if (src[j] === '$' && src[j + 1] === '{') { j = findInterpEnd(src, j + 2) + 1; continue; }
|
|
j++;
|
|
}
|
|
return j;
|
|
}
|
|
|
|
export function tokenize(src) {
|
|
const toks = [];
|
|
let i = 0;
|
|
let line = 1;
|
|
let col = 1;
|
|
let atLineStart = true;
|
|
|
|
const push = (kind, text) => {
|
|
toks.push({ kind, text, line, col });
|
|
for (const ch of text) {
|
|
if (ch === '\n') { line++; col = 1; } else { col++; }
|
|
}
|
|
if (kind !== 'whitespace' && kind !== 'comment') atLineStart = false;
|
|
};
|
|
|
|
const readWhile = (pred) => {
|
|
let j = i;
|
|
while (j < src.length && pred(src[j])) j++;
|
|
return src.slice(i, j);
|
|
};
|
|
|
|
while (i < src.length) {
|
|
const c = src[i];
|
|
|
|
// whitespace (newline re-arms directive detection)
|
|
if (c === ' ' || c === '\t' || c === '\r' || c === '\n') {
|
|
const t = readWhile((ch) => ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n');
|
|
if (t.includes('\n')) atLineStart = true;
|
|
push('whitespace', t);
|
|
i += t.length;
|
|
continue;
|
|
}
|
|
|
|
// comments
|
|
if (c === '/' && src[i + 1] === '/') {
|
|
let j = src.indexOf('\n', i);
|
|
if (j < 0) j = src.length;
|
|
push('comment', src.slice(i, j));
|
|
i = j;
|
|
continue;
|
|
}
|
|
if (c === '/' && src[i + 1] === '*') {
|
|
let j = src.indexOf('*/', i + 2);
|
|
j = j < 0 ? src.length : j + 2;
|
|
push('comment', src.slice(i, j));
|
|
i = j;
|
|
continue;
|
|
}
|
|
|
|
// preprocessor directive: '#' at line start; '\'-continuations join
|
|
if (c === '#' && atLineStart) {
|
|
let j = i;
|
|
for (;;) {
|
|
let nl = src.indexOf('\n', j);
|
|
if (nl < 0) { j = src.length; break; }
|
|
let k = nl - 1;
|
|
while (k > j && src[k] === '\r') k--;
|
|
if (src[k] === '\\') { j = nl + 1; continue; }
|
|
j = nl;
|
|
break;
|
|
}
|
|
push('directive', src.slice(i, j));
|
|
i = j;
|
|
continue;
|
|
}
|
|
|
|
// text blocks: @TERM / @@TERM ... TERM at line start
|
|
if (c === '@' && isIdentStart(src[i + 1] === '@' ? src[i + 2] ?? '' : src[i + 1] ?? '')) {
|
|
const arr = src[i + 1] === '@';
|
|
let j = i + (arr ? 2 : 1);
|
|
let term = '';
|
|
while (j < src.length && isIdentChar(src[j])) { term += src[j]; j++; }
|
|
const endRe = new RegExp(`^${term}(?![A-Za-z0-9_])`, 'm');
|
|
const rest = src.slice(j);
|
|
const m = endRe.exec(rest);
|
|
let end;
|
|
if (m) end = j + m.index + term.length;
|
|
else end = src.length;
|
|
push('textblock', src.slice(i, end));
|
|
i = end;
|
|
continue;
|
|
}
|
|
|
|
// strings
|
|
if (c === '"') {
|
|
let j = i + 1;
|
|
while (j < src.length && src[j] !== '"') {
|
|
if (src[j] === '\\') j++;
|
|
j++;
|
|
}
|
|
push('string', src.slice(i, Math.min(j + 1, src.length)));
|
|
i = Math.min(j + 1, src.length);
|
|
continue;
|
|
}
|
|
|
|
// template literals with ${ } interpolation: emit template fragments
|
|
// and recurse into expressions so interpolated code highlights too.
|
|
if (c === '`') {
|
|
let j = i + 1;
|
|
let frag = '`';
|
|
while (j < src.length) {
|
|
if (src[j] === '\\') { frag += src.slice(j, j + 2); j += 2; continue; }
|
|
if (src[j] === '`') { frag += '`'; j++; break; }
|
|
if (src[j] === '$' && src[j + 1] === '{') {
|
|
push('template', frag + '${');
|
|
i = j + 2;
|
|
// scan interpolation with brace tracking, recursively tokenized
|
|
const k = findInterpEnd(src, i);
|
|
const inner = src.slice(i, k);
|
|
for (const t of tokenize(inner)) {
|
|
toks.push({ ...t, line: 0, col: 0 });
|
|
}
|
|
// account positions for inner text
|
|
for (const ch of inner) {
|
|
if (ch === '\n') { line++; col = 1; } else { col++; }
|
|
}
|
|
i = k;
|
|
frag = '}';
|
|
j = i + 1;
|
|
if (src[i] === '}') { j = i + 1; i = j; j = i; }
|
|
j = i;
|
|
continue;
|
|
}
|
|
frag += src[j];
|
|
j++;
|
|
}
|
|
push('template', frag);
|
|
i = j;
|
|
continue;
|
|
}
|
|
|
|
// char literal -- escapes can be longer than one char (\xHH hex,
|
|
// \NNN octal are variable-length), so scan for the closing quote
|
|
// the same way string literals do rather than assuming a fixed width.
|
|
if (c === "'") {
|
|
let j = i + 1;
|
|
while (j < src.length && src[j] !== "'") {
|
|
if (src[j] === '\\') j++;
|
|
j++;
|
|
}
|
|
push('char', src.slice(i, Math.min(j + 1, src.length)));
|
|
i = Math.min(j + 1, src.length);
|
|
continue;
|
|
}
|
|
|
|
// numbers: 0x/0b, underscores, reals
|
|
if (isDigit(c)) {
|
|
let j = i;
|
|
if (c === '0' && (src[j + 1] === 'x' || src[j + 1] === 'X')) {
|
|
j += 2;
|
|
while (j < src.length && /[0-9A-Fa-f_]/.test(src[j])) j++;
|
|
} else if (c === '0' && (src[j + 1] === 'b' || src[j + 1] === 'B')) {
|
|
j += 2;
|
|
while (j < src.length && /[01_]/.test(src[j])) j++;
|
|
} else {
|
|
while (j < src.length && /[0-9_]/.test(src[j])) j++;
|
|
// Float: optional fraction (or trailing dot -- never consuming
|
|
// the ".." range operator), then an optional exponent; a bare
|
|
// exponent ("1e3") is a float too. "1e" with no digits is
|
|
// NUMBER(1) IDENT(e), so the exponent needs a lookahead digit.
|
|
if (src[j] === '.' && src[j + 1] !== '.') {
|
|
j++;
|
|
while (j < src.length && /[0-9_]/.test(src[j])) j++;
|
|
}
|
|
if ((src[j] === 'e' || src[j] === 'E') &&
|
|
(isDigit(src[j + 1] ?? '') ||
|
|
((src[j + 1] === '+' || src[j + 1] === '-') && isDigit(src[j + 2] ?? '')))) {
|
|
j++;
|
|
if (src[j] === '+' || src[j] === '-') j++;
|
|
while (j < src.length && /[0-9_]/.test(src[j])) j++;
|
|
}
|
|
}
|
|
push('number', src.slice(i, j));
|
|
i = j;
|
|
continue;
|
|
}
|
|
|
|
// identifiers / keywords ($N parameters too)
|
|
if (isIdentStart(c) || (c === '$' && isDigit(src[i + 1] ?? ''))) {
|
|
let j = i + (c === '$' ? 1 : 0);
|
|
while (j < src.length && isIdentChar(src[j])) j++;
|
|
const word = src.slice(i, j);
|
|
let kind = 'identifier';
|
|
if (KEYWORDS.has(word)) kind = word === 'efun' ? 'efunkw' : 'keyword';
|
|
else if (TYPES.has(word)) kind = 'type';
|
|
else if (MODIFIERS.has(word)) kind = 'modifier';
|
|
push(kind, word);
|
|
i = j;
|
|
continue;
|
|
}
|
|
|
|
// functional open/close before operators ("(:", ":)")
|
|
if (c === '(' && src[i + 1] === ':') { push('functional', '(:'); i += 2; continue; }
|
|
if (c === ':' && src[i + 1] === ')') { push('functional', ':)'); i += 2; continue; }
|
|
|
|
// operators, longest-match from the grammar contract
|
|
let matched = false;
|
|
for (const op of OPERATORS) {
|
|
if (src.startsWith(op, i)) {
|
|
push('operator', op);
|
|
i += op.length;
|
|
matched = true;
|
|
break;
|
|
}
|
|
}
|
|
if (matched) continue;
|
|
|
|
if (PUNCT.has(c)) {
|
|
push('punctuation', c);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
push('unknown', c);
|
|
i++;
|
|
}
|
|
return toks;
|
|
}
|