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}