Skip to main content

praxis_syntax/
template.rs

1//! Where a backtick template ends — **one** answer, for both scanners (D10).
2//!
3//! Two hand-written scanners have to agree about the extent of a
4//! `` `…` `` run: [`praxis-parser`'s lexer], which turns it into one
5//! `BacktickTemplate` token, and `praxis-input-parser`'s template scanner,
6//! which re-reads that token's interior and has to find the same nested
7//! templates and the same closing backtick inside it. Two implementations of
8//! one rule drift, so there is one implementation.
9//!
10//! `praxis-syntax` is the crate below both (it is where [`crate::ident`] and
11//! [`crate::numeric`] already live for exactly this reason), so the rule lives
12//! here and is called twice rather than written twice.
13//!
14//! # The rule
15//!
16//! Scanning starts just past the opening backtick and, until the run closes:
17//!
18//! - `\` hides the next scalar, so an escaped backtick cannot terminate a run.
19//! - `{` opens a capture and `}` closes one — this is the only thing brace
20//!   depth is for.
21//! - `"` **inside a capture** opens a string literal, which is skipped whole:
22//!   `one_of("{")`, `sep("}", int)` and `one_of("`")` all hold delimiters that
23//!   are text, not structure. Outside a capture a quote is ordinary literal
24//!   text (`` `He said "hi" {x:int}` ``), which is why the rule is conditioned
25//!   on depth rather than applied everywhere.
26//! - `` ` `` closes the run at capture depth 0. Inside a capture it *opens* a
27//!   template of its own, because a capture body is a full parser expression
28//!   (D10) and `` `{g:choice(A: `{x:int}`)}` `` is one template containing
29//!   another.
30//!
31//! At most [`MAX_TEMPLATE_NESTING`] templates may nest; past that a backtick
32//! simply closes, so adversarial input lands on the ordinary
33//! unterminated/unexpected-token paths rather than on the stack. There is one
34//! bound because there is one function.
35//!
36//! # The quoted-run and scalar primitives
37//!
38//! A `"…"` inside a capture and a `'…'` inside an interpolation hole are the
39//! same scan with a different closing byte, and both scanners have to step over
40//! a multi-byte scalar the same way. [`quoted_run`] and [`skip_scalar`]
41//! therefore live in this module — the lower of the two — and [`crate::interp`]
42//! calls them instead of keeping a second copy, for the same reason this module
43//! exists at all.
44//!
45//! [`praxis-parser`'s lexer]: https://docs.rs/praxis-parser
46
47use crate::MAX_TEMPLATE_NESTING;
48
49/// Where a `` `…` `` run ends.
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub enum TemplateEnd {
52    /// The run closed. The index is **just past** the closing backtick, so
53    /// `&src[open..end]` is the whole token, backticks included.
54    Closed(usize),
55    /// The line ended, or the text did, before the run closed.
56    ///
57    /// The index is where the run **stopped** — at the newline, or at the end
58    /// of the text — so `&src[open..end]` is still a bounded token. Bounding it
59    /// is the whole point of ADR-094: one typo yields one `T002` rather than a
60    /// token covering the rest of the file and a cascade behind it.
61    Unterminated(usize),
62}
63
64/// Find the end of the backtick template whose opening backtick is at `open`.
65///
66/// `src[open]` must be `` ` ``.
67///
68/// # A template ends at the line it opens on (ADR-094)
69///
70/// A raw newline may not appear inside a template; `\n` is how §7.2 says a
71/// template matches a line ending, and it is the only way. This is the same rule
72/// a `"…"` literal follows.
73///
74/// A raw newline would have no useful meaning here in any case. §7.2 lists
75/// literal text, a space run, `\s*`, `\s+`, `\n`, `\t`, `\x20` and the ordinary
76/// escapes; a raw newline is whitespace but is not a space, so it matches none
77/// of them and would fall through to *literal text* — which matches LF input and
78/// **fails on CRLF**, while the `\n` escape matches both.
79#[must_use]
80pub fn template_end(src: &str, open: usize) -> TemplateEnd {
81    debug_assert_eq!(src.as_bytes().get(open), Some(&b'`'));
82    match run(src.as_bytes(), open, 1) {
83        Ok(end) => TemplateEnd::Closed(end),
84        Err(stopped) => TemplateEnd::Unterminated(stopped),
85    }
86}
87
88/// Find the end of the `"…"` literal whose opening quote is at `open`,
89/// returning the index **just past** the closing quote, or `None` if the text
90/// ends first. `\` hides the next scalar, so `"\""` is one literal.
91#[must_use]
92pub fn string_end(src: &str, open: usize) -> Option<usize> {
93    debug_assert_eq!(src.as_bytes().get(open), Some(&b'"'));
94    quoted_run(src.as_bytes(), open, b'"').ok()
95}
96
97/// One `` `…` `` run. `level` is 1 for the outermost template.
98///
99/// Byte-wise scanning is safe here because every delimiter is ASCII and no
100/// UTF-8 continuation byte can be mistaken for one; the two places that step
101/// over something unconditionally ([`skip_scalar`]) step over a whole scalar so
102/// the returned index is always a character boundary.
103/// `Ok(end)` is just past the closing backtick; `Err(stopped)` is where the run
104/// gave up — at the newline that ended its line, or at the end of the text.
105fn run(bytes: &[u8], open: usize, level: usize) -> Result<usize, usize> {
106    let mut pos = open + 1; // past the opening backtick
107    let mut braces = 0usize;
108    while pos < bytes.len() {
109        match bytes[pos] {
110            // **A template ends at the line it opens on** (ADR-094). A `\r` is
111            // taken with the `\n` it precedes so the token does not end mid-CRLF
112            // and leave a stray `\r` for the next token to puzzle over.
113            b'\n' => return Err(pos),
114            b'\r' if bytes.get(pos + 1) == Some(&b'\n') => return Err(pos),
115            // An escape hides the next scalar, but it cannot hide a line break:
116            // `\` at the end of a line is a dangling escape, not a continuation.
117            b'\\' => {
118                if matches!(bytes.get(pos + 1), Some(b'\n') | None)
119                    || (bytes.get(pos + 1) == Some(&b'\r') && bytes.get(pos + 2) == Some(&b'\n'))
120                {
121                    return Err(pos + 1);
122                }
123                pos = skip_scalar(bytes, pos + 1);
124            }
125            // A quote is structure only inside a capture; in literal text it is
126            // just a quote.
127            b'"' if braces > 0 => pos = quoted_run(bytes, pos, b'"')?,
128            b'{' => {
129                braces += 1;
130                pos += 1;
131            }
132            b'}' => {
133                braces = braces.saturating_sub(1);
134                pos += 1;
135            }
136            b'`' => {
137                if braces == 0 || level >= MAX_TEMPLATE_NESTING {
138                    return Ok(pos + 1);
139                }
140                // A nested run that hits the line end ends the outer run too,
141                // and at the same place: one line, one token.
142                pos = run(bytes, pos, level + 1)?;
143            }
144            _ => pos = skip_scalar(bytes, pos),
145        }
146    }
147    Err(bytes.len())
148}
149
150/// One `'…'` or `"…"` run, honouring `\`. `bytes[open]` is the opening quote and
151/// `terminator` is the byte that closes it; `Ok` is the index just past that
152/// byte.
153///
154/// The two quotes are one rule with one byte different, so they are one
155/// function: the `"…"` inside a template capture and the `'…'` inside an
156/// interpolation hole (ADR-141) are measured by the same code.
157///
158/// Such a run is bounded by the same line rule as whatever holds it — the lexer
159/// already refuses a raw newline inside a `"…"` literal, and there is no reason
160/// for one nested in a capture or a hole to be different. `Err(stopped)`
161/// therefore propagates straight out of [`run`], and out of `interp`'s `hole`.
162///
163/// A dangling `\` before a CRLF stops *at* the `\r`, exactly as [`run`] and
164/// `interp`'s `fragment` do, so no token ever ends between a `\r` and the `\n`
165/// it precedes.
166pub(crate) fn quoted_run(bytes: &[u8], open: usize, terminator: u8) -> Result<usize, usize> {
167    let mut pos = open + 1;
168    while pos < bytes.len() {
169        match bytes[pos] {
170            b'\n' => return Err(pos),
171            b'\r' if bytes.get(pos + 1) == Some(&b'\n') => return Err(pos),
172            b'\\' => {
173                if matches!(bytes.get(pos + 1), Some(b'\n') | None)
174                    || (bytes.get(pos + 1) == Some(&b'\r') && bytes.get(pos + 2) == Some(&b'\n'))
175                {
176                    return Err(pos + 1);
177                }
178                pos = skip_scalar(bytes, pos + 1);
179            }
180            b if b == terminator => return Ok(pos + 1),
181            _ => pos = skip_scalar(bytes, pos),
182        }
183    }
184    Err(bytes.len())
185}
186
187/// The index just past the whole UTF-8 scalar beginning at `pos`.
188///
189/// Every scanner in this crate that steps over a byte it does not care about
190/// steps over a whole scalar instead, which is what keeps every index any of
191/// them return a character boundary.
192pub(crate) fn skip_scalar(bytes: &[u8], pos: usize) -> usize {
193    if pos >= bytes.len() {
194        return bytes.len();
195    }
196    let mut next = pos + 1;
197    while next < bytes.len() && (bytes[next] & 0xC0) == 0x80 {
198        next += 1;
199    }
200    next
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    /// `n` templates nested inside each other's captures, closed properly.
208    fn nested(n: usize) -> String {
209        let mut s = String::new();
210        for _ in 0..n {
211            s.push_str("`{a:");
212        }
213        s.push_str("int");
214        for _ in 0..n {
215            s.push_str("}`");
216        }
217        s
218    }
219
220    /// `n` nested templates whose innermost one holds a lone `"` as its literal
221    /// text.
222    ///
223    /// That quote is *text* only if the innermost template is really entered as
224    /// a template — at capture depth 0. If the bound stopped one level short,
225    /// the same byte sits inside the parent's capture instead, where a quote
226    /// opens a string literal that never closes. So this string closes at
227    /// exactly `n <= MAX_TEMPLATE_NESTING` and at no larger `n`, which is what
228    /// makes the bound observable.
229    fn nested_quote(n: usize) -> String {
230        let mut s = String::new();
231        for _ in 0..n - 1 {
232            s.push_str("`{a:");
233        }
234        s.push_str("`\"`");
235        for _ in 0..n - 1 {
236            s.push_str("}`");
237        }
238        s
239    }
240
241    fn closed(src: &str) -> bool {
242        template_end(src, 0) == TemplateEnd::Closed(src.len())
243    }
244
245    /// A `{`, `}` or backtick inside a string literal is text, not structure:
246    /// `one_of("{")` must not leave the brace counter above zero, or the closing
247    /// backtick reads as an opener.
248    #[test]
249    fn a_delimiter_inside_a_string_is_text() {
250        for src in [
251            r#"`{c:one_of("{")}`"#,
252            r#"`{c:one_of("}")}`"#,
253            r#"`{s:sep("{", int)}`"#,
254            r#"`{c:one_of("`")}`"#,
255            r#"`{c:one_of("\"")}`"#,
256            r#"`{c:one_of("{{{")}`"#,
257        ] {
258            assert!(closed(src), "{src}");
259        }
260    }
261
262    /// Outside a capture a quote is ordinary literal text. Conditioning the
263    /// string rule on depth is what keeps `` `He said "hi"` `` a template.
264    #[test]
265    fn a_quote_in_literal_text_is_not_a_string() {
266        assert!(closed(r#"`He said "hi`"#));
267        assert!(closed(r#"`" {x:int}`"#));
268    }
269
270    #[test]
271    fn a_nested_template_is_part_of_the_run() {
272        assert!(closed("`{g:choice(A: `{x:int}`, B: word)}`"));
273        assert!(closed("`{a:choice(A: `{b:choice(C: `{c:int}`)}`)}`"));
274        // An escaped backtick terminates nothing, at either depth.
275        assert!(closed(r"`a\`b`"));
276        assert!(closed(r"`{a:choice(A: `x\`y`)}`"));
277    }
278
279    #[test]
280    fn a_run_that_never_closes_is_unterminated() {
281        // The index is where the run stopped, which with no newline in the text
282        // is its end — so `&src[open..end]` is still a bounded token.
283        assert_eq!(
284            template_end("`never closes", 0),
285            TemplateEnd::Unterminated("`never closes".len())
286        );
287        assert_eq!(
288            template_end("`{g:choice(A: `{x:int}`)}", 0),
289            TemplateEnd::Unterminated("`{g:choice(A: `{x:int}`)}".len())
290        );
291        // An unterminated string swallows the rest, so the run cannot close.
292        assert_eq!(
293            template_end(r#"`{c:one_of("abc)}`"#, 0),
294            TemplateEnd::Unterminated(r#"`{c:one_of("abc)}`"#.len())
295        );
296    }
297
298    /// **ADR-094.** A template ends at the line it opens on, so an unterminated
299    /// run stops at the newline instead of swallowing the rest of the file.
300    #[test]
301    fn a_template_ends_at_the_line_it_opens_on() {
302        // The run stops *at* the newline, so the token is `` `{int` `` and the
303        // `}` on the next line is still the block's.
304        assert_eq!(
305            template_end("`{int\n}\n", 0),
306            TemplateEnd::Unterminated(5),
307            "the token is the first line's template, not the rest of the file"
308        );
309        // A closed template on one line is untouched.
310        assert_eq!(template_end("`{a:int}`\nrest", 0), TemplateEnd::Closed(9));
311        // CRLF: the run stops before the `\r`, so no token ends mid-sequence.
312        assert_eq!(template_end("`{int\r\n}", 0), TemplateEnd::Unterminated(5));
313        // A trailing backslash cannot swallow the line break — a dangling
314        // escape is not a continuation.
315        assert_eq!(
316            template_end("`abc\\\ndef`", 0),
317            TemplateEnd::Unterminated(5)
318        );
319        // A nested run that hits the line end ends the outer run too, at the
320        // same place: one line, one token.
321        assert_eq!(
322            template_end("`{g:choice(A: `{x:int}\n)}`", 0),
323            TemplateEnd::Unterminated(22)
324        );
325        // …and a string literal inside a capture is bounded by the same rule.
326        assert_eq!(
327            template_end("`{c:one_of(\"ab\n)}`", 0),
328            TemplateEnd::Unterminated(14)
329        );
330        // A dangling `\` inside that string is not a continuation either, and it
331        // stops at the line terminator's *first* byte — so a CRLF is not split.
332        assert_eq!(
333            template_end("`{c:one_of(\"ab\\\ncd\")}`", 0),
334            TemplateEnd::Unterminated(15)
335        );
336        assert_eq!(
337            template_end("`{c:one_of(\"ab\\\r\ncd\")}`", 0),
338            TemplateEnd::Unterminated(15)
339        );
340    }
341
342    /// The bound is a bound, and it is exactly [`MAX_TEMPLATE_NESTING`]:
343    /// `MAX_TEMPLATE_NESTING` nested templates are all entered, and the
344    /// `MAX_TEMPLATE_NESTING + 1`-th is not.
345    ///
346    /// An *unbounded* implementation passes the first assertion and fails the
347    /// second; a bound one level shorter or longer fails one of them.
348    #[test]
349    fn nesting_is_bounded_at_max_template_nesting() {
350        let at_the_bound = nested(MAX_TEMPLATE_NESTING);
351        assert_eq!(
352            template_end(&at_the_bound, 0),
353            TemplateEnd::Closed(at_the_bound.len()),
354            "a run nested exactly to the bound still closes at its own backtick"
355        );
356
357        let entered = nested_quote(MAX_TEMPLATE_NESTING);
358        assert_eq!(
359            template_end(&entered, 0),
360            TemplateEnd::Closed(entered.len()),
361            "the {MAX_TEMPLATE_NESTING}th template is entered, so its `\"` is literal text"
362        );
363        let not_entered = nested_quote(MAX_TEMPLATE_NESTING + 1);
364        assert_eq!(
365            template_end(&not_entered, 0),
366            TemplateEnd::Unterminated(not_entered.len()),
367            "one past the bound that template is not entered, so its `\"` is a string"
368        );
369
370        // And the pathological case terminates rather than recursing.
371        let deep = "`{a:".repeat(5_000);
372        assert_eq!(
373            template_end(&deep, 0),
374            TemplateEnd::Unterminated(deep.len())
375        );
376    }
377
378    #[test]
379    fn a_multibyte_scalar_after_a_backslash_is_stepped_over_whole() {
380        // The escape skips `λ` entirely; the run still closes at its backtick,
381        // and the returned index is a character boundary.
382        let src = "`a\\λb`";
383        assert_eq!(template_end(src, 0), TemplateEnd::Closed(src.len()));
384        assert!(src.is_char_boundary(src.len()));
385    }
386
387    #[test]
388    fn a_string_ends_at_its_own_unescaped_quote() {
389        assert_eq!(string_end(r#""ab" rest"#, 0), Some(4));
390        assert_eq!(string_end(r#""a\"b" rest"#, 0), Some(6));
391        assert_eq!(string_end(r#""unterminated"#, 0), None);
392    }
393}