Skip to main content

praxis_input_parser/
body.rs

1//! The capture-body parser (§7.3, ADR-072).
2//!
3//! A template capture's body is a **full parser expression**:
4//! `{items:csv(int)}`, `{x:optional(int)}`, `{s:sep("-", int)}`,
5//! `` {g:choice(A: `{n:int}`)} ``. §7.7's own monkey example writes the first
6//! one, so "atomics only" is not a smaller language — it is a language that
7//! cannot run the design document's text.
8//!
9//! Each capture's body is parsed here, in its own right: no later pass rescans
10//! the template, and no unrecognized name has a default.
11//!
12//! **This is a hand-written parser and not a call back into `praxis-parser`.**
13//! ADR-023 fixes the dependency direction — `praxis-input-parser` must not
14//! depend on the ordinary grammar — and the argument grammar is shared with the
15//! HIR bridge through [`crate::call::build_call`] instead, so the two cannot
16//! drift.
17
18use praxis_source::Span;
19use praxis_syntax::ident::{ident_run_len, is_ident_continue, is_ident_start};
20
21use crate::ast::{AtomicKind, Constructor, ParserAst, shift_part_spans};
22use crate::call::{CallArg, build_call, build_repeated_tail};
23use crate::scan::{Scan, ScanError, skip_string};
24
25/// Parse a capture body into its [`ParserAst`].
26///
27/// `at` is the body's byte offset within the text the caller is scanning, so
28/// spans and error offsets are meaningful; `depth` is how many templates are
29/// already open (see [`crate::scan::MAX_NESTING`]), threaded through to the backtick arm of
30/// [`parse_expr`] and not re-checked here.
31///
32/// **The bound is checked in one place**, `scan_template_at`, because that is
33/// the one place a template level is entered. A second guard here would count
34/// the same recursion twice and halve the limit the message names.
35///
36/// # Errors
37/// [`ScanError`] for an unknown parser name, an unknown constructor, a
38/// constructor whose arguments do not have §7.5's shape, or a body that is not
39/// a parser expression at all.
40pub(crate) fn parse_capture_body(
41    text: &str,
42    at: usize,
43    depth: usize,
44) -> Result<ParserAst, ScanError> {
45    let trimmed = text.trim();
46    if trimmed.is_empty() {
47        return Err(ScanError::EmptyCapture { byte_offset: at });
48    }
49    // The offset of `trimmed` within the caller's text, so errors point at the
50    // body and not at its leading space.
51    let base = at + (trimmed.as_ptr() as usize - text.as_ptr() as usize);
52    let mut cur = Scan::new(trimmed);
53    let ast = parse_expr(&mut cur, base, depth)?;
54    skip_ws(&mut cur);
55    if let Some((tail, _)) = cur.peek() {
56        return Err(ScanError::MalformedCaptureBody {
57            byte_offset: base + tail,
58            message: format!("unexpected `{}` after the parser", &trimmed[tail..]),
59        });
60    }
61    Ok(ast)
62}
63
64/// One parser expression: a nested template, a constructor call, or an atomic.
65fn parse_expr(cur: &mut Scan<'_>, base: usize, depth: usize) -> Result<ParserAst, ScanError> {
66    skip_ws(cur);
67    let Some((start, c)) = cur.peek() else {
68        return Err(ScanError::MalformedCaptureBody {
69            byte_offset: base,
70            message: "expected a parser".to_string(),
71        });
72    };
73
74    if c == '`' {
75        // A nested backtick template. Its own interior is scanned by the same
76        // scanner, one level deeper — and **in its own offsets**, which is the
77        // whole of the next three lines.
78        //
79        // `scan_template_at` is handed the text between the backticks and knows
80        // nothing about where that text sits here, so every span and every
81        // error offset it returns is relative to the nested interior. Both the
82        // outer `Template` node's span *and* the parts underneath it have to be
83        // rebased here: a single uniform shift applied later is right for one
84        // level and wrong for two, so a diagnostic inside
85        // `` `{a:sections(x: `{y:int}`)}` `` would name the wrong bytes.
86        //
87        // The nested interior begins one byte past the backtick at `start`.
88        let inner_base = base + start + 1;
89        let interior = crate::scan::take_template(cur)?;
90        let mut parts = crate::scan::scan_template_at(interior, depth + 1)
91            .map_err(|e| e.shifted(inner_base))?;
92        shift_part_spans(&mut parts, inner_base as u32);
93        return Ok(ParserAst::Template {
94            parts,
95            span: Span::new((base + start) as u32, (base + cur.pos()) as u32),
96        });
97    }
98
99    if !is_ident_start(c) {
100        return Err(ScanError::MalformedCaptureBody {
101            byte_offset: base + start,
102            message: format!("`{c}` cannot begin a parser"),
103        });
104    }
105    let name = take_ident(cur);
106    skip_ws(cur);
107
108    if cur.peek_char() != Some('(') {
109        // A bare name: an atomic (§7.4). A constructor written without its
110        // arguments is a shape error, not an unknown parser.
111        if let Some(kind) = AtomicKind::from_keyword(name) {
112            return Ok(ParserAst::Atomic {
113                kind,
114                span: Span::new((base + start) as u32, (base + cur.pos()) as u32),
115            });
116        }
117        if Constructor::from_keyword(name).is_some() {
118            return Err(ScanError::MalformedCaptureBody {
119                byte_offset: base + start,
120                message: format!("`{name}` is a constructor and needs arguments"),
121            });
122        }
123        // **No `Int` default**: an unrecognized name is an error.
124        return Err(ScanError::UnknownCaptureKind {
125            byte_offset: base + start,
126            name: name.to_string(),
127        });
128    }
129
130    let Some(ctor) = Constructor::from_keyword(name) else {
131        return Err(ScanError::UnknownConstructor {
132            byte_offset: base + start,
133            name: name.to_string(),
134        });
135    };
136    let args = parse_args(cur, base, depth, ctor)?;
137    let span = Span::new((base + start) as u32, (base + cur.pos()) as u32);
138    build_call(ctor, args, span).map_err(|mut errs| {
139        // `build_call` reports every problem; the scanner's channel carries one,
140        // and the first is the one the source wrote first.
141        ScanError::CallShape(errs.remove(0))
142    })
143}
144
145/// The argument list of `ctor(...)`. The cursor is on the `(`.
146///
147/// `ctor` is threaded through because whether a `name:` argument is a keyword
148/// or a named parser is **the constructor's** question, not the name's.
149fn parse_args(
150    cur: &mut Scan<'_>,
151    base: usize,
152    depth: usize,
153    ctor: Constructor,
154) -> Result<Vec<CallArg>, ScanError> {
155    let open = cur.pos();
156    cur.bump(); // `(`
157    let mut args = Vec::new();
158    loop {
159        skip_ws(cur);
160        match cur.peek_char() {
161            None => {
162                return Err(ScanError::MalformedCaptureBody {
163                    byte_offset: base + open,
164                    message: "unbalanced `(`".to_string(),
165                });
166            }
167            Some(')') => {
168                cur.bump();
169                return Ok(args);
170            }
171            Some(',') => {
172                cur.bump();
173            }
174            Some(_) => {
175                let at = args.len();
176                args.push(parse_arg(cur, base, depth, ctor, at)?);
177            }
178        }
179    }
180}
181
182/// One argument: a string literal, a whole number, a `name: value` pair, a bare
183/// flag, or a positional parser.
184///
185/// `at` is the argument's position in the list, which only `repeated`'s count
186/// needs: a name written where a count belongs has to be told it is not a
187/// count, and by the time [`parse_expr`] has failed on it the diagnostic is
188/// about the name instead.
189fn parse_arg(
190    cur: &mut Scan<'_>,
191    base: usize,
192    depth: usize,
193    ctor: Constructor,
194    at: usize,
195) -> Result<CallArg, ScanError> {
196    skip_ws(cur);
197    if cur.peek_char() == Some('"') {
198        return Ok(CallArg::String(take_string(cur, base)?));
199    }
200
201    // A positional whole number — `repeated(P, 6)`'s count. Which constructors
202    // take one is `Constructor::arg_shape`'s question, not this scanner's, for
203    // the same reason the ordinary grammar does not ask it either: a count
204    // written where none belongs should be told so by name.
205    if starts_a_number(cur) {
206        let at = cur.pos();
207        let text = take_number(cur);
208        // `praxis_syntax::numeric` is the workspace's one integer decoder, so
209        // the two front ends cannot disagree about what `0x10` or `1_000` mean.
210        let Some(n) = praxis_syntax::numeric::parse_int_literal(text) else {
211            return Err(ScanError::MalformedCaptureBody {
212                byte_offset: base + at,
213                message: format!("`{text}` is not a whole number"),
214            });
215        };
216        return Ok(CallArg::Int(n));
217    }
218
219    // A `name:` prefix, if the next identifier is immediately followed by a
220    // colon. Anything else is a positional parser.
221    if let Some(name) = peek_named_prefix(cur) {
222        for _ in 0..name.chars().count() {
223            cur.bump();
224        }
225        skip_ws(cur);
226        cur.bump(); // `:`
227        skip_ws(cur);
228        let name = name.to_string();
229
230        // `skip:` and `fill:` take a keyword, not a parser — but only for the
231        // constructor that has one (`chars` and `grid`). The question goes to
232        // the constructor and not to the name, so a `block` item or a
233        // `sections` field called `fill` stays an ordinary named parser.
234        if Some(name.as_str()) == ctor.keyword_arg() {
235            let value = take_keyword_value(cur);
236            return Ok(CallArg::Keyword { name, value });
237        }
238        // `name: repeated(P)` / `name: repeated(P, N)` is the named-sections
239        // group marker (§7.5): the field's parser is the `P`, and `repeated`
240        // says the field takes a group of sections rather than one.
241        // `build_call` refuses a bare `repeated(...)` outright, so the marker
242        // goes through `build_repeated_tail` — the same function the HIR
243        // bridge calls, so the two front ends cannot disagree about the
244        // marker's own shape, its count included.
245        if peek_ident(cur) == Some(Constructor::Repeated.keyword()) {
246            let at = cur.pos();
247            take_ident(cur);
248            skip_ws(cur);
249            if cur.peek_char() != Some('(') {
250                return Err(ScanError::MalformedCaptureBody {
251                    byte_offset: base + at,
252                    message: "`repeated` needs a parser argument".to_string(),
253                });
254            }
255            let args = parse_args(cur, base, depth, Constructor::Repeated)?;
256            return build_repeated_tail(name, args, Span::at((base + at) as u32))
257                .map_err(|mut errs| ScanError::CallShape(errs.remove(0)));
258        }
259        let parser = parse_expr(cur, base, depth)?;
260        return Ok(CallArg::Named { name, parser });
261    }
262
263    // A bare flag — today only `grid(P, ragged, fill: v)`'s `ragged`, and only
264    // for the constructor that has one. Asked of the *name* alone, `ragged`
265    // would be a flag in **every** constructor's argument list and the word
266    // would be reserved everywhere rather than in `grid`; `flag_arg` asks the
267    // constructor, exactly as `keyword_arg` does one argument kind over.
268    //
269    // `is_some_and` and not `peek_ident(cur) == ctor.flag_arg()`: that also
270    // holds when both are `None`, which is end-of-arguments, and would mint a
271    // flag out of nothing.
272    if ctor.flag_arg().is_some_and(|f| peek_ident(cur) == Some(f)) {
273        return Ok(CallArg::Flag(take_ident(cur).to_string()));
274    }
275
276    // A name after `repeated`'s parser is a count that is not a literal, and
277    // the rowan front end says so there (ADR-073 Decision 2: one rule, two
278    // spellings). Left to `parse_expr` this would report "unknown parser `n`",
279    // which is true and carries none of the fix — no name would have worked.
280    // A name that *is* a parser falls through to the shared shape check, which
281    // is where a second parser is decided.
282    if ctor == Constructor::Repeated
283        && at >= 1
284        && let Some(name) = peek_ident(cur)
285        && !crate::parser_names().any(|known| known == name)
286    {
287        return Err(ScanError::CallShape(crate::validate::ValidationError {
288            span: Span::at((base + cur.pos()) as u32),
289            code: praxis_source::DiagCode::InvalidConstructorArgument,
290            message: "`repeated`'s count must be a whole-number literal — the parser \
291                              plan is built when the program is compiled, so the count cannot \
292                              be a parser or a variable"
293                .to_string(),
294        }));
295    }
296
297    Ok(CallArg::Parser(parse_expr(cur, base, depth)?))
298}
299
300/// Peek at `ident` `:` without consuming, returning the identifier text.
301fn peek_named_prefix<'a>(cur: &mut Scan<'a>) -> Option<&'a str> {
302    let name = peek_ident(cur)?;
303    let src = cur.src();
304    let start = cur.pos();
305    let after = start + name.len();
306    let rest = src.get(after..)?;
307    let rest_trimmed = rest.trim_start();
308    if rest_trimmed.starts_with(':') {
309        Some(&src[start..after])
310    } else {
311        None
312    }
313}
314
315/// The identifier at the cursor, without consuming it.
316fn peek_ident<'a>(cur: &mut Scan<'a>) -> Option<&'a str> {
317    let rest = cur.src().get(cur.pos()..)?;
318    match ident_run_len(rest) {
319        0 => None,
320        n => Some(&rest[..n]),
321    }
322}
323
324/// Consume and return the identifier at the cursor.
325fn take_ident<'a>(cur: &mut Scan<'a>) -> &'a str {
326    let start = cur.pos();
327    while cur.peek_char().is_some_and(is_ident_continue) {
328        cur.bump();
329    }
330    &cur.src()[start..cur.pos()]
331}
332
333/// Whether a positional whole number starts at the cursor: a digit, or a `-`
334/// with a digit behind it. A lone `-` is not a number, so it still reaches
335/// [`parse_expr`] and earns that arm's diagnostic.
336fn starts_a_number(cur: &mut Scan<'_>) -> bool {
337    let rest = &cur.src()[cur.pos()..];
338    let mut chars = rest.chars();
339    match chars.next() {
340        Some(c) if c.is_ascii_digit() => true,
341        Some('-') => chars.next().is_some_and(|c| c.is_ascii_digit()),
342        _ => false,
343    }
344}
345
346/// Consume the number at the cursor: the sign, then the run of digits and
347/// digit separators. Decoding it is [`praxis_syntax::numeric`]'s job, so a run
348/// that is not a number is reported rather than reinterpreted.
349fn take_number<'a>(cur: &mut Scan<'a>) -> &'a str {
350    let start = cur.pos();
351    if cur.peek_char() == Some('-') {
352        cur.bump();
353    }
354    while cur
355        .peek_char()
356        .is_some_and(|c| c.is_ascii_digit() || c == '_')
357    {
358        cur.bump();
359    }
360    &cur.src()[start..cur.pos()]
361}
362
363/// Consume a `"…"` literal and decode it with the workspace's one decoder.
364///
365/// The **extent** is [`skip_string`]'s — [`praxis_syntax::template::string_end`]'s
366/// — rather than a second copy of the backslash/quote loop. The rebasing is not
367/// optional: this `Scan` runs over the capture body alone, so the offset
368/// `skip_string` reports is relative to *that* text, and without the shift every
369/// unterminated-literal caret in a capture body lands `base` bytes short.
370fn take_string(cur: &mut Scan<'_>, base: usize) -> Result<String, ScanError> {
371    let start = cur.pos();
372    skip_string(cur).map_err(|err| err.shifted(base))?;
373    Ok(praxis_syntax::literal::unquote_text(
374        &cur.src()[start..cur.pos()],
375    ))
376}
377
378/// The value of a `skip:`/`fill:` keyword argument: everything up to the next
379/// `,` or `)` **outside a string literal**.
380///
381/// The delimiter search is quote-aware, or `fill: ","` would end at the comma
382/// *inside* the literal and leave a lone `"` behind — text that is not
383/// malformed reported as an unterminated literal, while the rowan front end
384/// accepts the very same call. A quoted value is returned with its quotes;
385/// `build_call` decodes it, so both front ends get one answer from one place.
386/// Which literal is "a string" is [`skip_string`]'s question, the same one the
387/// extent scan and the lexer ask, so a third inline copy of the backslash/quote
388/// loop cannot drift away from them.
389fn take_keyword_value(cur: &mut Scan<'_>) -> String {
390    let start = cur.pos();
391    while let Some(c) = cur.peek_char() {
392        match c {
393            ',' | ')' => break,
394            '"' => {
395                // A literal with no end has no end to skip to, and
396                // `skip_string` reports that **without moving the cursor** — so
397                // this arm must consume the rest itself, or the loop re-reads
398                // this same quote forever. Running to the end leaves
399                // `parse_args` to report the unbalanced `(`, which is the
400                // malformed text's real complaint.
401                if skip_string(cur).is_err() {
402                    cur.advance_to(cur.src().len());
403                }
404            }
405            _ => {
406                cur.bump();
407            }
408        }
409    }
410    cur.src()[start..cur.pos()].trim().to_string()
411}
412
413fn skip_ws(cur: &mut Scan<'_>) {
414    while cur.peek_char().is_some_and(char::is_whitespace) {
415        cur.bump();
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use crate::ast::{AtomicKind, SectionItem, SkipPolicy};
423
424    fn parse(text: &str) -> Result<ParserAst, ScanError> {
425        parse_capture_body(text, 0, 0)
426    }
427
428    #[test]
429    fn an_atomic_body_is_the_atomic_it_names() {
430        for name in ["int", "word", "char", "text", "rest", "digit"] {
431            match parse(name) {
432                Ok(ParserAst::Atomic { kind, .. }) => {
433                    assert_eq!(kind, AtomicKind::from_keyword(name).unwrap());
434                }
435                other => panic!("{name} must be an atomic, got {other:?}"),
436            }
437        }
438    }
439
440    /// There is no default: an unknown name is reported, and it is reported as
441    /// `UnknownCaptureKind` (I012) rather than inheriting the generic
442    /// template-scan code.
443    #[test]
444    fn an_unknown_parser_name_has_no_default() {
445        match parse("intr") {
446            Err(err @ ScanError::UnknownCaptureKind { .. }) => {
447                assert_eq!(err.code(), praxis_source::DiagCode::UnknownCaptureKind);
448            }
449            other => panic!("expected UnknownCaptureKind, got {other:?}"),
450        }
451    }
452
453    #[test]
454    fn a_constructor_call_is_built_through_the_shared_table() {
455        assert!(matches!(parse("csv(int)"), Ok(ParserAst::Csv { .. })));
456        assert!(matches!(
457            parse("optional(int)"),
458            Ok(ParserAst::Optional { .. })
459        ));
460        match parse(r#"sep(",", int)"#) {
461            Ok(ParserAst::Sep { separator, .. }) => assert_eq!(separator.as_str(), ","),
462            other => panic!("expected Sep, got {other:?}"),
463        }
464        match parse(r#"chars(one_of("ab"), skip: newlines)"#) {
465            Ok(ParserAst::Characters { skip, .. }) => assert_eq!(skip, SkipPolicy::Newlines),
466            other => panic!("expected Characters, got {other:?}"),
467        }
468        // And the shape rules are the *same* rules, because they are the same
469        // function: a wrong arity here reports exactly as it does at top level.
470        assert!(matches!(
471            parse("csv(int, int)"),
472            Err(ScanError::CallShape(_))
473        ));
474        assert!(matches!(parse("choice(int)"), Err(ScanError::CallShape(_))));
475    }
476
477    #[test]
478    fn an_unknown_constructor_is_reported_as_one() {
479        match parse("frobnicate(int)") {
480            Err(err @ ScanError::UnknownConstructor { .. }) => {
481                assert_eq!(err.code(), praxis_source::DiagCode::UnknownConstructor);
482            }
483            other => panic!("expected UnknownConstructor, got {other:?}"),
484        }
485    }
486
487    #[test]
488    fn a_nested_template_is_a_parser_expression() {
489        match parse("choice(A: `{n:int}`, B: word)") {
490            Ok(ParserAst::Choice { cases, .. }) => {
491                assert_eq!(cases.len(), 2);
492                assert!(matches!(cases[0].1, ParserAst::Template { .. }));
493            }
494            other => panic!("expected Choice, got {other:?}"),
495        }
496    }
497
498    /// The tail rules are §7.5's, and they are the same rules the top-level
499    /// bridge applies — one `build_call`.
500    #[test]
501    fn a_sections_tail_is_last_and_singular_here_too() {
502        match parse("sections(draws: csv(int), boards: repeated(matrix(int)))") {
503            Ok(ParserAst::SectionsNamed {
504                fields,
505                repeated_tail,
506                ..
507            }) => {
508                assert_eq!(fields.len(), 1);
509                let (name, tail) = repeated_tail.expect("a tail");
510                assert_eq!(name, "boards");
511                // The field's parser is the `P`, not the `repeated(P)` marker.
512                assert!(matches!(*tail, ParserAst::Matrix { .. }));
513            }
514            other => panic!("expected SectionsNamed, got {other:?}"),
515        }
516        assert!(matches!(
517            parse("sections(boards: repeated(int), draws: csv(int))"),
518            Err(ScanError::CallShape(_))
519        ));
520        assert!(matches!(
521            parse("sections(a: repeated(int), b: repeated(int))"),
522            Err(ScanError::CallShape(_))
523        ));
524        assert!(matches!(
525            parse("repeated(int)"),
526            Err(ScanError::CallShape(_))
527        ));
528    }
529
530    /// **The counted form is the same one rule, in this front end too.** A
531    /// bounded `repeated(P, N)` consumes exactly N sections, so the position
532    /// argument the unbounded form rests on does not apply to it: something may
533    /// follow it, and it may itself be last. The count's own refusals — zero,
534    /// and a name where a literal belongs — are the shared builder's, so a
535    /// capture body earns them without this scanner knowing what they are.
536    #[test]
537    fn a_counted_group_is_bounded_here_too() {
538        match parse("sections(shapes: repeated(lines(int), 2), regions: lines(int))") {
539            Ok(ParserAst::SectionsNamed {
540                fields,
541                repeated_tail,
542                ..
543            }) => {
544                assert!(repeated_tail.is_none(), "a counted group is not the tail");
545                assert_eq!(fields.len(), 2, "and something may follow it");
546                match &fields[0] {
547                    SectionItem::Counted {
548                        name,
549                        count,
550                        parser,
551                    } => {
552                        assert_eq!(name, "shapes");
553                        assert_eq!(count.get(), 2);
554                        assert!(matches!(parser, ParserAst::Lines { .. }));
555                    }
556                    other => panic!("expected a counted group, got {other:?}"),
557                }
558                assert_eq!(fields[1].name(), "regions");
559            }
560            other => panic!("expected SectionsNamed, got {other:?}"),
561        }
562
563        // Last is also fine — "may appear anywhere" includes the end.
564        assert!(matches!(
565            parse("sections(regions: lines(int), shapes: repeated(lines(int), 2))"),
566            Ok(ParserAst::SectionsNamed { .. })
567        ));
568
569        // A group of no sections parses nothing, and a count that is not a
570        // literal cannot exist: the plan is built when the program is compiled.
571        for refused in [
572            "sections(a: repeated(int, 0))",
573            "sections(a: repeated(int, -1))",
574            "sections(a: repeated(int, word))",
575            // A name that is not a parser either: this earns the count's own
576            // diagnostic here, not "unknown parser `n`", which is the rowan
577            // front end's answer too.
578            "sections(a: repeated(int, n))",
579            "sections(a: repeated(int, 2, 3))",
580        ] {
581            assert!(
582                matches!(parse(refused), Err(ScanError::CallShape(_))),
583                "`{refused}` must be refused by the shared shape check"
584            );
585        }
586    }
587
588    /// A `"…"` argument's extent is `scan::skip_string`'s, and the offset it
589    /// reports is the body's own — so the caret has to be rebased onto the text
590    /// the caller is scanning before it names a byte.
591    #[test]
592    fn an_unterminated_string_argument_is_reported_at_its_own_quote() {
593        match parse_capture_body(r#"sep("-, int)"#, 10, 0) {
594            Err(ScanError::MalformedCaptureBody {
595                byte_offset,
596                message,
597            }) => {
598                assert_eq!(byte_offset, 10 + 4, "the caret must be rebased by `at`");
599                assert!(message.contains("unterminated string literal"), "{message}");
600            }
601            other => panic!("expected an unterminated literal, got {other:?}"),
602        }
603    }
604
605    /// The same rule reaches a keyword argument's value, where a failure is not
606    /// reported but *consumed*: the value runs to the end of the body. The point
607    /// of the test is that this terminates at all — `skip_string` does not move
608    /// the cursor when it fails, so an arm that only asked it to skip would loop
609    /// on the opening quote.
610    #[test]
611    fn an_unterminated_keyword_value_ends_the_body_rather_than_looping() {
612        assert!(matches!(
613            parse(r#"chars(one_of("ab"), skip: "newlines)"#),
614            Err(ScanError::MalformedCaptureBody { .. })
615        ));
616    }
617
618    #[test]
619    fn trailing_text_after_the_parser_is_an_error() {
620        assert!(matches!(
621            parse("int int"),
622            Err(ScanError::MalformedCaptureBody { .. })
623        ));
624        assert!(matches!(
625            parse("csv(int) x"),
626            Err(ScanError::MalformedCaptureBody { .. })
627        ));
628    }
629}