ReviewOS

zig-utils/zig-js

Errors: JavaScriptCore renders SyntaxError prose; zig-js emits an internal error tag and a position

#899
Closed chris opened this 9 days ago ·
9 days ago

Parent: #459

Problem

Every SyntaxError zig-js raises from the parser or lexer is rendered mechanically as <origin>: <ZigErrorEnumTag> at <line>:<col>. JavaScriptCore renders prose that names the offending token. A differential over 67 cases matches 4/67.

sourcezig-jsJavaScriptCore
eval("var q = ;")eval: UnexpectedToken at 1:11Unexpected token ';'
eval("if (true) {")eval: ExpectedToken at 1:12Unexpected end of script
eval("let d = 1; let d = 2;")eval: UnexpectedToken at 1:26Cannot declare a let variable twice: 'd'.
eval("return 1;")eval: UnexpectedToken at 1:1Return statements are only valid inside functions.
eval("1 = 2")eval: InvalidAssignmentTarget at 1:3Left side of assignment is not a reference.
eval("++1")eval: InvalidAssignmentTarget at 1:4Prefix ++ operator applied to value that is not a reference.
eval("var n = 0x")eval: InvalidNumber at 1:13No hexadecimal digits after '0x'
eval("var g = { get p(x) {} }")eval: UnexpectedToken at 1:35Unexpected identifier 'x'. getter functions must have no parameters.
eval("var p = { __proto__: 1, __proto__: 2 }")eval: UnexpectedToken at 1:41Attempted to redefine __proto__ property.
eval("class C { constructor(){} constructor(){} }")eval: UnexpectedToken at 1:46Cannot declare multiple constructors in a single class.

Three separable defects

1. The payload is an internal Zig error tag, not a message. ParseError has six tags — UnexpectedToken, ExpectedToken, InvalidAssignmentTarget, InvalidNumber, UnterminatedString, UnterminatedComment, UnexpectedCharacter — standing in for at least 38 distinct JavaScriptCore diagnostics. UnexpectedToken alone covers 21 of them. So this is not a string-tweak: the parser has to carry a reason discriminant (or at minimum the offending token's text) to the throw site.

The mechanism already exists and is simply unused. eval("super()") produces 'super' is only valid inside a method — prose, no tag, no position — so a prose path is already wired; the other 30+ sites do not use it.

2. zig-js appends at line:col; JavaScriptCore never puts a position in .message. It carries position on the Error object instead. Even with perfect prose, the suffix would still break any consumer matching on message text.

3. Where a position IS reported, it is frequently wrong. It is where the parser stopped, not where the error is: eval("let d10 = 1; let d10 = 2;") reports 1:26 (end of source) rather than column 18 where the duplicate sits. The same end-of-source position shows up for strict-mode octal, duplicate parameters, duplicate __proto__, duplicate constructors, undeclared labels, and getter/setter arity.

Suggested shape

Add a reason discriminant carried alongside ParseError (the way #889/#892 added a retained source span to AST nodes), set at each throw site, and render it at the boundary that currently formats <tag> at <l>:<c>. Dropping the position suffix is independent of the prose work and could land first.

Verification

scratchpad/sweep/probe.sh diffs both engines; the case set is sweep/. Drive probes from inside a function — zig-js --eval runs script code, so an indirect eval shares global scope with the harness and a case declaring var i silently truncates the run.

9 days ago

Investigation notes before anyone starts this. Two findings change the shape of the work, and one of them makes part of this a product decision rather than a bug fix.

Correction: the parser has no prose mechanism to reuse

I wrote above that "the mechanism already exists" because eval("super()") produces prose. That is wrong. That string is a hardcoded check in the interpreter's eval path (throwError("SyntaxError", "eval: 'super' is only valid inside a method")), not a parser diagnostic. The parser has no prose path at all — the mechanism has to be built.

Root cause of the wrong positions: 7 of 196 throw sites record one

Parser.fail() records last_error_offset, and errorLocation() falls back to "wherever self.pos currently is" when it is null:

self.fail(...)                : 7 sites
return ParseError.<X> (bare)  : 189 sites

So for 189 of 196 sites the reported position is wherever the parser happened to stop, which is exactly the end-of-source behaviour observed on strict octal, duplicate parameters, duplicate __proto__, duplicate constructors, undeclared labels and getter/setter arity. Routing those 189 sites through fail is a prerequisite for correct positions and a natural place to attach the reason discriminant at the same time — one edit per site, two payloads.

Dropping the position from .message is a product decision, not a fix

throwParserSyntaxErrorAt already sets line, column and byteOffset as own properties on the Error object — the same place JavaScriptCore keeps them — so removing the suffix from .message loses nothing structurally.

But it is deliberate today, and there is a test pinning it:

src/context.zig — Function("return )(") must yield a message containing
                  "Function body:" AND " at "

So the <context>: … at L:C shape is an intentional embedding-facing format, not an oversight. Changing it needs a decision about whether JSC parity outranks that; I have not made that call.

Measured: JavaScriptCore's token-kind naming

For whoever builds the generic renderer, the first sentence is chosen by token kind:

token kindJavaScriptCore
punctuationUnexpected token ';'
identifierUnexpected identifier 'b'
keyword (incl. this, null, true, var, if)Unexpected keyword 'if'
numberUnexpected number '2'
stringUnexpected string literal "s"
BigInt (2n)Unexpected token '2n' — token, not number
end of inputUnexpected end of script

JSC frequently appends a context clause (. Expected ';' after variable declaration.) that depends on parse state. Of 24 probed shapes, 12 have no clause and would match exactly from the token-kind rule alone; the other 12 would match as a prefix only.

Suggested split

  1. Route the 189 bare returns through fail (fixes positions; no message change).
  2. Add the reason discriminant on fail, render prose where it is unambiguous.
  3. Separately decide the .message position/prefix question against the Function body: contract.

Step 1 is independently valuable and independently verifiable.

9 days ago

Landed b34796fe (fix(parser): preserve class diagnostic reasons and locations). Related: #899, #904.

The parser now records 17 explicit reasons for invalid class declarations. These distinguish field/method/accessor collisions, static versus instance accessor conflicts, forbidden constructor/prototype declarations, and duplicate constructors. Declaration offsets are temporary parser scratch, and the grammar's existing acceptance rules remain intact.

eval and Function render these reasons as JSC-compatible prose. Location stays in non-enumerable line, column, and byteOffset properties. Errors inside nested tagged/untagged template substitutions retain the reason and translate normalized CRLF offsets back into the original source. Shared class-heritage validation also uses JSC's two distinct messages without changing evaluation order.

Measured evidence:

  • New declaration/heritage differential: 79/79 exact JSC matches, including every pair of field/method/getter/setter declarations across static and instance placement.
  • Existing broad diagnostic differential: 37/66 -> 43/66 exact matches in this commit (34/66 -> 43/66 across the two latest commits).
  • Class test262 subtree: 4,367 -> 4,367 pass, zero failures; flips 0 tested cases.
  • 55/55 parser tests, plus the two focused diagnostic tests: zero failures and zero leaks. Focused checks cover eval/Function messages, declaration offsets, legal accessor pairs, and nested CRLF template provenance.

Both issues remain open. Undeclared private-name lists, private delete/static-await/super diagnostics, other parser/lexer reasons, and the deliberate present-brand accessor-message merge remain separate work. This is not a claim that all SyntaxErrors now match JSC.

9 days ago

Landed another bounded diagnostic chunk in 3941ab4: assignment, destructuring, prefix/postfix update, strict-mode eval/arguments writes, and top-level return now carry explicit parser reasons and offending offsets. The JS boundary renders prose without the internal error tag or position suffix; structured location metadata remains available.

This preserves existing ParseError categories and sloppy Annex B call-target behavior. Regression tests verify that early errors prevent preceding side effects, while a sloppy call assignment still executes its callee and raises a runtime ReferenceError. No message-based workaround or syntax acceptance change.

Verification against the immediately preceding b34796f runner:

  • JSC differential: 6/38 -> 38/38 exact diagnostic matches, including accepted and runtime-error controls.
  • All 56 parser unit tests pass; all 4 class/assignment diagnostic tests pass; zero leaks in these runs.
  • Ten test262 subtrees: 1,537 -> 1,537 pass, 0 failures, zero behavioral flips. Counts include successful negative tests.
SubtreeBefore pass/failAfter pass/fail
assignment485/0485/0
assignmenttargettype324/0324/0
compound-assignment454/0454/0
logical-assignment78/078/0
optional-chaining38/038/0
postfix-decrement37/037/0
postfix-increment38/038/0
prefix-decrement34/034/0
prefix-increment33/033/0
return16/016/0

Jobs ran sequentially under the shared machine lock, with 4 GiB build and 3 GiB corpus ceilings. The before/after corpus plus JSC comparison peaked at 78 MB measured process-tree footprint. This is not a full-suite or performance claim. #899 remains open for the other parser/lexer diagnostics; the earlier class-declaration work also relates to #904.

9 days ago

Further progress: d2438f1 closes #910 and #911. Object/accessor diagnostics now match JSC 40/40 plus 19/19 edge probes; template-boundary messages 13/15 (both remaining differences are shorthand-initializer wording, not acceptance). Existing assignment 38/38 and class declaration 79/79 diagnostic probes remain matched. Nine relevant test262 groups: 11,374 -> 11,374 passes, zero failures. No global diagnostic-completeness claim; this umbrella stays open.

9 days ago

Split the next executable slice into #951 after rerunning a seven-case current-main differential against raw JavaScriptCore 625.1.22. It covers generic punctuation/identifier/keyword/number/string/BigInt/EOF rendering through the existing structured diagnostic path, with context-specific clauses left as explicit follow-ups rather than source-based special cases.

9 days ago

#951 is complete in 54fc295. Generic token-kind rendering now matches raw JSC for punctuation, identifier, keyword, number, string, BigInt and EOF through both eval and Function, with exact structured coordinates pinned. Full expressions/statements test262 transcripts are byte-identical to the exact parent (20,309 passes, 66 known failures, zero flips).\n\nPost-change bare-return audit: 159 -> 155 direct sites; all 56 expect sites and 8 statement-terminator sites now use the structured failure path. Remaining bounded clusters: statement/control 16, binding/catch/parameter conflicts 19, object/pattern 19, module imports 13, template escapes 9, and for heads 5.

9 days ago

#952 is complete in 8ba110f. Statement-placement and control-flow failures now retain exact grammar tokens and match raw JSC for 14/14 supported probes, including escaped keywords, strict with, label targeting, and Annex B function placement. The proposal-only using placement checks now have structured zig-js prose because JSC 625.1.22 does not implement that grammar. Full statements test262 remained byte-identical at 9,304 passes / 33 known failures.

9 days ago

#953 is complete in c124cca. The 19 parameter/body, catch, strict-parameter, and FunctionDeclaration-name bare returns now retain structured reasons, names, and stable grammar-site offsets. Raw JSC messages match 18/18 where WebKit implements the required rejection; the two WebKit acceptance divergences remain standards-correct per Node/test262. Eight exact-parent corpus pairs stayed byte-identical at 2,655 passes / 1 known failure.

The direct bare-return audit is now 120 sites, down from 155 before #952 and 159 before #951.

9 days ago

#954 is complete in c06af28. Object literal and destructuring refinement messages match raw JSC 23/23, and two adjacent acceptance bugs are fixed: object-rest assignment trailing commas and forbidden object BindingRestProperty names. Seven exact-parent corpus pairs stayed at 2,423/2,423; those two edge cases are absent from the pinned corpus and are backed by direct JSC/Node probes.

The direct bare-return audit is now 100 sites, down from 120 before #954 and 159 before #951.

9 days ago

Import grammar diagnostics are complete in #955 / 1ffe681.

The 13 direct bare returns across static and dynamic import parsing now retain exact tokens and structured reasons. Dynamic eval-visible messages match raw JavaScriptCore 625.1.22 on 7/7 probes; four exact-parent test262 subtrees remain 1,689/1,689 passing with 0 flips. The adjacent namespace-binding hole (import * as 1 from "m") is also fixed.

Direct return ParseError.UnexpectedToken; sites are now 81, down from 94 at the parent of this commit.

9 days ago

Malformed template escape diagnostics are complete in #956 / 78d0d08.

All 17 first, middle, and tail quasi probes now match raw JavaScriptCore 625.1.22 for malformed \x, \u, and numeric escapes. Exact backslash offsets survive CR/CRLF normalization, tagged invalid escapes retain undefined cooked values, and exact-parent template test262 subtrees remain 84/84 passing with 0 flips.

Direct return ParseError.UnexpectedToken; sites are now 73, down from 81 at the parent of this commit.

9 days ago

For-loop head diagnostics are complete in #957 / 40e64ef.

Eight malformed for-in, for-of, for-await, using, and await-using probes now match raw JavaScriptCore 625.1.22 byte for byte. Six exact-parent loop/resource test262 subtrees remain 2,280/2,280 passing with 0 flips, and the structural lookahead's valid-head one-pass path is unchanged.

Direct return ParseError.UnexpectedToken; sites are now 68, down from 73 at the parent of this commit.

9 days ago

Switch, throw, try, and do-while diagnostics are complete in #958 / 029a701.

Nine eval-visible statement grammar probes now match raw JavaScriptCore 625.1.22 byte for byte. Four exact-parent statement test262 subtrees remain 362/362 passing with 0 flips.

Direct return ParseError.UnexpectedToken; sites are now 64, down from 68 at the parent of this commit; the adjacent bare ExpectedToken exit is also structured.

9 days ago

Operator, optional-chain, member, and new-expression diagnostics are complete in #959 / 372a9a4.

Twelve shared rejection probes now match raw JavaScriptCore 625.1.22 byte for byte. The one oracle split, new await in an async function, remains standards-correctly rejected per Node 24.18 and now retains the exact token. Seven exact-parent expression test262 subtrees remain 270/270 passing with 0 flips.

Direct return ParseError.UnexpectedToken; sites are now 54, down from 64 at the parent of this commit.

9 days ago

Class field, class name, property, and super diagnostics are complete in #960 / 14b4273.

Six probes now match raw JavaScriptCore 625.1.22 byte for byte. Three exact-parent class/super test262 subtrees remain 8,456 pass / 64 known fail, with both 32-path failure inventories identical and 0 flips.

Direct return ParseError.UnexpectedToken; sites are now 50, down from 54 at the parent of this commit; one switch-arm bare tag was also removed.

9 days ago

Binding and variable-declaration diagnostics are complete in #961 / bc299cc.

Eleven standard declaration probes now match raw JavaScriptCore 625.1.22 byte for byte; two explicit-resource-management forms retain Node-validated grammar context. Six exact-parent declaration/resource test262 subtrees remain 650/650 passing with 0 flips.

Direct return ParseError.UnexpectedToken; sites are now 42, down from 50 at the parent of this commit.

9 days ago

Function, method, arrow, and dynamic-function parameter diagnostics are complete in #962 / 4841712.

Forty-seven parser probes now match raw JavaScriptCore 625.1.22 byte for byte. The change also corrects the grammar distinction that permits sloppy simple duplicate parameters in generator and async functions while retaining uniqueness for strict, non-simple, method, and arrow lists. Thirteen exact-parent function-related test262 subtrees remain 3,295 pass / 1 known fail, with identical failure inventories and 0 flips.

Direct return ParseError.UnexpectedToken; sites are now 29, down from 42 at the parent of this commit.

9 days ago

Escaped-keyword, strict-literal, and RegExp-flag diagnostics are complete in #963 / 8e8884c.

Nine probes now match raw JavaScriptCore 625.1.22 byte for byte. Five exact-parent identifier/literal test262 subtrees remain 700 pass / 50 known failures, with identical failure inventories and 0 flips. The RegExp path keeps its existing single validation scan and retained arena.

Direct return ParseError.UnexpectedToken; sites are now 24, down from 29 at the parent of this commit.

9 days ago

Landed #964 in 6b8c2ed: lexical declaration conflicts now retain exact JSC diagnostics for duplicate bindings, lexical-loop/body var collisions, nested-scope var collisions, and block function/async-function collisions. Validation was 21/21 exact JSC probes, 240/240 parser tests, and 1,959 pass / 1 known fail in an exact-parent test262 comparison with 0 flips. Direct generic parser exits are now 16 (down from 24 before this cluster and 42 at the start of the diagnostic pass).

9 days ago

Landed #965 in 538a667: generator and async function parameter defaults now retain exact JSC prose and source offsets for forbidden yield/await expressions across declarations, expressions, methods, destructuring, and async generators. Validation was 11/11 exact function-form probes, 240/240 parser tests, and 1,864/1,864 tests in an exact-parent test262 comparison with 0 flips. ast.Node remains 80 bytes. Arrow cover-grammar diagnostics remain a separate cluster because JSC points them at => or the await operand.

9 days ago

Landed #967 in 244d841: unterminated RegExp literals now retain their exact source spelling and JSC message instead of surfacing the lexer's internal UnterminatedString tag. Validation was 8/8 exact JSC probes, 241/241 parser tests, and a 22-case exact-parent RegExp corpus at 21 pass / 1 known fail with 0 flips. Successful RegExp scanning is unchanged.

9 days ago

Heads-up: until today every CI job failed to compile, because the zig-regex pin predated CompileErrorReason. I fixed that in 46dfb1b, 15f93fe and 9f8f2d0. With the build working again, CI run 35751363187 (unit shard 1/4) shows three unit tests that still expect the old SyntaxError shape and now fail:

  • context.test.ShadowRealm evaluate wraps child abrupt completions expects e.message.includes('ShadowRealm.evaluate:') && e.message.includes(' at 2:').
  • context.test.eval: direct eval runs in the caller's scope expects 'eval:', ' at 1:' and 'eval: UnterminatedString'.
  • c_api.test.C-API: evaluation syntax exceptions include source URL and line expects app.js:41:12 in the rendered exception.

On current main, eval('var =') throws Unexpected token '='. Expected a parameter pattern or a ')' in parameter list., while an unterminated string in eval still renders as eval: UnterminatedString at 2:2. The tests' expectations belong with the #951–#967 format decisions, so I've left them to you. The other three shards weren't visible in that run, because it was cancelled when a later push superseded it.

9 days ago

Landed #973 in 78cc6a3c: module early errors now retain exact diagnostics for declaration collisions, duplicate/default exports, unresolved exports, import attributes, module export names, and HTML comments. Validation was 37/37 exact WebKit/JSC module probes, 242/242 standalone parser tests, and 616/616 exact-parent module tests with 0 flips.

Direct generic parser exits are now 4, down from 15 before this cluster and 159 at the start of the diagnostic pass. Arrow cover suspension diagnostics remain tracked in #966.

9 days ago

Landed #966 in d0ff3e28: forbidden await/yield expressions in arrow parameter cover grammar now retain the grammar-specific token and clause across scalar, array, object, computed, parenthesized, inherited async/generator, and async-generator forms.

Validation was 40/40 exact WebKit/JSC probes, 243/243 standalone parser tests, the focused evaluator gate, and 2,050/2,050 exact-parent test262 cases with byte-identical transcripts and 0 flips. The direct return ParseError.UnexpectedToken; audit is now at zero sites.

9 days ago

Completed in cf30b2be. Lexer failures now retain an explicit reason, offending spelling, and source start, so SyntaxErrors render prose through eval, Function, ShadowRealm, and the C API while coordinates remain structured metadata. The final numeric path also accepts arbitrarily wide binary, octal, and hexadecimal literals and rounds them directly to binary64 after fixed-width parsing overflows.\n\nFinal evidence for this slice:\n\n- 39/42 lexer diagnostics exactly match raw JavaScriptCore 625.1.22; the remaining three are the deliberate Unexpected end of script boundary wording pinned by #972 rather than raw JSC's Unexpected EOF.\n- 36/36 wide radix values exactly match Node 24.18.\n- Exact-parent test262 comparison: 854 -> 854 passes, 0 failures, 0 flips across literals, identifiers, and comments.\n- Standalone parser suite: 245/245. Focused engine frontend: 79/79. C API audit: 117/117 declarations complete.\n- Lexer remains 688 bytes. Successful tokenization adds no diagnostic writes or allocations; the new conversion path is allocation-free and linear, and runs only after u128 overflow.\n\nTogether with the prior #951-#973 commits, the parser now has zero direct generic UnexpectedToken exits and lexer syntax failures no longer surface internal Zig tags. This completes the umbrella.

Sign in to comment on this issue.