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}