1use super::Finder;
2use std::ops::Range;
3
4#[derive(Default)]
5pub struct Env {}
6
7impl Env {
8 fn is_name_start(b: u8) -> bool {
9 b.is_ascii_alphabetic() || b == b'_'
10 }
11
12 fn is_name_char(b: u8) -> bool {
13 b.is_ascii_alphanumeric() || b == b'_'
14 }
15
16 fn is_operator_start(b: u8) -> bool {
21 matches!(
22 b,
23 b':' | b'%' | b'#' | b'/' | b'^' | b',' | b'-' | b'=' | b'+' | b'?' | b'@'
24 )
25 }
26
27 fn balanced_end(input: &[u8], from: usize) -> Option<usize> {
32 let mut depth = 0usize;
33 let mut i = from;
34 while i < input.len() {
35 match input[i] {
36 b'$' if i + 1 < input.len() && input[i + 1] == b'{' => {
37 depth += 1;
38 i += 2;
39 }
40 b'}' if depth == 0 => return Some(i + 1),
41 b'}' => {
42 depth -= 1;
43 i += 1;
44 }
45 b'\n' | b'\r' => return None,
47 _ => i += 1,
48 }
49 }
50 None
51 }
52
53 fn match_end(input: &[u8], pos: usize) -> Option<usize> {
56 let after = pos + 1;
57 if after >= input.len() {
58 return None;
59 }
60 if input[after] == b'{' {
61 let name_start = after + 1;
62 if name_start >= input.len() || !Self::is_name_start(input[name_start]) {
63 return None;
64 }
65 let mut name_end = name_start + 1;
66 while name_end < input.len() && Self::is_name_char(input[name_end]) {
67 name_end += 1;
68 }
69 if name_end >= input.len() {
70 return None;
71 }
72 if input[name_end] == b'}' {
73 return Some(name_end + 1);
74 }
75 if Self::is_operator_start(input[name_end]) {
76 return Self::balanced_end(input, name_end);
77 }
78 return None;
79 }
80 if Self::is_name_start(input[after]) {
81 let mut name_end = after + 1;
82 while name_end < input.len() && Self::is_name_char(input[name_end]) {
83 name_end += 1;
84 }
85 return Some(name_end);
86 }
87 None
88 }
89}
90
91impl Finder for Env {
92 fn line_agnostic(&self) -> bool {
93 true
96 }
97
98 fn id(&self) -> &'static str {
99 "env"
100 }
101
102 fn dispatchable(&self) -> bool {
103 true
104 }
105
106 fn could_start_at(&self, byte: u8) -> bool {
107 byte == b'$'
108 }
109
110 fn could_continue_with(&self, _cur: u8, next: u8) -> bool {
111 next == b'{' || Self::is_name_start(next)
112 }
113
114 fn try_at(&self, input: &[u8], pos: usize) -> Option<Range<usize>> {
115 if input[pos] != b'$' {
116 return None;
117 }
118 Self::match_end(input, pos).map(|end| pos..end)
119 }
120
121 fn find(&self, s: &str) -> Option<Range<usize>> {
122 let input = s.as_bytes();
123 let mut idx = 0;
124
125 while idx < input.len() {
126 let dollar = idx + input[idx..].iter().position(|&b| b == b'$')?;
127 if let Some(end) = Self::match_end(input, dollar) {
128 return Some(dollar..end);
129 }
130 idx = dollar + 1;
131 }
132
133 None
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140
141 #[test]
142 fn id_should_return_env() {
143 let finder = Env::default();
144 assert_eq!("env", finder.id());
145 }
146
147 #[test]
148 fn find_should_extract_simple_var() {
149 let finder = Env::default();
150 let input = "use $HOME for path";
151 let range = finder.find(input).unwrap();
152 assert_eq!("$HOME", &input[range]);
153 }
154
155 #[test]
156 fn find_should_extract_braced_var() {
157 let finder = Env::default();
158 let input = "use ${HOME} for path";
159 let range = finder.find(input).unwrap();
160 assert_eq!("${HOME}", &input[range]);
161 }
162
163 #[test]
164 fn find_should_extract_var_with_underscores() {
165 let finder = Env::default();
166 let input = "set $MY_VAR_123 here";
167 let range = finder.find(input).unwrap();
168 assert_eq!("$MY_VAR_123", &input[range]);
169 }
170
171 #[test]
172 fn find_should_extract_var_starting_with_underscore() {
173 let finder = Env::default();
174 let input = "$_private";
175 let range = finder.find(input).unwrap();
176 assert_eq!("$_private", &input[range]);
177 }
178
179 #[test]
180 fn find_should_reject_dollar_followed_by_digit() {
181 let finder = Env::default();
182 assert!(finder.find("$123").is_none());
183 }
184
185 #[test]
186 fn find_should_reject_bare_dollar() {
187 let finder = Env::default();
188 assert!(finder.find("$ foo").is_none());
189 }
190
191 #[test]
192 fn find_should_reject_dollar_at_end() {
193 let finder = Env::default();
194 assert!(finder.find("cost is $").is_none());
195 }
196
197 #[test]
198 fn find_should_reject_empty_braces() {
199 let finder = Env::default();
200 assert!(finder.find("${} foo").is_none());
201 }
202
203 #[test]
204 fn find_should_reject_unclosed_brace() {
205 let finder = Env::default();
206 assert!(finder.find("${HOME foo").is_none());
207 }
208
209 #[test]
210 fn find_should_reject_brace_with_digit_start() {
211 let finder = Env::default();
212 assert!(finder.find("${123}").is_none());
213 }
214
215 #[test]
216 fn find_should_extract_var_at_start() {
217 let finder = Env::default();
218 let input = "$PATH is set";
219 let range = finder.find(input).unwrap();
220 assert_eq!("$PATH", &input[range]);
221 }
222
223 #[test]
224 fn find_should_extract_var_at_end() {
225 let finder = Env::default();
226 let input = "see $PATH";
227 let range = finder.find(input).unwrap();
228 assert_eq!("$PATH", &input[range]);
229 }
230
231 #[test]
232 fn find_should_handle_empty_input() {
233 let finder = Env::default();
234 assert!(finder.find("").is_none());
235 }
236
237 #[test]
238 fn find_should_extract_multiple_vars_iteratively() {
239 let finder = Env::default();
240 let input = "$HOME and ${PATH} here";
241
242 let mut results = Vec::new();
243 let mut idx = 0;
244 while idx < input.len() {
245 if let Some(range) = finder.find(&input[idx..]) {
246 results.push(&input[idx + range.start..idx + range.end]);
247 idx += range.end;
248 } else {
249 break;
250 }
251 }
252
253 assert_eq!(vec!["$HOME", "${PATH}"], results);
254 }
255
256 #[test]
257 fn find_should_skip_invalid_and_find_valid() {
258 let finder = Env::default();
259 let input = "$123 then $VALID";
260 let range = finder.find(input).unwrap();
261 assert_eq!("$VALID", &input[range]);
262 }
263
264 #[test]
265 fn find_should_extract_var_in_quotes() {
266 let finder = Env::default();
267 let input = r#"echo "$HOME""#;
268 let range = finder.find(input).unwrap();
269 assert_eq!("$HOME", &input[range]);
270 }
271
272 #[test]
273 fn try_at_simple_var() {
274 let finder = Env::default();
275 assert_eq!(finder.try_at(b"$HOME rest", 0), Some(0..5));
276 }
277
278 #[test]
279 fn try_at_braced_var() {
280 let finder = Env::default();
281 assert_eq!(finder.try_at(b"${PATH}", 0), Some(0..7));
282 }
283
284 #[test]
285 fn try_at_rejects_non_dollar() {
286 let finder = Env::default();
287 assert!(finder.try_at(b"HOME", 0).is_none());
288 }
289
290 #[test]
291 fn try_at_dollar_at_end() {
292 let finder = Env::default();
293 assert!(finder.try_at(b"$", 0).is_none());
294 }
295
296 #[test]
297 fn try_at_dollar_digit() {
298 let finder = Env::default();
299 assert!(finder.try_at(b"$1", 0).is_none());
300 }
301
302 #[test]
303 fn try_at_unclosed_brace() {
304 let finder = Env::default();
305 assert!(finder.try_at(b"${FOO", 0).is_none());
306 }
307
308 #[test]
309 fn try_at_empty_brace() {
310 let finder = Env::default();
311 assert!(finder.try_at(b"${}", 0).is_none());
312 }
313
314 #[test]
315 fn find_adjacent_vars() {
316 let finder = Env::default();
317 let input = "$A$B";
318 let mut results = Vec::new();
319 let mut idx = 0;
320 while idx < input.len() {
321 if let Some(range) = finder.find(&input[idx..]) {
322 results.push(&input[idx + range.start..idx + range.end]);
323 idx += range.end;
324 } else {
325 break;
326 }
327 }
328 assert_eq!(results, vec!["$A", "$B"]);
329 }
330
331 #[test]
332 fn find_dollar_space_dollar() {
333 let finder = Env::default();
334 assert!(finder.find("$ $").is_none());
335 }
336}