From 50da73d3ce16f1f6a1614fb53d623053dffefbe8 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 5 Sep 2026 15:58:22 +0000 Subject: [PATCH 01/10] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/link-foundation/links-notation/issues/302 --- .gitkeep | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitkeep b/.gitkeep index 487e705..1676ccc 100644 --- a/.gitkeep +++ b/.gitkeep @@ -1,4 +1,5 @@ # .gitkeep file auto-generated at 2026-08-28T06:09:39.810Z for PR creation at branch issue-288-cc91e23553e8 for issue https://github.com/link-foundation/links-notation/issues/288 # Updated: 2026-08-28T07:33:05.436Z # Updated: 2026-08-28T11:11:17.987Z -# Updated: 2026-08-28T12:23:46.151Z \ No newline at end of file +# Updated: 2026-08-28T12:23:46.151Z +# Updated: 2026-09-05T15:58:22.114Z \ No newline at end of file From 4d0ef811778baa58d5be2afcfdb51aed3af819c0 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 5 Sep 2026 16:13:08 +0000 Subject: [PATCH 02/10] Point Rust parse errors at the line and column that broke The Rust parser handed the raw `nom` error to `format!("{:?}")`, so a malformed document was reported as `Error(Error { input: "", 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 --- .../examples/parse_error_positions.rs | 30 +++ rust/links-notation/src/lib.rs | 192 +++++++++++++++- rust/links-notation/src/parser.rs | 216 +++++++++++++++--- .../tests/parse_error_position_tests.rs | 180 +++++++++++++++ 4 files changed, 576 insertions(+), 42 deletions(-) create mode 100644 rust/links-notation/examples/parse_error_positions.rs create mode 100644 rust/links-notation/tests/parse_error_position_tests.rs diff --git a/rust/links-notation/examples/parse_error_positions.rs b/rust/links-notation/examples/parse_error_positions.rs new file mode 100644 index 0000000..08a69f0 --- /dev/null +++ b/rust/links-notation/examples/parse_error_positions.rs @@ -0,0 +1,30 @@ +//! What a parse error says: the line, the column, what could have stood there +//! and the offending line with a caret under it. +//! +//! Run with `cargo run --example parse_error_positions`. + +use links_notation::{parse_lino, ParseError}; + +fn main() { + let documents = [ + "# ok line\n# break: two\nci_gate x\n stage rust", + "a: b: c", + "a (b\n", + "a b)\n", + ]; + + for document in documents { + match parse_lino(document) { + Ok(links) => println!("{document:?} parses as {links}\n"), + Err(error) => { + println!("{error}"); + if let ParseError::SyntaxError(syntax) = &error { + println!( + " line {}, column {}, byte offset {}\n", + syntax.line, syntax.column, syntax.offset + ); + } + } + } + } +} diff --git a/rust/links-notation/src/lib.rs b/rust/links-notation/src/lib.rs index 28366ad..a5ddc08 100644 --- a/rust/links-notation/src/lib.rs +++ b/rust/links-notation/src/lib.rs @@ -26,8 +26,8 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub enum ParseError { /// Input string is empty or contains only whitespace EmptyInput, - /// Syntax error during parsing - SyntaxError(String), + /// The document does not parse, and this is where it stopped + SyntaxError(SyntaxError), /// Internal parser error InternalError(String), } @@ -36,7 +36,7 @@ impl fmt::Display for ParseError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { ParseError::EmptyInput => write!(f, "Empty input"), - ParseError::SyntaxError(msg) => write!(f, "Syntax error: {}", msg), + ParseError::SyntaxError(error) => write!(f, "Syntax error at {}", error), ParseError::InternalError(msg) => write!(f, "Internal error: {}", msg), } } @@ -44,6 +44,180 @@ impl fmt::Display for ParseError { impl StdError for ParseError {} +/// The number of characters of the offending line an error message quotes. +/// +/// A message has to fit in a log line, and the whole point of quoting one line +/// of context is that the message does not grow with the size of the document. +const QUOTED_LINE_WIDTH: usize = 80; + +/// What a message writes in place of the part of a long line it left out. +const ELLIPSIS: &str = "..."; + +/// A syntax error, with the position in the document it was found at. +/// +/// The position is the furthest one the parser reached, which is the character +/// the document stops making sense at rather than the point the last +/// alternative gave up on. +/// +/// # Examples +/// ``` +/// use links_notation::{parse_lino, ParseError}; +/// +/// let error = parse_lino("# ok line\n# break: two\n").unwrap_err(); +/// let ParseError::SyntaxError(error) = error else { panic!("expected a syntax error") }; +/// assert_eq!((error.line, error.column), (2, 8)); +/// assert_eq!(error.found, Some(':')); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyntaxError { + /// Byte offset of the offending position from the start of the document. + pub offset: usize, + /// Line the offending position is on, counted from 1. + pub line: usize, + /// Column the offending position is at, in characters, counted from 1. + pub column: usize, + /// What could have continued the document at this position. Empty when the + /// parser stopped somewhere it names no expectation for. + pub expected: Vec, + /// The character found instead, or `None` at the end of the document. + pub found: Option, + /// The offending line, as written, without its line ending. + pub line_text: String, +} + +impl SyntaxError { + /// The one-line summary: where the parser stopped, what could have stood + /// there and what does. + /// + /// # Examples + /// ``` + /// use links_notation::{parse_lino, ParseError}; + /// + /// let ParseError::SyntaxError(error) = parse_lino("a: b: c").unwrap_err() else { + /// panic!("expected a syntax error") + /// }; + /// assert_eq!( + /// error.summary(), + /// r#"line 1, column 5: expected "(", a reference or end of line, found ":""# + /// ); + /// ``` + pub fn summary(&self) -> String { + let found = match self.found { + Some(character) => format!("\"{}\"", character.escape_debug()), + None => "end of input".to_string(), + }; + match join_alternatives(&self.expected) { + Some(expected) => format!( + "line {}, column {}: expected {}, found {}", + self.line, self.column, expected, found + ), + None => format!( + "line {}, column {}: unexpected {}", + self.line, self.column, found + ), + } + } + + /// The offending line with a caret under the offending column, quoted the + /// way `rustc` quotes source. + /// + /// A long line is shown as a window around the caret, so the message stays + /// the same size whether the document has ten lines or fifteen hundred. + /// + /// # Examples + /// ``` + /// use links_notation::{parse_lino, ParseError}; + /// + /// let ParseError::SyntaxError(error) = parse_lino("a: b: c").unwrap_err() else { + /// panic!("expected a syntax error") + /// }; + /// assert_eq!(error.snippet(), "1 | a: b: c\n | ^"); + /// ``` + pub fn snippet(&self) -> String { + let (quoted, column) = quote_line(&self.line_text, self.column); + let number = self.line.to_string(); + let gutter = " ".repeat(number.len()); + format!( + "{} | {}\n{} | {}^", + number, + quoted, + gutter, + " ".repeat(column - 1) + ) + } +} + +impl fmt::Display for SyntaxError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}\n{}", self.summary(), self.snippet()) + } +} + +impl StdError for SyntaxError {} + +/// Writes alternatives the way prose does: `a`, `a or b`, `a, b or c`. +fn join_alternatives(alternatives: &[String]) -> Option { + match alternatives { + [] => None, + [only] => Some(only.clone()), + [rest @ .., last] => Some(format!("{} or {}", rest.join(", "), last)), + } +} + +/// Cuts `line` down to a window around `column`, and says which column the +/// offending character sits at in that window. Both columns count from 1. +fn quote_line(line: &str, column: usize) -> (String, usize) { + let characters: Vec = line.chars().collect(); + if characters.len() <= QUOTED_LINE_WIDTH { + return (line.to_string(), column); + } + + let target = column - 1; + let last_start = characters.len() - QUOTED_LINE_WIDTH; + let start = target.saturating_sub(QUOTED_LINE_WIDTH / 2).min(last_start); + let end = start + QUOTED_LINE_WIDTH; + + let mut quoted = String::new(); + if start > 0 { + quoted.push_str(ELLIPSIS); + } + quoted.extend(&characters[start..end]); + if end < characters.len() { + quoted.push_str(ELLIPSIS); + } + + let shift = if start > 0 { + ELLIPSIS.chars().count() + } else { + 0 + }; + (quoted, target - start + shift + 1) +} + +/// Turns the position the parser stopped at into a line, a column and the line +/// itself, so the message can point at the defect instead of quoting the rest +/// of the document. +fn locate(document: &str, failure: parser::ParseFailure) -> SyntaxError { + let offset = failure.offset.min(document.len()); + let before = &document[..offset]; + let line = before.matches('\n').count() + 1; + let line_start = before.rfind('\n').map_or(0, |position| position + 1); + let column = document[line_start..offset].chars().count() + 1; + let line_end = document[line_start..] + .find('\n') + .map_or(document.len(), |position| line_start + position); + let line_text = document[line_start..line_end].trim_end_matches('\r'); + + SyntaxError { + offset, + line, + column, + expected: failure.expected.iter().map(|s| s.to_string()).collect(), + found: document[offset..].chars().next(), + line_text: line_text.to_string(), + } +} + #[derive(Debug, Clone, PartialEq)] pub enum LiNo { Link { id: Option, values: Vec }, @@ -617,8 +791,8 @@ pub fn parse_lino(document: &str) -> Result, ParseError> { }); } - match parser::parse_document(document) { - Ok((_, links)) => { + match parser::parse_document_with_diagnostics(document) { + Ok(links) => { if links.is_empty() { Ok(LiNo::Link { id: None, @@ -633,7 +807,7 @@ pub fn parse_lino(document: &str) -> Result, ParseError> { }) } } - Err(e) => Err(ParseError::SyntaxError(format!("{:?}", e))), + Err(failure) => Err(ParseError::SyntaxError(locate(document, failure))), } } @@ -644,8 +818,8 @@ pub fn parse_lino_to_links(document: &str) -> Result>, ParseErr return Ok(vec![]); } - match parser::parse_document(document) { - Ok((_, links)) => { + match parser::parse_document_with_diagnostics(document) { + Ok(links) => { if links.is_empty() { Ok(vec![]) } else { @@ -654,7 +828,7 @@ pub fn parse_lino_to_links(document: &str) -> Result>, ParseErr Ok(flattened) } } - Err(e) => Err(ParseError::SyntaxError(format!("{:?}", e))), + Err(failure) => Err(ParseError::SyntaxError(locate(document, failure))), } } diff --git a/rust/links-notation/src/parser.rs b/rust/links-notation/src/parser.rs index 095b5bf..1ad300d 100644 --- a/rust/links-notation/src/parser.rs +++ b/rust/links-notation/src/parser.rs @@ -83,6 +83,39 @@ pub struct ParserState { indentation_stack: RefCell>, base_indentation: RefCell>, nested_depth: RefCell, + furthest: RefCell, +} + +/// The furthest position any alternative reached before failing, and what could +/// have continued the document there. +/// +/// The parser backtracks, so the position the last alternative happens to fail +/// at says little about where the document stops making sense: a defect in the +/// middle of line two is reported by `nom` as "expected end of input" at the +/// start of line two, because that is where the document last parsed cleanly. +/// The furthest position reached is what a PEG parser points at, and it is what +/// the JavaScript port reports. +#[derive(Debug, Clone, Default)] +struct FurthestFailure { + /// Address of the furthest failing position, as a pointer into the document + /// being parsed. Turned into an offset once the document is at hand again. + address: Option, + expected: Vec<&'static str>, +} + +/// Where the parser stopped, and what it could have accepted there. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseFailure { + /// Byte offset into the document the parser stopped at. + pub offset: usize, + /// What could have continued the document at `offset`, in the wording used + /// by the error message. Empty when the failure came from a place that + /// names no expectation. + pub expected: Vec<&'static str>, + /// The `nom` error kind. An internal detail of this parser: it says which + /// combinator gave up, not what is wrong with the document, so it is kept + /// out of the error message and reachable only through `Debug`. + pub kind: Option, } /// Indentation state of the context a parenthesized group was opened in. @@ -103,6 +136,7 @@ impl ParserState { indentation_stack: RefCell::new(vec![0]), base_indentation: RefCell::new(None), nested_depth: RefCell::new(0), + furthest: RefCell::new(FurthestFailure::default()), } } @@ -165,6 +199,71 @@ impl ParserState { pub fn is_inside_nested_context(&self) -> bool { *self.nested_depth.borrow() > 0 } + + /// Records that `what` could have continued the document at `at`, and that + /// nothing there did. Only the furthest such position is kept; every + /// expectation recorded at that same position is kept alongside it. + fn expected_at(&self, at: &str, what: &'static str) { + let address = at.as_ptr() as usize; + let mut furthest = self.furthest.borrow_mut(); + match furthest.address { + Some(recorded) if recorded > address => {} + Some(recorded) if recorded == address => { + if !furthest.expected.contains(&what) { + furthest.expected.push(what); + } + } + _ => { + furthest.address = Some(address); + furthest.expected = vec![what]; + } + } + } + + /// Turns everything recorded during a failed parse into a position in + /// `document`. + /// + /// `nom`'s own error position is the fallback and the floor: the parser + /// reached at least that far, whatever the tracked alternatives say. + fn failure(&self, document: &str, error: &nom::Err>) -> ParseFailure { + let base = document.as_ptr() as usize; + let (nom_offset, kind) = match error { + nom::Err::Error(e) | nom::Err::Failure(e) => ( + (e.input.as_ptr() as usize).saturating_sub(base), + Some(e.code), + ), + nom::Err::Incomplete(_) => (document.len(), None), + }; + let furthest = self.furthest.borrow(); + let tracked = furthest + .address + .map(|address| address.saturating_sub(base)) + .unwrap_or(0); + let offset = tracked.max(nom_offset).min(document.len()); + let expected = if tracked == offset { + furthest.expected.clone() + } else { + // The tracked expectations belong to an earlier position, so they + // do not describe the place being reported. + Vec::new() + }; + ParseFailure { + offset, + expected, + kind, + } + } +} + +/// Fails the way `nom` does, after recording what was expected at `input`. +fn expected<'a, T>( + input: &'a str, + state: &ParserState, + what: &'static str, + kind: nom::error::ErrorKind, +) -> IResult<&'a str, T> { + state.expected_at(input, what); + Err(nom::Err::Error(nom::error::Error::new(input, kind))) } fn is_whitespace_char(c: char) -> bool { @@ -320,25 +419,33 @@ fn backtick_quoted_dynamic(input: &str) -> IResult<&str, String> { parse_dynamic_quote_string(input, '`') } -fn reference(input: &str) -> IResult<&str, String> { +fn reference<'a>(input: &'a str, state: &ParserState) -> IResult<&'a str, String> { // Try quoted strings with dynamic quote detection (supports any N quotes) // Then fall back to simple unquoted reference - alt(( + let parsed = alt(( double_quoted_dynamic, single_quoted_dynamic, backtick_quoted_dynamic, simple_reference, )) - .parse(input) + .parse(input); + if parsed.is_err() { + state.expected_at(input, "a reference"); + } + parsed } fn eol<'a>(input: &'a str, state: &ParserState) -> IResult<&'a str, &'a str> { - alt(( + let parsed = alt(( preceded(horizontal_whitespace, line_ending), preceded(horizontal_whitespace, eof), |i| nested_group_end(i, state), )) - .parse(input) + .parse(input); + if parsed.is_err() { + state.expected_at(input, "end of line"); + } + parsed } /// Inside a parenthesized group the closing parenthesis ends the last line, @@ -354,10 +461,7 @@ fn nested_group_end<'a>(input: &'a str, state: &ParserState) -> IResult<&'a str, if rest.starts_with(')') { Ok((rest, "")) } else { - Err(nom::Err::Error(nom::error::Error::new( - input, - nom::error::ErrorKind::Char, - ))) + expected(rest, state, "\")\"", nom::error::ErrorKind::Char) } } @@ -381,7 +485,11 @@ fn strip_line_ending(input: &str) -> Option<&str> { } fn reference_or_link<'a>(input: &'a str, state: &ParserState) -> IResult<&'a str, Link> { - alt((|i| nested_group(i, state), reference.map(Link::new_singlet))).parse(input) + alt(( + |i| nested_group(i, state), + (|i| reference(i, state)).map(Link::new_singlet), + )) + .parse(input) } fn single_line_value_and_whitespace<'a>( @@ -396,15 +504,31 @@ fn single_line_values<'a>(input: &'a str, state: &ParserState) -> IResult<&'a st } fn single_line_link<'a>(input: &'a str, state: &ParserState) -> IResult<&'a str, Link> { - ( - horizontal_whitespace, - reference, - horizontal_whitespace, - char(':'), - |i| single_line_values(i, state), - ) - .map(|(_, id, _, _, values)| Link::new_link(Some(id), values)) - .parse(input) + let (input, _) = horizontal_whitespace(input)?; + let (input, id) = reference(input, state)?; + let (input, _) = horizontal_whitespace(input)?; + let (input, _) = colon(input, state)?; + let (input, values) = single_line_values(input, state)?; + Ok((input, Link::new_link(Some(id), values))) +} + +/// The colon that separates an identifier from its values. +fn colon<'a>(input: &'a str, state: &ParserState) -> IResult<&'a str, char> { + character(':', input, state, "\":\"") +} + +/// Matches one character, recording what was expected when it is not there. +fn character<'a>( + wanted: char, + input: &'a str, + state: &ParserState, + what: &'static str, +) -> IResult<&'a str, char> { + let parsed: IResult<&'a str, char> = char(wanted).parse(input); + match parsed { + Ok(parsed) => Ok(parsed), + Err(_) => expected(input, state, what, nom::error::ErrorKind::Char), + } } fn single_line_value_link<'a>(input: &'a str, state: &ParserState) -> IResult<&'a str, Link> { @@ -424,18 +548,18 @@ fn single_line_value_link<'a>(input: &'a str, state: &ParserState) -> IResult<&' } fn indented_id_link<'a>(input: &'a str, state: &ParserState) -> IResult<&'a str, Link> { - (reference, horizontal_whitespace, char(':'), |i| { - eol(i, state) - }) - .map(|(id, _, _, _)| Link::new_indented_id(id)) - .parse(input) + let (input, id) = reference(input, state)?; + let (input, _) = horizontal_whitespace(input)?; + let (input, _) = colon(input, state)?; + let (input, _) = eol(input, state)?; + Ok((input, Link::new_indented_id(id))) } /// A parenthesized group opens a nested context: its body starts fresh at /// indentation level zero and is parsed with the same rules as the root /// document, so indentation is structural inside parentheses as well. fn nested_group<'a>(input: &'a str, state: &ParserState) -> IResult<&'a str, Link> { - let (body_input, _) = char('(').parse(input)?; + let (body_input, _) = character('(', input, state, "\"(\"")?; let saved = state.enter_nested_context(); let result = nested_group_body(body_input, state); state.exit_nested_context(saved); @@ -445,14 +569,19 @@ fn nested_group<'a>(input: &'a str, state: &ParserState) -> IResult<&'a str, Lin fn nested_group_body<'a>(input: &'a str, state: &ParserState) -> IResult<&'a str, Link> { if let Ok((rest, body)) = links(skip_empty_lines(input), state) { let (rest, _) = whitespace(rest)?; - let (rest, _) = char(')').parse(rest)?; + let (rest, _) = closing_parenthesis(rest, state)?; return Ok((rest, Link::new_nested(body))); } let (rest, _) = whitespace(input)?; - let (rest, _) = char(')').parse(rest)?; + let (rest, _) = closing_parenthesis(rest, state)?; Ok((rest, Link::new_nested(vec![]))) } +/// The parenthesis that closes a group. +fn closing_parenthesis<'a>(input: &'a str, state: &ParserState) -> IResult<&'a str, char> { + character(')', input, state, "\")\"") +} + fn single_line_any_link<'a>(input: &'a str, state: &ParserState) -> IResult<&'a str, Link> { alt(( terminated(|i| single_line_link(i, state), |i| eol(i, state)), @@ -541,18 +670,39 @@ fn links<'a>(input: &'a str, state: &ParserState) -> IResult<&'a str, Vec> pub fn parse_document(input: &str) -> IResult<&str, Vec> { let state = ParserState::new(); + document(input, &state) +} + +/// Parses a document and, when it does not parse, says where it stopped. +/// +/// `parse_document` reports a failure the way `nom` does: with the whole +/// unconsumed remainder of the input and the combinator that gave up. Neither +/// tells a reader which line to look at, and the remainder grows with the size +/// of the document. This is the entry point the library uses. +pub fn parse_document_with_diagnostics(input: &str) -> Result, ParseFailure> { + let state = ParserState::new(); + match document(input, &state) { + Ok((_, links)) => Ok(links), + Err(error) => Err(state.failure(input, &error)), + } +} +fn document<'a>(input: &'a str, state: &ParserState) -> IResult<&'a str, Vec> { // Skip leading blank lines but preserve the line structure - let input = skip_empty_lines(input); + let document = skip_empty_lines(input); // Handle empty or whitespace-only documents - if input.trim().is_empty() { + if document.trim().is_empty() { return Ok(("", vec![])); } - let (input, result) = links(input, &state)?; - let (input, _) = whitespace(input)?; - let (input, _) = eof(input)?; + let (rest, result) = links(document, state)?; + let (rest, _) = whitespace(rest)?; + let end: IResult<&'a str, &'a str> = eof(rest); + let (rest, _) = match end { + Ok(parsed) => parsed, + Err(_) => return expected(rest, state, "end of input", nom::error::ErrorKind::Eof), + }; - Ok((input, result)) + Ok((rest, result)) } diff --git a/rust/links-notation/tests/parse_error_position_tests.rs b/rust/links-notation/tests/parse_error_position_tests.rs new file mode 100644 index 0000000..f123cca --- /dev/null +++ b/rust/links-notation/tests/parse_error_position_tests.rs @@ -0,0 +1,180 @@ +//! A parse error has to say where the document stopped making sense. +//! +//! The positions asserted here are the ones the JavaScript port reports for the +//! same input, so the two implementations can be held to the same contract +//! ([#302](https://github.com/link-foundation/links-notation/issues/302)). + +use links_notation::{parse_lino, parse_lino_to_links, ParseError, SyntaxError}; + +fn syntax_error(document: &str) -> SyntaxError { + match parse_lino(document) { + Ok(parsed) => panic!("expected {document:?} not to parse, got {parsed}"), + Err(ParseError::SyntaxError(error)) => error, + Err(other) => panic!("expected a syntax error for {document:?}, got {other}"), + } +} + +#[test] +fn test_reports_the_line_and_column_of_the_defect() { + // The example from the issue: the defect is the colon on line 2, and the + // two lines after it are fine. + let error = syntax_error("# ok line\n# break: two\nci_gate x\n stage rust"); + + assert_eq!(error.line, 2); + assert_eq!(error.column, 8); + assert_eq!(error.found, Some(':')); +} + +#[test] +fn test_offset_agrees_with_the_javascript_port() { + // JavaScript reports { offset: 17, line: 2, column: 8 } for this document. + let error = syntax_error("# ok line\n# break: two\n"); + + assert_eq!(error.offset, 17); + assert_eq!(error.line, 2); + assert_eq!(error.column, 8); +} + +#[test] +fn test_points_at_the_defect_rather_than_at_the_line_it_starts_on() { + // `nom` reports this failure at the start of line 2, because that is where + // the document last parsed cleanly. The defect is seven characters later. + let error = syntax_error("# ok line\n# break: two\n"); + + assert_ne!(error.column, 1); + assert_eq!(&error.line_text[error.column - 1..error.column], ":"); +} + +#[test] +fn test_reports_the_line_a_late_defect_is_on() { + let error = syntax_error("a\nb\nc\nd\ne: f: g\nh\n"); + + assert_eq!(error.line, 5); + assert_eq!(error.column, 5); + assert_eq!(error.line_text, "e: f: g"); +} + +#[test] +fn test_says_what_could_have_stood_there() { + let error = syntax_error("a: b: c"); + + assert_eq!(error.expected, vec!["\"(\"", "a reference", "end of line"]); +} + +#[test] +fn test_reports_the_end_of_the_document_when_a_group_is_never_closed() { + // JavaScript reports { offset: 5, line: 2, column: 1 } for this document. + let error = syntax_error("a (b\n"); + + assert_eq!(error.offset, 5); + assert_eq!(error.line, 2); + assert_eq!(error.column, 1); + assert_eq!(error.found, None); + assert!(error.expected.contains(&"\")\"".to_string())); +} + +#[test] +fn test_reports_an_unmatched_closing_parenthesis() { + // JavaScript reports { offset: 3, line: 1, column: 4 } for this document. + let error = syntax_error("a b)\n"); + + assert_eq!(error.offset, 3); + assert_eq!(error.line, 1); + assert_eq!(error.column, 4); + assert_eq!(error.found, Some(')')); +} + +#[test] +fn test_summary_reads_as_a_sentence() { + let error = syntax_error("# ok line\n# break: two\n"); + + assert_eq!( + error.summary(), + r#"line 2, column 8: expected "(", a reference or end of line, found ":""# + ); +} + +#[test] +fn test_snippet_points_a_caret_at_the_offending_character() { + let error = syntax_error("# ok line\n# break: two\n"); + + assert_eq!(error.snippet(), "2 | # break: two\n | ^"); +} + +#[test] +fn test_message_quotes_one_line_rather_than_the_rest_of_the_document() { + // The whole complaint in #302: the message used to carry the entire + // unconsumed remainder, so it grew with the size of the document. + let tail = "trailing line\n".repeat(500); + let document = format!("# ok line\n# break: two\n{tail}"); + + let message = format!("{}", parse_lino(&document).unwrap_err()); + + assert!(message.contains("line 2, column 8"), "{message}"); + assert!(!message.contains("trailing line"), "{message}"); + assert!(message.len() < 200, "message is {} bytes", message.len()); +} + +#[test] +fn test_message_of_a_long_line_stays_a_message() { + let document = format!("{}: {}: c", "a".repeat(400), "b".repeat(400)); + + let error = syntax_error(&document); + let message = format!("{error}"); + + assert_eq!(error.line, 1); + assert_eq!(error.column, 803); + assert!(message.contains("..."), "{message}"); + assert!(message.len() < 300, "message is {} bytes", message.len()); + // The caret still lands under the character the message is about. + let caret = message.lines().last().unwrap().find('^').unwrap(); + let quoted = message.lines().nth(1).unwrap(); + assert_eq!("ed[caret..caret + 1], ":"); +} + +#[test] +fn test_error_display_starts_with_the_position() { + let error = parse_lino("a: b: c").unwrap_err(); + + assert!( + format!("{error}").starts_with("Syntax error at line 1, column 5:"), + "{error}" + ); +} + +#[test] +fn test_nom_internals_stay_out_of_the_message() { + let message = format!("{}", parse_lino("a: b: c").unwrap_err()); + + for internal in ["ErrorKind", "code:", "Eof", "Verify", "TakeWhile1"] { + assert!(!message.contains(internal), "{message} mentions {internal}"); + } +} + +#[test] +fn test_both_entry_points_report_the_same_position() { + let document = "# ok line\n# break: two\n"; + + let one = syntax_error(document); + let Err(ParseError::SyntaxError(many)) = parse_lino_to_links(document) else { + panic!("expected a syntax error") + }; + + assert_eq!(one, many); +} + +#[test] +fn test_column_counts_characters_rather_than_bytes() { + // The identifier is six characters wide and twelve bytes long. + let error = syntax_error("привет: b: c"); + + assert_eq!(error.line, 1); + assert_eq!(error.column, 10); + assert_eq!(error.offset, 15); + assert_eq!(error.found, Some(':')); +} + +#[test] +fn test_a_document_that_parses_reports_nothing() { + assert!(parse_lino("a: b\n c: d\n").is_ok()); +} From 93adcca850960d56273c729ed7a330b0dfc596b8 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 5 Sep 2026 16:25:15 +0000 Subject: [PATCH 03/10] Say where a C# parse stopped instead of naming the start rule 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 --- .../EdgeCaseParserTests.cs | 12 +- .../IndentedIdSyntaxTests.cs | 2 +- .../ParseErrorPositionTests.cs | 141 ++++++++++++++++ .../SingleLineParserTests.cs | 12 +- .../FurthestFailureTracer.cs | 45 +++++ .../ParseException.cs | 159 ++++++++++++++++++ .../Link.Foundation.Links.Notation/Parser.cs | 41 +++++ .../Link.Foundation.Links.Notation/Parser.peg | 7 +- 8 files changed, 405 insertions(+), 14 deletions(-) create mode 100644 csharp/Link.Foundation.Links.Notation.Tests/ParseErrorPositionTests.cs create mode 100644 csharp/Link.Foundation.Links.Notation/FurthestFailureTracer.cs create mode 100644 csharp/Link.Foundation.Links.Notation/ParseException.cs create mode 100644 csharp/Link.Foundation.Links.Notation/Parser.cs diff --git a/csharp/Link.Foundation.Links.Notation.Tests/EdgeCaseParserTests.cs b/csharp/Link.Foundation.Links.Notation.Tests/EdgeCaseParserTests.cs index 092c8f2..f5b36e5 100644 --- a/csharp/Link.Foundation.Links.Notation.Tests/EdgeCaseParserTests.cs +++ b/csharp/Link.Foundation.Links.Notation.Tests/EdgeCaseParserTests.cs @@ -11,7 +11,7 @@ public static void EmptyLinkTest() var source = @":"; var parser = new Parser(); // Standalone ':' is now forbidden and should throw an exception - Assert.Throws(() => parser.Parse(source)); + Assert.Throws(() => parser.Parse(source)); } [Fact] @@ -31,7 +31,7 @@ public static void EmptyLinkWithEmptySelfReferenceTest() var source = @"(:)"; var parser = new Parser(); // '(:)' is now forbidden and should throw an exception - Assert.Throws(() => parser.Parse(source)); + Assert.Throws(() => parser.Parse(source)); } [Fact] @@ -49,11 +49,11 @@ public static void AllFeaturesTest() // Test link without id (single-line) - now forbidden input = ": value1 value2"; - Assert.Throws(() => new Parser().Parse(input)); + Assert.Throws(() => new Parser().Parse(input)); // Test link without id (multi-line) - now forbidden input = "(: value1 value2)"; - Assert.Throws(() => new Parser().Parse(input)); + Assert.Throws(() => new Parser().Parse(input)); // Test singlet link input = "(singlet)"; @@ -169,7 +169,7 @@ public static void EmptyLinksTest() // '(:)' is now forbidden input = "(:)"; - Assert.Throws(() => new Parser().Parse(input)); + Assert.Throws(() => new Parser().Parse(input)); input = "(id:)"; result = new Parser().Parse(input); @@ -181,7 +181,7 @@ public static void InvalidInputTest() { var input = "(invalid"; // Unclosed parentheses should throw an exception - Assert.Throws(() => new Parser().Parse(input)); + Assert.Throws(() => new Parser().Parse(input)); } } } \ No newline at end of file diff --git a/csharp/Link.Foundation.Links.Notation.Tests/IndentedIdSyntaxTests.cs b/csharp/Link.Foundation.Links.Notation.Tests/IndentedIdSyntaxTests.cs index 0fe8956..331f253 100644 --- a/csharp/Link.Foundation.Links.Notation.Tests/IndentedIdSyntaxTests.cs +++ b/csharp/Link.Foundation.Links.Notation.Tests/IndentedIdSyntaxTests.cs @@ -137,7 +137,7 @@ public static void UnsupportedColonOnlySyntaxShouldFailTest() mama"; var parser = new Parser(); - Assert.Throws(() => parser.Parse(input)); + Assert.Throws(() => parser.Parse(input)); } [Fact] diff --git a/csharp/Link.Foundation.Links.Notation.Tests/ParseErrorPositionTests.cs b/csharp/Link.Foundation.Links.Notation.Tests/ParseErrorPositionTests.cs new file mode 100644 index 0000000..a8fa794 --- /dev/null +++ b/csharp/Link.Foundation.Links.Notation.Tests/ParseErrorPositionTests.cs @@ -0,0 +1,141 @@ +using System; +using Xunit; + +namespace Link.Foundation.Links.Notation.Tests +{ + /// + /// A parse error has to say where the document stopped making sense. + /// + /// + /// The positions asserted here are the ones the JavaScript and Rust ports report for + /// the same input, so the implementations can be held to the same contract + /// (https://github.com/link-foundation/links-notation/issues/302). + /// + public static class ParseErrorPositionTests + { + private static ParseException SyntaxError(string document) => + Assert.Throws(() => new Parser().Parse(document)); + + [Fact] + public static void ReportsTheLineAndColumnOfTheDefectTest() + { + // The example from the issue: the defect is the colon on line 2, and the + // two lines after it are fine. + var error = SyntaxError("# ok line\n# break: two\nci_gate x\n stage rust"); + + Assert.Equal(2, error.Line); + Assert.Equal(8, error.Column); + Assert.Equal(':', error.Found); + } + + [Fact] + public static void OffsetAgreesWithTheOtherImplementationsTest() + { + // JavaScript and Rust report offset 17, line 2, column 8 for this document. + var error = SyntaxError("# ok line\n# break: two\n"); + + Assert.Equal(17, error.Offset); + Assert.Equal(2, error.Line); + Assert.Equal(8, error.Column); + } + + [Fact] + public static void ReportsTheLineALateDefectIsOnTest() + { + var error = SyntaxError("a\nb\nc\nd\ne: f: g\nh\n"); + + Assert.Equal(5, error.Line); + Assert.Equal(5, error.Column); + Assert.Equal("e: f: g", error.LineText); + } + + [Fact] + public static void ReportsTheEndOfTheDocumentWhenAGroupIsNeverClosedTest() + { + var error = SyntaxError("a (b\n"); + + Assert.Equal(5, error.Offset); + Assert.Equal(2, error.Line); + Assert.Equal(1, error.Column); + Assert.Null(error.Found); + Assert.Contains("end of input", error.Message); + } + + [Fact] + public static void ReportsAnUnmatchedClosingParenthesisTest() + { + var error = SyntaxError("a b)\n"); + + Assert.Equal(3, error.Offset); + Assert.Equal(1, error.Line); + Assert.Equal(4, error.Column); + Assert.Equal(')', error.Found); + } + + [Fact] + public static void MessagePointsACaretAtTheOffendingCharacterTest() + { + var error = SyntaxError("# ok line\n# break: two\n"); + + Assert.Equal("line 2, column 8: unexpected \":\"", error.Summary); + Assert.Equal("2 | # break: two\n | ^", error.Snippet); + Assert.Equal( + "Syntax error at line 2, column 8: unexpected \":\"\n2 | # break: two\n | ^", + error.Message); + } + + [Fact] + public static void MessageQuotesOneLineRatherThanTheRestOfTheDocumentTest() + { + var document = "# ok line\n# break: two\n" + + string.Concat(System.Linq.Enumerable.Repeat("trailing line\n", 500)); + + var error = SyntaxError(document); + + Assert.Contains("line 2, column 8", error.Message); + Assert.DoesNotContain("trailing line", error.Message); + Assert.True(error.Message.Length < 200, $"message is {error.Message.Length} characters"); + } + + [Fact] + public static void MessageOfALongLineStaysAMessageTest() + { + var document = new string('a', 400) + ": " + new string('b', 400) + ": c"; + + var error = SyntaxError(document); + + Assert.Equal(1, error.Line); + Assert.Equal(803, error.Column); + Assert.Contains("...", error.Message); + Assert.True(error.Message.Length < 300, $"message is {error.Message.Length} characters"); + } + + [Fact] + public static void KeepsCatchingCodeThatExpectsAFormatExceptionWorkingTest() + { + // The generated parser used to raise a bare FormatException; code that catches + // it keeps working, and now reads a position off the exception it catches. + var error = Assert.Throws(() => new Parser().Parse("a: b: c")); + + Assert.IsAssignableFrom(error); + Assert.Equal(4, error.Offset); + } + + [Fact] + public static void DoesNotMentionTheGrammarInternalsTest() + { + var error = SyntaxError("a: b: c"); + + Assert.DoesNotContain("Failed to parse", error.Message); + Assert.DoesNotContain("document", error.Message); + } + + [Fact] + public static void ADocumentThatParsesReportsNothingTest() + { + var links = new Parser().Parse("a: b\n c: d\n"); + + Assert.Equal(2, links.Count); + } + } +} diff --git a/csharp/Link.Foundation.Links.Notation.Tests/SingleLineParserTests.cs b/csharp/Link.Foundation.Links.Notation.Tests/SingleLineParserTests.cs index 8fd6efa..1cf7b81 100644 --- a/csharp/Link.Foundation.Links.Notation.Tests/SingleLineParserTests.cs +++ b/csharp/Link.Foundation.Links.Notation.Tests/SingleLineParserTests.cs @@ -106,7 +106,7 @@ public static void ParseValuesOnlyTest() var source = ": value1 value2"; var parser = new Parser(); // Standalone ':' is now forbidden and should throw an exception - Assert.Throws(() => parser.Parse(source)); + Assert.Throws(() => parser.Parse(source)); } [Fact] @@ -232,7 +232,7 @@ public static void SingleLineWithoutIdTest() { // Test link without id (single-line) - now forbidden var input = ": value1 value2"; - Assert.Throws(() => new Parser().Parse(input)); + Assert.Throws(() => new Parser().Parse(input)); } [Fact] @@ -240,7 +240,7 @@ public static void MultilineWithoutIdTest() { // Test link without id (multi-line) - now forbidden var input = "(: value1 value2)"; - Assert.Throws(() => new Parser().Parse(input)); + Assert.Throws(() => new Parser().Parse(input)); } [Fact] @@ -263,7 +263,7 @@ public static void MultiLineLinkWithIdTest() public static void LinkWithoutIdMultiLineTest() { var input = "(: value1 value2)"; - Assert.Throws(() => new Parser().Parse(input)); + Assert.Throws(() => new Parser().Parse(input)); } [Fact] @@ -334,7 +334,7 @@ public static void LinkWithoutIdSingleLineTest() var parser = new Parser(); // C# parser forbids this syntax (like JS/Rust) - Assert.Throws(() => parser.Parse(input)); + Assert.Throws(() => parser.Parse(input)); } [Fact] @@ -416,7 +416,7 @@ public static void ParseValuesOnlyStandaloneColonTest() var parser = new Parser(); // C# parser forbids this syntax (like JS/Rust) - Assert.Throws(() => parser.Parse(input)); + Assert.Throws(() => parser.Parse(input)); } [Fact] diff --git a/csharp/Link.Foundation.Links.Notation/FurthestFailureTracer.cs b/csharp/Link.Foundation.Links.Notation/FurthestFailureTracer.cs new file mode 100644 index 0000000..c5dad43 --- /dev/null +++ b/csharp/Link.Foundation.Links.Notation/FurthestFailureTracer.cs @@ -0,0 +1,45 @@ +using Pegasus.Common; +using Pegasus.Common.Tracing; + +namespace Link.Foundation.Links.Notation +{ + /// + /// Remembers the furthest position a parse reached. + /// + /// + /// A parsing expression grammar backtracks, so the cursor the generated parser raises + /// its error with is the start of the document rather than the place the document + /// stopped making sense. The furthest position any rule reached is that place, and the + /// tracer is the only hook the generated parser offers to observe it. + /// + internal sealed class FurthestFailureTracer : ITracer + { + /// Offset of the furthest position the parse reached. + public int Furthest { get; private set; } + + public void TraceRuleEnter(string ruleName, Cursor cursor) => Reach(cursor.Location); + + public void TraceRuleExit(string ruleName, Cursor cursor, IParseResult result) + { + if (result != null) Reach(result.EndCursor.Location); + } + + public void TraceCacheHit(string ruleName, Cursor cursor, CacheKey key, IParseResult result) + { + if (result != null) Reach(result.EndCursor.Location); + } + + public void TraceCacheMiss(string ruleName, Cursor cursor, CacheKey key) + { + } + + public void TraceInfo(string ruleName, Cursor cursor, string info) + { + } + + private void Reach(int location) + { + if (location > Furthest) Furthest = location; + } + } +} diff --git a/csharp/Link.Foundation.Links.Notation/ParseException.cs b/csharp/Link.Foundation.Links.Notation/ParseException.cs new file mode 100644 index 0000000..a0cf180 --- /dev/null +++ b/csharp/Link.Foundation.Links.Notation/ParseException.cs @@ -0,0 +1,159 @@ +using System; +using System.Text; + +namespace Link.Foundation.Links.Notation +{ + /// + /// The error raised when a document does not parse, carrying the position the parser + /// stopped at. + /// + /// + /// The position is the furthest one the parser reached, which is the character the + /// document stops making sense at rather than the point the last alternative gave up + /// on. It derives from , the exception the generated + /// parser raised on its own, so code that catches that keeps working. + /// + public class ParseException : FormatException + { + /// The number of characters a quoted line is cut down to. + private const int QuotedLineWidth = 80; + + /// What a message writes in place of the part of a long line it left out. + private const string Ellipsis = "..."; + + /// + /// Creates an exception describing where stopped parsing. + /// + /// The document that failed to parse. + /// Offset of the position the parser stopped at. + /// The error the generated parser raised, if any. + public ParseException(string subject, int offset, Exception? innerException = null) + : this(Locate(subject ?? string.Empty, offset), innerException) + { + } + + private ParseException(Position position, Exception? innerException) + : base(Describe(position), innerException) + { + Offset = position.Offset; + Line = position.Line; + Column = position.Column; + Found = position.Found; + LineText = position.LineText; + } + + /// Offset of the offending position from the start of the document. + public int Offset { get; } + + /// Line the offending position is on, counted from 1. + public int Line { get; } + + /// Column the offending position is at, counted from 1. + public int Column { get; } + + /// The character found instead, or null at the end of the document. + public char? Found { get; } + + /// The offending line, as written, without its line ending. + public string LineText { get; } + + /// The one-line summary: where the parser stopped and what stands there. + public string Summary => Summarize(new Position(Offset, Line, Column, Found, LineText)); + + /// + /// The offending line with a caret under the offending column, quoted the way a + /// compiler quotes source. A long line is shown as a window around the caret, so the + /// message stays the same size whether the document has ten lines or fifteen hundred. + /// + public string Snippet => Quote(new Position(Offset, Line, Column, Found, LineText)); + + /// Where the parser stopped, and what the document holds there. + private readonly record struct Position( + int Offset, + int Line, + int Column, + char? Found, + string LineText); + + /// The message the exception carries: the summary and the quoted line. + private static string Describe(Position position) => + $"Syntax error at {Summarize(position)}\n{Quote(position)}"; + + private static string Summarize(Position position) + { + var found = position.Found.HasValue + ? $"\"{Escape(position.Found.Value)}\"" + : "end of input"; + return $"line {position.Line}, column {position.Column}: unexpected {found}"; + } + + private static string Quote(Position position) + { + var (quoted, column) = QuoteLine(position.LineText, position.Column); + var number = position.Line.ToString(); + var gutter = new string(' ', number.Length); + return $"{number} | {quoted}\n{gutter} | {new string(' ', column - 1)}^"; + } + + private static string Escape(char character) => character switch + { + '\n' => "\\n", + '\r' => "\\r", + '\t' => "\\t", + '"' => "\\\"", + '\\' => "\\\\", + _ => character.ToString(), + }; + + /// + /// Turns the position the parser stopped at into a line, a column and the line + /// itself, so the message can point at the defect instead of quoting the rest of the + /// document. + /// + private static Position Locate(string document, int offset) + { + offset = Math.Max(0, Math.Min(offset, document.Length)); + + var line = 1; + var lineStart = 0; + for (var index = 0; index < offset; index++) + { + if (document[index] == '\n') + { + line++; + lineStart = index + 1; + } + } + + var lineEnd = document.IndexOf('\n', lineStart); + if (lineEnd < 0) lineEnd = document.Length; + + var lineText = document.Substring(lineStart, lineEnd - lineStart).TrimEnd('\r'); + var found = offset < document.Length ? document[offset] : (char?)null; + return new Position(offset, line, offset - lineStart + 1, found, lineText); + } + + /// + /// Cuts down to a window around , and + /// says which column the offending character sits at in that window. Both columns + /// count from 1. + /// + private static (string Quoted, int Column) QuoteLine(string line, int column) + { + if (line.Length <= QuotedLineWidth) return (line, column); + + var target = column - 1; + var lastStart = line.Length - QuotedLineWidth; + var start = Math.Min(Math.Max(target - (QuotedLineWidth / 2), 0), lastStart); + var end = start + QuotedLineWidth; + + var quoted = new StringBuilder(); + if (start > 0) quoted.Append(Ellipsis); + quoted.Append(line, start, QuotedLineWidth); + if (end < line.Length) quoted.Append(Ellipsis); + + var shift = start > 0 ? Ellipsis.Length : 0; + return (quoted.ToString(), target - start + shift + 1); + } + } +} diff --git a/csharp/Link.Foundation.Links.Notation/Parser.cs b/csharp/Link.Foundation.Links.Notation/Parser.cs new file mode 100644 index 0000000..2e61aa2 --- /dev/null +++ b/csharp/Link.Foundation.Links.Notation/Parser.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +namespace Link.Foundation.Links.Notation +{ + /// + /// Parses Links Notation documents into links. + /// + /// + /// The rules live in Parser.peg and are compiled into + /// ; this class runs them and, when a document does not + /// parse, reports the position the parser stopped at instead of the bare + /// the generated parser raises. + /// + public class Parser + { + /// + /// Parses a Links Notation document. + /// + /// The document to parse. + /// The optional file name to use in error messages. + /// The links the document holds. + /// + /// Thrown when the document does not parse. The exception carries the line, the + /// column and the offending line of the position the parser stopped at. + /// + public IList> Parse(string subject, string? fileName = null) + { + var tracer = new FurthestFailureTracer(); + var parser = new GeneratedParser { Tracer = tracer }; + try + { + return parser.Parse(subject, fileName); + } + catch (FormatException error) when (error is not ParseException) + { + throw new ParseException(subject, tracer.Furthest, error); + } + } + } +} diff --git a/csharp/Link.Foundation.Links.Notation/Parser.peg b/csharp/Link.Foundation.Links.Notation/Parser.peg index b23c516..bf08582 100644 --- a/csharp/Link.Foundation.Links.Notation/Parser.peg +++ b/csharp/Link.Foundation.Links.Notation/Parser.peg @@ -1,5 +1,10 @@ @namespace Link.Foundation.Links.Notation -@classname Parser +@classname GeneratedParser +@accessibility internal +// Tracing is what lets a failed parse report where it stopped: the rules backtrack, +// so only the furthest position any rule reached says where the document stops making +// sense. FurthestFailureTracer records it, Parser turns it into a ParseException. +@trace true @using System.Linq @members { From 471a21c28f4ee9132968fe501484f9ccd8e437e8 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 5 Sep 2026 16:30:12 +0000 Subject: [PATCH 04/10] Say where a JavaScript parse stopped in the message itself 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 --- js/dist/index.js | 450 ++++++++++++++++++---------- js/index.d.ts | 53 +++- js/src/ParseError.js | 104 +++++++ js/src/Parser.js | 8 +- js/src/index.js | 1 + js/tests/ParseErrorPosition.test.js | 109 +++++++ 6 files changed, 560 insertions(+), 165 deletions(-) create mode 100644 js/src/ParseError.js create mode 100644 js/tests/ParseErrorPosition.test.js diff --git a/js/dist/index.js b/js/dist/index.js index 2aad707..f1e5d74 100644 --- a/js/dist/index.js +++ b/js/dist/index.js @@ -36,9 +36,12 @@ class Link { return value && typeof value.toLinkOrIdString === "function" ? value.toLinkOrIdString() : String(value); } static escapeReference(reference) { - if (!reference || reference.trim() === "") { + if (reference === null || reference === undefined) { return ""; } + if (reference === "") { + return '""'; + } const hasSingleQuote = reference.includes("'"); const hasDoubleQuote = reference.includes('"'); const needsQuoting = reference.includes(":") || reference.includes("(") || reference.includes(")") || reference.includes(" ") || reference.includes("\t") || reference.includes(` @@ -266,6 +269,57 @@ class LinksGroup { return list.map((item) => `(${item.id || item})`).join(" "); } } +// src/ParseError.js +var QUOTED_LINE_WIDTH = 80; +var ELLIPSIS = "..."; + +class ParseError extends Error { + constructor(input, error) { + const start = error?.location?.start ?? { offset: 0, line: 1, column: 1 }; + const lineText = lineAt(input, start.offset); + const summary = `line ${start.line}, column ${start.column}: ${error.message}`; + const snippet = quote(start.line, lineText, start.column); + super(`Syntax error at ${summary} +${snippet}`); + this.name = "ParseError"; + this.cause = error; + this.location = error.location; + this.offset = start.offset; + this.line = start.line; + this.column = start.column; + this.found = error.found ?? null; + this.lineText = lineText; + this.snippet = snippet; + } +} +function lineAt(input, offset) { + const at = Math.max(0, Math.min(offset, input.length)); + const start = input.lastIndexOf(` +`, at - 1) + 1; + const end = input.indexOf(` +`, start); + const line = input.slice(start, end === -1 ? input.length : end); + return line.endsWith("\r") ? line.slice(0, -1) : line; +} +function quote(number, lineText, column) { + const [quoted, at] = windowAround(lineText, column); + const gutter = " ".repeat(String(number).length); + return `${number} | ${quoted} +${gutter} | ${" ".repeat(at - 1)}^`; +} +function windowAround(lineText, column) { + if (lineText.length <= QUOTED_LINE_WIDTH) { + return [lineText, column]; + } + const target = column - 1; + const lastStart = lineText.length - QUOTED_LINE_WIDTH; + const start = Math.min(Math.max(target - Math.floor(QUOTED_LINE_WIDTH / 2), 0), lastStart); + const end = start + QUOTED_LINE_WIDTH; + const quoted = (start > 0 ? ELLIPSIS : "") + lineText.slice(start, end) + (end < lineText.length ? ELLIPSIS : ""); + const shift = start > 0 ? ELLIPSIS.length : 0; + return [quoted, target - start + shift + 1]; +} + // src/parser-generated.js class peg$SyntaxError extends SyntaxError { constructor(message, expected, found, location) { @@ -378,9 +432,9 @@ function peg$parse(input, options) { document: peg$parsedocument }; let peg$startRuleFunction = peg$parsedocument; - const peg$c0 = ":"; - const peg$c1 = "("; - const peg$c2 = ")"; + const peg$c0 = "("; + const peg$c1 = ")"; + const peg$c2 = ":"; const peg$c3 = '"'; const peg$c4 = "'"; const peg$c5 = "`"; @@ -392,9 +446,9 @@ function peg$parse(input, options) { const peg$e0 = peg$classExpectation([" ", "\t"], false, false, false); const peg$e1 = peg$classExpectation(["\r", ` `], false, false, false); - const peg$e2 = peg$literalExpectation(":", false); - const peg$e3 = peg$literalExpectation("(", false); - const peg$e4 = peg$literalExpectation(")", false); + const peg$e2 = peg$literalExpectation("(", false); + const peg$e3 = peg$literalExpectation(")", false); + const peg$e4 = peg$literalExpectation(":", false); const peg$e5 = peg$literalExpectation('"', false); const peg$e6 = peg$anyExpectation(); const peg$e7 = peg$literalExpectation("'", false); @@ -405,17 +459,13 @@ function peg$parse(input, options) { const peg$e11 = peg$classExpectation([" ", "\t", ` `, "\r", "(", ":", ")"], true, false, false); function peg$f0() { - indentationStack = [0]; - baseIndentation = null; - return true; + return resetState(); } function peg$f1(links) { return links; } function peg$f2() { - indentationStack = [0]; - baseIndentation = null; - return true; + return resetState(); } function peg$f3() { return []; @@ -431,7 +481,7 @@ function peg$parse(input, options) { return l; } function peg$f7(e, l) { - return { id: e.id, values: e.values, children: l }; + return Object.assign({}, e, { children: l }); } function peg$f8(e) { return e; @@ -457,27 +507,28 @@ function peg$parse(input, options) { function peg$f15(vl) { return vl; } - function peg$f16(value) { - return value; + function peg$f16(body) { + exitNestedContext(); + return body; } - function peg$f17(list) { - return list; + function peg$f17(l) { + return { nested: l }; + } + function peg$f18() { + return { nested: [] }; } - function peg$f18(value) { + function peg$f19() { + return enterNestedContext(); + } + function peg$f20(value) { return value; } - function peg$f19(list) { + function peg$f21(list) { return list; } - function peg$f20(id, v) { + function peg$f22(id, v) { return { id, values: v }; } - function peg$f21(id, v) { - return { id, values: v }; - } - function peg$f22(v) { - return { values: v }; - } function peg$f23(v) { return { values: v }; } @@ -565,6 +616,9 @@ function peg$parse(input, options) { function peg$f44(spaces) { return checkIndentation(spaces); } + function peg$f45() { + return isInsideNestedContext(); + } let peg$currPos = options.peg$currPos | 0; let peg$savedPos = peg$currPos; const peg$posDetailsCache = [{ line: 1, column: 1 }]; @@ -924,7 +978,7 @@ function peg$parse(input, options) { function peg$parsereferenceOrLink() { let s0, s1; s0 = peg$currPos; - s1 = peg$parsemultiLineAnyLink(); + s1 = peg$parsenestedGroup(); if (s1 !== peg$FAILED) { peg$savedPos = s0; s1 = peg$f9(s1); @@ -944,7 +998,7 @@ function peg$parse(input, options) { function peg$parseanyLink() { let s0, s1, s2; s0 = peg$currPos; - s1 = peg$parsemultiLineAnyLink(); + s1 = peg$parsenestedGroup(); if (s1 !== peg$FAILED) { s2 = peg$parseeol(); if (s2 !== peg$FAILED) { @@ -978,14 +1032,6 @@ function peg$parse(input, options) { } return s0; } - function peg$parsemultiLineAnyLink() { - let s0; - s0 = peg$parsemultiLineValueLink(); - if (s0 === peg$FAILED) { - s0 = peg$parsemultiLineLink(); - } - return s0; - } function peg$parsesingleLineAnyLink() { let s0, s1, s2; s0 = peg$currPos; @@ -1022,32 +1068,97 @@ function peg$parse(input, options) { } return s0; } - function peg$parsemultiLineValueAndWhitespace() { - let s0, s1, s2; + function peg$parsenestedGroup() { + let s0, s1, s2, s3; s0 = peg$currPos; - s1 = peg$parsereferenceOrLink(); + if (input.charCodeAt(peg$currPos) === 40) { + s1 = peg$c0; + peg$currPos++; + } else { + s1 = peg$FAILED; + if (peg$silentFails === 0) { + peg$fail(peg$e2); + } + } if (s1 !== peg$FAILED) { - s2 = peg$parse_(); - peg$savedPos = s0; - s0 = peg$f16(s1); + s2 = peg$parseENTER_NESTED_CONTEXT(); + if (s2 !== peg$FAILED) { + s3 = peg$parsenestedGroupBody(); + if (s3 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f16(s3); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } } else { peg$currPos = s0; s0 = peg$FAILED; } return s0; } - function peg$parsemultiLineValues() { - let s0, s1, s2, s3; + function peg$parsenestedGroupBody() { + let s0, s1, s2, s3, s4; s0 = peg$currPos; - s1 = peg$parse_(); - s2 = []; - s3 = peg$parsemultiLineValueAndWhitespace(); - while (s3 !== peg$FAILED) { - s2.push(s3); - s3 = peg$parsemultiLineValueAndWhitespace(); + s1 = peg$parseskipEmptyLines(); + s2 = peg$parselinks(); + if (s2 !== peg$FAILED) { + s3 = peg$parse_(); + if (input.charCodeAt(peg$currPos) === 41) { + s4 = peg$c1; + peg$currPos++; + } else { + s4 = peg$FAILED; + if (peg$silentFails === 0) { + peg$fail(peg$e3); + } + } + if (s4 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f17(s2); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + if (s0 === peg$FAILED) { + s0 = peg$currPos; + s1 = peg$parse_(); + if (input.charCodeAt(peg$currPos) === 41) { + s2 = peg$c1; + peg$currPos++; + } else { + s2 = peg$FAILED; + if (peg$silentFails === 0) { + peg$fail(peg$e3); + } + } + if (s2 !== peg$FAILED) { + peg$savedPos = s0; + s0 = peg$f18(); + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } + return s0; + } + function peg$parseENTER_NESTED_CONTEXT() { + let s0; + peg$savedPos = peg$currPos; + s0 = peg$f19(); + if (s0) { + s0 = undefined; + } else { + s0 = peg$FAILED; } - peg$savedPos = s0; - s0 = peg$f17(s2); return s0; } function peg$parsesingleLineValueAndWhitespace() { @@ -1057,7 +1168,7 @@ function peg$parse(input, options) { s2 = peg$parsereferenceOrLink(); if (s2 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f18(s2); + s0 = peg$f20(s2); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -1079,7 +1190,7 @@ function peg$parse(input, options) { } if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f19(s1); + s1 = peg$f21(s1); } s0 = s1; return s0; @@ -1092,78 +1203,19 @@ function peg$parse(input, options) { if (s2 !== peg$FAILED) { s3 = peg$parse__(); if (input.charCodeAt(peg$currPos) === 58) { - s4 = peg$c0; + s4 = peg$c2; peg$currPos++; } else { s4 = peg$FAILED; if (peg$silentFails === 0) { - peg$fail(peg$e2); + peg$fail(peg$e4); } } if (s4 !== peg$FAILED) { s5 = peg$parsesingleLineValues(); if (s5 !== peg$FAILED) { peg$savedPos = s0; - s0 = peg$f20(s2, s5); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - return s0; - } - function peg$parsemultiLineLink() { - let s0, s1, s2, s3, s4, s5, s6, s7, s8; - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 40) { - s1 = peg$c1; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { - peg$fail(peg$e3); - } - } - if (s1 !== peg$FAILED) { - s2 = peg$parse_(); - s3 = peg$parsereference(); - if (s3 !== peg$FAILED) { - s4 = peg$parse_(); - if (input.charCodeAt(peg$currPos) === 58) { - s5 = peg$c0; - peg$currPos++; - } else { - s5 = peg$FAILED; - if (peg$silentFails === 0) { - peg$fail(peg$e2); - } - } - if (s5 !== peg$FAILED) { - s6 = peg$parsemultiLineValues(); - s7 = peg$parse_(); - if (input.charCodeAt(peg$currPos) === 41) { - s8 = peg$c2; - peg$currPos++; - } else { - s8 = peg$FAILED; - if (peg$silentFails === 0) { - peg$fail(peg$e4); - } - } - if (s8 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f21(s3, s6); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } + s0 = peg$f22(s2, s5); } else { peg$currPos = s0; s0 = peg$FAILED; @@ -1184,48 +1236,11 @@ function peg$parse(input, options) { s1 = peg$parsesingleLineValues(); if (s1 !== peg$FAILED) { peg$savedPos = s0; - s1 = peg$f22(s1); + s1 = peg$f23(s1); } s0 = s1; return s0; } - function peg$parsemultiLineValueLink() { - let s0, s1, s2, s3, s4; - s0 = peg$currPos; - if (input.charCodeAt(peg$currPos) === 40) { - s1 = peg$c1; - peg$currPos++; - } else { - s1 = peg$FAILED; - if (peg$silentFails === 0) { - peg$fail(peg$e3); - } - } - if (s1 !== peg$FAILED) { - s2 = peg$parsemultiLineValues(); - s3 = peg$parse_(); - if (input.charCodeAt(peg$currPos) === 41) { - s4 = peg$c2; - peg$currPos++; - } else { - s4 = peg$FAILED; - if (peg$silentFails === 0) { - peg$fail(peg$e4); - } - } - if (s4 !== peg$FAILED) { - peg$savedPos = s0; - s0 = peg$f23(s2); - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - } else { - peg$currPos = s0; - s0 = peg$FAILED; - } - return s0; - } function peg$parseindentedIdLink() { let s0, s1, s2, s3, s4; s0 = peg$currPos; @@ -1233,12 +1248,12 @@ function peg$parse(input, options) { if (s1 !== peg$FAILED) { s2 = peg$parse__(); if (input.charCodeAt(peg$currPos) === 58) { - s3 = peg$c0; + s3 = peg$c2; peg$currPos++; } else { s3 = peg$FAILED; if (peg$silentFails === 0) { - peg$fail(peg$e2); + peg$fail(peg$e4); } } if (s3 !== peg$FAILED) { @@ -1767,6 +1782,9 @@ function peg$parse(input, options) { } if (s2 === peg$FAILED) { s2 = peg$parseeof(); + if (s2 === peg$FAILED) { + s2 = peg$parsenestedGroupEnd(); + } } if (s2 !== peg$FAILED) { s1 = [s1, s2]; @@ -1777,6 +1795,48 @@ function peg$parse(input, options) { } return s0; } + function peg$parsenestedGroupEnd() { + let s0, s1, s2, s3; + s0 = peg$currPos; + peg$savedPos = peg$currPos; + s1 = peg$f45(); + if (s1) { + s1 = undefined; + } else { + s1 = peg$FAILED; + } + if (s1 !== peg$FAILED) { + s2 = peg$currPos; + peg$silentFails++; + if (input.charCodeAt(peg$currPos) === 41) { + s3 = peg$c1; + peg$currPos++; + } else { + s3 = peg$FAILED; + if (peg$silentFails === 0) { + peg$fail(peg$e3); + } + } + peg$silentFails--; + if (s3 !== peg$FAILED) { + peg$currPos = s2; + s2 = undefined; + } else { + s2 = peg$FAILED; + } + if (s2 !== peg$FAILED) { + s1 = [s1, s2]; + s0 = s1; + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + } else { + peg$currPos = s0; + s0 = peg$FAILED; + } + return s0; + } function peg$parseeof() { let s0, s1; s0 = peg$currPos; @@ -1863,6 +1923,30 @@ function peg$parse(input, options) { } let indentationStack = [0]; let baseIndentation = null; + let contextStack = []; + function resetState() { + indentationStack = [0]; + baseIndentation = null; + contextStack = []; + return true; + } + function enterNestedContext() { + contextStack.push({ indentationStack, baseIndentation }); + indentationStack = [0]; + baseIndentation = null; + return true; + } + function exitNestedContext() { + const saved = contextStack.pop(); + if (saved) { + indentationStack = saved.indentationStack; + baseIndentation = saved.baseIndentation; + } + return true; + } + function isInsideNestedContext() { + return contextStack.length > 0; + } function setBaseIndentation(spaces) { if (baseIndentation === null) { baseIndentation = spaces.length; @@ -1890,6 +1974,24 @@ function peg$parse(input, options) { function getCurrentIndentation() { return indentationStack[indentationStack.length - 1]; } + function isSubstantiveBody(content) { + let depth = 0; + let hasVisible = false; + for (const c of content) { + if (c === "(") { + depth++; + } else if (c === ")") { + depth--; + if (depth < 0) { + return false; + } + } + if (!/\s/.test(c)) { + hasVisible = true; + } + } + return hasVisible && depth === 0; + } function parseQuotedStringAt(inputStr, startPos, quoteChar) { if (startPos >= inputStr.length || inputStr[startPos] !== quoteChar) { return null; @@ -1900,6 +2002,8 @@ function peg$parse(input, options) { quoteCount++; pos++; } + const isEvenRun = quoteCount % 2 === 0; + const emptyReference = isEvenRun ? { value: "", length: quoteCount } : null; const closeSeq = quoteChar.repeat(quoteCount); const escapeSeq = quoteChar.repeat(quoteCount * 2); let content = ""; @@ -1912,6 +2016,9 @@ function peg$parse(input, options) { if (inputStr.substr(pos, quoteCount) === closeSeq) { const afterClose = pos + quoteCount; if (afterClose >= inputStr.length || inputStr[afterClose] !== quoteChar) { + if (isEvenRun && !isSubstantiveBody(content)) { + return emptyReference; + } return { value: content, length: afterClose - startPos @@ -1921,7 +2028,7 @@ function peg$parse(input, options) { content += inputStr[pos]; pos++; } - return null; + return emptyReference; } let parsedValue = null; let parsedLength = 0; @@ -1968,9 +2075,11 @@ class Parser { const rawResult = peg$parse(input); return this.transformResult(rawResult); } catch (error) { + if (error && error.location) { + throw new ParseError(input, error); + } const parseError = new Error(`Parse error: ${error.message}`); parseError.cause = error; - parseError.location = error.location; throw parseError; } } @@ -1988,7 +2097,7 @@ class Parser { if (item === null || item === undefined) return; if (item.children && item.children.length > 0) { - if (item.id && (!item.values || item.values.length === 0)) { + if (item.id !== undefined && item.id !== null && (!item.values || item.values.length === 0)) { const childValues = item.children.map((child) => { if (child.values && child.values.length === 1) { return this.transformLink(child.values[0]); @@ -2041,21 +2150,37 @@ class Parser { combined._isFromPathCombination = true; return combined; } + transformNested(nested) { + const nestedLinks = []; + for (const item of nested) { + if (item !== null && item !== undefined) { + this.collectLinks(item, [], nestedLinks); + } + } + const wrapsSingleGroup = nested.length === 1 && nested[0] && nested[0].nested !== undefined; + if (nestedLinks.length === 1 && !wrapsSingleGroup) { + return nestedLinks[0]; + } + return new Link(null, nestedLinks); + } transformLink(item) { if (item === null || item === undefined) return null; if (item instanceof Link) { return item; } + if (item.nested !== undefined) { + return this.transformNested(item.nested); + } if (item.id !== undefined && !item.values && !item.children) { return new Link(item.id); } if (item.values && Array.isArray(item.values)) { - const link = new Link(item.id || null, []); + const link = new Link(item.id ?? null, []); link.values = item.values.map((v) => this.transformLink(v)); return link; } - return new Link(item.id || null, []); + return new Link(item.id ?? null, []); } } // src/FormatOptions.js @@ -2089,6 +2214,7 @@ class FormatConfig extends FormatOptions { export { formatLinks, Parser, + ParseError, LinksGroup, Link, FormatOptions, diff --git a/js/index.d.ts b/js/index.d.ts index 8a6532f..4b8478d 100644 --- a/js/index.d.ts +++ b/js/index.d.ts @@ -144,11 +144,62 @@ export class Parser { * Parse Lino notation text into Link objects * @param input - The Lino notation text to parse * @returns Array of parsed Link objects - * @throws {Error} If parsing fails + * @throws {ParseError} If the text does not parse */ parse(input: string): Link[]; } +/** + * The position a parse stopped at, as the generated parser reports it + */ +export interface ParseErrorLocation { + start: { offset: number; line: number; column: number }; + end: { offset: number; line: number; column: number }; +} + +/** + * The error thrown when a document does not parse + * + * The message says where the document stopped making sense and quotes the + * offending line with a caret under it. + */ +export class ParseError extends Error { + /** + * Offset of the offending position from the start of the document + */ + offset: number; + + /** + * Line the offending position is on, counted from 1 + */ + line: number; + + /** + * Column the offending position is at, counted from 1 + */ + column: number; + + /** + * The character found instead, or null at the end of the document + */ + found: string | null; + + /** + * The offending line, as written, without its line ending + */ + lineText: string; + + /** + * The offending line with a caret under the offending column + */ + snippet: string; + + /** + * The position the generated parser reported + */ + location: ParseErrorLocation; +} + /** * Options for formatting links */ diff --git a/js/src/ParseError.js b/js/src/ParseError.js new file mode 100644 index 0000000..40945d9 --- /dev/null +++ b/js/src/ParseError.js @@ -0,0 +1,104 @@ +/** The number of characters a quoted line is cut down to. */ +const QUOTED_LINE_WIDTH = 80; + +/** What a message writes in place of the part of a long line it left out. */ +const ELLIPSIS = '...'; + +/** + * The error thrown when a document does not parse, carrying the position the + * parser stopped at. + * + * The generated parser reports that position, but only on the error object; the + * message it writes says what it expected without saying where. This error puts + * the line and the column in the message as well, and quotes the offending line + * with a caret under it, the way the Rust and C# ports do. + */ +export class ParseError extends Error { + /** + * @param {string} input - The document that failed to parse + * @param {Error} error - The error the generated parser threw + */ + constructor(input, error) { + const start = error?.location?.start ?? { offset: 0, line: 1, column: 1 }; + const lineText = lineAt(input, start.offset); + const summary = `line ${start.line}, column ${start.column}: ${error.message}`; + const snippet = quote(start.line, lineText, start.column); + super(`Syntax error at ${summary}\n${snippet}`); + + this.name = 'ParseError'; + /** @type {Error} The error the generated parser threw */ + this.cause = error; + /** @type {{start: {offset: number, line: number, column: number}}} */ + this.location = error.location; + /** @type {number} Offset of the offending position from the start of the document */ + this.offset = start.offset; + /** @type {number} Line the offending position is on, counted from 1 */ + this.line = start.line; + /** @type {number} Column the offending position is at, counted from 1 */ + this.column = start.column; + /** @type {string|null} The character found instead, or null at the end of the document */ + this.found = error.found ?? null; + /** @type {string} The offending line, as written, without its line ending */ + this.lineText = lineText; + /** @type {string} The offending line with a caret under the offending column */ + this.snippet = snippet; + } +} + +/** + * The line the given offset falls on, without its line ending. + * @param {string} input - The document being parsed + * @param {number} offset - Offset of the offending position + * @returns {string} The offending line + */ +function lineAt(input, offset) { + const at = Math.max(0, Math.min(offset, input.length)); + const start = input.lastIndexOf('\n', at - 1) + 1; + const end = input.indexOf('\n', start); + const line = input.slice(start, end === -1 ? input.length : end); + return line.endsWith('\r') ? line.slice(0, -1) : line; +} + +/** + * The offending line with a caret under the offending column, quoted the way a + * compiler quotes source. A long line is shown as a window around the caret, so + * the message stays the same size whether the document has ten lines or fifteen + * hundred. + * @param {number} number - The line number, counted from 1 + * @param {string} lineText - The offending line + * @param {number} column - The offending column, counted from 1 + * @returns {string} The quoted line and the caret under it + */ +function quote(number, lineText, column) { + const [quoted, at] = windowAround(lineText, column); + const gutter = ' '.repeat(String(number).length); + return `${number} | ${quoted}\n${gutter} | ${' '.repeat(at - 1)}^`; +} + +/** + * Cuts a line down to a window around the given column, and says which column + * the offending character sits at in that window. Both columns count from 1. + * @param {string} lineText - The offending line + * @param {number} column - The offending column, counted from 1 + * @returns {[string, number]} The window and the column within it + */ +function windowAround(lineText, column) { + if (lineText.length <= QUOTED_LINE_WIDTH) { + return [lineText, column]; + } + + const target = column - 1; + const lastStart = lineText.length - QUOTED_LINE_WIDTH; + const start = Math.min( + Math.max(target - Math.floor(QUOTED_LINE_WIDTH / 2), 0), + lastStart + ); + const end = start + QUOTED_LINE_WIDTH; + + const quoted = + (start > 0 ? ELLIPSIS : '') + + lineText.slice(start, end) + + (end < lineText.length ? ELLIPSIS : ''); + const shift = start > 0 ? ELLIPSIS.length : 0; + return [quoted, target - start + shift + 1]; +} diff --git a/js/src/Parser.js b/js/src/Parser.js index db24243..514f258 100644 --- a/js/src/Parser.js +++ b/js/src/Parser.js @@ -1,4 +1,5 @@ import { Link } from './Link.js'; +import { ParseError } from './ParseError.js'; import * as parserModule from './parser-generated.js'; export class Parser { @@ -35,10 +36,13 @@ export class Parser { const rawResult = parserModule.parse(input); return this.transformResult(rawResult); } catch (error) { - // Preserve original error information + // A syntax error knows where it stopped; anything else is passed on with + // the original error kept as the cause. + if (error && error.location) { + throw new ParseError(input, error); + } const parseError = new Error(`Parse error: ${error.message}`); parseError.cause = error; - parseError.location = error.location; throw parseError; } } diff --git a/js/src/index.js b/js/src/index.js index 54e133b..4fcf6b1 100644 --- a/js/src/index.js +++ b/js/src/index.js @@ -1,5 +1,6 @@ export { Link, formatLinks } from './Link.js'; export { LinksGroup } from './LinksGroup.js'; export { Parser } from './Parser.js'; +export { ParseError } from './ParseError.js'; export { FormatConfig } from './FormatConfig.js'; export { FormatOptions } from './FormatOptions.js'; diff --git a/js/tests/ParseErrorPosition.test.js b/js/tests/ParseErrorPosition.test.js new file mode 100644 index 0000000..7d52e9d --- /dev/null +++ b/js/tests/ParseErrorPosition.test.js @@ -0,0 +1,109 @@ +// A parse error has to say where the document stopped making sense. +// +// The positions asserted here are the ones the Rust and C# ports report for the +// same input, so the implementations can be held to the same contract +// (https://github.com/link-foundation/links-notation/issues/302). + +import { test, expect } from 'bun:test'; +import { Parser, ParseError } from '../src/index.js'; + +const parser = new Parser(); + +function syntaxError(document) { + try { + const links = parser.parse(document); + throw new Error( + `expected ${JSON.stringify(document)} not to parse, got ${links.length} links` + ); + } catch (error) { + expect(error).toBeInstanceOf(ParseError); + return error; + } +} + +test('reports the line and column of the defect', () => { + // The example from the issue: the defect is the colon on line 2, and the + // two lines after it are fine. + const error = syntaxError('# ok line\n# break: two\nci_gate x\n stage rust'); + + expect(error.line).toBe(2); + expect(error.column).toBe(8); + expect(error.found).toBe(':'); +}); + +test('offset agrees with the other implementations', () => { + // Rust and C# report offset 17, line 2, column 8 for this document. + const error = syntaxError('# ok line\n# break: two\n'); + + expect(error.offset).toBe(17); + expect(error.line).toBe(2); + expect(error.column).toBe(8); +}); + +test('reports the line a late defect is on', () => { + const error = syntaxError('a\nb\nc\nd\ne: f: g\nh\n'); + + expect(error.line).toBe(5); + expect(error.column).toBe(5); + expect(error.lineText).toBe('e: f: g'); +}); + +test('reports the end of the document when a group is never closed', () => { + const error = syntaxError('a (b\n'); + + expect(error.offset).toBe(5); + expect(error.line).toBe(2); + expect(error.column).toBe(1); + expect(error.found).toBeNull(); + expect(error.message).toContain('end of input'); +}); + +test('reports an unmatched closing parenthesis', () => { + const error = syntaxError('a b)\n'); + + expect(error.offset).toBe(3); + expect(error.line).toBe(1); + expect(error.column).toBe(4); + expect(error.found).toBe(')'); +}); + +test('message says where the document broke', () => { + const error = syntaxError('# ok line\n# break: two\n'); + + expect(error.message.startsWith('Syntax error at line 2, column 8:')).toBe( + true + ); + expect(error.snippet).toBe('2 | # break: two\n | ^'); +}); + +test('message quotes one line rather than the rest of the document', () => { + const document = `# ok line\n# break: two\n${'trailing line\n'.repeat(500)}`; + + const error = syntaxError(document); + + expect(error.message).toContain('line 2, column 8'); + expect(error.message).not.toContain('trailing line'); + expect(error.message.length).toBeLessThan(200); +}); + +test('message of a long line stays a message', () => { + const document = `${'a'.repeat(400)}: ${'b'.repeat(400)}: c`; + + const error = syntaxError(document); + + expect(error.line).toBe(1); + expect(error.column).toBe(803); + expect(error.message).toContain('...'); + expect(error.message.length).toBeLessThan(300); +}); + +test('the location the parser used to report is still there', () => { + const error = syntaxError('a: b: c'); + + expect(error.location.start).toEqual({ offset: 4, line: 1, column: 5 }); + expect(error.cause).toBeDefined(); +}); + +test('a document that parses reports nothing', () => { + expect(parser.parse('a: b\n c: d\n').length).toBe(2); +}); From 9f4d5750d320ad1f4c365065cfb6adc47e9e950c Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 5 Sep 2026 16:31:12 +0000 Subject: [PATCH 05/10] Say why a lino! macro invocation failed at runtime 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 --- rust/links-notation-macro/src/lib.rs | 13 ++++++++++--- rust/links-notation/tests/macro_tests.rs | 9 +++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/rust/links-notation-macro/src/lib.rs b/rust/links-notation-macro/src/lib.rs index 69b81d9..d910600 100644 --- a/rust/links-notation-macro/src/lib.rs +++ b/rust/links-notation-macro/src/lib.rs @@ -65,7 +65,8 @@ use syn::{parse::Parse, parse::ParseStream, LitStr}; /// The macro expands to code that: /// 1. Contains a compile-time validation check /// 2. Calls `parse_lino()` at runtime -/// 3. Unwraps the result (safe because validation passed at compile time) +/// 3. Unwraps the result, panicking with the position of the defect if the +/// parser refuses the text after all #[proc_macro] pub fn lino(input: TokenStream) -> TokenStream { let input2: proc_macro2::TokenStream = input.into(); @@ -111,8 +112,14 @@ pub fn lino(input: TokenStream) -> TokenStream { let _ = #lino_str; }; - // Runtime parsing - links_notation::parse_lino(#lino_str).expect("lino! macro: validated at compile time but runtime parse failed") + // Runtime parsing. Compile-time validation only checks that + // parentheses and quotes balance, so the parser can still refuse + // the text; when it does, the panic says where it stopped rather + // than only that it did. + match links_notation::parse_lino(#lino_str) { + Ok(parsed) => parsed, + Err(error) => panic!("lino!: {error}"), + } } }; diff --git a/rust/links-notation/tests/macro_tests.rs b/rust/links-notation/tests/macro_tests.rs index d3c404f..f5c3907 100644 --- a/rust/links-notation/tests/macro_tests.rs +++ b/rust/links-notation/tests/macro_tests.rs @@ -589,4 +589,13 @@ mod macro_tests { // Unbalanced parentheses cannot even be parsed as Rust tokens, // so they would cause compile errors automatically } + + #[test] + #[should_panic(expected = "lino!: Syntax error at line 1, column 5")] + fn test_runtime_failure_says_where_the_text_stopped_parsing() { + // Compile-time validation only checks that parentheses and quotes + // balance, so text like this reaches the parser and is refused there. + // The panic has to say where, the way a parse error does. + let _ = lino!("a: b: c"); + } } From 4228d7456af77548418ac67ee014e4508de4bef6 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 5 Sep 2026 16:32:49 +0000 Subject: [PATCH 06/10] Compare what every implementation says about a broken document 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 --- experiments/issue-302/.gitignore | 3 + experiments/issue-302/README.md | 60 ++++++++++++++ .../issue-302/csharp-probe/Probe.csproj | 11 +++ experiments/issue-302/csharp-probe/Program.cs | 17 ++++ experiments/issue-302/go-probe/go.mod | 7 ++ experiments/issue-302/go-probe/main.go | 19 +++++ experiments/issue-302/java-probe/Probe.java | 20 +++++ experiments/issue-302/probe.mjs | 13 +++ experiments/issue-302/probe.php | 16 ++++ experiments/issue-302/probe.py | 10 +++ experiments/issue-302/run.sh | 79 +++++++++++++++++++ 11 files changed, 255 insertions(+) create mode 100644 experiments/issue-302/.gitignore create mode 100644 experiments/issue-302/README.md create mode 100644 experiments/issue-302/csharp-probe/Probe.csproj create mode 100644 experiments/issue-302/csharp-probe/Program.cs create mode 100644 experiments/issue-302/go-probe/go.mod create mode 100644 experiments/issue-302/go-probe/main.go create mode 100644 experiments/issue-302/java-probe/Probe.java create mode 100644 experiments/issue-302/probe.mjs create mode 100644 experiments/issue-302/probe.php create mode 100644 experiments/issue-302/probe.py create mode 100755 experiments/issue-302/run.sh diff --git a/experiments/issue-302/.gitignore b/experiments/issue-302/.gitignore new file mode 100644 index 0000000..1bab812 --- /dev/null +++ b/experiments/issue-302/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +classes/ diff --git a/experiments/issue-302/README.md b/experiments/issue-302/README.md new file mode 100644 index 0000000..2876f93 --- /dev/null +++ b/experiments/issue-302/README.md @@ -0,0 +1,60 @@ +# What every implementation says when a document does not parse + +Written for [#302](https://github.com/link-foundation/links-notation/issues/302), +where the Rust parser answered a broken document with the raw `nom` error: + +``` +Syntax error: Error(Error { input: "", code: Eof }) +``` + +`run.sh` asks every implementation about the same five documents and prints the +answers next to each other. Four of the five do not parse: + +| document | what is wrong | +| --- | --- | +| `# ok line\n# break: two\nci_gate x\n` | second colon on line 2 | +| `a: b: c` | second colon | +| `a (b\n` | group is never closed | +| `a b)\n` | closing parenthesis with nothing open | +| `:` | a colon on its own | + +Run it from anywhere: + +```sh +./experiments/issue-302/run.sh +``` + +Toolchains that are not installed are reported as skipped, so the script is +useful on a machine that has only some of them. The Java probe needs the +classes built first (`mvn -f java/pom.xml compile`). + +## What the run says + +Rust, JavaScript and C# refuse all four broken documents and agree on where +each one breaks, to the offset: + +| document | offset | line:column | +| --- | --- | --- | +| `# ok line\n# break: two\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 | + +Each of the three quotes the offending line with a caret under it, so the +message says where the document stopped making sense without carrying the rest +of the document with it. + +Python, Go, Java and PHP accept all five. They are hand-written parsers that +treat anything they do not recognise as part of a reference, so +`a: b: c` becomes a link whose first value is the reference `b:`, and `a (b` +becomes two references, one of which is `(b`. Each of the four declares a parse error type, +but none of those types is ever 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 the parsers accept, not in what they say when they +fail, so it belongs to the syntax parity work in +[#138](https://github.com/link-foundation/links-notation/issues/138) rather than +to #302: there is no diagnostic to improve until there is a failure to report. diff --git a/experiments/issue-302/csharp-probe/Probe.csproj b/experiments/issue-302/csharp-probe/Probe.csproj new file mode 100644 index 0000000..6247023 --- /dev/null +++ b/experiments/issue-302/csharp-probe/Probe.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + disable + enable + + + + + diff --git a/experiments/issue-302/csharp-probe/Program.cs b/experiments/issue-302/csharp-probe/Program.cs new file mode 100644 index 0000000..0da6bfc --- /dev/null +++ b/experiments/issue-302/csharp-probe/Program.cs @@ -0,0 +1,17 @@ +using Link.Foundation.Links.Notation; + +string[] docs = { "# ok line\n# break: two\nci_gate x\n", "a: b: c", "a (b\n", "a b)\n", ":" }; +foreach (var doc in docs) +{ + var shown = doc.Replace("\n", "\\n"); + try + { + var links = new Parser().Parse(doc); + Console.WriteLine($"\"{shown}\" -> PARSED {links.Count} links"); + } + catch (ParseException error) + { + Console.WriteLine($"\"{shown}\" -> offset {error.Offset}"); + Console.WriteLine(error.Message); + } +} diff --git a/experiments/issue-302/go-probe/go.mod b/experiments/issue-302/go-probe/go.mod new file mode 100644 index 0000000..feae6ea --- /dev/null +++ b/experiments/issue-302/go-probe/go.mod @@ -0,0 +1,7 @@ +module probe + +go 1.24 + +require github.com/link-foundation/links-notation/go v0.0.0 + +replace github.com/link-foundation/links-notation/go => ../../../go diff --git a/experiments/issue-302/go-probe/main.go b/experiments/issue-302/go-probe/main.go new file mode 100644 index 0000000..1e7a987 --- /dev/null +++ b/experiments/issue-302/go-probe/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "fmt" + + lino "github.com/link-foundation/links-notation/go" +) + +func main() { + docs := []string{"# ok line\n# break: two\nci_gate x\n", "a: b: c", "a (b\n", "a b)\n", ":"} + for _, d := range docs { + links, err := lino.NewParser().Parse(d) + if err != nil { + fmt.Printf("%q -> ERROR %v\n", d, err) + } else { + fmt.Printf("%q -> PARSED %v\n", d, links) + } + } +} diff --git a/experiments/issue-302/java-probe/Probe.java b/experiments/issue-302/java-probe/Probe.java new file mode 100644 index 0000000..ddf4eec --- /dev/null +++ b/experiments/issue-302/java-probe/Probe.java @@ -0,0 +1,20 @@ +import io.github.linkfoundation.linksnotation.Parser; +import java.util.List; + +public class Probe { + public static void main(String[] args) { + String[] docs = {"# ok line\n# break: two\nci_gate x\n", "a: b: c", "a (b\n", "a b)\n", ":"}; + for (String doc : docs) { + try { + List links = new Parser().parse(doc); + System.out.println(quote(doc) + " -> PARSED " + links.size() + " links"); + } catch (Throwable e) { + System.out.println(quote(doc) + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage()); + } + } + } + + static String quote(String s) { + return "\"" + s.replace("\n", "\\n") + "\""; + } +} diff --git a/experiments/issue-302/probe.mjs b/experiments/issue-302/probe.mjs new file mode 100644 index 0000000..2c0e0ec --- /dev/null +++ b/experiments/issue-302/probe.mjs @@ -0,0 +1,13 @@ +// What the JavaScript parser says about documents that do not parse. +import { Parser } from '../../js/src/index.js'; + +const docs = ['# ok line\n# break: two\nci_gate x\n', 'a: b: c', 'a (b\n', 'a b)\n', ':']; +for (const doc of docs) { + try { + const links = new Parser().parse(doc); + console.log(`${JSON.stringify(doc)} -> PARSED ${links.length} links`); + } catch (error) { + console.log(`${JSON.stringify(doc)} -> offset ${error.offset}`); + console.log(error.message); + } +} diff --git a/experiments/issue-302/probe.php b/experiments/issue-302/probe.php new file mode 100644 index 0000000..8c2e1cd --- /dev/null +++ b/experiments/issue-302/probe.php @@ -0,0 +1,16 @@ +parse($doc); + echo json_encode($doc) . " -> PARSED " . count($links) . " links\n"; + } catch (Throwable $e) { + echo json_encode($doc) . " -> " . get_class($e) . ": " . $e->getMessage() . "\n"; + } +} diff --git a/experiments/issue-302/probe.py b/experiments/issue-302/probe.py new file mode 100644 index 0000000..ceb9b84 --- /dev/null +++ b/experiments/issue-302/probe.py @@ -0,0 +1,10 @@ +import sys +sys.path.insert(0, "python") +from links_notation.parser import Parser +docs = ["# ok line\n# break: two\nci_gate x\n", "a: b: c", "a (b\n", "a b)\n", ":"] +for d in docs: + try: + r = Parser().parse(d) + print(f"{d!r} -> PARSED {r}") + except Exception as e: + print(f"{d!r} -> {type(e).__name__}: {e}") diff --git a/experiments/issue-302/run.sh b/experiments/issue-302/run.sh new file mode 100755 index 0000000..1d8230e --- /dev/null +++ b/experiments/issue-302/run.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Asks every implementation what it says about the same five documents, four of +# which do not parse. Written for https://github.com/link-foundation/links-notation/issues/302: +# the answers are what the issue is about, and what the parity gap in the +# implementations that accept all five looks like. +# +# Run from anywhere; every path below is relative to the repository root. +# Toolchains that are not installed are reported as skipped rather than failing +# the run, so this is useful even on a machine with only some of them. +set -uo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(cd "$here/../.." && pwd)" +cd "$root" + +section() { + printf '\n=== %s ===\n' "$1" +} + +skip() { + printf '(skipped: %s)\n' "$1" +} + +section rust +if command -v cargo >/dev/null; then + cargo run --quiet --manifest-path rust/links-notation/Cargo.toml \ + --example parse_error_positions +else + skip "cargo is not installed" +fi + +section javascript +if command -v bun >/dev/null; then + bun "$here/probe.mjs" +elif command -v node >/dev/null; then + node "$here/probe.mjs" +else + skip "neither bun nor node is installed" +fi + +section csharp +if command -v dotnet >/dev/null; then + dotnet run --project "$here/csharp-probe/Probe.csproj" --verbosity quiet +else + skip "dotnet is not installed" +fi + +section python +if command -v python3 >/dev/null; then + python3 "$here/probe.py" +else + skip "python3 is not installed" +fi + +section go +if command -v go >/dev/null; then + (cd "$here/go-probe" && go run .) +else + skip "go is not installed" +fi + +section java +if command -v java >/dev/null; then + classes="$(ls -d "$root"/java/target/classes 2>/dev/null)" + if [ -n "$classes" ]; then + java -cp "$classes" "$here/java-probe/Probe.java" + else + skip "java/target/classes is missing, run: mvn -f java/pom.xml compile" + fi +else + skip "java is not installed" +fi + +section php +if command -v php >/dev/null; then + php "$here/probe.php" +else + skip "php is not installed" +fi From e6c5077d479b46c240c3ed45bda5c77e58eb70ca Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 5 Sep 2026 16:35:02 +0000 Subject: [PATCH 07/10] Document what a failed parse now reports 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 --- csharp/README.md | 28 +++++++++++++++ csharp/README.ru.md | 29 +++++++++++++++ js/README.md | 33 +++++++++++++++++ js/README.ru.md | 33 +++++++++++++++++ rust/links-notation/README.md | 35 ++++++++++++++----- rust/links-notation/README.ru.md | 35 ++++++++++++++----- .../examples/parse_error_positions.rs | 1 + 7 files changed, 178 insertions(+), 16 deletions(-) diff --git a/csharp/README.md b/csharp/README.md index 3fbf097..95b2dd8 100644 --- a/csharp/README.md +++ b/csharp/README.md @@ -162,6 +162,34 @@ Console.WriteLine(links[0]); // (value ((id 1) (label one))) - **Parser\**: Main parser class for converting strings to links - **Link\**: Represents a single link with ID and values - **LinksGroup\**: Container for grouping related links +- **ParseException**: Thrown when a document does not parse + +### Error Handling + +`Parse` throws a `ParseException` whose message says where the document stopped +making sense and quotes the offending line with a caret under it: + +```csharp +try +{ + new Parser().Parse("# ok line\n# break: two\nci_gate x\n"); +} +catch (ParseException error) +{ + Console.Error.WriteLine(error.Message); + Console.Error.WriteLine($"{error.Line}:{error.Column} (offset {error.Offset})"); +} +``` + +```text +Syntax error at line 2, column 8: unexpected ":" +2 | # break: two + | ^ +``` + +`ParseException` derives from `FormatException`, so callers that already catch +`FormatException` keep working, and carries `Offset`, `Line`, `Column`, `Found`, +`LineText`, `Summary` and `Snippet` for callers that report errors themselves. ### Extension Methods diff --git a/csharp/README.ru.md b/csharp/README.ru.md index 569723b..0c5246a 100644 --- a/csharp/README.ru.md +++ b/csharp/README.ru.md @@ -162,6 +162,35 @@ Console.WriteLine(links[0]); // (value ((id 1) (label one))) - **Parser\**: Основной класс парсера для преобразования строк в связи - **Link\**: Представляет одну связь с ID и значениями - **LinksGroup\**: Контейнер для группировки связанных связей +- **ParseException**: Выбрасывается, когда документ не разбирается + +### Обработка ошибок + +`Parse` выбрасывает `ParseException`, сообщение которого говорит, где документ +перестал быть понятным, и цитирует сломанную строку с указателем под ней: + +```csharp +try +{ + new Parser().Parse("# ok line\n# break: two\nci_gate x\n"); +} +catch (ParseException error) +{ + Console.Error.WriteLine(error.Message); + Console.Error.WriteLine($"{error.Line}:{error.Column} (смещение {error.Offset})"); +} +``` + +```text +Syntax error at line 2, column 8: unexpected ":" +2 | # break: two + | ^ +``` + +`ParseException` наследуется от `FormatException`, поэтому вызывающий код, +который уже ловит `FormatException`, продолжает работать, и несет `Offset`, +`Line`, `Column`, `Found`, `LineText`, `Summary` и `Snippet` для кода, который +сообщает об ошибках сам. ### Методы расширения diff --git a/js/README.md b/js/README.md index 75e36b8..d4d9383 100644 --- a/js/README.md +++ b/js/README.md @@ -279,11 +279,44 @@ Container for grouping related links. - `constructor(links)` - Create a new group - `format()` - Format the group as a string +#### `ParseError` + +Thrown by `parse(input)` when the document does not parse. The message says +where the document stopped making sense and quotes the offending line: + +```js +import { Parser, ParseError } from 'links-notation'; + +try { + new Parser().parse('# ok line\n# break: two\nci_gate x\n'); +} catch (error) { + console.error(error.message); + if (error instanceof ParseError) { + console.error(`${error.line}:${error.column} (offset ${error.offset})`); + } +} +``` + +```text +Syntax error at line 2, column 8: Expected "(", [ \t], [\r\n], or [^ \t\n\r(:)] but ":" found. +2 | # break: two + | ^ +``` + +- `offset` - Offset of the offending position from the start of the document +- `line`, `column` - Where the document stopped parsing, counted from 1 +- `found` - The character found instead, or `null` at the end of the document +- `lineText` - The offending line, as written +- `snippet` - The offending line with a caret under the offending column +- `location` - The position as the generated parser reports it +- `cause` - The error the generated parser threw + ## Project Structure - `src/grammar.pegjs` - Peggy.js grammar definition - `src/Link.js` - Link data structure - `src/LinksGroup.js` - Links group container +- `src/ParseError.js` - Parse error with the position of the defect - `src/Parser.js` - Parser wrapper - `src/index.js` - Main entry point - `tests/` - Test files diff --git a/js/README.ru.md b/js/README.ru.md index 6d2d9ca..13e86d2 100644 --- a/js/README.ru.md +++ b/js/README.ru.md @@ -198,11 +198,44 @@ console.log(formatLinks(links)); // (value ((id 1) (label one))) - `constructor(links)` - Создание новой группы - `format()` - Форматирование группы в строку +#### `ParseError` + +Выбрасывается методом `parse(input)`, когда документ не разбирается. Сообщение +говорит, где документ перестал быть понятным, и цитирует сломанную строку: + +```js +import { Parser, ParseError } from 'links-notation'; + +try { + new Parser().parse('# ok line\n# break: two\nci_gate x\n'); +} catch (error) { + console.error(error.message); + if (error instanceof ParseError) { + console.error(`${error.line}:${error.column} (смещение ${error.offset})`); + } +} +``` + +```text +Syntax error at line 2, column 8: Expected "(", [ \t], [\r\n], or [^ \t\n\r(:)] but ":" found. +2 | # break: two + | ^ +``` + +- `offset` - Смещение сломанной позиции от начала документа +- `line`, `column` - Где документ перестал разбираться, счет с 1 +- `found` - Найденный символ или `null` в конце документа +- `lineText` - Сломанная строка, как она написана +- `snippet` - Сломанная строка с указателем под нужным столбцом +- `location` - Позиция в том виде, в каком ее сообщает сгенерированный парсер +- `cause` - Ошибка, которую выбросил сгенерированный парсер + ## Структура проекта - `src/grammar.pegjs` - Определение грамматики Peggy.js - `src/Link.js` - Структура данных связи - `src/LinksGroup.js` - Контейнер групп связей +- `src/ParseError.js` - Ошибка разбора с позицией дефекта - `src/Parser.js` - Обертка парсера - `src/index.js` - Главная точка входа - `tests/` - Файлы тестов diff --git a/rust/links-notation/README.md b/rust/links-notation/README.md index 900215f..0bc4da2 100644 --- a/rust/links-notation/README.md +++ b/rust/links-notation/README.md @@ -573,20 +573,39 @@ pre-commit install ## Error Handling -The parser returns descriptive error messages for: - -- Empty or whitespace-only input -- Malformed syntax -- Unclosed parentheses -- Invalid characters +A parse error says where the document stopped making sense. Printing it gives +the line and the column, what could have stood there, and the offending line +with a caret under it: ```rust -match parse_lino("(invalid") { +match parse_lino("# ok line\n# break: two\nci_gate x\n") { Ok(parsed) => println!("Parsed: {}", parsed), - Err(error) => eprintln!("Error: {}", error), + Err(error) => eprintln!("{}", error), +} +``` + +```text +Syntax error at line 2, column 8: expected "(", a reference or end of line, found ":" +2 | # break: two + | ^ +``` + +The same position is available as fields, for callers that report errors +themselves rather than printing them: + +```rust +use links_notation::{parse_lino, ParseError}; + +if let Err(ParseError::SyntaxError(error)) = parse_lino("a: b: c") { + println!("{}:{} (byte offset {})", error.line, error.column, error.offset); + println!("expected {:?}, found {:?}", error.expected, error.found); } ``` +`ParseError::EmptyInput` is returned for input that is empty or only +whitespace. `cargo run --example parse_error_positions` prints what several +broken documents report. + ## Maintenance ### Code Formatting diff --git a/rust/links-notation/README.ru.md b/rust/links-notation/README.ru.md index 7865d1e..47a2bc5 100644 --- a/rust/links-notation/README.ru.md +++ b/rust/links-notation/README.ru.md @@ -250,16 +250,35 @@ println!("{}", format_links(&links)); // (value ((id 1) (label one))) ## Обработка ошибок -Парсер возвращает описательные сообщения об ошибках для: - -- Пустого ввода или ввода только из пробелов -- Неправильного синтаксиса -- Незакрытых скобок -- Недопустимых символов +Ошибка разбора сообщает, где документ перестал быть понятным. При выводе она +показывает строку и столбец, что могло стоять на этом месте, и саму строку с +указателем под ней: ```rust -match parse_lino("(недопустимо") { +match parse_lino("# ok line\n# break: two\nci_gate x\n") { Ok(parsed) => println!("Распарсено: {}", parsed), - Err(error) => eprintln!("Ошибка: {}", error), + Err(error) => eprintln!("{}", error), +} +``` + +```text +Syntax error at line 2, column 8: expected "(", a reference or end of line, found ":" +2 | # break: two + | ^ +``` + +Та же позиция доступна в виде полей — для вызывающего кода, который сообщает об +ошибках сам, а не печатает их: + +```rust +use links_notation::{parse_lino, ParseError}; + +if let Err(ParseError::SyntaxError(error)) = parse_lino("a: b: c") { + println!("{}:{} (байтовое смещение {})", error.line, error.column, error.offset); + println!("ожидалось {:?}, найдено {:?}", error.expected, error.found); } ``` + +`ParseError::EmptyInput` возвращается для пустого ввода или ввода только из +пробелов. `cargo run --example parse_error_positions` печатает то, что сообщают +несколько сломанных документов. diff --git a/rust/links-notation/examples/parse_error_positions.rs b/rust/links-notation/examples/parse_error_positions.rs index 08a69f0..7a95ccd 100644 --- a/rust/links-notation/examples/parse_error_positions.rs +++ b/rust/links-notation/examples/parse_error_positions.rs @@ -11,6 +11,7 @@ fn main() { "a: b: c", "a (b\n", "a b)\n", + ":", ]; for document in documents { From 7873d35341f64c541dfaf677b48e1da7e3c053c8 Mon Sep 17 00:00:00 2001 From: konard Date: Sat, 5 Sep 2026 16:37:03 +0000 Subject: [PATCH 08/10] Bump every implementation to 0.18.0 and record the change 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 --- CHANGELOG.md | 43 +++++++++++++++++++ benchmarks/java/pom.xml | 2 +- benchmarks/js/package-lock.json | 2 +- .../Link.Foundation.Links.Notation.csproj | 2 +- docs/website/package-lock.json | 6 +-- docs/website/package.json | 2 +- go/VERSION | 2 +- java/README.md | 4 +- java/README.ru.md | 4 +- java/pom.xml | 2 +- js/package-lock.json | 4 +- js/package.json | 2 +- php/README.md | 2 +- php/README.ru.md | 2 +- php/composer.json | 2 +- python/pyproject.toml | 2 +- rust/links-notation-benchmark/Cargo.toml | 2 +- rust/links-notation/Cargo.toml | 2 +- 18 files changed, 65 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5451805..e2529eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 NuGet, Maven Central, Packagist and proxy.golang.org are each polled for the version just released, and the GitHub release is created only once the registry confirms it ([#290](https://github.com/link-foundation/links-notation/issues/290)) +- Rust: `ParseError::SyntaxError` carries where a document stopped parsing — + `offset`, `line`, `column`, `expected`, `found` and the offending line — and + `SyntaxError::summary()` and `SyntaxError::snippet()` render it. The crate + also exports `parse_document_with_diagnostics`, and + `cargo run --example parse_error_positions` prints what several broken + documents report ([#302](https://github.com/link-foundation/links-notation/issues/302)) +- JavaScript: `ParseError`, exported from the package, thrown by + `Parser.parse` when a document does not parse. It carries `offset`, `line`, + `column`, `found`, `lineText`, `snippet`, the generated parser's `location` + and the original error as `cause` ([#302](https://github.com/link-foundation/links-notation/issues/302)) +- C#: `ParseException`, thrown by `Parser.Parse`, carrying `Offset`, `Line`, + `Column`, `Found`, `LineText`, `Summary` and `Snippet`. The Pegasus grammar + turns on `@trace true` so `FurthestFailureTracer` can record the furthest + position any rule reached, which is the only position that says where a + backtracking parser gave up ([#302](https://github.com/link-foundation/links-notation/issues/302)) +- `experiments/issue-302/run.sh` asks all seven implementations about the same + five documents, four of which do not parse, and prints the answers next to + each other ([#302](https://github.com/link-foundation/links-notation/issues/302)) ### Changed - Every manifest checked against what the registries publish today and updated: @@ -143,6 +161,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 where a cancelled run should not continue ([#290](https://github.com/link-foundation/links-notation/issues/290)) - C# packaging and DocFX configuration are vendored in the repository instead of being fetched at run time ([#290](https://github.com/link-foundation/links-notation/issues/290)) +- Rust, JavaScript and C# report a failed parse the same way: a first line + saying the position and what was expected, then the offending line with a + caret under it. All three agree on the offset of every defect the comparison + script checks ([#302](https://github.com/link-foundation/links-notation/issues/302)) +- Rust: `lino!` used to panic with a fixed sentence that named neither the + reason nor the position when text that balances its parentheses is refused by + the parser at runtime; it now panics with the parse error ([#302](https://github.com/link-foundation/links-notation/issues/302)) +- C#: `Parser.Parse` throws `ParseException` rather than the generated parser's + `FormatException`. `ParseException` derives from `FormatException`, so callers + that catch the base type keep working ([#302](https://github.com/link-foundation/links-notation/issues/302)) ### Fixed - Docs: nested contexts were described in the English READMEs and in the root @@ -237,6 +265,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `experiments/test_coverage_data.json` into the `go` flag ([#290](https://github.com/link-foundation/links-notation/issues/290)) - Docs website: `docs/website/package.json` declares `"type": "module"`, so the ESM `vite.config.js` is no longer loaded as CommonJS and every build no longer warns ([#290](https://github.com/link-foundation/links-notation/issues/290)) +- Rust: a failed parse printed the raw `nom` error — + `Error(Error { input: "", code: Eof })` — + which named no line, no column and nothing that was expected, and grew with + the size of the document. It now says + `line 2, column 8: expected "(", a reference or end of line, found ":"` and + quotes one line. The reported position is the furthest any alternative + reached, so it points at the defect rather than at the start of the line the + parser last accepted ([#302](https://github.com/link-foundation/links-notation/issues/302)) +- C#: a failed parse said `Failed to parse 'document'.` and pinned its cursor at + line 1, column 1, because the generated parser backtracks out of the start + rule before it throws. It now reports the position the document really stopped + at ([#302](https://github.com/link-foundation/links-notation/issues/302)) +- JavaScript: the generated parser reported the position on the error object but + not in the message, so a caller that printed the message lost it + ([#302](https://github.com/link-foundation/links-notation/issues/302)) ## [0.11.2] - 2024-XX-XX diff --git a/benchmarks/java/pom.xml b/benchmarks/java/pom.xml index 39b681c..8229a0d 100644 --- a/benchmarks/java/pom.xml +++ b/benchmarks/java/pom.xml @@ -23,7 +23,7 @@ io.github.link-foundation links-notation - 0.17.0 + 0.18.0