mirror of
https://github.com/fluffos/fluffos
synced 2026-08-12 18:26:06 -04:00
fix(lpc-syntax): tokenizer mis-lexes bare (::name( parent-call guard
`if (::name(...))` -- a bare parent-call immediately inside a
control-flow condition's parens, with no whitespace between `(` and
`::` -- was mis-tokenized. tokenizer.mjs's functional-literal-open rule
only checked `src[i+1] === ':'` before greedily emitting the `(:`
token, without checking whether a second `:` follows. For `(::name`,
that swallows the `(` and the FIRST colon as `(:`, leaving a lone `:`
behind: the `::` scope-resolution operator gets torn into `: :`, and
every formatting decision built on top of that token (indentation,
brace placement, statement grouping) cascades into visibly broken
output -- in the worst observed case an `if`'s condition and body come
out with the wrong line breaks and a spurious extra `{`.
Root cause: this is a real lexical ambiguity in "(::" -- it can start
either a functional-literal open ("(:" ... ":)") or a bare parent call
("(" followed by "::"). The driver's own lexer.l resolves it explicitly
via a `"("{WS}*"::"` rule (see the "(::" longest-match guard comment
there) that returns just '(' and pushes the rest back so "::" scans as
its own token. tokenizer.mjs had no equivalent rule and always took the
functional-literal reading.
Fix: add isParentCallOpenParen() to tokenizer.mjs, mirroring lexer.l's
rule -- look past any intervening whitespace (lexer.l's WS class:
space/tab/CR/LF/VT/FF) for "::" before deciding a "(:"-shaped span is a
functional-literal open; if "::" follows, emit only '(' and let the
whitespace/"::" scan normally on subsequent iterations.
This is also a case study in the documented self-check blind spot
(tools/lpc-syntax/README.md, "Validating a formatter change"): the
corpus safety net (token-sequence equivalence + idempotency) reported
this file clean both before and after formatting, because the same
(buggy) tokenizer was used to check its own output -- it mis-lexed the
input and the corrupted output identically, so the "before"/"after"
token signatures trivially matched. Verified this reproduces on master
(both checks report `true`/no mismatch pre-fix) and that the driver
build has no such blind spot -- lexer.l already gets "(::" right, which
is how two real mudlibs' player-body classes (`::move()`/`::query()`
guards) compiled fine originally and only broke after this formatter
ran over them.
Found scanning ~91 real-world LPC mudlibs (sibling project, not part of
this repo) with `find . -name '*.lpc' | node
tools/lpc-syntax/bin/format-corpus.mjs`; the testsuite corpus itself
has no `(::` occurrences, which is why this slipped past `test.mjs` and
`testsuite/format.sh --check` -- both still pass unchanged (779 files,
0 errors, same single pre-existing unrelated `wouldChange` as before
this branch).
Also updates the token-merge safety-net comments/test (format.mjs,
test.mjs, README.md): `f( ::g() )` used to need a forced space to avoid
the tokenizer re-lexing its own tight `f(::g())` output wrong. With the
tokenizer fix that space is no longer needed -- `(` before a bare `::`
is exactly as safe to render tight as any other qualified-scope site
(`efun::foo()`), so it now does (`f(::g());`). Adds a dedicated
regression test for the original bug (single-line guard, `if (...) {`
shape, and a whitespace-separated `( ::` variant).
node tools/lpc-syntax/test.mjs: all pass (was 1 failure pre-fix, the
stale `f( ::g() )` expectation, once master's own test.mjs is patched
in isolation to reproduce -- see above).
testsuite/format.sh --check: 779 files, 0 errors, 1 pre-existing
unrelated wouldChange (testsuite/tmp_eval_file.c, present identically
on master).
build/src/driver etc/config.test -ftest: passes except one pre-existing,
unrelated failure (deep_macro_nesting.lpc) present with no changes from
this branch checked out (confirmed via `git diff --stat` touching only
tools/lpc-syntax/*).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VyQCUoTo1Z93Py9aVFHQi1
This commit is contained in:
parent
3ec802f6a7
commit
d64c3fa483
4 changed files with 84 additions and 11 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1598,11 +1598,16 @@ function renderLine(toks, mappingContext = false, pendingTernary = 0) {
|
|||
// Token-merge safety net: never butt two tokens together whose
|
||||
// concatenation re-lexes as something else -- `a - --b` must not
|
||||
// become `a ---b` (re-lexing as `(a--) - b`), `- -x` must not become
|
||||
// `--x` (a pre-decrement!), and `f( ::g() )` must not become
|
||||
// `f(::g())` (whose `(:` re-lexes as a functional-literal opener).
|
||||
// Longest-match lexing means ANY tight rule above can accidentally
|
||||
// manufacture a longer operator; checking against the real tokenizer
|
||||
// catches every such pair, present and future, in one place.
|
||||
// `--x` (a pre-decrement!). (`f( ::g() )` going tight to `f(::g())`
|
||||
// used to trip this too, back when the tokenizer's own "(:" rule
|
||||
// didn't look past a "::" -- see isParentCallOpenParen() in
|
||||
// tokenizer.mjs -- and mis-lexed the "(::" it produced. Now that the
|
||||
// tokenizer itself gets that case right, `(` before a bare `::` is
|
||||
// exactly as safe to render tight as any other qualified-scope site,
|
||||
// e.g. `efun::`.) Longest-match lexing means ANY tight rule above can
|
||||
// accidentally manufacture a longer operator; checking against the
|
||||
// real tokenizer catches every such pair, present and future, in one
|
||||
// place.
|
||||
if (sep === '' && prev && tokensWouldMerge(prev, t)) sep = ' ';
|
||||
out += sep + t.text;
|
||||
|
||||
|
|
|
|||
|
|
@ -765,12 +765,13 @@ check('short code is unaffected by a small printWidth when it already fits',
|
|||
check('token-merge safety net: two tokens are never butted together if their'
|
||||
+ ' concatenation re-lexes as a different token sequence -- `a - --b`'
|
||||
+ ' must not become `a ---b` (which re-lexes as `(a--) - b`), `- -x`'
|
||||
+ ' must not become `--x` (a pre-decrement!), and `f( ::g() )` must'
|
||||
+ ' not become `f(::g())` (whose `(:` re-lexes as a functional-literal'
|
||||
+ ' opener); already-tight `i--`/`a[0]`/`-1`/`efun::` forms stay tight',
|
||||
+ ' must not become `--x` (a pre-decrement!); already-tight'
|
||||
+ ' `i--`/`a[0]`/`-1`/`efun::` forms stay tight, and a bare `::` after'
|
||||
+ ' `(` goes tight too (`f( ::g() )` -> `f(::g())`) now that the'
|
||||
+ ' tokenizer itself (not this safety net) gets "(::" right',
|
||||
(() => {
|
||||
const cases = [
|
||||
['x = f( ::g() );\n', 'f( ::g());'],
|
||||
['x = f( ::g() );\n', 'f(::g());'],
|
||||
['y = a - --b;\n', 'a - --b'],
|
||||
['y = a + ++b;\n', 'a + ++b'],
|
||||
['y = - -x;\n', '- -x'],
|
||||
|
|
@ -787,6 +788,41 @@ check('token-merge safety net: two tokens are never butted together if their'
|
|||
}
|
||||
return true;
|
||||
})());
|
||||
// A bare parent-call `::name(...)` immediately inside a control-flow
|
||||
// condition (`if (::name())`) used to mis-tokenize: the tokenizer's naive
|
||||
// "(' followed by ':'" check matched "(:" (the functional-literal open)
|
||||
// before ever considering that "(::" is "(' + '::" (the scope-resolution
|
||||
// operator), leaving a lone ':' behind and corrupting everything the
|
||||
// formatter built on top of that token (the '::' rendered as ': :', and
|
||||
// the `if`'s condition/body structure came out wrong -- an extra `{`
|
||||
// materialized in one real-world case). Caught across a ~91-mudlib
|
||||
// corpus scan; two mudlibs' boot broke on exactly this (a player-body
|
||||
// class's `::move()`/`::query()` guard). Fixed by tokenizer.mjs's
|
||||
// isParentCallOpenParen(), mirroring lexer.l's own `"("{WS}*"::"` guard.
|
||||
check('bare `::name(...)` guard in an `if` condition tokenizes and formats'
|
||||
+ ' correctly -- the "(::" ambiguity with the "(:" functional-literal'
|
||||
+ ' opener must resolve to \'(\' + \'::\', not \'(:\' + \':\'',
|
||||
(() => {
|
||||
const cases = [
|
||||
// Single-line guard directly on the condition.
|
||||
['if (::valid_leave(me, dir)) return notify_fail("no\\n");\n',
|
||||
'if (::valid_leave(me, dir)) return notify_fail("no\\n");\n'],
|
||||
// The `if (cond) {` shape from the original bug report.
|
||||
['if (::do_read(arg)) {\n return 1;\n}\n',
|
||||
'if (::do_read(arg)) {\n return 1;\n}\n'],
|
||||
// Whitespace/newline between '(' and '::' (lexer.l's WS* class).
|
||||
['if ( ::do_read(arg)) return 1;\n',
|
||||
'if (::do_read(arg)) return 1;\n'],
|
||||
];
|
||||
for (const [src, want] of cases) {
|
||||
const out = formatLPC(src);
|
||||
if (out !== want) return false;
|
||||
if (formatLPC(out) !== out) return false;
|
||||
// The tokenizer must see one '::' operator token, not two ':'s.
|
||||
if (!kinds(out).includes('operator:::')) return false;
|
||||
}
|
||||
return true;
|
||||
})());
|
||||
check('keyword-tail safety net: a reserved word never has the next token'
|
||||
+ ' glued onto it (`return !x;` not `return!x`, `return ~x;`,'
|
||||
+ ' `return -1;`, `case ..0:` not `case..0:` -- the pristine corpus'
|
||||
|
|
|
|||
|
|
@ -23,6 +23,18 @@ 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);
|
||||
const isLexWs = (c) => c === ' ' || c === '\t' || c === '\r' || c === '\n' || c === '\v' || c === '\f';
|
||||
|
||||
// True when `src[i]` is a '(' immediately (modulo lexer.l's WS class:
|
||||
// space/tab/CR/LF/VT/FF) followed by "::" -- i.e. a bare parent-call guard
|
||||
// like `(::name(...))`, not a functional-literal open. Mirrors lexer.l's
|
||||
// `"("{WS}*"::"` rule, which LPC_YYLESS(1)s back to just '(' so "::" is
|
||||
// re-scanned as L_COLON_COLON.
|
||||
function isParentCallOpenParen(src, i) {
|
||||
let j = i + 1;
|
||||
while (j < src.length && isLexWs(src[j])) j++;
|
||||
return src[j] === ':' && src[j + 1] === ':';
|
||||
}
|
||||
|
||||
// Brace-depth scan for a template interpolation body: a raw '{'/'}' count
|
||||
// is fooled by a '}' inside a nested string/char/comment/template (e.g.
|
||||
|
|
@ -337,7 +349,27 @@ export function tokenize(src) {
|
|||
}
|
||||
|
||||
// functional open/close before operators ("(:", ":)")
|
||||
if (c === '(' && src[i + 1] === ':') { push('functional', '(:'); i += 2; continue; }
|
||||
//
|
||||
// "(::" (optionally with whitespace between the '(' and the "::") is
|
||||
// NOT a functional-literal open -- it's an ordinary '(' followed by
|
||||
// the "::" scope-resolution operator, as in a bare parent-call guard
|
||||
// `if (::name(...))`. lexer.l's own "("{WS}*"::" rule exists for
|
||||
// exactly this: it returns just '(' and pushes the rest back so "::"
|
||||
// scans as its own token next (see the "(::" longest-match guard
|
||||
// comment there). Without this guard, greedily matching '(' + ':' as
|
||||
// "(:" leaves a lone ':' behind, corrupting the token stream (`::` ->
|
||||
// `: :`) and everything the formatter builds on top of it.
|
||||
if (c === '(' && src[i + 1] === ':') {
|
||||
if (isParentCallOpenParen(src, i)) {
|
||||
// Bare '(' -- do not let the operator table below match "(:" as a
|
||||
// functional-literal open either; emit just the paren and let "::"
|
||||
// (and any whitespace between them) scan on the next iterations.
|
||||
push('punctuation', '(');
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
push('functional', '(:'); i += 2; continue;
|
||||
}
|
||||
if (c === ':' && src[i + 1] === ')') { push('functional', ':)'); i += 2; continue; }
|
||||
|
||||
// operators, longest-match from the grammar contract
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue