Skip to main content

squeeze/
env.rs

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    /// Bytes that can begin a POSIX/bash parameter-expansion operator right
17    /// after the name inside `${...}`: `:-` `:=` `:+` `:?` (and their bare
18    /// `-` `=` `+` `?` forms), `:offset:length`, `%`/`%%`, `#`/`##`,
19    /// `/pat/repl`, `^`/`^^`, `,`/`,,`, and `@` transforms.
20    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    /// Scan from `from` (inside a `${...}` reference, after the name) to
28    /// the balanced closing `}`, tracking nested `${...}` references.
29    /// Returns the position just past the closing brace, or `None` when
30    /// the reference is unclosed.
31    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                // An expression never spans lines.
46                b'\n' | b'\r' => return None,
47                _ => i += 1,
48            }
49        }
50        None
51    }
52
53    /// Match a reference starting exactly at `pos` (which must hold `$`).
54    /// Returns the exclusive end of the match.
55    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        // Matches never contain a line terminator and `\n`/`\r` end every
94        // walk exactly like the end of the input does.
95        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}