1use std::fmt;
4
5#[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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
15pub enum Syntax {
16 #[error("unterminated `${{` at offset {offset}")]
18 Unterminated { offset: usize },
19 #[error("empty reference `${{}}`")]
21 EmptyRef,
22 #[error("nested `${{` in `${{{body}}}`")]
24 Nested { body: String },
25 #[error("empty variable name in `${{env:}}`")]
27 EmptyEnvName,
28 #[error("empty segment in reference `${{{body}}}`")]
30 EmptySegment { body: String },
31 #[error("malformed index in reference `${{{body}}}`")]
33 BadIndex { body: String },
34}
35
36pub 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; let mut literal = 0; 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 _ => 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
113pub 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}