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(¬_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}