Skip to main content

knf/interp/
scan.rs

1//! Splitting a string into literal text and reference bodies.
2//!
3//! Flat and non-recursive by design. This runs on [`Value::String`] leaves
4//! *after* the document has been parsed, so it never meets a TOML literal-vs-basic
5//! string, a multi-line string, or a JSON `\u` escape — those were resolved by
6//! the format parser long before the merge, let alone this pass.
7//!
8//! [`Value::String`]: crate::Value::String
9
10use std::fmt;
11
12/// One span of a scanned string.
13///
14/// A `Ref` body is deliberately left unparsed: the `env:` prefix and the
15/// [`RefPath`](crate::RefPath) split are resolution's business, not the
16/// scanner's, and keeping them apart is what makes this a `find` loop rather
17/// than a grammar.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum Piece<'a> {
20    Literal(&'a str),
21    Ref(&'a str),
22    Malformed { spelling: &'a str, error: Syntax },
23}
24
25/// A malformed reference.
26///
27/// Carries the offending text or offset and nothing else — no key path (the
28/// caller knows where it was reading) and no flag names.
29#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
30pub enum Syntax {
31    /// `${` with no `}` after it.
32    #[error("unterminated `${{` at offset {offset}")]
33    Unterminated { offset: usize },
34    /// `${}` — a reference to nothing.
35    #[error("empty reference `${{}}`")]
36    EmptyRef,
37    /// `${a${b}}`. Finding the end of a reference is `find('}')`, so a nested
38    /// `${` has no reading that is not a guess.
39    #[error("nested `${{` in `${{{body}}}`")]
40    Nested { body: String },
41    /// `${env:}` — the namespace with no variable after it. Raised downstream,
42    /// where the prefix is recognised, but it is the same class of mistake.
43    #[error("empty variable name in `${{env:}}`")]
44    EmptyEnvName,
45    /// `${a..b}` — a dotted path with an empty segment. Also raised downstream,
46    /// where the body is parsed as a reference path.
47    #[error("empty segment in reference `${{{body}}}`")]
48    EmptySegment { body: String },
49    /// `${servers[x]}` — a bracket step that is not an array index: empty,
50    /// non-numeric, too big, or unclosed. Also raised downstream.
51    #[error("malformed index in reference `${{{body}}}`")]
52    BadIndex { body: String },
53}
54
55/// Splits `s` into literals and reference bodies.
56///
57/// Returns an **empty** vector when `s` contains no `$` at all — the common
58/// case, and the caller's signal to leave the value alone rather than rebuild an
59/// identical string.
60///
61/// `$$` yields a literal `$`; a `$` followed by anything else is ordinary text,
62/// so `USD $5` needs no escaping.
63///
64/// Malformed references are returned as pieces rather than aborting the scan.
65/// A delimited malformed reference is recoverable, so later references are
66/// still found; an unterminated reference consumes the remainder of the string.
67pub fn scan(s: &str) -> Vec<Piece<'_>> {
68    if !s.contains('$') {
69        return Vec::new();
70    }
71
72    let mut pieces = Vec::new();
73    let mut cursor = 0; // where the next `$` is searched from
74    let mut literal = 0; // start of the pending literal run
75
76    while let Some(rel) = s[cursor..].find('$') {
77        let at = cursor + rel;
78        // `$` is ASCII, so `at + 1` is in bounds-or-None and a UTF-8
79        // continuation byte can never equal `$` or `{`.
80        match s.as_bytes().get(at + 1) {
81            Some(b'$') => {
82                push_literal(&mut pieces, &s[literal..at]);
83                pieces.push(Piece::Literal("$"));
84                cursor = at + 2;
85                literal = cursor;
86            }
87            Some(b'{') => {
88                let body_start = at + 2;
89                let Some(rel_end) = s[body_start..].find('}') else {
90                    push_literal(&mut pieces, &s[literal..at]);
91                    pieces.push(Piece::Malformed {
92                        spelling: &s[at..],
93                        error: Syntax::Unterminated { offset: at },
94                    });
95                    cursor = s.len();
96                    literal = cursor;
97                    break;
98                };
99                let body = &s[body_start..body_start + rel_end];
100                let after = body_start + rel_end + 1;
101                if body.is_empty() {
102                    push_literal(&mut pieces, &s[literal..at]);
103                    pieces.push(Piece::Malformed {
104                        spelling: &s[at..after],
105                        error: Syntax::EmptyRef,
106                    });
107                    cursor = after;
108                    literal = cursor;
109                    continue;
110                }
111                if body.contains("${") {
112                    push_literal(&mut pieces, &s[literal..at]);
113                    pieces.push(Piece::Malformed {
114                        spelling: &s[at..after],
115                        error: Syntax::Nested {
116                            body: body.to_string(),
117                        },
118                    });
119                    cursor = after;
120                    literal = cursor;
121                    continue;
122                }
123                push_literal(&mut pieces, &s[literal..at]);
124                pieces.push(Piece::Ref(body));
125                cursor = after;
126                literal = cursor;
127            }
128            // A bare `$`: ordinary text, and part of the pending literal.
129            _ => cursor = at + 1,
130        }
131    }
132    push_literal(&mut pieces, &s[literal..]);
133    pieces
134}
135
136fn push_literal<'a>(pieces: &mut Vec<Piece<'a>>, text: &'a str) {
137    if !text.is_empty() {
138        pieces.push(Piece::Literal(text));
139    }
140}
141
142/// The source spelling of a reference, for splicing back the text of a piece
143/// that could not be resolved.
144pub struct Spelled<'a>(pub &'a str);
145
146impl fmt::Display for Spelled<'_> {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        write!(f, "${{{}}}", self.0)
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    fn lit(s: &str) -> Piece<'_> {
157        Piece::Literal(s)
158    }
159
160    fn re(s: &str) -> Piece<'_> {
161        Piece::Ref(s)
162    }
163
164    fn malformed(spelling: &str, error: Syntax) -> Piece<'_> {
165        Piece::Malformed { spelling, error }
166    }
167
168    /// The empty result is load-bearing: it is how the resolver tells "nothing
169    /// to do" from "all literal, rebuild it".
170    #[test]
171    fn a_string_without_a_dollar_scans_to_nothing() {
172        assert_eq!(scan("plain text"), []);
173        assert_eq!(scan(""), []);
174    }
175
176    #[test]
177    fn a_whole_string_reference_is_one_piece() {
178        assert_eq!(scan("${db.host}"), [re("db.host")]);
179        assert_eq!(scan("${env:PORT}"), [re("env:PORT")]);
180    }
181
182    #[test]
183    fn embedded_references_keep_their_surroundings() {
184        assert_eq!(
185            scan("http://${host}:${port}/health"),
186            [
187                lit("http://"),
188                re("host"),
189                lit(":"),
190                re("port"),
191                lit("/health"),
192            ]
193        );
194    }
195
196    /// Adjacent references have no literal between them, which is exactly the
197    /// case an off-by-one in the cursor would corrupt.
198    #[test]
199    fn adjacent_references_have_no_literal_between_them() {
200        assert_eq!(scan("${a}${b}"), [re("a"), re("b")]);
201    }
202
203    #[test]
204    fn dollar_dollar_is_a_literal_dollar() {
205        assert_eq!(scan("$$"), [lit("$")]);
206        assert_eq!(scan("$${a}"), [lit("$"), lit("{a}")]);
207        assert_eq!(scan("a$$b"), [lit("a"), lit("$"), lit("b")]);
208    }
209
210    /// Only `${` starts a reference, so prose and prices need no escaping.
211    #[test]
212    fn a_bare_dollar_is_ordinary_text() {
213        assert_eq!(scan("USD $5"), [lit("USD $5")]);
214        assert_eq!(scan("$"), [lit("$")]);
215        assert_eq!(scan("$ {a}"), [lit("$ {a}")]);
216        assert_eq!(scan("a$"), [lit("a$")]);
217    }
218
219    #[test]
220    fn malformed_references_are_returned_as_pieces() {
221        assert_eq!(
222            scan("a ${b"),
223            [
224                lit("a "),
225                malformed("${b", Syntax::Unterminated { offset: 2 })
226            ]
227        );
228        assert_eq!(scan("${}"), [malformed("${}", Syntax::EmptyRef)]);
229        assert_eq!(
230            scan("${a${b}}"),
231            [
232                malformed(
233                    "${a${b}",
234                    Syntax::Nested {
235                        body: "a${b".to_string()
236                    }
237                ),
238                lit("}")
239            ]
240        );
241    }
242
243    #[test]
244    fn scanning_continues_around_malformed_references() {
245        assert_eq!(
246            scan("${before} ${} ${after}"),
247            [
248                re("before"),
249                lit(" "),
250                malformed("${}", Syntax::EmptyRef),
251                lit(" "),
252                re("after"),
253            ]
254        );
255        assert_eq!(
256            scan("${before} ${after"),
257            [
258                re("before"),
259                lit(" "),
260                malformed("${after", Syntax::Unterminated { offset: 10 }),
261            ]
262        );
263    }
264
265    /// Multi-byte text must not shift the offsets a `${` is found at.
266    #[test]
267    fn non_ascii_literals_survive() {
268        assert_eq!(
269            scan("héllo ${who} ☃"),
270            [lit("héllo "), re("who"), lit(" ☃")]
271        );
272    }
273
274    #[test]
275    fn spelling_round_trips_a_reference() {
276        assert_eq!(Spelled("db.host").to_string(), "${db.host}");
277    }
278}