yash_syntax/parser/
error.rs

1// This file is part of yash, an extended POSIX shell.
2// Copyright (C) 2020 WATANABE Yuki
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17//! Definition of errors that happen in the parser
18
19use crate::source::Location;
20use crate::source::pretty::{
21    Footnote, FootnoteType, Report, ReportType, Snippet, Span, SpanRole, add_span,
22};
23use crate::syntax::AndOr;
24use std::borrow::Cow;
25use std::rc::Rc;
26use thiserror::Error;
27
28/// Types of syntax errors
29#[derive(Clone, Debug, Eq, Error, PartialEq)]
30#[error("{}", self.message())]
31#[non_exhaustive]
32pub enum SyntaxError {
33    /// A backslash is at the end of the input.
34    IncompleteEscape,
35    /// A backslash is not followed by a character that makes a valid escape.
36    InvalidEscape,
37    /// A `(` lacks a closing `)`.
38    UnclosedParen { opening_location: Location },
39    /// A single quotation lacks a closing `'`.
40    UnclosedSingleQuote { opening_location: Location },
41    /// A double quotation lacks a closing `"`.
42    UnclosedDoubleQuote { opening_location: Location },
43    /// A `$'` lacks a closing `'`.
44    UnclosedDollarSingleQuote { opening_location: Location },
45    /// A parameter expansion lacks a closing `}`.
46    UnclosedParam { opening_location: Location },
47    /// A parameter expansion lacks a name.
48    EmptyParam,
49    /// A parameter expansion has an invalid name.
50    InvalidParam,
51    /// A modifier does not have a valid form in a parameter expansion.
52    InvalidModifier,
53    /// A braced parameter expansion has both a prefix and suffix modifier.
54    MultipleModifier,
55    /// A command substitution started with `$(` but lacks a closing `)`.
56    UnclosedCommandSubstitution { opening_location: Location },
57    /// A command substitution started with `` ` `` but lacks a closing `` ` ``.
58    UnclosedBackquote { opening_location: Location },
59    /// An arithmetic expansion lacks a closing `))`.
60    UnclosedArith { opening_location: Location },
61    /// A command begins with an inappropriate keyword or operator token.
62    InvalidCommandToken,
63    /// A separator is missing between commands.
64    MissingSeparator,
65    /// The file descriptor specified for a redirection cannot be used.
66    FdOutOfRange,
67    /// An I/O location prefix attached to a redirection has an unsupported format.
68    InvalidIoLocation,
69    /// A redirection operator is missing its operand.
70    MissingRedirOperand,
71    /// A here-document operator is missing its delimiter token.
72    MissingHereDocDelimiter,
73    /// A here-document operator is missing its corresponding content.
74    MissingHereDocContent,
75    /// A here-document content is missing its delimiter.
76    UnclosedHereDocContent { redir_op_location: Location },
77    /// An array assignment started with `=(` but lacks a closing `)`.
78    UnclosedArrayValue { opening_location: Location },
79    /// A `}` appears without a matching `{`.
80    UnopenedGrouping,
81    /// A grouping is not closed.
82    UnclosedGrouping { opening_location: Location },
83    /// A grouping contains no commands.
84    EmptyGrouping,
85    /// A `)` appears without a matching `(`.
86    UnopenedSubshell,
87    /// A subshell is not closed.
88    UnclosedSubshell { opening_location: Location },
89    /// A subshell contains no commands.
90    EmptySubshell,
91    /// A `do` appears outside a loop.
92    UnopenedLoop,
93    /// A `done` appears outside a loop.
94    UnopenedDoClause,
95    /// A do clause is not closed.
96    UnclosedDoClause { opening_location: Location },
97    /// A do clause contains no commands.
98    EmptyDoClause,
99    /// The variable name is missing in a for loop.
100    MissingForName,
101    /// The variable name is not a valid word in a for loop.
102    InvalidForName,
103    /// A value is not a valid word in a for loop.
104    InvalidForValue,
105    /// A for loop is missing a do clause.
106    MissingForBody { opening_location: Location },
107    /// A while loop is missing a do clause.
108    UnclosedWhileClause { opening_location: Location },
109    /// A while loop's condition is empty.
110    EmptyWhileCondition,
111    /// An until loop is missing a do clause.
112    UnclosedUntilClause { opening_location: Location },
113    /// An until loop's condition is empty.
114    EmptyUntilCondition,
115    /// An if command is missing the then clause.
116    IfMissingThen { if_location: Location },
117    /// An if command's condition is empty.
118    EmptyIfCondition,
119    /// An if command's body is empty.
120    EmptyIfBody,
121    /// An elif clause is missing the then clause.
122    ElifMissingThen { elif_location: Location },
123    /// An elif clause's condition is empty.
124    EmptyElifCondition,
125    /// An elif clause's body is empty.
126    EmptyElifBody,
127    /// An else clause is empty.
128    EmptyElse,
129    /// An `elif`, `else`, `then`, or `fi` appears outside an if command.
130    UnopenedIf,
131    /// An if command is not closed.
132    UnclosedIf { opening_location: Location },
133    /// The case command is missing its subject.
134    MissingCaseSubject,
135    /// The subject of the case command is not a valid word.
136    InvalidCaseSubject,
137    /// The case command is missing `in` after the subject.
138    MissingIn { opening_location: Location },
139    /// The `)` is missing in a case item.
140    UnclosedPatternList,
141    /// The pattern is missing in a case item.
142    MissingPattern,
143    /// The pattern is not a valid word token.
144    InvalidPattern,
145    /// The first pattern of a case item is `esac`.
146    #[deprecated = "this error no longer occurs"]
147    EsacAsPattern,
148    /// An `esac` or `;;` appears outside a case command.
149    UnopenedCase,
150    /// A case command is not closed.
151    UnclosedCase { opening_location: Location },
152    /// The `(` is not followed by `)` in a function definition.
153    UnmatchedParenthesis,
154    /// The function body is missing in a function definition command.
155    MissingFunctionBody,
156    /// A function body is not a compound command.
157    InvalidFunctionBody,
158    /// The keyword `in` is used as a command name.
159    InAsCommandName,
160    /// A pipeline is missing after a `&&` or `||` token.
161    MissingPipeline(AndOr),
162    /// Two successive `!` tokens.
163    DoubleNegation,
164    /// A `|` token is followed by a `!`.
165    BangAfterBar,
166    /// A command is missing after a `!` token.
167    MissingCommandAfterBang,
168    /// A command is missing after a `|` token.
169    MissingCommandAfterBar,
170    /// There is a redundant token.
171    RedundantToken,
172    /// A control escape (`\c...`) is incomplete in a dollar-single-quoted string.
173    IncompleteControlEscape,
174    /// A control-backslash escape (`\c\\`) is incomplete in a dollar-single-quoted string.
175    IncompleteControlBackslashEscape,
176    /// A control escape (`\c...`) does not have a valid control character.
177    InvalidControlEscape,
178    /// An octal escape is out of range (greater than `\377`) in a dollar-single-quoted string.
179    OctalEscapeOutOfRange,
180    /// An hexadecimal escape (`\x...`) is incomplete in a dollar-single-quoted string.
181    IncompleteHexEscape,
182    /// A Unicode escape (`\u...`) is incomplete in a dollar-single-quoted string.
183    IncompleteShortUnicodeEscape,
184    /// A Unicode escape (`\U...`) is incomplete in a dollar-single-quoted string.
185    IncompleteLongUnicodeEscape,
186    /// A Unicode escape (`\u...` or `\U...`) is out of range in a dollar-single-quoted string.
187    UnicodeEscapeOutOfRange,
188    /// The unsupported version of function definition syntax is used.
189    UnsupportedFunctionDefinitionSyntax,
190    /// A `[[ ... ]]` command is used.
191    UnsupportedDoubleBracketCommand,
192    /// A process redirection (`>(...)` or `<(...)`) is used.
193    UnsupportedProcessRedirection,
194}
195
196impl SyntaxError {
197    /// Returns an error message describing the error.
198    #[must_use]
199    pub fn message(&self) -> &'static str {
200        use SyntaxError::*;
201        match self {
202            IncompleteEscape => "the backslash is escaping nothing",
203            InvalidEscape => "the backslash escape is invalid",
204            UnclosedParen { .. } => "the parenthesis is not closed",
205            UnclosedSingleQuote { .. } => "the single quote is not closed",
206            UnclosedDoubleQuote { .. } => "the double quote is not closed",
207            UnclosedDollarSingleQuote { .. } => "the dollar single quote is not closed",
208            UnclosedParam { .. } => "the parameter expansion is not closed",
209            EmptyParam => "the parameter name is missing",
210            InvalidParam => "the parameter name is invalid",
211            InvalidModifier => "the parameter expansion contains a malformed modifier",
212            MultipleModifier => "a suffix modifier cannot be used together with a prefix modifier",
213            UnclosedCommandSubstitution { .. } => "the command substitution is not closed",
214            UnclosedBackquote { .. } => "the backquote is not closed",
215            UnclosedArith { .. } => "the arithmetic expansion is not closed",
216            InvalidCommandToken => "the command starts with an inappropriate token",
217            MissingSeparator => "a separator is missing between the commands",
218            FdOutOfRange => "the file descriptor is too large",
219            InvalidIoLocation => "the I/O location prefix is not valid",
220            MissingRedirOperand => "the redirection operator is missing its operand",
221            MissingHereDocDelimiter => "the here-document operator is missing its delimiter",
222            MissingHereDocContent => "content of the here-document is missing",
223            UnclosedHereDocContent { .. } => {
224                "the delimiter to close the here-document content is missing"
225            }
226            UnclosedArrayValue { .. } => "the array assignment value is not closed",
227            UnopenedGrouping | UnopenedSubshell | UnopenedLoop | UnopenedDoClause | UnopenedIf
228            | UnopenedCase | InAsCommandName => "the compound command delimiter is unmatched",
229            UnclosedGrouping { .. } => "the grouping is not closed",
230            EmptyGrouping => "the grouping is missing its content",
231            UnclosedSubshell { .. } => "the subshell is not closed",
232            EmptySubshell => "the subshell is missing its content",
233            UnclosedDoClause { .. } => "the `do` clause is missing its closing `done`",
234            EmptyDoClause => "the `do` clause is missing its content",
235            MissingForName => "the variable name is missing in the `for` loop",
236            InvalidForName => "the variable name is invalid",
237            InvalidForValue => "the operator token is invalid in the word list of the `for` loop",
238            MissingForBody { .. } => "the `for` loop is missing its `do` clause",
239            UnclosedWhileClause { .. } => "the `while` loop is missing its `do` clause",
240            EmptyWhileCondition => "the `while` loop is missing its condition",
241            UnclosedUntilClause { .. } => "the `until` loop is missing its `do` clause",
242            EmptyUntilCondition => "the `until` loop is missing its condition",
243            IfMissingThen { .. } => "the `if` command is missing the `then` clause",
244            EmptyIfCondition => "the `if` command is missing its condition",
245            EmptyIfBody => "the `if` command is missing its body",
246            ElifMissingThen { .. } => "the `elif` clause is missing the `then` clause",
247            EmptyElifCondition => "the `elif` clause is missing its condition",
248            EmptyElifBody => "the `elif` clause is missing its body",
249            EmptyElse => "the `else` clause is missing its content",
250            UnclosedIf { .. } => "the `if` command is missing its closing `fi`",
251            MissingCaseSubject => "the subject is missing after `case`",
252            InvalidCaseSubject => "the `case` command subject is not a valid word",
253            MissingIn { .. } => "`in` is missing in the `case` command",
254            UnclosedPatternList => "the pattern list is not properly closed by a `)`",
255            MissingPattern => "a pattern is missing in the `case` command",
256            InvalidPattern => "the pattern is not a valid word token",
257            #[allow(deprecated)]
258            EsacAsPattern => "`esac` cannot be the first of a pattern list",
259            UnclosedCase { .. } => "the `case` command is missing its closing `esac`",
260            UnmatchedParenthesis => "`)` is missing after `(`",
261            MissingFunctionBody => "the function body is missing",
262            InvalidFunctionBody => "the function body must be a compound command",
263            MissingPipeline(AndOr::AndThen) => "a command is missing after `&&`",
264            MissingPipeline(AndOr::OrElse) => "a command is missing after `||`",
265            DoubleNegation => "`!` cannot be used twice in a row",
266            BangAfterBar => "`!` cannot be used in the middle of a pipeline",
267            MissingCommandAfterBang => "a command is missing after `!`",
268            MissingCommandAfterBar => "a command is missing after `|`",
269            RedundantToken => "there is a redundant token",
270            IncompleteControlEscape => "the control escape is incomplete",
271            IncompleteControlBackslashEscape => "the control-backslash escape is incomplete",
272            InvalidControlEscape => "the control escape is invalid",
273            OctalEscapeOutOfRange => "the octal escape is out of range",
274            IncompleteHexEscape => "the hexadecimal escape is incomplete",
275            IncompleteShortUnicodeEscape | IncompleteLongUnicodeEscape => {
276                "the Unicode escape is incomplete"
277            }
278            UnicodeEscapeOutOfRange => "the Unicode escape is out of range",
279            UnsupportedFunctionDefinitionSyntax
280            | UnsupportedDoubleBracketCommand
281            | UnsupportedProcessRedirection => "unsupported syntax",
282        }
283    }
284
285    /// Returns a label for annotating the error location.
286    #[must_use]
287    pub fn label(&self) -> &'static str {
288        use SyntaxError::*;
289        match self {
290            IncompleteEscape => "expected an escaped character after the backslash",
291            InvalidEscape => "invalid escape sequence",
292            UnclosedParen { .. }
293            | UnclosedCommandSubstitution { .. }
294            | UnclosedArrayValue { .. }
295            | UnclosedSubshell { .. }
296            | UnclosedPatternList
297            | UnmatchedParenthesis => "expected `)`",
298            EmptyGrouping
299            | EmptySubshell
300            | EmptyDoClause
301            | EmptyWhileCondition
302            | EmptyUntilCondition
303            | EmptyIfCondition
304            | EmptyIfBody
305            | EmptyElifCondition
306            | EmptyElifBody
307            | EmptyElse
308            | MissingPipeline(_)
309            | MissingCommandAfterBang
310            | MissingCommandAfterBar => "expected a command",
311            InvalidForValue | MissingCaseSubject | InvalidCaseSubject | MissingPattern
312            | InvalidPattern => "expected a word",
313            UnclosedSingleQuote { .. } | UnclosedDollarSingleQuote { .. } => "expected `'`",
314            UnclosedDoubleQuote { .. } => "expected `\"`",
315            UnclosedParam { .. } | UnclosedGrouping { .. } => "expected `}`",
316            EmptyParam => "expected a parameter name",
317            InvalidParam => "not a valid named or positional parameter",
318            InvalidModifier => "broken modifier",
319            MultipleModifier => "conflicting modifier",
320            UnclosedBackquote { .. } => "expected '`'",
321            UnclosedArith { .. } => "expected `))`",
322            InvalidCommandToken => "does not begin a valid command",
323            MissingSeparator => "expected `;` or `&` before this token",
324            FdOutOfRange => "unsupported file descriptor",
325            InvalidIoLocation => "unsupported I/O location prefix",
326            MissingRedirOperand => "expected a redirection operand",
327            MissingHereDocDelimiter => "expected a delimiter word",
328            MissingHereDocContent => "content not found",
329            UnclosedHereDocContent { .. } => "missing delimiter",
330            UnopenedGrouping => "no grouping command to close",
331            UnopenedSubshell => "no subshell to close",
332            UnopenedLoop => "not in a loop",
333            UnopenedDoClause => "no `do` clause to close",
334            UnclosedDoClause { .. } => "expected `done`",
335            MissingForName => "expected a variable name",
336            InvalidForName => "not a valid variable name",
337            MissingForBody { .. } | UnclosedWhileClause { .. } | UnclosedUntilClause { .. } => {
338                "expected `do ... done`"
339            }
340            IfMissingThen { .. } | ElifMissingThen { .. } => "expected `then ... fi`",
341            UnopenedIf => "not in an `if` command",
342            UnclosedIf { .. } => "expected `fi`",
343            MissingIn { .. } => "expected `in`",
344            #[allow(deprecated)]
345            EsacAsPattern => "needs quoting",
346            UnopenedCase => "not in a `case` command",
347            UnclosedCase { .. } => "expected `esac`",
348            MissingFunctionBody | InvalidFunctionBody => "expected a compound command",
349            InAsCommandName => "cannot be used as a command name",
350            DoubleNegation => "only one `!` allowed",
351            BangAfterBar => "`!` not allowed here",
352            RedundantToken => "unexpected token",
353            IncompleteControlEscape => r"expected a control character after `\c`",
354            IncompleteControlBackslashEscape => r"expected another backslash after `\c\`",
355            InvalidControlEscape => "not a valid control character",
356            OctalEscapeOutOfRange => r"expected a value between \0 and \377",
357            IncompleteHexEscape => r"expected a hexadecimal digit after `\x`",
358            IncompleteShortUnicodeEscape => r"expected a hexadecimal digit after `\u`",
359            IncompleteLongUnicodeEscape => r"expected a hexadecimal digit after `\U`",
360            UnicodeEscapeOutOfRange => "not a valid Unicode scalar value",
361            UnsupportedFunctionDefinitionSyntax => "the `function` keyword is not yet supported",
362            UnsupportedDoubleBracketCommand => "the `[[ ... ]]` command is not yet supported",
363            UnsupportedProcessRedirection => "process redirection is not yet supported",
364        }
365    }
366
367    /// Returns a location related with the error cause and a message describing
368    /// the location.
369    #[must_use]
370    pub fn related_location(&self) -> Option<(&Location, &'static str)> {
371        use SyntaxError::*;
372        match self {
373            UnclosedParen { opening_location }
374            | UnclosedSubshell { opening_location }
375            | UnclosedArrayValue { opening_location } => {
376                Some((opening_location, "the opening parenthesis was here"))
377            }
378            UnclosedSingleQuote { opening_location }
379            | UnclosedDoubleQuote { opening_location }
380            | UnclosedDollarSingleQuote { opening_location } => {
381                Some((opening_location, "the opening quote was here"))
382            }
383            UnclosedParam { opening_location } => {
384                Some((opening_location, "the parameter started here"))
385            }
386            UnclosedCommandSubstitution { opening_location } => {
387                Some((opening_location, "the command substitution started here"))
388            }
389            UnclosedBackquote { opening_location } => {
390                Some((opening_location, "the opening backquote was here"))
391            }
392            UnclosedArith { opening_location } => {
393                Some((opening_location, "the arithmetic expansion started here"))
394            }
395            UnclosedHereDocContent { redir_op_location } => {
396                Some((redir_op_location, "the redirection operator was here"))
397            }
398            UnclosedGrouping { opening_location } => {
399                Some((opening_location, "the opening brace was here"))
400            }
401            UnclosedDoClause { opening_location } => {
402                Some((opening_location, "the `do` clause started here"))
403            }
404            MissingForBody { opening_location } => {
405                Some((opening_location, "the `for` loop started here"))
406            }
407            UnclosedWhileClause { opening_location } => {
408                Some((opening_location, "the `while` loop started here"))
409            }
410            UnclosedUntilClause { opening_location } => {
411                Some((opening_location, "the `until` loop started here"))
412            }
413            IfMissingThen { if_location }
414            | UnclosedIf {
415                opening_location: if_location,
416            } => Some((if_location, "the `if` command started here")),
417            ElifMissingThen { elif_location } => {
418                Some((elif_location, "the `elif` clause started here"))
419            }
420            MissingIn { opening_location } | UnclosedCase { opening_location } => {
421                Some((opening_location, "the `case` command started here"))
422            }
423            _ => None,
424        }
425    }
426}
427
428/// Types of errors that may happen in parsing
429#[derive(Clone, Debug, Error)]
430#[error("{}", self.message())]
431pub enum ErrorCause {
432    /// Error in an underlying input function
433    Io(#[from] Rc<std::io::Error>),
434    /// Syntax error
435    Syntax(#[from] SyntaxError),
436}
437
438impl PartialEq for ErrorCause {
439    fn eq(&self, other: &Self) -> bool {
440        match (self, other) {
441            (ErrorCause::Syntax(e1), ErrorCause::Syntax(e2)) => e1 == e2,
442            _ => false,
443        }
444    }
445}
446
447impl ErrorCause {
448    /// Returns an error message describing the error cause.
449    #[must_use]
450    pub fn message(&self) -> Cow<'static, str> {
451        use ErrorCause::*;
452        match self {
453            Io(e) => format!("cannot read commands: {e}").into(),
454            Syntax(e) => e.message().into(),
455        }
456    }
457
458    /// Returns a label for annotating the error location.
459    #[must_use]
460    pub fn label(&self) -> &'static str {
461        use ErrorCause::*;
462        match self {
463            Io(_) => "the command could be read up to here",
464            Syntax(e) => e.label(),
465        }
466    }
467
468    /// Returns a location related with the error cause and a message describing
469    /// the location.
470    #[must_use]
471    pub fn related_location(&self) -> Option<(&Location, &'static str)> {
472        use ErrorCause::*;
473        match self {
474            Io(_) => None,
475            Syntax(e) => e.related_location(),
476        }
477    }
478}
479
480impl From<std::io::Error> for ErrorCause {
481    fn from(e: std::io::Error) -> ErrorCause {
482        ErrorCause::from(Rc::new(e))
483    }
484}
485
486/// Explanation of a failure in parsing
487#[derive(Clone, Debug, Error, PartialEq)]
488#[error("{cause}")]
489pub struct Error {
490    pub cause: ErrorCause,
491    pub location: Location,
492}
493
494impl Error {
495    /// Returns a report for the error.
496    #[must_use]
497    pub fn to_report(&self) -> Report<'_> {
498        let mut report = Report::new();
499        report.r#type = ReportType::Error;
500        report.title = self.cause.message();
501        report.snippets = Snippet::with_primary_span(&self.location, self.cause.label().into());
502
503        if let Some((location, label)) = self.cause.related_location() {
504            let label = label.into();
505            let span = Span {
506                range: location.byte_range(),
507                role: SpanRole::Supplementary { label },
508            };
509            add_span(&location.code, span, &mut report.snippets);
510        }
511
512        if let ErrorCause::Syntax(SyntaxError::BangAfterBar) = &self.cause {
513            // TODO Suggest the change with SpanRole::Patch
514            report.footnotes.push(Footnote {
515                r#type: FootnoteType::Suggestion,
516                label: "surround the pipeline component in a grouping: `{ ! ...; }`".into(),
517            });
518        }
519
520        report
521    }
522}
523
524/// Converts the error into a report by calling [`Error::to_report`].
525impl<'a> From<&'a Error> for Report<'a> {
526    #[inline(always)]
527    fn from(error: &'a Error) -> Self {
528        error.to_report()
529    }
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::source::Code;
536    use crate::source::Source;
537    use assert_matches::assert_matches;
538    use std::num::NonZeroU64;
539    use std::rc::Rc;
540
541    #[test]
542    fn display_for_error() {
543        let code = Rc::new(Code {
544            value: "".to_string().into(),
545            start_line_number: NonZeroU64::new(1).unwrap(),
546            source: Source::Unknown.into(),
547        });
548        let location = Location { code, range: 0..42 };
549        let error = Error {
550            cause: SyntaxError::MissingHereDocDelimiter.into(),
551            location,
552        };
553        assert_eq!(
554            error.to_string(),
555            "the here-document operator is missing its delimiter"
556        );
557    }
558
559    #[test]
560    fn from_error_for_report() {
561        let code = Rc::new(Code {
562            value: "!!!".to_string().into(),
563            start_line_number: NonZeroU64::new(1).unwrap(),
564            source: Source::Unknown.into(),
565        });
566        let error = Error {
567            cause: SyntaxError::MissingHereDocDelimiter.into(),
568            location: Location { code, range: 0..42 },
569        };
570
571        let report = Report::from(&error);
572
573        assert_eq!(report.r#type, ReportType::Error);
574        assert_eq!(
575            report.title,
576            "the here-document operator is missing its delimiter"
577        );
578        assert_eq!(report.snippets.len(), 1);
579        assert_eq!(*report.snippets[0].code.value.borrow(), "!!!");
580        assert_eq!(report.snippets[0].spans.len(), 1);
581        assert_eq!(report.snippets[0].spans[0].range, 0..3);
582        assert_matches!(
583            &report.snippets[0].spans[0].role,
584            SpanRole::Primary { label } if label == "expected a delimiter word"
585        );
586        assert_eq!(report.footnotes, []);
587    }
588}