1use std::fmt;
11
12#[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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
30pub enum Syntax {
31 #[error("unterminated `${{` at offset {offset}")]
33 Unterminated { offset: usize },
34 #[error("empty reference `${{}}`")]
36 EmptyRef,
37 #[error("nested `${{` in `${{{body}}}`")]
40 Nested { body: String },
41 #[error("empty variable name in `${{env:}}`")]
44 EmptyEnvName,
45 #[error("empty segment in reference `${{{body}}}`")]
48 EmptySegment { body: String },
49 #[error("malformed index in reference `${{{body}}}`")]
52 BadIndex { body: String },
53}
54
55pub 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; let mut literal = 0; while let Some(rel) = s[cursor..].find('$') {
77 let at = cursor + rel;
78 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 _ => 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
142pub 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 #[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 #[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 #[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 #[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}