recursive_env/
lib.rs

1use anyhow::{bail, Context, Result};
2use std::env;
3// TODO: we dont really need anyhow and can drop the dep
4// TODO: should we have used a parser combinator library?
5
6// TODO: We don't need to handle escape characters, right? It wouldn't make sense because they
7// aren't valid for var names
8// TODO: shoule we also implement our own equlivalent of std::env::var_os(), std::env::vars(), and std::env::set_var()?
9// TODO: we should have a sub function that evaluates without doing the acutal check of the env
10// vars. This way we can test that function without setting actual env vars and then run the tests
11// in parallel again
12// TODO: stop evaluation of variables with out braces on double quotes
13// TODO: allow escaping of '$' ('\$' and '\${}')
14// TOOD: it would be really cool if the test cases ran a bash shell and matched the output with ops
15// done in bash or maybe just sh
16
17/// Read a variable from the environment and look up an other env variables contained within its
18/// definition.
19pub fn var(key: &str) -> Result<String> {
20    let value = env::var(key).with_context(|| {
21        format!(
22            "'{}' not found in env. Require to evaulate another var",
23            key,
24        )
25    })?;
26
27    // TODO: lots of nested logic here. Maybe we need a state machine or something. Or a
28    // parser combinator
29
30    let mut previous_frame: Option<String> = None;
31    let mut current_frame = String::new();
32    let mut stop_on_whitespace = false;
33    let mut in_double_quotes = false;
34    let mut capturing = false;
35    let mut add_next_quote = false;
36    let mut single_quote_no_special_handeling = false;
37
38    let mut chars = value.chars().peekable();
39    loop {
40        let Some(current_char) = chars.next() else {
41            break;
42        };
43
44        if single_quote_no_special_handeling {
45            if current_char == '\\' {
46                // Add escaped backslash to the current frame
47                let Some(next_char) = chars.next() else {
48                    bail!("Unable to parse: Lone '\\' at end of input");
49                };
50                match next_char {
51                    '"' => {
52                        current_frame.push(next_char);
53                    }
54                    _ => {
55                        // Keep the escape char
56                        current_frame.push(current_char);
57                        current_frame.push(next_char);
58                    }
59                }
60                continue;
61            }
62
63            // TODO: need to handle escaped single quote
64            current_frame.push(current_char);
65        } else {
66            match current_char {
67                '\\' => {
68                    let Some(next_char) = chars.next() else {
69                        bail!("Unable to parse: Lone '\\' at end of input");
70                    };
71
72                    match next_char {
73                        '"' => {
74                            current_frame.push(next_char);
75                        }
76                        _ => {
77                            // Keep the escape char
78                            current_frame.push(current_char);
79                            current_frame.push(next_char);
80                        }
81                    }
82
83                    continue;
84                }
85                '$' => {
86                    let Some(next_char) = chars.peek() else {
87                        bail!("Unable to parse: Lone '$' at end of input");
88                    };
89
90                    // Pause the current frame and start a new one
91                    previous_frame = Some(current_frame);
92                    current_frame = String::new();
93
94                    match next_char {
95                        &'{' => {
96                            // Actually move on to next char to skip the '{' (was peeked previously)
97                            chars.next().unwrap();
98                        }
99                        &'(' => {
100                            // "$(...)" messes up our check for standalone '$' without a following '{'
101                            // Push the dollar sign and move on as usual. We aren't going to try and
102                            // evaulate a sub expression
103                            current_frame.push(current_char);
104                        }
105                        _ => {
106                            stop_on_whitespace = true;
107                            capturing = true;
108                        }
109                    }
110                    continue;
111                }
112                '}' => {
113                    // We've finished pushing a variable name to the current frame. Look it up
114                    let value = var(&current_frame).with_context(|| {
115                        format!(
116                            "'{}' not found in env. Require to evaulate another var",
117                            value,
118                        )
119                    })?;
120                    // Jump back to the last frame and append our value in place of where the '${KEY}'
121                    // would have been
122                    let Some(ref prev) = previous_frame else {
123                        bail!("Found an unmatched '}}'");
124                    };
125                    current_frame = prev.to_owned();
126                    current_frame.push_str(&value);
127                    continue;
128                }
129                ' ' | '\t' => {
130                    if stop_on_whitespace {
131                        // TODO: most of this code is duplicated with the match '}' branch
132                        // We've finished pushing a variable name to the current frame. Look it up
133                        let value = var(&current_frame).with_context(|| {
134                            format!(
135                                "'{}' not found in env. Require to evaulate another var",
136                                value,
137                            )
138                        })?;
139                        // Jump back to the last frame and append our value in place of where the '${KEY}'
140                        // would have been
141                        let Some(ref prev) = previous_frame else {
142                            bail!("Error. TODO: better error message");
143                        };
144                        current_frame = prev.to_owned();
145                        current_frame.push_str(&value);
146                        // Now that we've done all that we can push the whitespace on
147                        stop_on_whitespace = false;
148                        capturing = false;
149                        // we will continue on to add the whitespace to the current frame
150                    }
151                }
152                '\'' => {
153                    // Toggle single_quote_no_special_handeling
154                    single_quote_no_special_handeling = !single_quote_no_special_handeling;
155                }
156                '"' => {
157                    if in_double_quotes {
158                        if add_next_quote {
159                            current_frame.push(current_char);
160                            add_next_quote = false;
161                        }
162
163                        // We've found the closing quote
164                        if capturing {
165                            // TODO: most of this code is duplicated with the match '}' branch
166                            // We've finished pushing a variable name to the current frame. Look it up
167                            let value = var(&current_frame).with_context(|| {
168                                format!(
169                                    "'{}' not found in env. Require to evaulate another var",
170                                    value,
171                                )
172                            })?;
173                            // Jump back to the last frame and append our value in place of where the '${KEY}'
174                            // would have been
175                            let Some(ref prev) = previous_frame else {
176                                bail!("Error. TODO: better error message");
177                            };
178                            current_frame = prev.to_owned();
179                            current_frame.push_str(&value);
180                            capturing = false;
181                        }
182                    } else {
183                        in_double_quotes = true;
184
185                        let Some(next_char) = chars.peek() else {
186                            bail!("Unable to parse: Lone '\"' at end of input");
187                        };
188
189                        if next_char != &'$' {
190                            add_next_quote = true; // Denote that we need to capture the closing
191                                                   // quote
192                            current_frame.push(current_char);
193                        }
194                    }
195
196                    continue;
197                }
198                _ => {
199                    // No need to do anything special for any other char
200                }
201            }
202            current_frame.push(current_char);
203        }
204    }
205
206    if capturing {
207        // Finish out the final var we were working on
208        // TODO: This is all duplicate code
209
210        // We've finished pushing a variable name to the current frame. Look it up
211        let value = var(&current_frame).with_context(|| {
212            format!(
213                "'{}' not found in env. Require to evaulate another var",
214                value,
215            )
216        })?;
217        // Jump back to the last frame and append our value in place of where the '${KEY}'
218        // would have been
219        let Some(ref prev) = previous_frame else {
220            bail!("Error. TODO: better error message");
221        };
222        current_frame = prev.to_owned();
223        current_frame.push_str(&value);
224    }
225
226    Ok(current_frame)
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use pretty_assertions::{assert_eq, assert_ne};
233    use serial_test::serial;
234
235    // I think that when running in parallel the setting/unsetting of env vars was causing the
236    // tests to mess with eachother
237
238    #[test]
239    #[serial]
240    fn test_nonrecursive() {
241        let key = "KEY";
242        let value = "VALUE".to_string();
243        env::set_var(key, &value);
244        assert_eq!(var(key).unwrap(), value);
245    }
246
247    #[test]
248    #[serial]
249    fn test_recursive() {
250        let key1 = "KEY1";
251        let key2 = "KEY2";
252        env::set_var(key2, "number2");
253        env::set_var(key1, "number1 ${KEY2} number3");
254        assert_eq!(var(key2).unwrap(), "number2".to_string());
255        assert_eq!(var(key1).unwrap(), "number1 number2 number3".to_string());
256    }
257
258    #[test]
259    #[serial]
260    fn test_more_recursive() {
261        let key1 = "KEY1";
262        let key2 = "KEY2";
263        let key3 = "KEY3";
264        env::set_var(key3, "number3");
265        env::set_var(key2, "number2 ${KEY3} number4");
266        env::set_var(key1, "number1 ${KEY2} number5");
267        assert_eq!(var(key3).unwrap(), "number3".to_string());
268        assert_eq!(var(key2).unwrap(), "number2 number3 number4".to_string());
269        assert_eq!(
270            var(key1).unwrap(),
271            "number1 number2 number3 number4 number5".to_string()
272        );
273    }
274
275    #[test]
276    #[serial]
277    fn test_key_not_found() {
278        let key = "KEY";
279        let _ = env::remove_var(key); // Ignore an error if it comes up. This probably means the
280                                      // key wasn't set to begin with
281        assert!(var(key).is_err());
282    }
283
284    #[test]
285    #[serial]
286    fn test_real_world_example() {
287        env::set_var("HOME", "/home/agaia");
288        env::set_var("XDG_DATA_HOME", "${HOME}/.local/share");
289        env::set_var("DATABASE_URL", "sqlite:${XDG_DATA_HOME}/taskrs/data.db");
290        let db_path = var("DATABASE_URL").unwrap();
291        assert_eq!(db_path, "sqlite:/home/agaia/.local/share/taskrs/data.db");
292    }
293
294    #[test]
295    #[serial]
296    fn test_recursive_without_braces() {
297        let key1 = "KEY1";
298        let key2 = "KEY2";
299        env::set_var(key2, "number2");
300        env::set_var(key1, "number1 $KEY2 number3");
301        assert_eq!(var(key2).unwrap(), "number2".to_string());
302        assert_eq!(var(key1).unwrap(), "number1 number2 number3".to_string());
303    }
304
305    #[test]
306    #[serial]
307    fn test_more_recursive_without_braces() {
308        let key1 = "KEY1";
309        let key2 = "KEY2";
310        let key3 = "KEY3";
311        env::set_var(key3, "number3");
312        env::set_var(key2, "number2 $KEY3 number4");
313        env::set_var(key1, "number1 $KEY2 number5");
314        assert_eq!(var(key3).unwrap(), "number3".to_string());
315        assert_eq!(var(key2).unwrap(), "number2 number3 number4".to_string());
316        assert_eq!(
317            var(key1).unwrap(),
318            "number1 number2 number3 number4 number5".to_string()
319        );
320    }
321
322    #[test]
323    #[serial]
324    fn test_no_braces_stop_on_whitespace() {
325        let key1 = "KEY1";
326        let key1_longer = "KEY1BUTLONGER";
327        let key2 = "KEY2";
328        env::set_var(key1, "1");
329        env::set_var(key1_longer, "2");
330        env::set_var(key2, "prefix$KEY1BUTLONGER ");
331        assert_eq!(var(key2).unwrap(), "prefix2 ".to_string());
332    }
333
334    #[test]
335    #[serial]
336    fn test_no_braces_no_ending_whitespace() {
337        let key = "KEY";
338        let key2 = "KEY2";
339        env::set_var(key, "test");
340        env::set_var(key2, "$KEY");
341        assert_eq!(var(key2).unwrap(), "test".to_string());
342    }
343
344    #[test]
345    #[serial]
346    fn test_do_not_eval_subexpression() {
347        let key = "KEY";
348        let value = String::from("$(subexpression)");
349        env::set_var(&key, &value);
350        assert_eq!(var(key).unwrap(), value);
351    }
352
353    #[test]
354    #[serial]
355    fn test_simple_single_quote() {
356        env::set_var("KEY", "''");
357        assert_eq!(&var("KEY").unwrap(), "''");
358    }
359
360    #[test]
361    #[serial]
362    fn test_single_quote_with_dollar() {
363        env::set_var("KEY", "'$'");
364        assert_eq!(&var("KEY").unwrap(), "'$'");
365    }
366
367    #[test]
368    #[serial]
369    fn test_single_quote_with_not_var() {
370        env::set_var("KEY", "'${KEY2}'");
371        env::set_var("KEY2", "bad");
372        assert_eq!(&var("KEY").unwrap(), "'${KEY2}'");
373    }
374
375    #[test]
376    #[serial]
377    fn test_single_quote_with_not_var_no_braces() {
378        env::set_var("KEY", "'$KEY2'");
379        env::set_var("KEY2", "bad");
380        assert_eq!(&var("KEY").unwrap(), "'$KEY2'");
381    }
382
383    #[test]
384    #[serial]
385    fn test_single_quote_with_non_matching_brace() {
386        env::set_var("KEY", "'}'");
387        assert_eq!(&var("KEY").unwrap(), "'}'");
388    }
389
390    #[test]
391    #[serial]
392    fn test_single_quotes_encapsulating_quote() {
393        env::set_var("KEY", "'\"'");
394        assert_eq!(&var("KEY").unwrap(), "'\"'");
395    }
396
397    #[test]
398    #[serial]
399    fn test_some_nonrecursive_wierd_ones_from_my_env() {
400        let vars = vec![
401            ("is_vim", "ps -o state= -o comm= -t '#{pane_tty}'     | grep -iqE '^[^TXZ ]+ +(\\S+\\/)?g?(view|n?vim?x?)(diff)?$'"),
402            ("tmux_version", "$(tmux -V | sed -En \"s/^tmux ([0-9]+(.[0-9]+)?).*/\\1/p\")"),
403        ];
404        for (k, v) in vars {
405            env::set_var(k, v);
406            assert_eq!(&var(k).unwrap(), v);
407        }
408    }
409
410    #[test]
411    #[serial]
412    fn test_trim_quotes() {
413        let key1 = "KEY1";
414        let key2 = "KEY2";
415        env::set_var(key2, "test");
416        env::set_var(key1, "\"${KEY2}\"");
417        assert_eq!(var(key1).unwrap(), "test".to_string());
418    }
419
420    #[test]
421    #[serial]
422    fn test_trim_quotes_with_random_padding_chars() {
423        let key1 = "KEY1";
424        let key2 = "KEY2";
425        env::set_var(key2, "test");
426        env::set_var(key1, "--\"${KEY2}\"--");
427        assert_eq!(var(key1).unwrap(), "--test--".to_string());
428    }
429
430    #[test]
431    #[serial]
432    fn test_recursive_and_trim_quotes() {
433        let key1 = "KEY1";
434        let key2 = "KEY2";
435        env::set_var(key2, "number2");
436        env::set_var(key1, "number1 \"${KEY2}\" number3");
437        assert_eq!(var(key2).unwrap(), "number2".to_string());
438        assert_eq!(var(key1).unwrap(), "number1 number2 number3".to_string());
439    }
440
441    #[test]
442    #[serial]
443    fn test_more_recursive_and_trim_quotes() {
444        let key1 = "KEY1";
445        let key2 = "KEY2";
446        let key3 = "KEY3";
447        env::set_var(key3, "number3");
448        env::set_var(key2, "number2 \"${KEY3}\" number4");
449        env::set_var(key1, "number1 \"${KEY2}\" number5");
450        assert_eq!(var(key3).unwrap(), "number3".to_string());
451        assert_eq!(var(key2).unwrap(), "number2 number3 number4".to_string());
452        assert_eq!(
453            var(key1).unwrap(),
454            "number1 number2 number3 number4 number5".to_string()
455        );
456    }
457
458    #[test]
459    #[serial]
460    fn test_stop_no_brace_var_on_quotes() {
461        let key1 = "KEY1";
462        let key2 = "KEY2";
463        env::set_var(key2, "number2");
464        env::set_var(key1, "number1\"$KEY2\"number3");
465        assert_eq!(var(key2).unwrap(), "number2".to_string());
466        assert_eq!(var(key1).unwrap(), "number1number2number3".to_string());
467    }
468
469    #[test]
470    #[serial]
471    fn test_stop_no_brace_var_on_quotes_more_recursive() {
472        let key1 = "KEY1";
473        let key2 = "KEY2";
474        let key3 = "KEY3";
475        env::set_var(key3, "number3");
476        env::set_var(key2, "number2\"$KEY3\"number4");
477        env::set_var(key1, "number1\"$KEY2\"number5");
478        assert_eq!(var(key3).unwrap(), "number3".to_string());
479        assert_eq!(var(key2).unwrap(), "number2number3number4".to_string());
480        assert_eq!(
481            var(key1).unwrap(),
482            "number1number2number3number4number5".to_string()
483        );
484    }
485}