Skip to main content

praxis_input_parser/
validate.rs

1//! Static validation of a parser AST (§7.9 step 2).
2//!
3//! Catches structural errors before type synthesis / plan construction: mixed
4//! named/anonymous captures in one template (§7.3), duplicate capture names, and
5//! wrong constructor arity.
6//!
7//! Returns lightweight [`ValidationError`]s carrying the source [`Span`] (byte
8//! offsets). The HIR layer, which knows the [`FileId`], converts these into full
9//! [`Diagnostic`]s with the `I0xx` (input-parser) category.
10
11use crate::ast::{ArgShape, Constructor, ParserAst, TemplatePart};
12use praxis_source::{DiagCode, Span};
13
14/// A structural error found while validating a parser AST.
15#[derive(Clone, Debug, PartialEq, Eq)]
16pub struct ValidationError {
17    /// The byte span of the offending node.
18    pub span: Span,
19    /// Which diagnostic this is.
20    pub code: DiagCode,
21    /// A human-readable explanation.
22    pub message: String,
23}
24
25/// Validate a parser AST. Returns the list of errors (empty on success).
26pub fn validate(ast: &ParserAst) -> Vec<ValidationError> {
27    let mut errs = Vec::new();
28    validate_node(ast, &mut errs);
29    errs
30}
31
32fn validate_node(ast: &ParserAst, errs: &mut Vec<ValidationError>) {
33    match ast {
34        ParserAst::Atomic { .. } => {}
35        ParserAst::Template { parts, span } => {
36            validate_template(parts, *span, errs);
37        }
38        // Every constructor whose only structure is its one child. The extra
39        // payload some of them carry is not this pass's to check, and is
40        // already answered where the node is *built*: `sep`'s separator by
41        // `Separator::new`, the type's only constructor, which refuses the `""`
42        // that never advances a cursor; `chars`'s skip policy and ragged
43        // `grid`'s fill by `build_call`. A check `validate` performs is one the
44        // next construction site can forget.
45        ParserAst::Lines { child, .. }
46        | ParserAst::Sections { child, .. }
47        | ParserAst::Csv { child, .. }
48        | ParserAst::Ws { child, .. }
49        | ParserAst::Grid { child, .. }
50        | ParserAst::Sep { child, .. }
51        | ParserAst::Optional { child, .. }
52        | ParserAst::Scan { child, .. }
53        | ParserAst::Characters { child, .. }
54        | ParserAst::Matrix { child, .. }
55        | ParserAst::GridRagged { child, .. } => {
56            validate_node(child, errs);
57        }
58        ParserAst::SectionsNamed {
59            fields,
60            repeated_tail,
61            span,
62        } => {
63            // At least one named field is required (§7.5: a named sections call
64            // with zero fields is malformed). The test is `fields`, not "no
65            // named argument at all": `sections(boards: repeated(P))` is a
66            // greedy tail and nothing else, which is spelled `sections(P)` and
67            // is still refused here. A *counted* group is an ordinary field —
68            // it is in `fields` — so `sections(shapes: repeated(P, 6))` alone
69            // is legal, which is the point of it being bounded.
70            if fields.is_empty() {
71                errs.push(ValidationError {
72                    span: *span,
73                    code: DiagCode::EmptyFieldList,
74                    message: "named `sections` requires at least one field".to_string(),
75                });
76            }
77            // Field names must be unique.
78            let mut seen = Vec::new();
79            for item in fields {
80                let name = item.name();
81                if seen.iter().any(|s: &String| s == name) {
82                    errs.push(ValidationError {
83                        span: *span,
84                        code: DiagCode::DuplicateSectionField,
85                        message: format!("duplicate section field `{name}`"),
86                    });
87                }
88                seen.push(name.to_string());
89                validate_node(item.parser(), errs);
90            }
91            // The tail is a field of the generated record too, so its name
92            // shares the uniqueness check: `sections(items: lines(int), items:
93            // repeated(int))` would otherwise synthesize a record with two
94            // fields called `items`.
95            if let Some((name, tail)) = repeated_tail {
96                if seen.contains(name) {
97                    errs.push(ValidationError {
98                        span: *span,
99                        code: DiagCode::DuplicateSectionField,
100                        message: format!("duplicate section field `{name}`"),
101                    });
102                }
103                seen.push(name.clone());
104                validate_node(tail, errs);
105            }
106        }
107        ParserAst::Block { items, span } => {
108            // §7.5: a positional parser returning a scalar must be explicitly
109            // named to avoid an unclear field name. A positional template with
110            // named captures flattens its captures into the block record. All
111            // flattened field names must be unique.
112            let mut seen: Vec<String> = Vec::new();
113            for item in items {
114                match item {
115                    crate::ast::BlockItem::Positional(p) => {
116                        // Collect the field names this positional contributes.
117                        let contributed = block_positional_field_names(p);
118                        // §7.5: a positional parser returning a *scalar* must be
119                        // explicitly named (unclear field name). A template —
120                        // even one with no captures (a pure literal match) — is
121                        // fine: a no-capture template contributes no fields but
122                        // legitimately consumes input; a named-capture template
123                        // flattens its fields.
124                        let is_template = matches!(p, ParserAst::Template { .. });
125                        if contributed.is_empty() && !is_template {
126                            errs.push(ValidationError {
127                                span: *span,
128                                code: DiagCode::UnnamedScalarBlockItem,
129                                message:
130                                    "a positional `block` item returning a scalar must be named"
131                                        .to_string(),
132                            });
133                        }
134                        for n in &contributed {
135                            if seen.contains(n) {
136                                errs.push(ValidationError {
137                                    span: *span,
138                                    code: DiagCode::DuplicateSectionField,
139                                    message: format!("duplicate block field `{n}`"),
140                                });
141                            }
142                            seen.push(n.clone());
143                        }
144                        validate_node(p, errs);
145                    }
146                    crate::ast::BlockItem::Named { name, parser } => {
147                        if seen.contains(name) {
148                            errs.push(ValidationError {
149                                span: *span,
150                                code: DiagCode::DuplicateSectionField,
151                                message: format!("duplicate block field `{name}`"),
152                            });
153                        }
154                        seen.push(name.clone());
155                        validate_node(parser, errs);
156                    }
157                }
158            }
159        }
160        ParserAst::Choice { cases, span } => {
161            // §7.5: at least one case; unique case names; recurse.
162            if cases.is_empty() {
163                errs.push(ValidationError {
164                    span: *span,
165                    code: DiagCode::EmptyFieldList,
166                    message: "`choice` requires at least one case".to_string(),
167                });
168            }
169            let mut seen = Vec::new();
170            for (name, parser) in cases {
171                if seen.contains(name) {
172                    errs.push(ValidationError {
173                        span: *span,
174                        code: DiagCode::DuplicateChoiceCase,
175                        message: format!("duplicate choice case `{name}`"),
176                    });
177                }
178                seen.push(name.clone());
179                validate_node(parser, errs);
180            }
181        }
182        ParserAst::OneOf { .. } => {}
183    }
184}
185
186/// The field names a positional `block` item contributes via flattening (§7.5).
187/// A named-capture template contributes its capture names; anything else (a
188/// scalar atomic, a constructor) contributes nothing — which means a bare
189/// scalar positional must be explicitly named (validation rejects it).
190fn block_positional_field_names(p: &ParserAst) -> Vec<String> {
191    match p {
192        ParserAst::Template { parts, .. } => parts
193            .iter()
194            .filter_map(|part| match part {
195                TemplatePart::Capture { name: Some(n), .. } => Some(n.as_str().to_string()),
196                _ => None,
197            })
198            .collect(),
199        _ => Vec::new(),
200    }
201}
202
203/// Validate a template: no mixing named and anonymous captures (§7.3), and no
204/// duplicate capture names.
205fn validate_template(parts: &[TemplatePart], span: Span, errs: &mut Vec<ValidationError>) {
206    let mut has_named = false;
207    let mut has_anonymous = false;
208    for part in parts {
209        if let TemplatePart::Capture { name, parser, .. } = part {
210            if name.is_some() {
211                has_named = true;
212            } else {
213                has_anonymous = true;
214            }
215            validate_node(parser, errs);
216        }
217    }
218    if has_named && has_anonymous {
219        errs.push(ValidationError {
220            span,
221            code: DiagCode::MixedCaptureNaming,
222            message: "named and anonymous captures may not be mixed in one template".to_string(),
223        });
224    }
225
226    // Named captures must have unique names within a template.
227    let mut seen_names = Vec::new();
228    for part in parts {
229        if let TemplatePart::Capture { name: Some(n), .. } = part {
230            if seen_names.contains(n) {
231                errs.push(ValidationError {
232                    span,
233                    code: DiagCode::DuplicateCaptureName,
234                    message: format!("duplicate capture name `{n}` in template"),
235                });
236            }
237            seen_names.push(n.clone());
238        }
239    }
240}
241
242/// What one argument of a constructor call *is*, with no payload — enough to
243/// decide whether the call has the shape §7.5 gives it.
244///
245/// The payloads live in `praxis-hir`'s `CallArg` (they hold a `ParserAst` and
246/// the rowan text). This is the projection both callers can share: the HIR
247/// bridge and the capture-body parser in [`crate::body`] check the same table,
248/// so the two grammars cannot drift.
249#[derive(Clone, Debug, PartialEq, Eq)]
250pub enum ArgKind {
251    /// A positional parser expression.
252    Parser,
253    /// A positional string literal.
254    String,
255    /// A positional whole-number literal — the count of `repeated(P, N)`.
256    Int,
257    /// A bare keyword flag, e.g. the `ragged` of `grid(P, ragged, fill: 0)`.
258    Flag(String),
259    /// A named argument `name: parser` — the value is a parser expression.
260    Named(String),
261    /// A named argument `name: keyword` whose value is a **keyword, not a
262    /// parser**: `chars`'s `skip:`, `grid`'s `fill:`.
263    ///
264    /// Distinct from [`ArgKind::Named`] because [`check_call`] must tell the
265    /// two apart: only the constructors §7.5 gives a keyword argument accept
266    /// one, and `block`, `choice` and named `sections` must refuse it.
267    Keyword(String),
268    /// A named `name: repeated(P)` tail.
269    RepeatedTail(String),
270}
271
272impl ArgKind {
273    pub(crate) fn describe(&self) -> String {
274        match self {
275            ArgKind::Parser => "a parser".to_string(),
276            ArgKind::String => "a string literal".to_string(),
277            ArgKind::Int => "a whole-number literal".to_string(),
278            ArgKind::Flag(f) => format!("the flag `{f}`"),
279            ArgKind::Named(n) => format!("the named argument `{n}:`"),
280            ArgKind::Keyword(n) => format!("the keyword argument `{n}:`"),
281            ArgKind::RepeatedTail(n) => format!("the repeated tail `{n}:`"),
282        }
283    }
284}
285
286/// Check a constructor call against §7.5's shape for that constructor —
287/// **before anything is built**.
288///
289/// Returns every problem found; an empty vector means the argument list has
290/// exactly the shape the constructor's builder expects, so the builder has
291/// nothing left to drop. Each constructor gets its own [`ArgShape`] arm, and
292/// each argument is checked *in place* rather than only counted: `choice(int)`
293/// (a positional in a named-only constructor) and `sep(int, int)` (a parser
294/// where a separator belongs) both have the right arity and the wrong
295/// arguments.
296pub fn check_call(ctor: Constructor, args: &[ArgKind], span: Span) -> Vec<ValidationError> {
297    let mut errs = Vec::new();
298    let name = ctor.keyword();
299    let arity = |errs: &mut Vec<ValidationError>, expected: &str, actual: usize| {
300        errs.push(ValidationError {
301            span,
302            code: DiagCode::ConstructorArity,
303            message: format!("`{name}` expects {expected}, got {actual}"),
304        });
305    };
306    let bad_arg = |errs: &mut Vec<ValidationError>, at: usize, arg: &ArgKind, wanted: &str| {
307        errs.push(ValidationError {
308            span,
309            code: DiagCode::InvalidConstructorArgument,
310            message: format!(
311                "`{name}` argument {} is {}, but {wanted}",
312                at + 1,
313                arg.describe()
314            ),
315        });
316    };
317
318    match ctor.arg_shape() {
319        ArgShape::Positional(n) => {
320            if args.len() != n {
321                arity(
322                    &mut errs,
323                    &format!("{n} argument{}", if n == 1 { "" } else { "s" }),
324                    args.len(),
325                );
326            }
327            for (i, a) in args.iter().enumerate() {
328                if *a != ArgKind::Parser {
329                    bad_arg(&mut errs, i, a, "every argument must be a parser");
330                }
331            }
332        }
333        ArgShape::StringThenParser => {
334            if args.len() != 2 {
335                arity(&mut errs, "2 arguments", args.len());
336            }
337            for (i, a) in args.iter().enumerate() {
338                let wanted = if i == 0 {
339                    ("the separator must be a string literal", ArgKind::String)
340                } else {
341                    ("the element parser must be a parser", ArgKind::Parser)
342                };
343                if *a != wanted.1 {
344                    bad_arg(&mut errs, i, a, wanted.0);
345                }
346            }
347        }
348        ArgShape::OneString => {
349            if args.len() != 1 {
350                arity(&mut errs, "1 argument", args.len());
351            }
352            for (i, a) in args.iter().enumerate() {
353                if *a != ArgKind::String {
354                    bad_arg(
355                        &mut errs,
356                        i,
357                        a,
358                        "the character set must be a string literal",
359                    );
360                }
361            }
362        }
363        ArgShape::ParserWithSkip => {
364            match args.first() {
365                Some(ArgKind::Parser) => {}
366                Some(other) => bad_arg(&mut errs, 0, other, "the first argument must be a parser"),
367                None => arity(&mut errs, "1 or 2 arguments", 0),
368            }
369            for (i, a) in args.iter().enumerate().skip(1) {
370                match a {
371                    // The keyword is the constructor's (`keyword_arg`), not a
372                    // name this table repeats: one table entry, one spelling.
373                    ArgKind::Keyword(n) if Some(n.as_str()) == ctor.keyword_arg() && i == 1 => {}
374                    other => bad_arg(&mut errs, i, other, "only `skip:` may follow the parser"),
375                }
376            }
377            if args.len() > 2 {
378                arity(&mut errs, "1 or 2 arguments", args.len());
379            }
380        }
381        ArgShape::ParserWithOptionalCount => {
382            // The arity check comes **first**, unlike the arms above, because
383            // one caller reads only the first error: the capture-body scanner
384            // turns a shape failure into a single `ScanError::CallShape`. A
385            // wrong number of arguments is the more useful of the two things a
386            // three-argument `repeated` is wrong about, and it is what the
387            // rowan front end reports for it.
388            if args.is_empty() || args.len() > 2 {
389                arity(&mut errs, "1 or 2 arguments", args.len());
390            }
391            match args.first() {
392                // An empty list is the arity error above and nothing else.
393                None | Some(ArgKind::Parser) => {}
394                Some(other) => {
395                    bad_arg(&mut errs, 0, other, "the repeated parser must be a parser");
396                }
397            }
398            match args.get(1) {
399                None | Some(ArgKind::Int) => {}
400                // The reason is in the message because there is no other
401                // spelling that would have worked: the parser plan is built
402                // when the program is compiled, so a count read from a value
403                // cannot exist.
404                Some(other) => bad_arg(
405                    &mut errs,
406                    1,
407                    other,
408                    "the count must be a whole-number literal — the parser plan is built when \
409                     the program is compiled, so the count cannot be a parser or a variable",
410                ),
411            }
412        }
413        ArgShape::GridMaybeRagged => {
414            match args.first() {
415                Some(ArgKind::Parser) => {}
416                Some(other) => {
417                    bad_arg(&mut errs, 0, other, "the cell parser must be a parser");
418                }
419                None => arity(&mut errs, "1 or 3 arguments", 0),
420            }
421            let mut ragged = false;
422            let mut fill = false;
423            for (i, a) in args.iter().enumerate().skip(1) {
424                match a {
425                    // Both names come from the constructor — `flag_arg` for
426                    // `ragged`, `keyword_arg` for `fill:` — so this table and
427                    // the two front ends that mint the arguments read one
428                    // spelling between them.
429                    ArgKind::Flag(f) if Some(f.as_str()) == ctor.flag_arg() && !ragged => {
430                        ragged = true
431                    }
432                    ArgKind::Keyword(n) if Some(n.as_str()) == ctor.keyword_arg() && !fill => {
433                        fill = true
434                    }
435                    other => bad_arg(
436                        &mut errs,
437                        i,
438                        other,
439                        "only `ragged` and `fill:` may follow the cell parser",
440                    ),
441                }
442            }
443            // §7.5 spells them together: `grid(P, ragged, fill: value)`.
444            // Accepting a `fill:` with no `ragged` would silently build the
445            // ragged form, a different parser than the one written.
446            if ragged != fill {
447                errs.push(ValidationError {
448                    span,
449                    code: DiagCode::InvalidConstructorArgument,
450                    message:
451                        "`grid`'s ragged form is written `grid(P, ragged, fill: value)` — `ragged` \
452                         and `fill:` come together or not at all"
453                            .to_string(),
454                });
455            }
456        }
457        ArgShape::OnePositionalOrNamed => {
458            let named = args
459                .iter()
460                .filter(|a| matches!(a, ArgKind::Named(_) | ArgKind::RepeatedTail(_)))
461                .count();
462            if named == 0 {
463                // Homogeneous `sections(P)`.
464                if args.len() != 1 {
465                    arity(&mut errs, "1 argument, or named sections", args.len());
466                }
467                for (i, a) in args.iter().enumerate() {
468                    if *a != ArgKind::Parser {
469                        bad_arg(&mut errs, i, a, "the section parser must be a parser");
470                    }
471                }
472            } else {
473                // Heterogeneous `sections(name: P, …)`: named arguments only,
474                // and every one of them names a *parser*. `sections` has no
475                // keyword argument (`Constructor::keyword_arg`), so a keyword
476                // reaching here is one no constructor asked for.
477                for (i, a) in args.iter().enumerate() {
478                    if !matches!(a, ArgKind::Named(_) | ArgKind::RepeatedTail(_)) {
479                        bad_arg(
480                            &mut errs,
481                            i,
482                            a,
483                            "a named `sections` takes only named arguments",
484                        );
485                    }
486                }
487            }
488        }
489        ArgShape::Items => {
490            if args.is_empty() {
491                arity(&mut errs, "at least 1 item", 0);
492            }
493            for (i, a) in args.iter().enumerate() {
494                if !matches!(a, ArgKind::Parser | ArgKind::Named(_)) {
495                    bad_arg(
496                        &mut errs,
497                        i,
498                        a,
499                        "a `block` item is a parser or a named parser",
500                    );
501                }
502            }
503        }
504        ArgShape::NamedOnly { at_least } => {
505            let named = args
506                .iter()
507                .filter(|a| matches!(a, ArgKind::Named(_)))
508                .count();
509            if named < at_least {
510                arity(
511                    &mut errs,
512                    &format!("at least {at_least} named argument(s)"),
513                    named,
514                );
515            }
516            for (i, a) in args.iter().enumerate() {
517                if !matches!(a, ArgKind::Named(_)) {
518                    bad_arg(&mut errs, i, a, "every argument must be `Name: parser`");
519                }
520            }
521        }
522    }
523    errs
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use crate::ast::{
530        AtomicKind, CaptureName, EmptySeparator, RepeatCount, SectionItem, Separator,
531    };
532    use praxis_source::{DiagCode, Span};
533
534    fn atom() -> ParserAst {
535        ParserAst::Atomic {
536            kind: AtomicKind::Int,
537            span: Span::at(0),
538        }
539    }
540
541    #[test]
542    fn clean_tree_validates() {
543        let ast = ParserAst::Lines {
544            child: Box::new(atom()),
545            span: Span::at(0),
546        };
547        assert!(validate(&ast).is_empty());
548    }
549
550    #[test]
551    fn mixed_captures_rejected() {
552        let ast = ParserAst::Template {
553            parts: vec![
554                TemplatePart::Capture {
555                    name: Some(CaptureName::parse("x").expect("an identifier")),
556                    parser: Box::new(atom()),
557                    span: Span::at(0),
558                    name_span: None,
559                },
560                TemplatePart::Capture {
561                    name: None,
562                    parser: Box::new(atom()),
563                    span: Span::at(0),
564                    name_span: None,
565                },
566            ],
567            span: Span::at(0),
568        };
569        let errs = validate(&ast);
570        assert_eq!(errs.len(), 1);
571        assert_eq!(errs[0].code, DiagCode::MixedCaptureNaming);
572    }
573
574    #[test]
575    fn duplicate_named_capture_rejected() {
576        let ast = ParserAst::Template {
577            parts: vec![
578                TemplatePart::Capture {
579                    name: Some(CaptureName::parse("x").expect("an identifier")),
580                    parser: Box::new(atom()),
581                    span: Span::at(0),
582                    name_span: None,
583                },
584                TemplatePart::Capture {
585                    name: Some(CaptureName::parse("x").expect("an identifier")),
586                    parser: Box::new(atom()),
587                    span: Span::at(0),
588                    name_span: None,
589                },
590            ],
591            span: Span::at(0),
592        };
593        let errs = validate(&ast);
594        assert_eq!(errs.len(), 1);
595        assert_eq!(errs[0].code, DiagCode::DuplicateCaptureName);
596    }
597
598    #[test]
599    fn arity_mismatch_reported() {
600        let errs = check_call(Constructor::Sep, &[ArgKind::String], Span::at(0));
601        assert!(!errs.is_empty());
602        assert_eq!(errs[0].code, DiagCode::ConstructorArity);
603    }
604
605    /// **A keyword argument is not a named parser.**
606    ///
607    /// `CallArg::Keyword{name}` and `CallArg::Named{name}` project onto
608    /// distinct `ArgKind`s so [`check_call`] can tell them apart: only the
609    /// constructors §7.5 gives a keyword argument accept one, and `block`,
610    /// `choice` and named `sections` refuse it rather than having their
611    /// builders `filter_map` it away.
612    ///
613    /// The last two assertions are the ones that make this a test of the
614    /// *distinction* rather than of a blanket refusal: the same position,
615    /// holding a named parser, is still accepted.
616    #[test]
617    fn a_keyword_argument_is_accepted_only_where_the_shape_has_one() {
618        let kw = |n: &str| ArgKind::Keyword(n.to_string());
619        let named = |n: &str| ArgKind::Named(n.to_string());
620
621        // The two constructors §7.5 gives a keyword argument.
622        assert!(
623            check_call(
624                Constructor::Chars,
625                &[ArgKind::Parser, kw("skip")],
626                Span::at(0)
627            )
628            .is_empty()
629        );
630        assert!(
631            check_call(
632                Constructor::Grid,
633                &[
634                    ArgKind::Parser,
635                    ArgKind::Flag("ragged".to_string()),
636                    kw("fill")
637                ],
638                Span::at(0)
639            )
640            .is_empty()
641        );
642
643        // Everywhere else — including the *other* constructor's keyword.
644        for (ctor, args) in [
645            (Constructor::Block, vec![ArgKind::Parser, kw("fill")]),
646            (Constructor::Choice, vec![named("A"), kw("fill")]),
647            (Constructor::Sections, vec![named("rules"), kw("fill")]),
648            (Constructor::Lines, vec![kw("skip")]),
649            (Constructor::Chars, vec![ArgKind::Parser, kw("fill")]),
650        ] {
651            let errs = check_call(ctor, &args, Span::at(0));
652            assert!(
653                errs.iter()
654                    .any(|e| e.code == DiagCode::InvalidConstructorArgument),
655                "`{}` must refuse a keyword it does not have",
656                ctor.keyword()
657            );
658        }
659
660        // The same shape with a named *parser* there is fine.
661        assert!(
662            check_call(
663                Constructor::Block,
664                &[ArgKind::Parser, named("fill")],
665                Span::at(0)
666            )
667            .is_empty()
668        );
669        assert!(
670            check_call(
671                Constructor::Sections,
672                &[named("rules"), named("fill")],
673                Span::at(0)
674            )
675            .is_empty()
676        );
677    }
678
679    /// **A keyword argument's *value* is part of its shape.**
680    ///
681    /// [`check_call`] answers from `ArgKind`s, which carry names and no values,
682    /// so the shape table can never see this — the check belongs to the
683    /// builder. `chars`'s `skip:` refuses a policy it does not recognize;
684    /// `grid`'s `fill:` refuses an empty pad, the same rule one field over from
685    /// `Separator::new` refusing `""`: an empty separator never advances, and
686    /// an empty pad fills nothing.
687    ///
688    /// The builder is shared, so both front ends inherit whatever it decides —
689    /// which is the point of asserting it here rather than at either one.
690    #[test]
691    fn a_keyword_argument_with_no_value_is_not_a_shape() {
692        use crate::call::{CallArg, build_call};
693
694        let grid = |fill: &str| {
695            build_call(
696                Constructor::Grid,
697                vec![
698                    CallArg::Parser(ParserAst::Atomic {
699                        kind: crate::ast::AtomicKind::Char,
700                        span: Span::at(0),
701                    }),
702                    CallArg::Flag("ragged".to_string()),
703                    CallArg::Keyword {
704                        name: "fill".to_string(),
705                        value: fill.to_string(),
706                    },
707                ],
708                Span::at(0),
709            )
710        };
711
712        for empty in ["", "\"\""] {
713            let errs = grid(empty).expect_err("an empty fill pads nothing");
714            assert_eq!(errs[0].code, DiagCode::InvalidConstructorArgument);
715        }
716
717        // And the values that *are* values still build, decoded.
718        for (written, decoded) in [("0", "0"), ("\"-\"", "-"), ("\" \"", " ")] {
719            match grid(written).expect("a fill with a value") {
720                ParserAst::GridRagged { fill, .. } => assert_eq!(fill, decoded),
721                other => panic!("`fill: {written}` built a {other:?}"),
722            }
723        }
724    }
725
726    /// **The assertion is inverted on purpose.**
727    ///
728    /// An empty separator drives `walk_sep`'s
729    /// `region[pos..].starts_with(sep_bytes)` loop, which is unconditionally
730    /// true for an empty needle, so the cursor never advances and the loop
731    /// allocates forever. A check `validate` performs is one the *next*
732    /// construction site can forget, so the rule the name states is enforced by
733    /// the type instead: `Separator` has exactly one constructor and it refuses
734    /// `""`. The empty separator is not rejected before plan construction — it
735    /// is not constructible at all.
736    #[test]
737    fn empty_separator_is_rejected_before_plan_construction() {
738        assert_eq!(
739            Separator::new(""),
740            Err(EmptySeparator),
741            "the one constructor refuses the separator that cannot advance"
742        );
743
744        let comma = Separator::new(",").expect("a one-character separator is fine");
745        assert_eq!(comma.as_str(), ",");
746
747        // The AST field is the newtype, not a `String`: this is what stops the
748        // empty value from being reintroduced by a future construction site.
749        let ast = ParserAst::Sep {
750            separator: comma,
751            child: Box::new(atom()),
752            span: Span::at(0),
753        };
754        assert!(validate(&ast).is_empty(), "a real separator validates");
755    }
756
757    #[test]
758    fn repeated_section_tail_cannot_reuse_a_fixed_field_name() {
759        let ast = ParserAst::SectionsNamed {
760            fields: vec![SectionItem::One {
761                name: "items".to_string(),
762                parser: atom(),
763            }],
764            repeated_tail: Some(("items".to_string(), Box::new(atom()))),
765            span: Span::at(0),
766        };
767
768        let errors = validate(&ast);
769        assert!(
770            errors
771                .iter()
772                .any(|error| error.code == DiagCode::DuplicateSectionField),
773            "the generated record cannot contain two fields named `items`"
774        );
775    }
776
777    /// A counted group is a record field like any other, so it collides with
778    /// the tail's name the same way a fixed field does. The duplicate check
779    /// reads `SectionItem::name`, which is one answer for both variants — a
780    /// check that only knew about `One` would let `Counted` past.
781    #[test]
782    fn a_counted_group_and_the_tail_cannot_share_a_name() {
783        let ast = ParserAst::SectionsNamed {
784            fields: vec![SectionItem::Counted {
785                name: "shapes".to_string(),
786                count: RepeatCount::new(2).expect("two sections"),
787                parser: atom(),
788            }],
789            repeated_tail: Some(("shapes".to_string(), Box::new(atom()))),
790            span: Span::at(0),
791        };
792
793        let errors = validate(&ast);
794        assert!(
795            errors
796                .iter()
797                .any(|error| error.code == DiagCode::DuplicateSectionField),
798            "a counted group's name is a field name too"
799        );
800    }
801
802    /// **A counted group alone is a `sections` call; an unbounded tail alone is
803    /// not.** `sections(boards: repeated(P))` is `sections(P)` written the long
804    /// way round — every section, one parser — which is what I025 says. A
805    /// counted group consumes a *known* prefix and leaves the rest unread, so
806    /// it is a heterogeneous call with one field, and the same check must let
807    /// it through. That is why the emptiness test is `fields`, which holds the
808    /// counted item and not the tail.
809    #[test]
810    fn a_counted_group_alone_is_a_field_but_an_unbounded_tail_alone_is_not() {
811        let counted_only = ParserAst::SectionsNamed {
812            fields: vec![SectionItem::Counted {
813                name: "shapes".to_string(),
814                count: RepeatCount::new(2).expect("two sections"),
815                parser: atom(),
816            }],
817            repeated_tail: None,
818            span: Span::at(0),
819        };
820        assert!(validate(&counted_only).is_empty());
821
822        let tail_only = ParserAst::SectionsNamed {
823            fields: Vec::new(),
824            repeated_tail: Some(("boards".to_string(), Box::new(atom()))),
825            span: Span::at(0),
826        };
827        assert!(
828            validate(&tail_only)
829                .iter()
830                .any(|e| e.code == DiagCode::EmptyFieldList),
831            "a greedy tail and nothing else is `sections(P)`"
832        );
833    }
834
835    /// The shape table owns `repeated`'s arity, not the marker's builder —
836    /// ADR-073's "check the shape before building" covers this call site like
837    /// every other, and this is the table answering.
838    #[test]
839    fn repeated_takes_a_parser_and_an_optional_count() {
840        let ok = |args: &[ArgKind]| check_call(Constructor::Repeated, args, Span::at(0));
841        assert!(ok(&[ArgKind::Parser]).is_empty());
842        assert!(ok(&[ArgKind::Parser, ArgKind::Int]).is_empty());
843
844        // No parser at all, and a count where the parser belongs.
845        assert!(ok(&[]).iter().any(|e| e.code == DiagCode::ConstructorArity));
846        assert!(
847            ok(&[ArgKind::Int])
848                .iter()
849                .any(|e| e.code == DiagCode::InvalidConstructorArgument)
850        );
851        // A second parser is not a count — this is the diagnostic a
852        // non-literal `repeated(P, n)` earns.
853        assert!(
854            ok(&[ArgKind::Parser, ArgKind::Parser])
855                .iter()
856                .any(|e| e.code == DiagCode::InvalidConstructorArgument)
857        );
858        assert!(
859            ok(&[ArgKind::Parser, ArgKind::Int, ArgKind::Int])
860                .iter()
861                .any(|e| e.code == DiagCode::ConstructorArity)
862        );
863    }
864}