Say where a parse failed: line, column and the offending line, in every parser that fails - #303
Merged
Conversation
Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: #302
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
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
Member
Author
🤖 Solution Draft LogThis 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)
Total: (4.8K new + 305.9K cache writes + 16.4M cache reads) input tokens, 146.9K output tokens, $14.970989 cost 🤖 Models used:
📎 Log file uploaded as Gist (5503KB)Now working session is ended, feel free to review and add any feedback on the solution draft. |
Member
Author
🎉 Auto-mergedThis pull request has been automatically merged by hive-mind.
Auto-merged by hive-mind with --auto-merge flag |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
nomerror:No line, no column, nothing about what was expected, and a payload that grows with the document. It now answers:
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
Error(Error { input: "<rest of document>", code: Eof })Syntax error at line 2, column 8: expected "(", a reference or end of line, found ":"+ quoted lineFormatException: Failed to parse 'document'., cursor pinned at line 1, column 1Syntax error at line 2, column 8: unexpected ":"+ quoted lineExpected … but ":" found.— the position was on the error object but not in the messageSyntax error at line 2, column 8: Expected … but ":" found.+ quoted linelino!macrolino! macro: validated at compile time but runtime parse failedlino!: Syntax error at line 1, column 5: …All three implementations now agree on the offset of every defect:
# ok line\n# break: two\nci_gate x\na: b: ca (b\na b)\n: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:
nom) reported the start of line 2 — the last position the document parsed cleanly — with the whole remainder as the payload.ParserStatenow records the furthest position any alternative reached along with what was expected there, and the error takesmax(tracked, nom's own offset).Data["cursor"]was always location 0. The grammar now sets@trace true, andFurthestFailureTracer(anITracer) 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.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: creports column 10 at byte offset 15); JavaScript and C# count UTF-16 code units, matching what peggy andCursorreport.Structured fields, not just a string
ParseError::SyntaxError(SyntaxError)withoffset,line,column,expected,found,line_text, plussummary()andsnippet().parse_document_with_diagnosticsis thenom-level entry point.ParseError(exported) withoffset,line,column,found,lineText,snippet, the generated parser'slocationand the original error ascause.location.startis unchanged, so existing callers keep working.ParseExceptionwithOffset,Line,Column,Found,LineText,Summary,Snippet. It derives fromFormatException, 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: cbecomes a link whose first value is the referenceb:;a (bbecomes two references, one of which is(b. Each declares a parse error type that is never raised for malformed syntax: Go never constructsParseError, PHP never throwsParseException, Java namesParseExceptioninthrowsclauses without throwing it, and Python raisesParseErroronly 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
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.mdrecords what the run says.Tests
rust/links-notation/tests/parse_error_position_tests.rscsharp/Link.Foundation.Links.Notation.Tests/ParseErrorPositionTests.csjs/tests/ParseErrorPosition.test.jsmacro_tests.rs::test_runtime_failure_says_where_the_text_stopped_parsingEach 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 nonominternals (ErrorKind,Eof,Verify) appear in the message.Locally: Rust
cargo fmt --check,cargo clippy -D warnings,cargo test --allgreen (299 tests); C#dotnet format --verify-no-changes,build -c Release,testgreen (207); JavaScriptformat:check,lint,build,bun testgreen (214); Gogo 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.mjspasses;TEST_CASE_COMPARISON.mdand the README test tables were regenerated withnode scripts/create-test-case-comparison.mjs;CHANGELOG.mdrecords the work under## [Unreleased].