praxis_syntax/interp.rs
1//! Where a `"…"` literal ends and where its holes are — **one** answer, for
2//! both readers (§8.1, ADR-147).
3//!
4//! A `{` inside a text literal opens an *interpolation hole* holding a full
5//! expression (ADR-147 decision 1), so `"Part 2: {part2}"` is not one token: the
6//! lexer splits it into fragment tokens with the hole's ordinary tokens between
7//! them, which is what gives a name inside a hole a real range in the lossless
8//! tree and therefore makes it a closure capture.
9//!
10//! Two readers have to agree about the extent of such a run: [`text_end`], which
11//! the lexer asks **before** it emits anything, and [`fragment_end`], which the
12//! lexer asks again each time a hole closes and it has to resume scanning text.
13//! They are the same rule read from two starting points, so they live in one
14//! module and call one another rather than each owning a copy of it — see
15//! [`template`](crate::template)'s doc.
16//!
17//! # The rule
18//!
19//! A text literal ends at the line it opens on; [`crate::template`] gives
20//! backtick templates the same bound. Within a literal:
21//!
22//! - `\` hides the next scalar, so `"\""` is one literal and `"\{"` is a
23//! literal brace ([`crate::literal::decode_escape`] owns which escapes mean
24//! what; this module only needs to know that a backslash consumes one scalar).
25//! - `{` opens a hole. A `}` in ordinary text closes nothing and is literal —
26//! the asymmetry is deliberate, because outside a hole there is nothing for a
27//! `}` to be ambiguous with.
28//! - Inside a hole the scanner reads *expression* source: braces nest, and a
29//! `"…"`, a `'…'`, a `` `…` `` and a `/* … */` are each skipped whole, because
30//! each of them can hold a `}` that is not structure. A nested `"…"` is
31//! skipped by re-entering [`text_end`], so `"{f("{y}")}"` is one literal
32//! containing another.
33//! - A `//` inside a hole means the rest of the line is a comment, so the
34//! literal cannot close on its line: that is an unterminated literal, and it
35//! is reported as one.
36//!
37//! # Why an unterminated run is the whole answer
38//!
39//! [`TextEnd::Unterminated`] is not a detail of error reporting. The lexer only
40//! enters interpolation mode — the brace-depth stack that decides whether a `}`
41//! closes a hole or a block — for a literal this module has already proved
42//! closes. So there is no path on which a newline or an EOF reaches that stack,
43//! and an unterminated literal is one `TextLit` token plus `T004` (ADR-147
44//! decision 5).
45//!
46//! That is also why nesting past [`MAX_INTERPOLATION_NESTING`] answers
47//! `Unterminated` rather than "stop treating quotes as structure". Refusing to
48//! *enter* is a bound both readers observe by construction; a bound that changed
49//! what a quote means would put the lexer's resume path and this scanner on
50//! different rules at exactly the depth nobody tests.
51
52// `quoted_run` and `skip_scalar` are `template`'s: a `'…'` in a hole is a `"…"`
53// in a capture with one byte changed, and both scanners step over a multi-byte
54// scalar the same way. This module keeps no copy of either, for the reason that
55// module's doc gives.
56use crate::MAX_INTERPOLATION_NESTING;
57use crate::template::{quoted_run, skip_scalar, template_end};
58
59/// Where a `"…"` literal ends, and whether it has any holes.
60#[derive(Clone, Copy, Debug, PartialEq, Eq)]
61pub enum TextEnd {
62 /// The literal closed on its own line with every hole balanced.
63 Closed {
64 /// Just past the closing quote, so `&src[open..end]` is the whole
65 /// literal, quotes included.
66 end: usize,
67 /// The index of the `{` opening the **first** hole, or `None` when the
68 /// literal has none.
69 ///
70 /// `None` is what keeps an ordinary literal on the simple path: one
71 /// `TextLit` token, no mode stack, no new node.
72 first_hole: Option<usize>,
73 },
74 /// The line ended, the text ended, a hole never closed, or the nesting bound
75 /// was reached. The index is where the scan **stopped**, so
76 /// `&src[open..stopped]` is still a bounded token — the same bound ADR-094
77 /// gave an unterminated template.
78 Unterminated { stopped: usize },
79}
80
81/// What ends a run of literal text inside a `"…"`.
82#[derive(Clone, Copy, Debug, PartialEq, Eq)]
83pub enum FragmentEnd {
84 /// A hole opened. The index is of the `{`, so the fragment token the lexer
85 /// emits covers up to and including it.
86 Hole(usize),
87 /// The literal closed. The index is **just past** the quote.
88 Close(usize),
89}
90
91/// Find the end of the `"…"` literal whose opening quote is at `open`, and the
92/// first hole in it.
93///
94/// `src[open]` must be `"`.
95///
96/// This is the question the lexer asks first, and its answer decides which of
97/// two token shapes the literal gets: a `Closed` with no `first_hole` is one
98/// `TextLit`, a `Closed` with one is a fragment/hole/fragment run, and an
99/// `Unterminated` is one `TextLit` plus `T004`.
100#[must_use]
101pub fn text_end(src: &str, open: usize) -> TextEnd {
102 match run(src, open, 1) {
103 Ok((end, first_hole)) => TextEnd::Closed { end, first_hole },
104 Err(stopped) => TextEnd::Unterminated { stopped },
105 }
106}
107
108/// Find where the run of literal text starting just past `at` ends.
109///
110/// `src[at]` is the delimiter the fragment opens on: the `"` that opened the
111/// literal, or the `}` that closed the hole before it. `None` means the line or
112/// the text ended first.
113///
114/// The lexer calls this to resume after a hole closes. It is the same scan
115/// [`text_end`] performs, entered in the middle — which is the point of having
116/// one function: the pre-scan proved the literal closes, and the resume path has
117/// to find the very same fragment boundaries inside it.
118#[must_use]
119pub fn fragment_end(src: &str, at: usize) -> Option<FragmentEnd> {
120 debug_assert!(matches!(src.as_bytes().get(at), Some(b'"') | Some(b'}')));
121 fragment(src.as_bytes(), at).ok()
122}
123
124/// One whole literal. `level` is 1 for the outermost; a literal nested inside a
125/// hole of this one is 2, and so on.
126///
127/// `Ok((end, first_hole))` is just past the closing quote; `Err(stopped)` is
128/// where the scan gave up.
129fn run(src: &str, open: usize, level: usize) -> Result<(usize, Option<usize>), usize> {
130 let bytes = src.as_bytes();
131 let mut at = open;
132 let mut first_hole = None;
133 loop {
134 match fragment(bytes, at)? {
135 FragmentEnd::Close(end) => return Ok((end, first_hole)),
136 FragmentEnd::Hole(brace) => {
137 first_hole.get_or_insert(brace);
138 // Resume the next fragment *on* the closing brace, which is the
139 // delimiter that fragment opens with — the same position the
140 // lexer's resume path is at when it reaches it.
141 at = hole(src, brace, level)?;
142 }
143 }
144 }
145}
146
147/// One run of literal text, from the delimiter at `at` to the next `{` or the
148/// closing `"`.
149fn fragment(bytes: &[u8], at: usize) -> Result<FragmentEnd, usize> {
150 let mut pos = at + 1;
151 while pos < bytes.len() {
152 match bytes[pos] {
153 // A text literal ends at the line it opens on. The `\r` is taken
154 // with the `\n` it precedes so no token ends mid-CRLF.
155 b'\n' => return Err(pos),
156 b'\r' if bytes.get(pos + 1) == Some(&b'\n') => return Err(pos),
157 b'\\' => {
158 // An escape hides the next scalar but cannot hide a line break:
159 // a trailing `\` is a dangling escape, not a continuation.
160 if matches!(bytes.get(pos + 1), Some(b'\n') | None)
161 || (bytes.get(pos + 1) == Some(&b'\r') && bytes.get(pos + 2) == Some(&b'\n'))
162 {
163 return Err(pos + 1);
164 }
165 pos = skip_scalar(bytes, pos + 1);
166 }
167 b'{' => return Ok(FragmentEnd::Hole(pos)),
168 b'"' => return Ok(FragmentEnd::Close(pos + 1)),
169 _ => pos = skip_scalar(bytes, pos),
170 }
171 }
172 Err(bytes.len())
173}
174
175/// One hole, from the `{` at `open` to its matching `}`. Answers the index
176/// **of** that `}` — the delimiter the next fragment opens on.
177///
178/// The body is expression source, so everything in it that can hold a `}` is
179/// skipped whole. Getting any one of these wrong does not merely mis-measure the
180/// hole: it moves where the literal ends, and the lexer would then tokenize the
181/// rest of the line as something the program did not write.
182fn hole(src: &str, open: usize, level: usize) -> Result<usize, usize> {
183 // Refuse to *enter* past the bound rather than changing what a delimiter
184 // means at depth — see the module doc. The caller turns this into an
185 // ordinary unterminated literal.
186 if level > MAX_INTERPOLATION_NESTING {
187 return Err(open);
188 }
189 let bytes = src.as_bytes();
190 let mut pos = open + 1;
191 let mut depth = 1usize;
192 while pos < bytes.len() {
193 match bytes[pos] {
194 b'\n' => return Err(pos),
195 b'\r' if bytes.get(pos + 1) == Some(&b'\n') => return Err(pos),
196 b'{' => {
197 depth += 1;
198 pos += 1;
199 }
200 b'}' => {
201 depth -= 1;
202 if depth == 0 {
203 return Ok(pos);
204 }
205 pos += 1;
206 }
207 // A nested text literal, which may itself hold holes: `"{f("{y}")}"`
208 // is one literal containing another. Re-entering `run` is what makes
209 // the two agree — a scan that merely looked for the next quote would
210 // stop inside the inner literal's own hole.
211 b'"' => pos = run(src, pos, level + 1)?.0,
212 // `'}'` is a character, not the end of the hole (ADR-141).
213 b'\'' => pos = quoted_run(bytes, pos, b'\'')?,
214 // A backtick template's interior is the input-parser DSL's and is
215 // full of braces. `template_end` is the one rule for its extent, and
216 // it is the rule the lexer's `eat_template` follows.
217 b'`' => match template_end(src, pos) {
218 crate::template::TemplateEnd::Closed(end) => pos = end,
219 crate::template::TemplateEnd::Unterminated(stopped) => return Err(stopped),
220 },
221 // `//` eats the rest of the line, so the literal cannot close on it.
222 b'/' if bytes.get(pos + 1) == Some(&b'/') => return Err(pos),
223 b'/' if bytes.get(pos + 1) == Some(&b'*') => pos = block_comment(bytes, pos)?,
224 _ => pos = skip_scalar(bytes, pos),
225 }
226 }
227 Err(bytes.len())
228}
229
230/// One `/* … */` run, nestable exactly as the lexer's own is. A block comment
231/// may span lines and a text literal may not, so a line break inside one ends
232/// the literal.
233fn block_comment(bytes: &[u8], open: usize) -> Result<usize, usize> {
234 let mut pos = open + 2;
235 let mut depth = 1usize;
236 while pos < bytes.len() {
237 if bytes[pos] == b'\n' {
238 return Err(pos);
239 }
240 if bytes[pos..].starts_with(b"/*") {
241 depth += 1;
242 pos += 2;
243 } else if bytes[pos..].starts_with(b"*/") {
244 depth -= 1;
245 pos += 2;
246 if depth == 0 {
247 return Ok(pos);
248 }
249 } else {
250 pos = skip_scalar(bytes, pos);
251 }
252 }
253 Err(bytes.len())
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 fn closed(src: &str) -> bool {
261 matches!(text_end(src, 0), TextEnd::Closed { end, .. } if end == src.len())
262 }
263
264 fn holes(src: &str) -> Option<usize> {
265 match text_end(src, 0) {
266 TextEnd::Closed { first_hole, .. } => first_hole,
267 TextEnd::Unterminated { .. } => panic!("expected a closed literal: {src}"),
268 }
269 }
270
271 /// A literal with no brace in it has no holes and says so: `first_hole` is
272 /// `None`, which is the answer that keeps it a single `TextLit`.
273 #[test]
274 fn a_literal_with_no_brace_has_no_holes() {
275 for src in [r#""""#, r#""hello""#, r#""a\"b""#, r#""tab\there""#] {
276 assert!(closed(src), "{src}");
277 assert_eq!(holes(src), None, "{src}");
278 }
279 }
280
281 /// A `}` in ordinary text closes nothing (ADR-147 decision 4). This is the
282 /// asymmetry the decision records, and the case that would break if the
283 /// scanner tracked depth in text as well as in holes.
284 #[test]
285 fn a_brace_that_closes_nothing_is_literal_text() {
286 assert!(closed(r#""}""#));
287 assert_eq!(holes(r#""}""#), None);
288 assert!(closed(r#""a } b""#));
289 assert_eq!(holes(r#""a } b""#), None);
290 }
291
292 #[test]
293 fn a_hole_is_found_at_its_opening_brace() {
294 assert_eq!(holes(r#""Part 2: {p}""#), Some(9));
295 assert_eq!(holes(r#""{p}""#), Some(1));
296 // Only the *first* hole is reported; the rest are found by the resume
297 // path, which is `fragment_end`.
298 assert_eq!(holes(r#""{a}{b}""#), Some(1));
299 }
300
301 /// An escaped brace opens nothing, so `"\{"` is a literal brace and the
302 /// literal has no holes at all.
303 #[test]
304 fn an_escaped_brace_opens_no_hole() {
305 assert!(closed(r#""\{""#));
306 assert_eq!(holes(r#""\{""#), None);
307 assert_eq!(holes(r#""\{not a hole\}""#), None);
308 // …and an escape before the brace does not hide the *next* one.
309 assert_eq!(holes(r#""\{{x}""#), Some(3));
310 }
311
312 /// A hole holds a full expression, so everything an expression can contain
313 /// has to be measured rather than scanned past (ADR-147 decision 1).
314 #[test]
315 fn a_hole_holds_a_full_expression() {
316 for src in [
317 r#""{a + b}""#,
318 r#""{p.0}""#,
319 r#""{xs.len()}""#,
320 r#""{m["k"]}""#,
321 r#""{if x { 1 } else { 2 }}""#,
322 r#""{xs.map(|v| v * 2).sum()}""#,
323 ] {
324 assert!(closed(src), "{src}");
325 assert!(holes(src).is_some(), "{src}");
326 }
327 }
328
329 /// **The case a naive scanner gets wrong.** A `}` inside a nested string, a
330 /// character literal, a template or a comment is not the end of the hole. A
331 /// scanner that missed any of these would end the literal early and hand the
332 /// lexer the rest of the line as source it was never written as.
333 #[test]
334 fn a_brace_inside_something_skipped_whole_is_not_the_end_of_a_hole() {
335 for src in [
336 r#""{m["}"]}""#,
337 r#""{c == '}'}""#,
338 r#""{parse(s, `{x:int}`)}""#,
339 r#""{a /* } */ + b}""#,
340 r#""{f("{y}")}""#,
341 ] {
342 assert!(closed(src), "{src}");
343 assert_eq!(
344 text_end(src, 0),
345 TextEnd::Closed {
346 end: src.len(),
347 first_hole: Some(1)
348 },
349 "{src}"
350 );
351 }
352 }
353
354 /// A text literal ends at the line it opens on. Every one of these is one
355 /// `TextLit` plus `T004` (ADR-147 decision 5).
356 #[test]
357 fn a_literal_that_does_not_close_on_its_line_is_unterminated() {
358 // A hole that never closes.
359 assert_eq!(
360 text_end("\"a {b\ncd\"", 0),
361 TextEnd::Unterminated { stopped: 5 }
362 );
363 // A quote that never closes, with no hole in it at all.
364 assert_eq!(
365 text_end("\"never closes\n", 0),
366 TextEnd::Unterminated { stopped: 13 }
367 );
368 // A `//` in a hole eats the rest of the line.
369 assert_eq!(
370 text_end("\"{a // b}\"\n", 0),
371 TextEnd::Unterminated { stopped: 4 }
372 );
373 // A block comment that spans a line takes the literal with it.
374 assert_eq!(
375 text_end("\"{a /* x\ny */}\"", 0),
376 TextEnd::Unterminated { stopped: 8 }
377 );
378 // A trailing backslash is a dangling escape, not a continuation.
379 assert_eq!(
380 text_end("\"abc\\\ndef\"", 0),
381 TextEnd::Unterminated { stopped: 5 }
382 );
383 // End of text with nothing after it.
384 assert_eq!(text_end("\"{a}", 0), TextEnd::Unterminated { stopped: 4 });
385 }
386
387 /// A dangling `\` inside a character literal in a hole is not a continuation
388 /// either, and it stops at the line terminator's *first* byte, so a CRLF is
389 /// not split — the same byte [`fragment`] stops at.
390 #[test]
391 fn a_dangling_escape_in_a_char_literal_stops_at_the_line_terminator() {
392 // The `'` opens at 7 and the `\` is at 8, so 9 is the `\n` in one case
393 // and the `\r` in the other — the same index either way.
394 assert_eq!(
395 text_end("\"{c == '\\\nx'}\"", 0),
396 TextEnd::Unterminated { stopped: 9 }
397 );
398 assert_eq!(
399 text_end("\"{c == '\\\r\nx'}\"", 0),
400 TextEnd::Unterminated { stopped: 9 }
401 );
402 }
403
404 /// Only the quote that opened a run closes it: the shared scanner takes its
405 /// terminator as an argument, so a `"` inside a `'…'` is an ordinary
406 /// character and the hole is still open after it.
407 #[test]
408 fn a_quote_inside_a_char_literal_does_not_end_the_hole() {
409 assert!(closed(r#""{c == '"'}""#));
410 assert_eq!(holes(r#""{c == '"'}""#), Some(1));
411 }
412
413 /// Nesting is bounded, and past the bound the answer is an ordinary
414 /// unterminated literal — never a literal measured under a second rule.
415 #[test]
416 fn nesting_is_bounded_and_the_bound_refuses_to_enter() {
417 fn nested(n: usize) -> String {
418 let mut s = String::new();
419 for _ in 0..n {
420 s.push_str("\"{");
421 }
422 s.push('x');
423 for _ in 0..n {
424 s.push_str("}\"");
425 }
426 s
427 }
428 let at_the_bound = nested(MAX_INTERPOLATION_NESTING);
429 assert!(
430 closed(&at_the_bound),
431 "a literal nested exactly to the bound still closes"
432 );
433 let past = nested(MAX_INTERPOLATION_NESTING + 1);
434 assert!(
435 matches!(text_end(&past, 0), TextEnd::Unterminated { .. }),
436 "one past the bound is an ordinary unterminated literal"
437 );
438 // And the pathological case terminates rather than recursing.
439 let deep = "\"{".repeat(5_000);
440 assert!(matches!(text_end(&deep, 0), TextEnd::Unterminated { .. }));
441 }
442
443 /// The resume path finds the same boundaries the pre-scan did, entered in
444 /// the middle. This is the property that makes one function two readers.
445 #[test]
446 fn the_resume_path_walks_the_same_fragments() {
447 let src = r#""a{x}b{y}c""#;
448 // 0123456789
449 assert_eq!(fragment_end(src, 0), Some(FragmentEnd::Hole(2)));
450 assert_eq!(fragment_end(src, 4), Some(FragmentEnd::Hole(6)));
451 assert_eq!(fragment_end(src, 8), Some(FragmentEnd::Close(src.len())));
452 }
453
454 #[test]
455 fn adjacent_holes_leave_empty_fragments() {
456 let src = r#""{a}{b}""#;
457 assert_eq!(fragment_end(src, 0), Some(FragmentEnd::Hole(1)));
458 assert_eq!(fragment_end(src, 3), Some(FragmentEnd::Hole(4)));
459 assert_eq!(fragment_end(src, 6), Some(FragmentEnd::Close(src.len())));
460 }
461
462 #[test]
463 fn a_multibyte_scalar_is_stepped_over_whole() {
464 let src = "\"héllo {x} wörld\"";
465 assert!(closed(src));
466 let TextEnd::Closed { end, first_hole } = text_end(src, 0) else {
467 panic!("expected closed");
468 };
469 assert!(src.is_char_boundary(end));
470 assert!(src.is_char_boundary(first_hole.unwrap()));
471 // An escape steps over a whole scalar, not a byte of one.
472 assert!(closed("\"a\\λb\""));
473 }
474}