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}