Skip to main content

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}