recursive-env 0.1.1

Lookup env vars that are defined by other env vars
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
use anyhow::{bail, Context, Result};
use std::env;
// TODO: we dont really need anyhow and can drop the dep
// TODO: should we have used a parser combinator library?

// TODO: We don't need to handle escape characters, right? It wouldn't make sense because they
// aren't valid for var names
// TODO: shoule we also implement our own equlivalent of std::env::var_os(), std::env::vars(), and std::env::set_var()?
// TODO: we should have a sub function that evaluates without doing the acutal check of the env
// vars. This way we can test that function without setting actual env vars and then run the tests
// in parallel again
// TODO: stop evaluation of variables with out braces on double quotes
// TODO: allow escaping of '$' ('\$' and '\${}')
// TOOD: it would be really cool if the test cases ran a bash shell and matched the output with ops
// done in bash or maybe just sh

/// Read a variable from the environment and look up an other env variables contained within its
/// definition.
pub fn var(key: &str) -> Result<String> {
    let value = env::var(key).with_context(|| {
        format!(
            "'{}' not found in env. Require to evaulate another var",
            key,
        )
    })?;

    // TODO: lots of nested logic here. Maybe we need a state machine or something. Or a
    // parser combinator

    let mut previous_frame: Option<String> = None;
    let mut current_frame = String::new();
    let mut stop_on_whitespace = false;
    let mut in_double_quotes = false;
    let mut capturing = false;
    let mut add_next_quote = false;
    let mut single_quote_no_special_handeling = false;

    let mut chars = value.chars().peekable();
    loop {
        let Some(current_char) = chars.next() else {
            break;
        };

        if single_quote_no_special_handeling {
            if current_char == '\\' {
                // Add escaped backslash to the current frame
                let Some(next_char) = chars.next() else {
                    bail!("Unable to parse: Lone '\\' at end of input");
                };
                match next_char {
                    '"' => {
                        current_frame.push(next_char);
                    }
                    _ => {
                        // Keep the escape char
                        current_frame.push(current_char);
                        current_frame.push(next_char);
                    }
                }
                continue;
            }

            // TODO: need to handle escaped single quote
            current_frame.push(current_char);
        } else {
            match current_char {
                '\\' => {
                    let Some(next_char) = chars.next() else {
                        bail!("Unable to parse: Lone '\\' at end of input");
                    };

                    match next_char {
                        '"' => {
                            current_frame.push(next_char);
                        }
                        _ => {
                            // Keep the escape char
                            current_frame.push(current_char);
                            current_frame.push(next_char);
                        }
                    }

                    continue;
                }
                '$' => {
                    let Some(next_char) = chars.peek() else {
                        bail!("Unable to parse: Lone '$' at end of input");
                    };

                    // Pause the current frame and start a new one
                    previous_frame = Some(current_frame);
                    current_frame = String::new();

                    match next_char {
                        &'{' => {
                            // Actually move on to next char to skip the '{' (was peeked previously)
                            chars.next().unwrap();
                        }
                        &'(' => {
                            // "$(...)" messes up our check for standalone '$' without a following '{'
                            // Push the dollar sign and move on as usual. We aren't going to try and
                            // evaulate a sub expression
                            current_frame.push(current_char);
                        }
                        _ => {
                            stop_on_whitespace = true;
                            capturing = true;
                        }
                    }
                    continue;
                }
                '}' => {
                    // We've finished pushing a variable name to the current frame. Look it up
                    let value = var(&current_frame).with_context(|| {
                        format!(
                            "'{}' not found in env. Require to evaulate another var",
                            value,
                        )
                    })?;
                    // Jump back to the last frame and append our value in place of where the '${KEY}'
                    // would have been
                    let Some(ref prev) = previous_frame else {
                        bail!("Found an unmatched '}}'");
                    };
                    current_frame = prev.to_owned();
                    current_frame.push_str(&value);
                    continue;
                }
                ' ' | '\t' => {
                    if stop_on_whitespace {
                        // TODO: most of this code is duplicated with the match '}' branch
                        // We've finished pushing a variable name to the current frame. Look it up
                        let value = var(&current_frame).with_context(|| {
                            format!(
                                "'{}' not found in env. Require to evaulate another var",
                                value,
                            )
                        })?;
                        // Jump back to the last frame and append our value in place of where the '${KEY}'
                        // would have been
                        let Some(ref prev) = previous_frame else {
                            bail!("Error. TODO: better error message");
                        };
                        current_frame = prev.to_owned();
                        current_frame.push_str(&value);
                        // Now that we've done all that we can push the whitespace on
                        stop_on_whitespace = false;
                        capturing = false;
                        // we will continue on to add the whitespace to the current frame
                    }
                }
                '\'' => {
                    // Toggle single_quote_no_special_handeling
                    single_quote_no_special_handeling = !single_quote_no_special_handeling;
                }
                '"' => {
                    if in_double_quotes {
                        if add_next_quote {
                            current_frame.push(current_char);
                            add_next_quote = false;
                        }

                        // We've found the closing quote
                        if capturing {
                            // TODO: most of this code is duplicated with the match '}' branch
                            // We've finished pushing a variable name to the current frame. Look it up
                            let value = var(&current_frame).with_context(|| {
                                format!(
                                    "'{}' not found in env. Require to evaulate another var",
                                    value,
                                )
                            })?;
                            // Jump back to the last frame and append our value in place of where the '${KEY}'
                            // would have been
                            let Some(ref prev) = previous_frame else {
                                bail!("Error. TODO: better error message");
                            };
                            current_frame = prev.to_owned();
                            current_frame.push_str(&value);
                            capturing = false;
                        }
                    } else {
                        in_double_quotes = true;

                        let Some(next_char) = chars.peek() else {
                            bail!("Unable to parse: Lone '\"' at end of input");
                        };

                        if next_char != &'$' {
                            add_next_quote = true; // Denote that we need to capture the closing
                                                   // quote
                            current_frame.push(current_char);
                        }
                    }

                    continue;
                }
                _ => {
                    // No need to do anything special for any other char
                }
            }
            current_frame.push(current_char);
        }
    }

    if capturing {
        // Finish out the final var we were working on
        // TODO: This is all duplicate code

        // We've finished pushing a variable name to the current frame. Look it up
        let value = var(&current_frame).with_context(|| {
            format!(
                "'{}' not found in env. Require to evaulate another var",
                value,
            )
        })?;
        // Jump back to the last frame and append our value in place of where the '${KEY}'
        // would have been
        let Some(ref prev) = previous_frame else {
            bail!("Error. TODO: better error message");
        };
        current_frame = prev.to_owned();
        current_frame.push_str(&value);
    }

    Ok(current_frame)
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::{assert_eq, assert_ne};
    use serial_test::serial;

    // I think that when running in parallel the setting/unsetting of env vars was causing the
    // tests to mess with eachother

    #[test]
    #[serial]
    fn test_nonrecursive() {
        let key = "KEY";
        let value = "VALUE".to_string();
        env::set_var(key, &value);
        assert_eq!(var(key).unwrap(), value);
    }

    #[test]
    #[serial]
    fn test_recursive() {
        let key1 = "KEY1";
        let key2 = "KEY2";
        env::set_var(key2, "number2");
        env::set_var(key1, "number1 ${KEY2} number3");
        assert_eq!(var(key2).unwrap(), "number2".to_string());
        assert_eq!(var(key1).unwrap(), "number1 number2 number3".to_string());
    }

    #[test]
    #[serial]
    fn test_more_recursive() {
        let key1 = "KEY1";
        let key2 = "KEY2";
        let key3 = "KEY3";
        env::set_var(key3, "number3");
        env::set_var(key2, "number2 ${KEY3} number4");
        env::set_var(key1, "number1 ${KEY2} number5");
        assert_eq!(var(key3).unwrap(), "number3".to_string());
        assert_eq!(var(key2).unwrap(), "number2 number3 number4".to_string());
        assert_eq!(
            var(key1).unwrap(),
            "number1 number2 number3 number4 number5".to_string()
        );
    }

    #[test]
    #[serial]
    fn test_key_not_found() {
        let key = "KEY";
        let _ = env::remove_var(key); // Ignore an error if it comes up. This probably means the
                                      // key wasn't set to begin with
        assert!(var(key).is_err());
    }

    #[test]
    #[serial]
    fn test_real_world_example() {
        env::set_var("HOME", "/home/agaia");
        env::set_var("XDG_DATA_HOME", "${HOME}/.local/share");
        env::set_var("DATABASE_URL", "sqlite:${XDG_DATA_HOME}/taskrs/data.db");
        let db_path = var("DATABASE_URL").unwrap();
        assert_eq!(db_path, "sqlite:/home/agaia/.local/share/taskrs/data.db");
    }

    #[test]
    #[serial]
    fn test_recursive_without_braces() {
        let key1 = "KEY1";
        let key2 = "KEY2";
        env::set_var(key2, "number2");
        env::set_var(key1, "number1 $KEY2 number3");
        assert_eq!(var(key2).unwrap(), "number2".to_string());
        assert_eq!(var(key1).unwrap(), "number1 number2 number3".to_string());
    }

    #[test]
    #[serial]
    fn test_more_recursive_without_braces() {
        let key1 = "KEY1";
        let key2 = "KEY2";
        let key3 = "KEY3";
        env::set_var(key3, "number3");
        env::set_var(key2, "number2 $KEY3 number4");
        env::set_var(key1, "number1 $KEY2 number5");
        assert_eq!(var(key3).unwrap(), "number3".to_string());
        assert_eq!(var(key2).unwrap(), "number2 number3 number4".to_string());
        assert_eq!(
            var(key1).unwrap(),
            "number1 number2 number3 number4 number5".to_string()
        );
    }

    #[test]
    #[serial]
    fn test_no_braces_stop_on_whitespace() {
        let key1 = "KEY1";
        let key1_longer = "KEY1BUTLONGER";
        let key2 = "KEY2";
        env::set_var(key1, "1");
        env::set_var(key1_longer, "2");
        env::set_var(key2, "prefix$KEY1BUTLONGER ");
        assert_eq!(var(key2).unwrap(), "prefix2 ".to_string());
    }

    #[test]
    #[serial]
    fn test_no_braces_no_ending_whitespace() {
        let key = "KEY";
        let key2 = "KEY2";
        env::set_var(key, "test");
        env::set_var(key2, "$KEY");
        assert_eq!(var(key2).unwrap(), "test".to_string());
    }

    #[test]
    #[serial]
    fn test_do_not_eval_subexpression() {
        let key = "KEY";
        let value = String::from("$(subexpression)");
        env::set_var(&key, &value);
        assert_eq!(var(key).unwrap(), value);
    }

    #[test]
    #[serial]
    fn test_simple_single_quote() {
        env::set_var("KEY", "''");
        assert_eq!(&var("KEY").unwrap(), "''");
    }

    #[test]
    #[serial]
    fn test_single_quote_with_dollar() {
        env::set_var("KEY", "'$'");
        assert_eq!(&var("KEY").unwrap(), "'$'");
    }

    #[test]
    #[serial]
    fn test_single_quote_with_not_var() {
        env::set_var("KEY", "'${KEY2}'");
        env::set_var("KEY2", "bad");
        assert_eq!(&var("KEY").unwrap(), "'${KEY2}'");
    }

    #[test]
    #[serial]
    fn test_single_quote_with_not_var_no_braces() {
        env::set_var("KEY", "'$KEY2'");
        env::set_var("KEY2", "bad");
        assert_eq!(&var("KEY").unwrap(), "'$KEY2'");
    }

    #[test]
    #[serial]
    fn test_single_quote_with_non_matching_brace() {
        env::set_var("KEY", "'}'");
        assert_eq!(&var("KEY").unwrap(), "'}'");
    }

    #[test]
    #[serial]
    fn test_single_quotes_encapsulating_quote() {
        env::set_var("KEY", "'\"'");
        assert_eq!(&var("KEY").unwrap(), "'\"'");
    }

    #[test]
    #[serial]
    fn test_some_nonrecursive_wierd_ones_from_my_env() {
        let vars = vec![
            ("is_vim", "ps -o state= -o comm= -t '#{pane_tty}'     | grep -iqE '^[^TXZ ]+ +(\\S+\\/)?g?(view|n?vim?x?)(diff)?$'"),
            ("tmux_version", "$(tmux -V | sed -En \"s/^tmux ([0-9]+(.[0-9]+)?).*/\\1/p\")"),
        ];
        for (k, v) in vars {
            env::set_var(k, v);
            assert_eq!(&var(k).unwrap(), v);
        }
    }

    #[test]
    #[serial]
    fn test_trim_quotes() {
        let key1 = "KEY1";
        let key2 = "KEY2";
        env::set_var(key2, "test");
        env::set_var(key1, "\"${KEY2}\"");
        assert_eq!(var(key1).unwrap(), "test".to_string());
    }

    #[test]
    #[serial]
    fn test_trim_quotes_with_random_padding_chars() {
        let key1 = "KEY1";
        let key2 = "KEY2";
        env::set_var(key2, "test");
        env::set_var(key1, "--\"${KEY2}\"--");
        assert_eq!(var(key1).unwrap(), "--test--".to_string());
    }

    #[test]
    #[serial]
    fn test_recursive_and_trim_quotes() {
        let key1 = "KEY1";
        let key2 = "KEY2";
        env::set_var(key2, "number2");
        env::set_var(key1, "number1 \"${KEY2}\" number3");
        assert_eq!(var(key2).unwrap(), "number2".to_string());
        assert_eq!(var(key1).unwrap(), "number1 number2 number3".to_string());
    }

    #[test]
    #[serial]
    fn test_more_recursive_and_trim_quotes() {
        let key1 = "KEY1";
        let key2 = "KEY2";
        let key3 = "KEY3";
        env::set_var(key3, "number3");
        env::set_var(key2, "number2 \"${KEY3}\" number4");
        env::set_var(key1, "number1 \"${KEY2}\" number5");
        assert_eq!(var(key3).unwrap(), "number3".to_string());
        assert_eq!(var(key2).unwrap(), "number2 number3 number4".to_string());
        assert_eq!(
            var(key1).unwrap(),
            "number1 number2 number3 number4 number5".to_string()
        );
    }

    #[test]
    #[serial]
    fn test_stop_no_brace_var_on_quotes() {
        let key1 = "KEY1";
        let key2 = "KEY2";
        env::set_var(key2, "number2");
        env::set_var(key1, "number1\"$KEY2\"number3");
        assert_eq!(var(key2).unwrap(), "number2".to_string());
        assert_eq!(var(key1).unwrap(), "number1number2number3".to_string());
    }

    #[test]
    #[serial]
    fn test_stop_no_brace_var_on_quotes_more_recursive() {
        let key1 = "KEY1";
        let key2 = "KEY2";
        let key3 = "KEY3";
        env::set_var(key3, "number3");
        env::set_var(key2, "number2\"$KEY3\"number4");
        env::set_var(key1, "number1\"$KEY2\"number5");
        assert_eq!(var(key3).unwrap(), "number3".to_string());
        assert_eq!(var(key2).unwrap(), "number2number3number4".to_string());
        assert_eq!(
            var(key1).unwrap(),
            "number1number2number3number4number5".to_string()
        );
    }
}