Skip to content

Say where a parse failed: line, column and the offending line, in every parser that fails - #303

Merged
konard merged 10 commits into
mainfrom
issue-302-ab4ea4d5b342
Sep 5, 2026
Merged

Say where a parse failed: line, column and the offending line, in every parser that fails#303
konard merged 10 commits into
mainfrom
issue-302-ab4ea4d5b342

Conversation

@konard

@konard konard commented Sep 5, 2026

Copy link
Copy Markdown
Member

Summary

Closes #302 — a failed parse now says where the document stopped making sense, in every implementation that fails at all.

The Rust parser answered a broken document with the raw nom error:

Syntax error: Error(Error { input: "ci_gate x\n  stage rust\n…the rest of the document…", code: Eof })

No line, no column, nothing about what was expected, and a payload that grows with the document. It now answers:

Syntax error at line 2, column 8: expected "(", a reference or end of line, found ":"
2 | # break: two
  |        ^

The issue asked for the Rust parser. The comment on it — "We need to double check debugging information on any fail of parsing is displayed in all parsers (in all supported languages) as well as it can" — asked for the sweep, so all seven were audited and two more were fixed.

Before and after, per language

before after
Rust Error(Error { input: "<rest of document>", code: Eof }) Syntax error at line 2, column 8: expected "(", a reference or end of line, found ":" + quoted line
C# FormatException: Failed to parse 'document'., cursor pinned at line 1, column 1 Syntax error at line 2, column 8: unexpected ":" + quoted line
JavaScript Expected … but ":" found. — the position was on the error object but not in the message Syntax error at line 2, column 8: Expected … but ":" found. + quoted line
lino! macro lino! macro: validated at compile time but runtime parse failed lino!: Syntax error at line 1, column 5: …

All three implementations now agree on the offset of every defect:

document offset line:column
# ok line\n# break: two\nci_gate x\n 17 2:8
a: b: c 4 1:5
a (b\n 5 2:1
a b)\n 3 1:4
: 0 1:1

Offset 17 / line 2 / column 8 is what the JavaScript port already reported in the issue, so the three implementations are now held to one contract.

Root cause

Both the Rust and the C# parser backtrack. When the last alternative fails, the parser has already given up everything it tried, so the position it holds is where it started, not where the document broke:

  • Rust (nom) reported the start of line 2 — the last position the document parsed cleanly — with the whole remainder as the payload. ParserState now records the furthest position any alternative reached along with what was expected there, and the error takes max(tracked, nom's own offset).
  • C# (Pegasus) threw from the start rule after backtracking out of everything, which is why Data["cursor"] was always location 0. The grammar now sets @trace true, and FurthestFailureTracer (an ITracer) records the furthest location any rule reached. Measured cost of tracing on a 20 000-line document: 505/522/547 ms traced against 437/464/551 ms untraced, best of seven — within run-to-run noise.
  • JavaScript (peggy) already computed the right position; it just never put it in the message.

What the message contains

One line of context, never the rest of the document, so the message is the same size for a 10-line file and a 1500-line one. Lines longer than 80 characters are shown as a window around the caret with .... Rust counts columns in characters (so привет: b: c reports column 10 at byte offset 15); JavaScript and C# count UTF-16 code units, matching what peggy and Cursor report.

Structured fields, not just a string

  • Rust: ParseError::SyntaxError(SyntaxError) with offset, line, column, expected, found, line_text, plus summary() and snippet(). parse_document_with_diagnostics is the nom-level entry point.
  • JavaScript: ParseError (exported) with offset, line, column, found, lineText, snippet, the generated parser's location and the original error as cause. location.start is unchanged, so existing callers keep working.
  • C#: ParseException with Offset, Line, Column, Found, LineText, Summary, Snippet. It derives from FormatException, so callers that catch the base type keep working.

Audit of the other four

Python, Go, Java and PHP accept all five broken documents above. a: b: c becomes a link whose first value is the reference b:; a (b becomes two references, one of which is (b. Each declares a parse error type that is never raised for malformed syntax: Go never constructs ParseError, PHP never throws ParseException, Java names ParseException in throws clauses without throwing it, and Python raises ParseError only to wrap an unexpected internal exception.

That is a difference in what they accept, not in what they say when they fail, so it belongs to the syntax parity work in #138 rather than here — there is no diagnostic to improve until there is a failure to report. Making four hand-written parsers strict is a semantic change to what the notation accepts, which is not something this PR should decide quietly.

How to reproduce and verify

./experiments/issue-302/run.sh

Asks all seven implementations about the same five documents and prints the answers next to each other; toolchains that are not installed are reported as skipped. experiments/issue-302/README.md records what the run says.

cargo run --manifest-path rust/links-notation/Cargo.toml --example parse_error_positions

Tests

file tests
Rust rust/links-notation/tests/parse_error_position_tests.rs 16
C# csharp/Link.Foundation.Links.Notation.Tests/ParseErrorPositionTests.cs 11
JavaScript js/tests/ParseErrorPosition.test.js 10
Rust macro macro_tests.rs::test_runtime_failure_says_where_the_text_stopped_parsing 1

Each suite asserts the same contract: the exact line, column and offset for each document; the exact summary()/snippet() text; that a document with 500 trailing lines still produces a message under 200 characters; that a 800-character line is windowed; and, in Rust, that no nom internals (ErrorKind, Eof, Verify) appear in the message.

Locally: Rust cargo fmt --check, cargo clippy -D warnings, cargo test --all green (299 tests); C# dotnet format --verify-no-changes, build -c Release, test green (207); JavaScript format:check, lint, build, bun test green (214); Go go test ./... green.

Release

All seven implementations bumped 0.17.0 → 0.18.0 — new public API in three of them — with the manifests that name the library by version rather than by path (rust/links-notation-benchmark, benchmarks/java/pom.xml, the private website package) and the lockfiles following. scripts/version-consistency.mjs passes; TEST_CASE_COMPARISON.md and the README test tables were regenerated with node scripts/create-test-case-comparison.mjs; CHANGELOG.md records the work under ## [Unreleased].

Adding .gitkeep for PR creation (default mode).
This file will be removed when the task is complete.

Issue: #302
@konard konard self-assigned this Sep 5, 2026
The Rust parser handed the raw `nom` error to `format!("{:?}")`, so a
malformed document was reported as `Error(Error { input: "<the whole
remainder>", code: Eof })`: no line, no column, no expectation, and a
payload that grew with the document. `nom` reports the position where the
last alternative gave up, which is the start of the line, not the place
where the document stopped making sense.

The parser state now remembers the furthest position any alternative
reached and what could have stood there, and `parse_lino` turns that into
a 1-based line and column with one quoted line of context:

    Syntax error at line 2, column 8: expected "(", a reference or end of
    line, found ":"
    2 | # break: two
      |        ^

The offsets match the ones the JavaScript port reports for the same
documents, so both implementations can be held to one contract.

Refs #302
The generated Pegasus parser raised `FormatException: Failed to parse
'document'.`, and the cursor it attaches to the exception is the start of
the document, because the grammar backtracks all the way out of the start
rule before it gives up. So the C# port said nothing at all about where a
document went wrong, not even a line.

Tracing is the hook the generated parser offers for watching the parse, so
the grammar now enables it and `FurthestFailureTracer` records the furthest
position any rule reached. That position is where the document stops making
sense, and `Parser` turns it into a `ParseException` that reads the way the
Rust one does:

    Syntax error at line 2, column 8: unexpected ":"
    2 | # break: two
      |        ^

The offsets agree with the JavaScript and Rust ports for the same
documents. `ParseException` derives from `FormatException`, so code that
catches what the parser used to raise keeps working; the tests that assert
the exact exception type now name the exception that carries the position.

Refs #302
The generated parser reports the position on the error object, but the
message it writes says what it expected without saying where, so a caller
that only prints the message loses the position. ParseError puts the line
and the column in the message and quotes the offending line with a caret
under it, matching what the Rust and C# ports now report, and keeps the
position, the found character and the quoted line as fields.

Refs #302
Compile-time validation only checks that parentheses and quotes balance,
so the parser can still refuse the text. It used to panic with a fixed
sentence that named neither the reason nor the position; now it panics
with the parse error, which says the line, the column and what stood
there.

Refs #302
run.sh asks all seven parsers about the same five documents, four of which
do not parse, and prints the answers next to each other. Rust, JavaScript
and C# now agree on the offset of every defect; Python, Go, Java and PHP
accept all five, which is a difference in what they accept rather than in
what they report.

Refs #302
The Rust, JavaScript and C# READMEs described error handling as
"descriptive error messages" without showing one. They now show the
message a broken document produces, with the line, the column and the
quoted line, and list the fields a caller can read instead of printing it.

Refs #302
A minor bump rather than a patch: the Rust crate exports SyntaxError and
parse_document_with_diagnostics, JavaScript exports ParseError and C# now
throws ParseException, all of which are new public API.

All seven declare 0.18.0, so scripts/version-consistency.mjs passes,
including the installation snippets in the Java and PHP READMEs. The
manifests that name the library by version rather than by path follow:
rust/links-notation-benchmark/Cargo.toml, benchmarks/java/pom.xml and the
private website package.

Refs #302
The new parse error position tests in Rust, JavaScript and C# are counted
now, which is what docs CI checks with --check.

Refs #302
@konard konard changed the title [WIP] Rust parse errors carry no line or column, while the JavaScript port reports both Say where a parse failed: line, column and the offending line, in every parser that fails Sep 5, 2026
@konard
konard marked this pull request as ready for review September 5, 2026 16:40
The report footer names the version of the parser it measured, which it
reads from the crate, so the version bump made the committed output stale
and the benchmarks workflow said so. The numbers are unchanged.

Refs #302
@konard

konard commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

🤖 Solution Draft Log

This log file contains the complete execution trace of the AI solution draft process.

💰 Cost: $14.970989

📊 Context and tokens usage:

Claude Opus 5: (3 sub-sessions)

  1. 116.2K / 1M (12%) input tokens, 47.1K / 128K (37%) output tokens
  2. 115.7K / 1M (12%) input tokens, 45.5K / 128K (36%) output tokens
  3. 112.5K / 1M (11%) input tokens, 35.8K / 128K (28%) output tokens

Total: (4.8K new + 305.9K cache writes + 16.4M cache reads) input tokens, 146.9K output tokens, $14.970989 cost

🤖 Models used:

  • Tool: Anthropic Claude Code
  • Requested: opus (claude-opus-5)
  • Thinking level: high (~23999 tokens)
  • Model: Claude Opus 5 (claude-opus-5)

📎 Log file uploaded as Gist (5503KB)


Now working session is ended, feel free to review and add any feedback on the solution draft.

@konard
konard merged commit a842f22 into main Sep 5, 2026
75 checks passed
@konard

konard commented Sep 5, 2026

Copy link
Copy Markdown
Member Author

🎉 Auto-merged

This pull request has been automatically merged by hive-mind.

  • All CI checks have passed

Auto-merged by hive-mind with --auto-merge flag

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rust parse errors carry no line or column, while the JavaScript port reports both

1 participant