Skip to main content

praxis_input_parser/
scan.rs

1//! Interior re-scan of a backtick template (§7.2, §7.3).
2//!
3//! A `BacktickTemplate` token spans both backticks but its interior is opaque at
4//! lex time. This module re-scans the interior into a sequence of
5//! [`TemplatePart`]s: literal runs and `{...}` captures. Whitespace policy
6//! escapes (`\s*`, `\s+`, `\n`, `\t`, `\x20`) are recognized here (§7.2).
7//!
8//! **A capture body is a full parser expression** (D10). `{items:csv(int)}` is
9//! §7.7's own monkey example, so "atomics only" is not a smaller language — it
10//! is a language that cannot run the design document's text. The body is parsed
11//! *here*, by [`crate::body`], and not handed back to `praxis-parser`: ADR-023
12//! fixes the dependency direction, and this crate must not depend on the
13//! ordinary grammar.
14//!
15//! # The cursor
16//!
17//! Every position here is a **scalar** boundary with its absolute byte offset in
18//! `interior`. The scanner walks `char_indices`, never bytes: `char::from(u8)`
19//! is a Latin-1 decode, and it would both split a multi-byte scalar and turn
20//! `λ=` into `λ=`.
21
22use std::iter::Peekable;
23use std::str::CharIndices;
24
25use praxis_source::{DiagCode, Span};
26
27use crate::ast::{TemplatePart, WsPolicy};
28use crate::validate::ValidationError;
29
30/// How deeply captures and nested templates may nest before the scanner
31/// refuses (D10).
32///
33/// [`scan_template`] and [`crate::body::parse_capture_body`] are mutually
34/// recursive once a capture body may hold a template of its own, so
35/// `"{a:" + "{".repeat(100_000)` is adversarial input — and a *compiler* may
36/// not answer adversarial input with a stack overflow. The bound is far above
37/// anything a person writes.
38pub use praxis_syntax::MAX_TEMPLATE_NESTING as MAX_NESTING;
39
40/// An error encountered while scanning a template interior or a capture body.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum ScanError {
43    /// An invalid escape sequence (e.g. `\q`). `seq` is **the text the source
44    /// actually wrote**, sliced from it, not a re-`format!` of a guessed byte.
45    InvalidEscape { byte_offset: usize, seq: String },
46    /// An unterminated capture `{...` (no closing `}`).
47    UnterminatedCapture { byte_offset: usize },
48    /// An empty capture `{}`.
49    EmptyCapture { byte_offset: usize },
50    /// A capture whose name is not an identifier (§4.1).
51    InvalidCaptureName { byte_offset: usize, name: String },
52    /// A capture body naming a parser that does not exist.
53    UnknownCaptureKind { byte_offset: usize, name: String },
54    /// A capture body calling a constructor that does not exist (§7.5).
55    UnknownConstructor { byte_offset: usize, name: String },
56    /// A capture body that is not a parser expression at all.
57    MalformedCaptureBody { byte_offset: usize, message: String },
58    /// A constructor call in a capture body whose arguments do not have §7.5's
59    /// shape. Carries the [`ValidationError`] `check_call` produced, so the
60    /// code and the message are the same ones a top-level call would report.
61    CallShape(ValidationError),
62    /// Nesting past [`MAX_NESTING`].
63    ///
64    /// `what` is **which** nesting hit the bound — templates, `{`, or `(`. A
65    /// diagnostic that states the wrong thing is worse than a vague one: a
66    /// `csv(` thirty-three deep is a parenthesis bound, and calling it template
67    /// nesting misnames the limit in text that holds one template.
68    NestingTooDeep {
69        byte_offset: usize,
70        what: &'static str,
71    },
72}
73
74impl ScanError {
75    /// The byte offset in the scanned text this error is anchored at.
76    #[must_use]
77    pub fn byte_offset(&self) -> usize {
78        match self {
79            ScanError::InvalidEscape { byte_offset, .. }
80            | ScanError::UnterminatedCapture { byte_offset }
81            | ScanError::EmptyCapture { byte_offset }
82            | ScanError::InvalidCaptureName { byte_offset, .. }
83            | ScanError::UnknownCaptureKind { byte_offset, .. }
84            | ScanError::UnknownConstructor { byte_offset, .. }
85            | ScanError::MalformedCaptureBody { byte_offset, .. }
86            | ScanError::NestingTooDeep { byte_offset, .. } => *byte_offset,
87            ScanError::CallShape(err) => err.span.start().to_u32() as usize,
88        }
89    }
90
91    /// The same error, anchored `delta` bytes later.
92    ///
93    /// A nested template's interior is scanned in **its own** offsets: the
94    /// scanner is handed the text between the backticks and knows nothing about
95    /// where that text sits in the template that contains it. The caller does
96    /// know, and this is how the two are joined. Without it every caret under a
97    /// nested template lands short by the nested interior's own offset.
98    #[must_use]
99    pub fn shifted(self, delta: usize) -> ScanError {
100        let bump = |at: usize| at + delta;
101        match self {
102            ScanError::InvalidEscape { byte_offset, seq } => ScanError::InvalidEscape {
103                byte_offset: bump(byte_offset),
104                seq,
105            },
106            ScanError::UnterminatedCapture { byte_offset } => ScanError::UnterminatedCapture {
107                byte_offset: bump(byte_offset),
108            },
109            ScanError::EmptyCapture { byte_offset } => ScanError::EmptyCapture {
110                byte_offset: bump(byte_offset),
111            },
112            ScanError::InvalidCaptureName { byte_offset, name } => ScanError::InvalidCaptureName {
113                byte_offset: bump(byte_offset),
114                name,
115            },
116            ScanError::UnknownCaptureKind { byte_offset, name } => ScanError::UnknownCaptureKind {
117                byte_offset: bump(byte_offset),
118                name,
119            },
120            ScanError::UnknownConstructor { byte_offset, name } => ScanError::UnknownConstructor {
121                byte_offset: bump(byte_offset),
122                name,
123            },
124            ScanError::MalformedCaptureBody {
125                byte_offset,
126                message,
127            } => ScanError::MalformedCaptureBody {
128                byte_offset: bump(byte_offset),
129                message,
130            },
131            ScanError::NestingTooDeep { byte_offset, what } => ScanError::NestingTooDeep {
132                byte_offset: bump(byte_offset),
133                what,
134            },
135            ScanError::CallShape(mut err) => {
136                err.span = err.span.shifted(delta as u32);
137                ScanError::CallShape(err)
138            }
139        }
140    }
141
142    /// The diagnostic this error is reported under.
143    ///
144    /// **Exhaustive on purpose.** A wildcard would flatten every variant it
145    /// caught into `DiagCode::TemplateScan` (I030) and leave the codes ADR-051
146    /// allocates for these cases — `InvalidCaptureName` I011,
147    /// `UnknownCaptureKind` I012, `UnknownConstructor` I013 — constructed
148    /// nowhere in the tree. A `match` with no wildcard is what stops the next
149    /// variant from silently inheriting I030.
150    #[must_use]
151    pub fn code(&self) -> DiagCode {
152        match self {
153            ScanError::InvalidCaptureName { .. } => DiagCode::InvalidCaptureName,
154            ScanError::UnknownCaptureKind { .. } => DiagCode::UnknownCaptureKind,
155            ScanError::UnknownConstructor { .. } => DiagCode::UnknownConstructor,
156            ScanError::CallShape(err) => err.code,
157            ScanError::InvalidEscape { .. }
158            | ScanError::UnterminatedCapture { .. }
159            | ScanError::EmptyCapture { .. }
160            | ScanError::MalformedCaptureBody { .. }
161            | ScanError::NestingTooDeep { .. } => DiagCode::TemplateScan,
162        }
163    }
164
165    /// The parser name this error could not resolve, when that is what went
166    /// wrong — the word a "did you mean" would replace (ADR-132).
167    ///
168    /// Exhaustive, so a variant added later has to answer: a name that reaches a
169    /// caller by accident is a fix offered for the wrong span, and a name that
170    /// does not reach it is a fix silently not offered.
171    #[must_use]
172    pub fn unknown_parser_name(&self) -> Option<&str> {
173        match self {
174            ScanError::UnknownCaptureKind { name, .. }
175            | ScanError::UnknownConstructor { name, .. } => Some(name),
176            ScanError::InvalidEscape { .. }
177            | ScanError::UnterminatedCapture { .. }
178            | ScanError::EmptyCapture { .. }
179            | ScanError::InvalidCaptureName { .. }
180            | ScanError::MalformedCaptureBody { .. }
181            | ScanError::CallShape(_)
182            | ScanError::NestingTooDeep { .. } => None,
183        }
184    }
185}
186
187impl std::fmt::Display for ScanError {
188    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        match self {
190            ScanError::InvalidEscape { byte_offset, seq } => {
191                write!(f, "invalid escape `{seq}` at byte {byte_offset}")
192            }
193            ScanError::UnterminatedCapture { byte_offset } => {
194                write!(f, "unterminated capture starting at byte {byte_offset}")
195            }
196            ScanError::EmptyCapture { byte_offset } => {
197                write!(f, "empty capture `{{}}` at byte {byte_offset}")
198            }
199            ScanError::InvalidCaptureName { byte_offset, name } => write!(
200                f,
201                "`{name}` at byte {byte_offset} is not a capture name: a capture name is an \
202                 identifier"
203            ),
204            ScanError::UnknownCaptureKind { byte_offset, name } => write!(
205                f,
206                "unknown parser `{name}` at byte {byte_offset}: no atomic or constructor is \
207                 spelled that way"
208            ),
209            ScanError::UnknownConstructor { byte_offset, name } => {
210                write!(
211                    f,
212                    "unknown parser constructor `{name}` at byte {byte_offset}"
213                )
214            }
215            ScanError::MalformedCaptureBody {
216                byte_offset,
217                message,
218            } => write!(f, "malformed capture body at byte {byte_offset}: {message}"),
219            ScanError::CallShape(err) => f.write_str(&err.message),
220            // **The number is the number that is enforced**, and `what` is
221            // what was counted: a `{`, a `(` and a template are three different
222            // bounds, and the message names the one that tripped.
223            ScanError::NestingTooDeep { byte_offset, what } => write!(
224                f,
225                "{what} nesting is deeper than {MAX_NESTING} at byte {byte_offset}"
226            ),
227        }
228    }
229}
230
231impl std::error::Error for ScanError {}
232
233// ===========================================================================
234// The scalar cursor.
235// ===========================================================================
236
237/// A cursor over the scalars of a `&str`, each with its absolute byte offset.
238///
239/// The invariant: [`Scan::pos`] is always a UTF-8 boundary in [`Scan::src`], so
240/// `&src[a..cur.pos()]` is always a valid slice and a multi-byte scalar can
241/// never be split.
242pub(crate) struct Scan<'a> {
243    src: &'a str,
244    iter: Peekable<CharIndices<'a>>,
245    pos: usize,
246}
247
248impl<'a> Scan<'a> {
249    pub(crate) fn new(src: &'a str) -> Self {
250        Scan {
251            src,
252            iter: src.char_indices().peekable(),
253            pos: 0,
254        }
255    }
256
257    /// The next scalar and its offset, without consuming it.
258    pub(crate) fn peek(&mut self) -> Option<(usize, char)> {
259        self.iter.peek().copied()
260    }
261
262    /// The next scalar's value, without consuming it.
263    pub(crate) fn peek_char(&mut self) -> Option<char> {
264        self.peek().map(|(_, c)| c)
265    }
266
267    /// Consume and return the next scalar and its offset.
268    pub(crate) fn bump(&mut self) -> Option<(usize, char)> {
269        let next = self.iter.next();
270        self.pos = match next {
271            Some((at, c)) => at + c.len_utf8(),
272            None => self.src.len(),
273        };
274        next
275    }
276
277    /// Consume the next scalar iff it is `c`.
278    pub(crate) fn eat(&mut self, c: char) -> bool {
279        if self.peek_char() == Some(c) {
280            self.bump();
281            true
282        } else {
283            false
284        }
285    }
286
287    /// The byte offset just past the last consumed scalar.
288    pub(crate) fn pos(&self) -> usize {
289        self.pos
290    }
291
292    /// The source this cursor walks.
293    pub(crate) fn src(&self) -> &'a str {
294        self.src
295    }
296
297    /// Advance to `byte`, which must be at or after the current position and on
298    /// a scalar boundary. This is how a run whose extent was decided by
299    /// [`praxis_syntax::template`] — one byte index — is handed back to a
300    /// cursor that walks scalars.
301    pub(crate) fn advance_to(&mut self, byte: usize) {
302        while self.pos < byte && self.bump().is_some() {}
303    }
304}
305
306// ===========================================================================
307// scan_template
308// ===========================================================================
309
310/// Re-scan the interior of a backtick template into template parts.
311///
312/// `interior` is the text *between* the backticks (the caller strips them). The
313/// returned parts alternate between literal runs and captures; consecutive
314/// literal characters are coalesced into one `Literal` part with the whitespace
315/// policy of the run. Spans are **relative to `interior`**; the HIR bridge
316/// rebases them onto the file by the template token's start + 1.
317///
318/// # Errors
319/// Returns [`ScanError`] on a malformed template: a bad escape, an unterminated
320/// or empty capture, a capture name that is not an identifier, or a capture
321/// body that is not a parser expression.
322pub fn scan_template(interior: &str) -> Result<Vec<TemplatePart>, ScanError> {
323    scan_template_at(interior, 0)
324}
325
326/// [`scan_template`] with the current nesting depth (D10).
327///
328/// `depth` is **how many templates are already open**, so the outermost is
329/// scanned at `0` and the bound is `MAX_NESTING` levels — the same count, and
330/// the same limit, as `praxis_syntax::template::template_end`, which is what
331/// decides how much text the lexer hands over in the first place.
332pub(crate) fn scan_template_at(
333    interior: &str,
334    depth: usize,
335) -> Result<Vec<TemplatePart>, ScanError> {
336    if depth >= MAX_NESTING {
337        return Err(ScanError::NestingTooDeep {
338            byte_offset: 0,
339            what: "template",
340        });
341    }
342    let mut parts = Vec::new();
343    // The accumulating literal run, and the **source** extent it was decoded
344    // from. The two lengths differ whenever an escape contributes a character
345    // (`` \` `` is two bytes and one char), which is why the run carries its own
346    // start/end rather than being reconstructed from `lit.len()`.
347    let mut lit = String::new();
348    let mut lit_run: Option<(usize, usize)> = None;
349    let mut cur = Scan::new(interior);
350
351    while let Some((at, c)) = cur.peek() {
352        match c {
353            '{' => {
354                flush(&mut lit, &mut lit_run, &mut parts);
355                let (name, body_text, body_at) = capture_extent(&mut cur, at)?;
356                // The capture's extent is what the cursor just crossed: the `{`
357                // it started on through the `}` it stopped after.
358                let span = Span::new(at as u32, cur.pos() as u32);
359                let name_span = name.map(|(raw, raw_at)| trimmed_span(raw, raw_at));
360                let name = match name {
361                    Some((raw, _)) => Some(capture_name(raw, at)?),
362                    None => None,
363                };
364                // **Not `depth + 1`.** A capture body is inside *this*
365                // template, not inside a further one; the level is added where
366                // a level is entered, which is `body::parse_expr`'s backtick
367                // arm calling back into here.
368                let parser = crate::body::parse_capture_body(body_text, body_at, depth)?;
369                parts.push(TemplatePart::Capture {
370                    name,
371                    parser: Box::new(parser),
372                    span,
373                    name_span,
374                });
375            }
376            '\\' => {
377                // **No guard.** End-of-input is a case *inside* the escape
378                // handler, which is what makes the handler total: a terminal
379                // backslash is an invalid escape, not literal text.
380                cur.bump();
381                match escape(&mut cur, at)? {
382                    Escape::Policy(ws) => {
383                        flush(&mut lit, &mut lit_run, &mut parts);
384                        parts.push(TemplatePart::Literal {
385                            text: String::new(),
386                            ws,
387                            span: Span::new(at as u32, cur.pos() as u32),
388                        });
389                    }
390                    Escape::Char(ch) => {
391                        lit.push(ch);
392                        extend_run(&mut lit_run, at, cur.pos());
393                    }
394                }
395            }
396            _ => {
397                cur.bump();
398                lit.push(c);
399                extend_run(&mut lit_run, at, cur.pos());
400            }
401        }
402    }
403    flush(&mut lit, &mut lit_run, &mut parts);
404    Ok(parts)
405}
406
407/// Grow the literal run's source extent to cover `[at, end)`.
408fn extend_run(run: &mut Option<(usize, usize)>, at: usize, end: usize) {
409    match run {
410        Some((_, e)) => *e = end,
411        None => *run = Some((at, end)),
412    }
413}
414
415/// The span of `raw` **after trimming**, given that `raw` starts at `base`.
416///
417/// `capture_name` parses the trimmed text, so `{ n :int}` names `n`; the span
418/// has to name the same three-byte-shorter thing, or hover and semantic tokens
419/// would paint the surrounding spaces as part of the name.
420fn trimmed_span(raw: &str, base: usize) -> Span {
421    let lead = raw.len() - raw.trim_start().len();
422    let start = base + lead;
423    Span::new(start as u32, (start + raw.trim().len()) as u32)
424}
425
426/// Flush the accumulated literal run, turning a space/tab run at **either** end
427/// of it into a whitespace policy part.
428///
429/// **A space/tab run at either end of a literal is flexible whitespace, not
430/// text.** §7.2's rule is that an ordinary run of spaces or tabs is flexible,
431/// and a literal's own policy is applied before its bytes are matched. Leaving
432/// a run in the text would make it *also* exact, so it would have to be matched
433/// twice:
434///
435///   - A leading run could never match at all. §3.3's own
436///     `` `{x1:int},{y1:int} -> {x2:int},{y2:int}` `` would fail at the `-` of
437///     `->` on every input, because the policy consumes the space and then the
438///     literal " -> " is compared against "-> ".
439///   - A trailing run would make the spacing rigid on one side only: `1 -> 2`
440///     matching and `1->2` not.
441///
442/// # Each end is its own part
443///
444/// A literal has one policy slot and it sits in front of the text, so one
445/// `Literal` part cannot carry a run on both sides. The trailing run therefore
446/// becomes **its own part** — an empty literal carrying `SpaceRun`, which is the
447/// representation `` `{a:int} {b:int}` `` already lowers to and every consumer
448/// already handles. `x: ` is `Literal{"x:", None}` then `Literal{"", SpaceRun}`.
449///
450/// Stripping a trailing run without representing it would leave it neither
451/// required nor consumed: `` `x: {a:rest}` `` would match `x:hello`, and over
452/// `x: hello` would hand `rest` the space the template wrote. A capture is
453/// offered the bytes at the cursor, whitespace and all, and `walk_atomic`
454/// decides — `int` and `word` self-trim, `char`, `text` and `rest` do not.
455///
456/// A literal that strips to **nothing** is one run, not two, and emits one
457/// part: counting it at both ends would make `` `{a:int} {b:int}` `` demand two
458/// separate whitespace runs between the captures.
459///
460/// A run *inside* a literal is untouched — nothing else consumes it, so it stays
461/// exact — and `\x20` is the escape for a space that must be matched literally.
462/// (`\x20` never reaches here as text: [`escape`] returns it as a policy part of
463/// its own, so an exact space cannot be mistaken for a run.)
464///
465/// **The policy is derived, not assumed.** A literal that had a run stripped
466/// from its **front** carries `SpaceRun`; one that did not carries
467/// [`WsPolicy::None`], and then `SpaceRun` can require the one-or-more its own
468/// definition promises — at both ends. Tagging every literal `SpaceRun`
469/// unconditionally would leave the runtime unable to distinguish "the template
470/// wrote a space here" from "it did not", and would force `SpaceRun` to be
471/// implemented as zero-or-more to keep `{a:int},{b:int}` matching.
472fn flush(lit: &mut String, run: &mut Option<(usize, usize)>, parts: &mut Vec<TemplatePart>) {
473    if lit.is_empty() {
474        *run = None;
475        return;
476    }
477    let text = std::mem::take(lit);
478    // The run's source extent. A non-empty `lit` always has one — every push
479    // site calls `extend_run` — so the fallback is unreachable and empty rather
480    // than a guess.
481    let (run_start, run_end) = run.take().unwrap_or((0, 0));
482    let after_lead = text.trim_start_matches([' ', '\t']);
483    let lead = text.len() - after_lead.len();
484    let stripped = after_lead.trim_end_matches([' ', '\t']);
485    let trail = after_lead.len() - stripped.len();
486    let had_leading_run = lead > 0;
487    // A literal whose runs strip it to nothing is pure whitespace: one run, one
488    // part, and the policy *is* the part. `had_leading_run` is true here by
489    // construction (a non-empty all-whitespace text starts with whitespace), so
490    // the single part carries the policy.
491    let had_trailing_run = !stripped.is_empty() && trail > 0;
492    // The stripped runs are spaces and tabs, and a space or a tab only ever
493    // reaches `lit` as **one source byte** — `\t` and `\x20` are policy parts,
494    // and the two escapes that do produce a character produce `` ` `` or `\`.
495    // So the byte counts on either end are the source byte counts too.
496    let (text_start, text_end) = if stripped.is_empty() {
497        (run_start, run_end)
498    } else {
499        (run_start + lead, run_end - trail)
500    };
501    parts.push(TemplatePart::Literal {
502        text: stripped.to_string(),
503        ws: if had_leading_run {
504            WsPolicy::SpaceRun
505        } else {
506            WsPolicy::None
507        },
508        span: Span::new(text_start as u32, text_end as u32),
509    });
510    if had_trailing_run {
511        // The trailing run has no slot on the literal it followed — the slot is
512        // in front of the text — so it gets a part of its own.
513        parts.push(TemplatePart::Literal {
514            text: String::new(),
515            ws: WsPolicy::SpaceRun,
516            span: Span::new(text_end as u32, run_end as u32),
517        });
518    }
519}
520
521/// What one escape sequence produced.
522enum Escape {
523    /// A whitespace policy part (`\s*`, `\s+`, `\n`, `\t`, `\x20`).
524    Policy(WsPolicy),
525    /// An escaped literal character (`` \` ``, `\\`).
526    Char(char),
527}
528
529/// Decode the escape whose backslash was at `at` and has already been consumed.
530///
531/// **Total on end of input** and **honest about what it read**: the reported
532/// `seq` is `&interior[at..cur.pos()]` — the text the source actually wrote,
533/// not a message rebuilt from the byte that *chose* the arm.
534fn escape(cur: &mut Scan<'_>, at: usize) -> Result<Escape, ScanError> {
535    let invalid = |cur: &Scan<'_>| ScanError::InvalidEscape {
536        byte_offset: at,
537        seq: cur.src()[at..cur.pos()].to_string(),
538    };
539
540    let Some((_, c)) = cur.bump() else {
541        // A backslash at the very end of the interior. `seq` is `\`.
542        return Err(invalid(cur));
543    };
544    match c {
545        's' => {
546            if cur.eat('*') {
547                Ok(Escape::Policy(WsPolicy::ZeroOrMore))
548            } else if cur.eat('+') {
549                Ok(Escape::Policy(WsPolicy::OneOrMore))
550            } else {
551                // Consume whatever did follow, so the message shows it.
552                cur.bump();
553                Err(invalid(cur))
554            }
555        }
556        'n' => Ok(Escape::Policy(WsPolicy::Newline)),
557        't' => Ok(Escape::Policy(WsPolicy::Tab)),
558        'x' => {
559            // `\x20` is §7.2's exact-space escape. Consume up to two more
560            // scalars either way, so `\x2` at the end reports `\x2` and `\x21`
561            // reports `\x21` rather than both reporting `\x`.
562            for _ in 0..2 {
563                if cur.peek().is_some() {
564                    cur.bump();
565                }
566            }
567            if &cur.src()[at..cur.pos()] == "\\x20" {
568                Ok(Escape::Policy(WsPolicy::ExactSpace))
569            } else {
570                Err(invalid(cur))
571            }
572        }
573        '`' | '\\' => Ok(Escape::Char(c)),
574        _ => Err(invalid(cur)),
575    }
576}
577
578/// Consume a capture starting at the `{` the cursor is sitting on, returning
579/// `(optional name text **and where it starts**, body text, body's byte
580/// offset)`.
581///
582/// The name's own offset is returned rather than derived, because the two
583/// offsets are not the same: the body begins after the `:`, so computing the
584/// name's span from the body's base would put the capture-*name* token on top
585/// of the capture *type* and report `{name:word}`'s name as `word`.
586///
587/// **The `}`-scan is depth-aware** (D10). A capture body may hold `}` inside a
588/// string (`{c:one_of("}")}`), `)` and `,` inside a call (`{xs:sep(",", int)}`),
589/// and a template of its own; a scan to the *first* `}` would cut §7.7's own
590/// `{items:csv(int)}` short and force the grammar down to "atomics only".
591type CaptureExtent<'a> = (Option<(&'a str, usize)>, &'a str, usize);
592
593fn capture_extent<'a>(cur: &mut Scan<'a>, open: usize) -> Result<CaptureExtent<'a>, ScanError> {
594    cur.bump(); // the `{`
595    let body_at = cur.pos();
596    let mut braces = 1usize;
597    let mut parens = 0usize;
598    // The offset of the `:` that separates the name from the parser, if one
599    // occurs at nesting depth zero. `{g:choice(A: word)}`'s inner colon is not
600    // one, and neither is the colon in `{c:one_of(":")}`.
601    let mut name_colon = None;
602
603    let close = loop {
604        let Some((at, c)) = cur.peek() else {
605            return Err(ScanError::UnterminatedCapture { byte_offset: open });
606        };
607        match c {
608            '"' => skip_string(cur)?,
609            '`' => {
610                take_template(cur)?;
611            }
612            '\\' => {
613                cur.bump();
614                cur.bump();
615            }
616            '{' => {
617                braces += 1;
618                if braces > MAX_NESTING {
619                    return Err(ScanError::NestingTooDeep {
620                        byte_offset: at,
621                        what: "`{`",
622                    });
623                }
624                cur.bump();
625            }
626            '}' => {
627                braces -= 1;
628                cur.bump();
629                if braces == 0 {
630                    break at;
631                }
632            }
633            '(' => {
634                parens += 1;
635                if parens > MAX_NESTING {
636                    return Err(ScanError::NestingTooDeep {
637                        byte_offset: at,
638                        what: "`(`",
639                    });
640                }
641                cur.bump();
642            }
643            ')' => {
644                if parens == 0 {
645                    return Err(ScanError::MalformedCaptureBody {
646                        byte_offset: at,
647                        message: "unbalanced `)`".to_string(),
648                    });
649                }
650                parens -= 1;
651                cur.bump();
652            }
653            ':' => {
654                if braces == 1 && parens == 0 && name_colon.is_none() {
655                    name_colon = Some(at);
656                }
657                cur.bump();
658            }
659            _ => {
660                cur.bump();
661            }
662        }
663    };
664
665    if parens != 0 {
666        return Err(ScanError::MalformedCaptureBody {
667            byte_offset: open,
668            message: "unbalanced `(`".to_string(),
669        });
670    }
671
672    let src = cur.src();
673    let body = &src[body_at..close];
674    if body.trim().is_empty() {
675        return Err(ScanError::EmptyCapture { byte_offset: open });
676    }
677    match name_colon {
678        Some(colon) => Ok((
679            Some((&src[body_at..colon], body_at)),
680            &src[colon + 1..close],
681            colon + 1,
682        )),
683        None => Ok((None, body, body_at)),
684    }
685}
686
687/// Consume a `"…"` run, honouring `\\`. The cursor is on the opening quote.
688///
689/// The rule is [`praxis_syntax::template::string_end`]'s, not a second copy of
690/// it: the lexer has to skip the same literals when it decides where this
691/// template's token ends, and [`crate::body`] has to skip the same ones again
692/// when it reads the capture's arguments — so this is the one place they live.
693///
694/// Two things a caller has to know. The error is anchored at the opening quote
695/// **in this cursor's own offsets**, so a caller scanning a sub-slice rebases it
696/// with [`ScanError::shifted`]. And on `Err` the cursor has *not* moved, so a
697/// caller that keeps scanning past the failure must advance it itself or it will
698/// re-read this same quote forever.
699pub(crate) fn skip_string(cur: &mut Scan<'_>) -> Result<(), ScanError> {
700    let open = cur.pos();
701    match praxis_syntax::template::string_end(cur.src(), open) {
702        Some(end) => {
703            cur.advance_to(end);
704            Ok(())
705        }
706        None => Err(ScanError::MalformedCaptureBody {
707            byte_offset: open,
708            message: "unterminated string literal".to_string(),
709        }),
710    }
711}
712
713/// Consume a nested `` `…` `` template run and return its **interior** (the
714/// text between the backticks). The cursor is on the opening backtick.
715///
716/// **The extent rule is [`praxis_syntax::template::template_end`]'s**, the same
717/// one the lexer applies when it decides where the enclosing token ends. There
718/// is one notion of where a template ends, and one nesting bound, because there
719/// is one function.
720pub(crate) fn take_template<'a>(cur: &mut Scan<'a>) -> Result<&'a str, ScanError> {
721    let open = cur.pos();
722    match praxis_syntax::template::template_end(cur.src(), open) {
723        praxis_syntax::template::TemplateEnd::Closed(end) => {
724            cur.advance_to(end);
725            Ok(&cur.src()[open + 1..end - 1])
726        }
727        praxis_syntax::template::TemplateEnd::Unterminated(_) => {
728            Err(ScanError::MalformedCaptureBody {
729                byte_offset: open,
730                message: "unterminated nested template".to_string(),
731            })
732        }
733    }
734}
735
736/// Validate a capture's name against the language's **one** identifier class
737/// (§4.1).
738///
739/// A local ASCII rule (`is_ascii_alphabetic`) would not recognize `{λ:int}` as a
740/// named capture at all — the whole body `λ:int` would be reinterpreted as the
741/// parser expression, and fail for an unrelated reason. A name the lexer would
742/// not have produced is *reported*, never silently re-read as something else.
743fn capture_name(raw: &str, at: usize) -> Result<crate::ast::CaptureName, ScanError> {
744    crate::ast::CaptureName::parse(raw.trim()).map_err(|_| ScanError::InvalidCaptureName {
745        byte_offset: at,
746        name: raw.trim().to_string(),
747    })
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753    use crate::ast::{AtomicKind, ParserAst};
754
755    fn literals(parts: &[TemplatePart]) -> Vec<&str> {
756        parts
757            .iter()
758            .filter_map(|p| match p {
759                TemplatePart::Literal { text, .. } => Some(text.as_str()),
760                _ => None,
761            })
762            .collect()
763    }
764
765    fn policies(parts: &[TemplatePart]) -> Vec<WsPolicy> {
766        parts
767            .iter()
768            .filter_map(|p| match p {
769                TemplatePart::Literal { ws, .. } => Some(*ws),
770                _ => None,
771            })
772            .collect()
773    }
774
775    fn capture_kind(part: &TemplatePart) -> AtomicKind {
776        match part {
777            TemplatePart::Capture { parser, .. } => match parser.as_ref() {
778                ParserAst::Atomic { kind, .. } => *kind,
779                other => panic!("expected an atomic capture, got {other:?}"),
780            },
781            other => panic!("expected a capture, got {other:?}"),
782        }
783    }
784
785    #[test]
786    fn plain_literal_template() {
787        let parts = scan_template("hello").unwrap();
788        assert_eq!(parts.len(), 1);
789        match &parts[0] {
790            TemplatePart::Literal { text, .. } => assert_eq!(text, "hello"),
791            _ => panic!("expected literal"),
792        }
793    }
794
795    #[test]
796    fn single_anonymous_capture() {
797        let parts = scan_template("{int}").unwrap();
798        assert_eq!(parts.len(), 1);
799        match &parts[0] {
800            TemplatePart::Capture { name, .. } => assert!(name.is_none()),
801            _ => panic!("expected capture"),
802        }
803    }
804
805    #[test]
806    fn named_capture_with_literal() {
807        let parts = scan_template("{x:int},{y:int}").unwrap();
808        assert_eq!(parts.len(), 3);
809        match &parts[0] {
810            TemplatePart::Capture { name, .. } => {
811                assert_eq!(name.as_ref().map(|n| n.as_str()), Some("x"));
812            }
813            _ => panic!("expected capture"),
814        }
815        match &parts[1] {
816            TemplatePart::Literal { text, .. } => assert_eq!(text, ","),
817            _ => panic!("expected literal"),
818        }
819        match &parts[2] {
820            TemplatePart::Capture { name, .. } => {
821                assert_eq!(name.as_ref().map(|n| n.as_str()), Some("y"));
822            }
823            _ => panic!("expected capture"),
824        }
825    }
826
827    /// A space/tab run at either end of a literal becomes the whitespace policy
828    /// and leaves the text — the leading run as the literal's own `ws`, the
829    /// trailing run as an empty literal of its own, because a literal has one
830    /// policy slot and it sits in front of the text.
831    ///
832    /// Here as well as in the JIT tests because this is the *scanner's* rule:
833    /// the interpreter honours a policy it is given, and those same spaces must
834    /// not also be in the bytes it has to match.
835    #[test]
836    fn a_literals_edge_whitespace_is_its_policy_and_not_its_text() {
837        // §3.3's own template. The middle literal is `->`, not ` -> ` and not
838        // `-> `: the run on each side is a policy, and the trailing one is a
839        // part of its own.
840        let parts = scan_template("{x1:int},{y1:int} -> {x2:int},{y2:int}").unwrap();
841        assert_eq!(literals(&parts), vec![",", "->", "", ","]);
842        // …and the policy says which of them had a run in front of it.
843        // A comma the template wrote with nothing before it must not match an
844        // input that has a space there.
845        assert_eq!(
846            policies(&parts),
847            vec![
848                WsPolicy::None,
849                WsPolicy::SpaceRun,
850                WsPolicy::SpaceRun,
851                WsPolicy::None
852            ],
853            "only the literals a run was written against carry SpaceRun"
854        );
855
856        // Both ends, and a run *inside* a literal, which stays exact — nothing else
857        // consumes it, and `\\x20` is the escape for a space that must match.
858        let parts = scan_template("{a:int} a b {b:int}").unwrap();
859        match &parts[1] {
860            TemplatePart::Literal { text, ws, .. } => {
861                assert_eq!(text, "a b");
862                assert_eq!(*ws, WsPolicy::SpaceRun);
863            }
864            _ => panic!("expected literal"),
865        }
866        // The trailing run of that same literal, as its own part.
867        match &parts[2] {
868            TemplatePart::Literal { text, ws, .. } => {
869                assert!(text.is_empty());
870                assert_eq!(*ws, WsPolicy::SpaceRun);
871            }
872            _ => panic!("expected the trailing run's part"),
873        }
874
875        // **A trailing run with no leading one is still a policy.** `x: ` is
876        // `"x:"` with no policy, then the run.
877        let parts = scan_template("x: {a:rest}").unwrap();
878        assert_eq!(literals(&parts), vec!["x:", ""]);
879        assert_eq!(
880            policies(&parts),
881            vec![WsPolicy::None, WsPolicy::SpaceRun],
882            "the run after `x:` is the policy, not text, and not nothing"
883        );
884
885        // A literal that is only whitespace strips to nothing and is still emitted:
886        // the policy is the part — **one** part. Counting it as a leading run and
887        // a trailing run would make this template demand two separate runs.
888        let parts = scan_template("{a:int} {b:int}").unwrap();
889        assert_eq!(parts.len(), 3);
890        match &parts[1] {
891            TemplatePart::Literal { text, ws, .. } => {
892                assert!(text.is_empty());
893                assert_eq!(*ws, WsPolicy::SpaceRun);
894            }
895            _ => panic!("expected literal"),
896        }
897
898        // An escaped policy is untouched: it carries no text to strip.
899        let parts = scan_template(r"{a:int}\s+{b:int}").unwrap();
900        match &parts[1] {
901            TemplatePart::Literal { text, ws, .. } => {
902                assert!(text.is_empty());
903                assert_eq!(*ws, WsPolicy::OneOrMore);
904            }
905            _ => panic!("expected ws literal"),
906        }
907    }
908
909    #[test]
910    fn whitespace_escape_policies() {
911        let parts = scan_template("a\\s*b").unwrap();
912        assert_eq!(parts.len(), 3);
913        match &parts[1] {
914            TemplatePart::Literal { ws, .. } => assert_eq!(*ws, WsPolicy::ZeroOrMore),
915            _ => panic!("expected ws literal"),
916        }
917    }
918
919    #[test]
920    fn unterminated_capture_errors() {
921        assert!(matches!(
922            scan_template("{int"),
923            Err(ScanError::UnterminatedCapture { .. })
924        ));
925    }
926
927    #[test]
928    fn empty_capture_errors() {
929        assert!(matches!(
930            scan_template("{}"),
931            Err(ScanError::EmptyCapture { .. })
932        ));
933    }
934
935    #[test]
936    fn escaped_backtick_is_literal() {
937        let parts = scan_template("a\\`b").unwrap();
938        assert_eq!(parts.len(), 1);
939        match &parts[0] {
940            TemplatePart::Literal { text, .. } => assert_eq!(text, "a`b"),
941            _ => panic!("expected literal"),
942        }
943    }
944
945    /// Ordinary template text keeps its scalars: a byte-by-byte copy through
946    /// `char::from(b)` is a Latin-1 decode, and would make `λ=` into `λ=`.
947    #[test]
948    fn regression_unicode_literal_text_is_preserved() {
949        let parts = scan_template("λ={int}").unwrap();
950        match &parts[0] {
951            TemplatePart::Literal { text, .. } => assert_eq!(text, "λ="),
952            _ => panic!("expected literal"),
953        }
954    }
955
956    /// A terminal backslash is an invalid escape, not literal text. Note the
957    /// offset: the error is anchored at the backslash.
958    #[test]
959    fn regression_trailing_backslash_is_an_invalid_escape() {
960        assert!(matches!(
961            scan_template("prefix\\"),
962            Err(ScanError::InvalidEscape { byte_offset: 6, .. })
963        ));
964    }
965
966    /// `seq` is the exact source substring at `byte_offset` — not a message
967    /// rebuilt from the byte that *selected* the arm — which is a property the
968    /// assertions below compare against the input directly.
969    #[test]
970    fn an_invalid_escape_reports_the_sequence_the_source_actually_wrote() {
971        for (src, expected) in [
972            (r"a\sq", r"\sq"),
973            (r"a\s", r"\s"),
974            (r"a\x2", r"\x2"),
975            (r"a\x21", r"\x21"),
976            (r"a\q", r"\q"),
977            ("a\\", "\\"),
978            // A non-ASCII scalar after the backslash is one scalar, not two
979            // bytes — the byte walk would have sliced it in half.
980            (r"a\λ", r"\λ"),
981        ] {
982            match scan_template(src) {
983                Err(ScanError::InvalidEscape { seq, byte_offset }) => {
984                    assert_eq!(seq, expected, "for {src:?}");
985                    assert_eq!(
986                        &src[byte_offset..byte_offset + seq.len()],
987                        seq,
988                        "`seq` must be the source's own text at `byte_offset`, for {src:?}"
989                    );
990                }
991                other => panic!("{src:?} must be an invalid escape, got {other:?}"),
992            }
993        }
994        // And the valid ones are still valid.
995        assert!(scan_template(r"a\x20b").is_ok());
996        assert!(scan_template(r"a\s*b").is_ok());
997        assert!(scan_template(r"a\s+b").is_ok());
998    }
999
1000    /// A capture name is the language's own identifier (§4.1), not a local
1001    /// ASCII rule: `{λ:int}` is a capture named `λ`, not an *anonymous* capture
1002    /// whose parser expression is the whole text `λ:int`.
1003    #[test]
1004    fn a_capture_name_is_the_languages_own_identifier() {
1005        for (src, name) in [
1006            ("{λ:int}", "λ"),
1007            ("{日本語:int}", "日本語"),
1008            ("{_x9:int}", "_x9"),
1009        ] {
1010            let parts = scan_template(src).unwrap();
1011            match &parts[0] {
1012                TemplatePart::Capture { name: got, .. } => {
1013                    assert_eq!(got.as_ref().map(|n| n.as_str()), Some(name), "for {src}");
1014                }
1015                other => panic!("{src} must be a named capture, got {other:?}"),
1016            }
1017        }
1018
1019        // A name that is not an identifier is reported, not silently reread as
1020        // an anonymous capture over the whole body.
1021        for src in ["{9x:int}", "{a b:int}", "{:int}", "{+:int}"] {
1022            assert!(
1023                matches!(
1024                    scan_template(src),
1025                    Err(ScanError::InvalidCaptureName { .. })
1026                ),
1027                "{src} must report an invalid capture name"
1028            );
1029        }
1030
1031        // No colon at all is still an anonymous capture.
1032        let parts = scan_template("{int}").unwrap();
1033        match &parts[0] {
1034            TemplatePart::Capture { name, .. } => assert!(name.is_none()),
1035            other => panic!("expected an anonymous capture, got {other:?}"),
1036        }
1037    }
1038
1039    /// Each capture keeps its **own** parser — `{name:word},{port:int}` is a
1040    /// `word` and an `int`, not one kind recovered for both — and a name that
1041    /// resolves to no parser is reported rather than defaulted.
1042    #[test]
1043    fn every_capture_keeps_its_own_parser() {
1044        let parts = scan_template("{name:word},{port:int}").unwrap();
1045        assert_eq!(capture_kind(&parts[0]), AtomicKind::Word);
1046        assert_eq!(capture_kind(&parts[2]), AtomicKind::Int);
1047
1048        // Anonymous captures too.
1049        let parts = scan_template("{word} {int}").unwrap();
1050        assert_eq!(capture_kind(&parts[0]), AtomicKind::Word);
1051        assert_eq!(capture_kind(&parts[2]), AtomicKind::Int);
1052
1053        // There is no `Int` default: an unknown name is reported.
1054        assert!(matches!(
1055            scan_template("{value:intr}"),
1056            Err(ScanError::UnknownCaptureKind { .. })
1057        ));
1058        assert!(matches!(
1059            scan_template("{intr}"),
1060            Err(ScanError::UnknownCaptureKind { .. })
1061        ));
1062    }
1063
1064    /// **D10's gate.** A capture body is a full parser expression, so the
1065    /// `}`-scan has to be brace-, paren- and string-aware. §7.7's own monkey
1066    /// example is `` `  Starting items: {items:csv(int)}` ``.
1067    #[test]
1068    fn a_capture_body_is_a_parser_expression() {
1069        // `parts[2]`: `"Starting items: "` is the literal `"Starting items:"`
1070        // and then the trailing run's own whitespace part.
1071        let parts = scan_template("Starting items: {items:csv(int)}").unwrap();
1072        match &parts[2] {
1073            TemplatePart::Capture { parser, .. } => {
1074                assert!(matches!(parser.as_ref(), ParserAst::Csv { .. }));
1075            }
1076            other => panic!("expected a capture, got {other:?}"),
1077        }
1078
1079        let parts = scan_template("{x:optional(int)}").unwrap();
1080        match &parts[0] {
1081            TemplatePart::Capture { parser, .. } => {
1082                assert!(matches!(parser.as_ref(), ParserAst::Optional { .. }));
1083            }
1084            other => panic!("expected a capture, got {other:?}"),
1085        }
1086
1087        // A string argument, with a comma and a colon inside it — the extent
1088        // scan must not end the capture, and the name split must not fire.
1089        let parts = scan_template(r#"{s:sep("-", int)}"#).unwrap();
1090        match &parts[0] {
1091            TemplatePart::Capture { name, parser, .. } => {
1092                assert_eq!(name.as_ref().map(|n| n.as_str()), Some("s"));
1093                match parser.as_ref() {
1094                    ParserAst::Sep { separator, .. } => assert_eq!(separator.as_str(), "-"),
1095                    other => panic!("expected Sep, got {other:?}"),
1096                }
1097            }
1098            other => panic!("expected a capture, got {other:?}"),
1099        }
1100
1101        // A brace inside a string does **not** end the capture. Both braces are
1102        // covered on purpose: `"}"` happens to keep a brace *counter* balanced,
1103        // so a counter that ignores strings passes that case and fails this one.
1104        for (body, expect) in [
1105            (r#"{c:one_of("}")}"#, "}"),
1106            (r#"{c:one_of("{")}"#, "{"),
1107            (r#"{c:one_of("`")}"#, "`"),
1108        ] {
1109            let parts = scan_template(body).unwrap();
1110            match &parts[0] {
1111                TemplatePart::Capture { parser, .. } => match parser.as_ref() {
1112                    ParserAst::OneOf { chars, .. } => assert_eq!(chars, expect, "{body}"),
1113                    other => panic!("expected OneOf, got {other:?}"),
1114                },
1115                other => panic!("expected a capture, got {other:?}"),
1116            }
1117        }
1118
1119        // A colon inside a nested call is not the name separator.
1120        let parts = scan_template("{g:choice(A: word, B: int)}").unwrap();
1121        match &parts[0] {
1122            TemplatePart::Capture { name, parser, .. } => {
1123                assert_eq!(name.as_ref().map(|n| n.as_str()), Some("g"));
1124                match parser.as_ref() {
1125                    ParserAst::Choice { cases, .. } => assert_eq!(cases.len(), 2),
1126                    other => panic!("expected Choice, got {other:?}"),
1127                }
1128            }
1129            other => panic!("expected a capture, got {other:?}"),
1130        }
1131
1132        // Malformed bodies report rather than being silently accepted.
1133        assert!(scan_template("{x:csv(int}").is_err());
1134        assert!(scan_template("{x:csv(int, int)}").is_err());
1135        assert!(scan_template("{x:frobnicate(int)}").is_err());
1136    }
1137
1138    /// **A span is the text it names**, at every depth of nesting.
1139    ///
1140    /// `ParserAst::shift_spans` and `Span::shifted` are recursive span
1141    /// arithmetic: a nested template's parts are scanned in the **nested**
1142    /// interior's offsets and must be rebased onto the enclosing one, because
1143    /// `convert_template`'s single uniform shift is right for one level only.
1144    ///
1145    /// The assertion is the strongest available one: slice the interior by the
1146    /// span and compare it to the source text the node was built from.
1147    #[test]
1148    fn every_span_is_the_text_it_names_even_inside_a_nested_template() {
1149        fn text_at(interior: &str, span: praxis_source::Span) -> &str {
1150            &interior[span.start().to_usize()..span.end().to_usize()]
1151        }
1152        fn capture_parser(part: &TemplatePart) -> &ParserAst {
1153            match part {
1154                TemplatePart::Capture { parser, .. } => parser,
1155                other => panic!("expected a capture, got {other:?}"),
1156            }
1157        }
1158
1159        // One level: the capture's parser span is the `int` that named it.
1160        // `parts[2]`: `"x = "` is `"x ="` plus the trailing run's own
1161        // whitespace part.
1162        let interior = "x = {x:int}";
1163        let parts = scan_template(interior).unwrap();
1164        assert_eq!(
1165            text_at(interior, capture_parser(&parts[2]).span()),
1166            "int",
1167            "a top-level capture's span"
1168        );
1169
1170        // Two levels. `int` lives inside the *nested* interior, and its span
1171        // must still name it in the text `scan_template` was handed.
1172        let interior = "{g:choice(A: `{x:int}`, B: word)}";
1173        let parts = scan_template(interior).unwrap();
1174        let ParserAst::Choice { cases, span } = capture_parser(&parts[0]) else {
1175            panic!("expected a choice");
1176        };
1177        assert_eq!(
1178            text_at(interior, *span),
1179            "choice(A: `{x:int}`, B: word)",
1180            "the choice call's own span"
1181        );
1182        let ParserAst::Template {
1183            parts: inner,
1184            span: inner_span,
1185        } = &cases[0].1
1186        else {
1187            panic!("expected a nested template");
1188        };
1189        assert_eq!(text_at(interior, *inner_span), "`{x:int}`");
1190        assert_eq!(
1191            text_at(interior, capture_parser(&inner[0]).span()),
1192            "int",
1193            "a capture inside a nested template — this is what was never rebased"
1194        );
1195        assert_eq!(
1196            text_at(interior, cases[1].1.span()),
1197            "word",
1198            "the un-nested sibling, which was always right"
1199        );
1200
1201        // And the error channel is rebased too: the caret for a bad call inside
1202        // a nested template must name it, not the *enclosing* call.
1203        let interior = "{g:choice(A: `{x:csv(int, int)}`)}";
1204        let err = scan_template(interior).unwrap_err();
1205        assert_eq!(
1206            err.byte_offset(),
1207            interior.find("csv").unwrap(),
1208            "the offset must name the `csv` that is wrong, not the `choice` around it"
1209        );
1210    }
1211
1212    /// A compiler must not answer adversarial input with a stack overflow
1213    /// (D10). `scan_template` and `parse_capture_body` are mutually recursive,
1214    /// so the bound is not optional.
1215    #[test]
1216    fn nesting_past_the_bound_is_an_error_and_not_a_stack_overflow() {
1217        let deep = format!("{}{}", "{a:".repeat(2_000), "}".repeat(2_000));
1218        assert!(
1219            matches!(scan_template(&deep), Err(ScanError::NestingTooDeep { .. })),
1220            "deep nesting must be refused before it recurses"
1221        );
1222        // The bound is far above what anyone writes: three levels is fine.
1223        assert!(scan_template("{a:optional(csv(int))}").is_ok());
1224    }
1225
1226    /// The interior of a template nested `n` levels deep: `n = 1` is
1227    /// `{a:int}`, and each further level wraps the last in a capture holding a
1228    /// backtick template.
1229    fn nested_interior(n: usize) -> String {
1230        let mut interior = "{a:int}".to_string();
1231        for _ in 1..n {
1232            interior = format!("{{a:`{interior}`}}");
1233        }
1234        interior
1235    }
1236
1237    /// **The number in the message is the number that is enforced, and it is
1238    /// the lexer's number.**
1239    ///
1240    /// Two layers bound template nesting: `praxis_syntax::template::template_end`
1241    /// decides how much text the lexer hands over, and the scanner's own
1242    /// recursion refuses before it can overflow the stack. A level is added at
1243    /// one hop of that mutual recursion only, so the depth the scanner enforces
1244    /// is the depth the message names.
1245    ///
1246    /// A stricter inner bound would be defensible. A diagnostic naming a limit
1247    /// that nothing enforces is not, which is why this asserts the *rendered*
1248    /// number and not only the behaviour.
1249    #[test]
1250    fn the_two_template_nesting_bounds_are_the_same_number_and_the_message_says_it() {
1251        use praxis_syntax::template::{TemplateEnd, template_end};
1252
1253        // The deepest nest the scanner accepts, measured rather than assumed.
1254        let deepest = (1..=MAX_NESTING + 4)
1255            .take_while(|n| scan_template(&nested_interior(*n)).is_ok())
1256            .last()
1257            .expect("one level at least");
1258        assert_eq!(
1259            deepest, MAX_NESTING,
1260            "the scanner's effective limit must be MAX_NESTING, not half of it"
1261        );
1262
1263        // One past it refuses, and says so about *templates*.
1264        let err = scan_template(&nested_interior(MAX_NESTING + 1)).expect_err("one too deep");
1265        assert!(
1266            matches!(
1267                err,
1268                ScanError::NestingTooDeep {
1269                    what: "template",
1270                    ..
1271                }
1272            ),
1273            "the {}-level nest must be refused as template nesting, got {err}",
1274            MAX_NESTING + 1
1275        );
1276        let rendered = err.to_string();
1277        let named: usize = rendered
1278            .split_whitespace()
1279            .find_map(|w| w.parse().ok())
1280            .expect("the message names a limit");
1281        assert_eq!(
1282            named, deepest,
1283            "the message says {named} and the checker enforces {deepest}: {rendered}"
1284        );
1285
1286        // And it is the lexer's number: `MAX_NESTING` *is*
1287        // `MAX_TEMPLATE_NESTING`, and at exactly that depth both layers take
1288        // the template whole — the lexer delivers one token spanning all of it
1289        // and the scanner reads it.
1290        //
1291        // Past the bound the two are not symmetric: `template_end` stops
1292        // treating a backtick as an *opener* rather than refusing, so it still
1293        // hands over a token. The scanner is the layer that says no, which is
1294        // why its number has to be this one and its message has to name it.
1295        assert_eq!(MAX_NESTING, praxis_syntax::MAX_TEMPLATE_NESTING);
1296        let at_the_bound = format!("`{}`", nested_interior(MAX_NESTING));
1297        assert_eq!(
1298            template_end(&at_the_bound, 0),
1299            TemplateEnd::Closed(at_the_bound.len()),
1300            "the lexer delivers a {MAX_NESTING}-level template whole"
1301        );
1302
1303        // A `(` bound is not a template bound, and the message must not say it
1304        // is: this text holds exactly one template.
1305        let parens = format!("{{a:{}int{}}}", "csv(".repeat(64), ")".repeat(64));
1306        let err = scan_template(&parens).expect_err("too many parens");
1307        assert!(
1308            matches!(err, ScanError::NestingTooDeep { what: "`(`", .. }),
1309            "a parenthesis bound must name parentheses, got {err}"
1310        );
1311    }
1312}