Skip to main content

knf/interp/
scan.rs

1//! Splitting a parsed string value into literal text and reference bodies.
2
3use std::fmt;
4
5/// One span of a scanned string. `Ref` bodies are left unparsed.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum Piece<'a> {
8    Literal(&'a str),
9    Ref(&'a str),
10    Malformed { spelling: &'a str, error: Syntax },
11}
12
13/// A malformed reference.
14#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
15pub enum Syntax {
16    /// `${` with no `}` after it.
17    #[error("unterminated `${{` at offset {offset}")]
18    Unterminated { offset: usize },
19    /// `${}` — a reference to nothing.
20    #[error("empty reference `${{}}`")]
21    EmptyRef,
22    /// `${a${b}}`: nesting is not supported.
23    #[error("nested `${{` in `${{{body}}}`")]
24    Nested { body: String },
25    /// `${env:}`. Raised by the resolver.
26    #[error("empty variable name in `${{env:}}`")]
27    EmptyEnvName,
28    /// `${a..b}`. Raised by the resolver.
29    #[error("empty segment in reference `${{{body}}}`")]
30    EmptySegment { body: String },
31    /// `${servers[x]}`. Raised by the resolver.
32    #[error("malformed index in reference `${{{body}}}`")]
33    BadIndex { body: String },
34}
35
36/// Splits `s` into literals and reference bodies.
37///
38/// Returns an empty vector when `s` has no `$`. Malformed references become
39/// [`Piece::Malformed`]; an unterminated one consumes the rest of the string.
40pub fn scan(s: &str) -> Vec<Piece<'_>> {
41    if !s.contains('$') {
42        return Vec::new();
43    }
44
45    let mut pieces = Vec::new();
46    let mut cursor = 0; // where the next `$` is searched from
47    let mut literal = 0; // start of the pending literal run
48
49    while let Some(rel) = s[cursor..].find('$') {
50        let at = cursor + rel;
51        match s.as_bytes().get(at + 1) {
52            Some(b'$') => {
53                push_literal(&mut pieces, &s[literal..at]);
54                pieces.push(Piece::Literal("$"));
55                cursor = at + 2;
56                literal = cursor;
57            }
58            Some(b'{') => {
59                let body_start = at + 2;
60                let Some(rel_end) = s[body_start..].find('}') else {
61                    push_literal(&mut pieces, &s[literal..at]);
62                    pieces.push(Piece::Malformed {
63                        spelling: &s[at..],
64                        error: Syntax::Unterminated { offset: at },
65                    });
66                    cursor = s.len();
67                    literal = cursor;
68                    break;
69                };
70                let body = &s[body_start..body_start + rel_end];
71                let after = body_start + rel_end + 1;
72                if body.is_empty() {
73                    push_literal(&mut pieces, &s[literal..at]);
74                    pieces.push(Piece::Malformed {
75                        spelling: &s[at..after],
76                        error: Syntax::EmptyRef,
77                    });
78                    cursor = after;
79                    literal = cursor;
80                    continue;
81                }
82                if body.contains("${") {
83                    push_literal(&mut pieces, &s[literal..at]);
84                    pieces.push(Piece::Malformed {
85                        spelling: &s[at..after],
86                        error: Syntax::Nested {
87                            body: body.to_string(),
88                        },
89                    });
90                    cursor = after;
91                    literal = cursor;
92                    continue;
93                }
94                push_literal(&mut pieces, &s[literal..at]);
95                pieces.push(Piece::Ref(body));
96                cursor = after;
97                literal = cursor;
98            }
99            // A bare `$` is ordinary text.
100            _ => cursor = at + 1,
101        }
102    }
103    push_literal(&mut pieces, &s[literal..]);
104    pieces
105}
106
107fn push_literal<'a>(pieces: &mut Vec<Piece<'a>>, text: &'a str) {
108    if !text.is_empty() {
109        pieces.push(Piece::Literal(text));
110    }
111}
112
113/// Renders a reference body back as `${body}`.
114pub struct Spelled<'a>(pub &'a str);
115
116impl fmt::Display for Spelled<'_> {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        write!(f, "${{{}}}", self.0)
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    fn lit(s: &str) -> Piece<'_> {
127        Piece::Literal(s)
128    }
129
130    fn re(s: &str) -> Piece<'_> {
131        Piece::Ref(s)
132    }
133
134    fn malformed(spelling: &str, error: Syntax) -> Piece<'_> {
135        Piece::Malformed { spelling, error }
136    }
137
138    #[test]
139    fn a_string_without_a_dollar_scans_to_nothing() {
140        assert_eq!(scan("plain text"), []);
141        assert_eq!(scan(""), []);
142    }
143
144    #[test]
145    fn a_whole_string_reference_is_one_piece() {
146        assert_eq!(scan("${db.host}"), [re("db.host")]);
147        assert_eq!(scan("${env:PORT}"), [re("env:PORT")]);
148    }
149
150    #[test]
151    fn embedded_references_keep_their_surroundings() {
152        assert_eq!(
153            scan("http://${host}:${port}/health"),
154            [
155                lit("http://"),
156                re("host"),
157                lit(":"),
158                re("port"),
159                lit("/health"),
160            ]
161        );
162    }
163
164    #[test]
165    fn adjacent_references_have_no_literal_between_them() {
166        assert_eq!(scan("${a}${b}"), [re("a"), re("b")]);
167    }
168
169    #[test]
170    fn dollar_dollar_is_a_literal_dollar() {
171        assert_eq!(scan("$$"), [lit("$")]);
172        assert_eq!(scan("$${a}"), [lit("$"), lit("{a}")]);
173        assert_eq!(scan("a$$b"), [lit("a"), lit("$"), lit("b")]);
174    }
175
176    #[test]
177    fn a_bare_dollar_is_ordinary_text() {
178        assert_eq!(scan("USD $5"), [lit("USD $5")]);
179        assert_eq!(scan("$"), [lit("$")]);
180        assert_eq!(scan("$ {a}"), [lit("$ {a}")]);
181        assert_eq!(scan("a$"), [lit("a$")]);
182    }
183
184    #[test]
185    fn malformed_references_are_returned_as_pieces() {
186        assert_eq!(
187            scan("a ${b"),
188            [
189                lit("a "),
190                malformed("${b", Syntax::Unterminated { offset: 2 })
191            ]
192        );
193        assert_eq!(scan("${}"), [malformed("${}", Syntax::EmptyRef)]);
194        assert_eq!(
195            scan("${a${b}}"),
196            [
197                malformed(
198                    "${a${b}",
199                    Syntax::Nested {
200                        body: "a${b".to_string()
201                    }
202                ),
203                lit("}")
204            ]
205        );
206    }
207
208    #[test]
209    fn scanning_continues_around_malformed_references() {
210        assert_eq!(
211            scan("${before} ${} ${after}"),
212            [
213                re("before"),
214                lit(" "),
215                malformed("${}", Syntax::EmptyRef),
216                lit(" "),
217                re("after"),
218            ]
219        );
220        assert_eq!(
221            scan("${before} ${after"),
222            [
223                re("before"),
224                lit(" "),
225                malformed("${after", Syntax::Unterminated { offset: 10 }),
226            ]
227        );
228    }
229
230    #[test]
231    fn non_ascii_literals_survive() {
232        assert_eq!(
233            scan("héllo ${who} ☃"),
234            [lit("héllo "), re("who"), lit(" ☃")]
235        );
236    }
237
238    #[test]
239    fn spelling_round_trips_a_reference() {
240        assert_eq!(Spelled("db.host").to_string(), "${db.host}");
241    }
242}