Skip to main content

qk_executor/
dotenv.rs

1//! Dotenv files as Nx reads them, with npm's `dotenv` and `dotenv-expand`:
2//! every file of a set is parsed first, then `$VAR` and `${VAR}` references
3//! are expanded once across all of them.
4
5use std::collections::BTreeMap;
6use std::ffi::{OsStr, OsString};
7use std::path::Path;
8use std::sync::OnceLock;
9
10use anyhow::{Context, Result};
11use regex::Regex;
12
13/// The entries of one dotenv file, in order, as `dotenv`'s `parse` reads
14/// them: lines it cannot read are left out, a key given twice keeps its
15/// first place and its last value, quotes are removed, and `\n` and `\r`
16/// become line breaks inside double quotes. Nothing is expanded here.
17pub fn parse(text: &str) -> Vec<(String, String)> {
18    static LINE: OnceLock<Regex> = OnceLock::new();
19    // dotenv 16's LINE expression, with JavaScript's ASCII `\w`.
20    let line = LINE.get_or_init(|| {
21        Regex::new(
22            r#"(?m)(?:^|^)\s*(?:export\s+)?((?-u:[\w.-])+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?$"#,
23        )
24        .unwrap()
25    });
26    let text = text.replace("\r\n", "\n").replace('\r', "\n");
27    let mut entries: Vec<(String, String)> = Vec::new();
28    for captures in line.captures_iter(&text) {
29        let key = captures[1].to_owned();
30        let raw = captures.get(2).map_or("", |value| value.as_str()).trim();
31        let quote = raw.chars().next();
32        let mut value = match quote {
33            Some(quote @ ('\'' | '"' | '`')) if raw.len() >= 2 && raw.ends_with(quote) => {
34                raw[1..raw.len() - 1].to_owned()
35            }
36            _ => raw.to_owned(),
37        };
38        if quote == Some('"') {
39            value = value.replace("\\n", "\n").replace("\\r", "\r");
40        }
41        match entries.iter_mut().find(|(name, _)| *name == key) {
42            Some(entry) => entry.1 = value,
43            None => entries.push((key, value)),
44        }
45    }
46    entries
47}
48
49/// Reads `paths` into `env` as Nx's `loadAndExpandDotEnvFile` does. The files
50/// are merged first, the first to set a key winning; each key `env` does not
51/// hold is set; then each value is expanded against `env` and what the files
52/// expanded before it, and set, which also fills a variable set but empty. A
53/// variable `env` already holds with another value keeps it. A file that
54/// does not exist is skipped.
55pub fn load(paths: &[impl AsRef<Path>], env: &mut BTreeMap<OsString, OsString>) -> Result<()> {
56    let mut parsed: Vec<(String, String)> = Vec::new();
57    for path in paths {
58        let path = path.as_ref();
59        let text = match std::fs::read(path) {
60            Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
61            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
62            Err(error) => {
63                return Err(error).with_context(|| format!("cannot read {}", path.display()));
64            }
65        };
66        for (key, value) in parse(&text) {
67            if !parsed.iter().any(|(name, _)| *name == key) {
68                parsed.push((key, value));
69            }
70        }
71    }
72    for (key, value) in &parsed {
73        let name = crate::environment_key(env, key.into());
74        env.entry(name).or_insert_with(|| value.into());
75    }
76    // Expanded against `env` as populated, which takes the results only
77    // once every value is expanded, as dotenv-expand does.
78    let mut running: BTreeMap<String, String> = BTreeMap::new();
79    let mut expanded = Vec::new();
80    for (key, value) in &parsed {
81        let name = crate::environment_key(env, key.into());
82        let current = env
83            .get(&name)
84            .map(|value| value.to_string_lossy().into_owned());
85        let value = match current {
86            Some(current) if !current.is_empty() && current != *value => current,
87            _ => expand(value, env, &running),
88        };
89        let value = value.replace("\\$", "$");
90        running.insert(key.clone(), value.clone());
91        expanded.push((name, value));
92    }
93    for (name, value) in expanded {
94        env.insert(name, value.into());
95    }
96    Ok(())
97}
98
99/// The variables of one file as `load` would set them in an empty
100/// environment: what Nx compares to unload a root dotenv file.
101pub fn read_alone(path: &Path) -> Result<BTreeMap<OsString, OsString>> {
102    let mut env = BTreeMap::new();
103    load(&[path], &mut env)?;
104    Ok(env)
105}
106
107/// `dotenv-expand`'s expansion of one value: `${KEY}`, `$KEY` and the
108/// `${KEY:-default}`, `${KEY-default}`, `${KEY:+alternate}` and
109/// `${KEY+alternate}` forms, not preceded by a backslash, the environment
110/// winning over values the files set before. A reference is expanded again
111/// until nothing changes, and one that comes back to an earlier result takes
112/// its default instead.
113fn expand(
114    value: &str,
115    env: &BTreeMap<OsString, OsString>,
116    running: &BTreeMap<String, String>,
117) -> String {
118    let lookup = |key: &str| -> Option<String> {
119        env.get(OsStr::new(key))
120            .map(|value| value.to_string_lossy().into_owned())
121            .or_else(|| running.get(key).cloned())
122            .filter(|value| !value.is_empty())
123    };
124    let mut result = value.to_owned();
125    let mut seen: Vec<String> = Vec::new();
126    while let Some((start, end, expression)) = reference(&result) {
127        seen.push(result.clone());
128        let (key, operator, rest) = match ["-", ":-", "+", ":+"]
129            .iter()
130            .filter_map(|operator| expression.find(operator).map(|index| (index, *operator)))
131            // The first operator found, the longer at the same place.
132            .min_by_key(|(index, operator)| (*index, std::cmp::Reverse(operator.len())))
133        {
134            Some((index, operator)) => (
135                &expression[..index],
136                Some(operator),
137                expression[index..]
138                    .split(operator)
139                    .skip(1)
140                    .collect::<Vec<_>>()
141                    .join(operator),
142            ),
143            None => (expression.as_str(), None, String::new()),
144        };
145        let replacement = match operator {
146            Some("+" | ":+") => {
147                if lookup(key).is_some() {
148                    rest
149                } else {
150                    String::new()
151                }
152            }
153            _ => match lookup(key) {
154                Some(found) if seen.contains(&found) => rest,
155                Some(found) => found,
156                None => rest,
157            },
158        };
159        result.replace_range(start..end, &replacement);
160        if running.get(key) == Some(&result) {
161            break;
162        }
163    }
164    result
165}
166
167/// The first `${...}` or `$NAME` in `text` not preceded by a backslash: its
168/// span and what it names.
169fn reference(text: &str) -> Option<(usize, usize, String)> {
170    let bytes = text.as_bytes();
171    let mut index = 0;
172    while let Some(offset) = text[index..].find('$') {
173        let start = index + offset;
174        index = start + 1;
175        if start > 0 && bytes[start - 1] == b'\\' {
176            continue;
177        }
178        let rest = &text[start + 1..];
179        if let Some(inner) = rest.strip_prefix('{') {
180            if let Some(close) = inner.find(['{', '}'])
181                && inner.as_bytes()[close] == b'}'
182                && close > 0
183            {
184                return Some((start, start + 2 + close + 1, inner[..close].to_owned()));
185            }
186            continue;
187        }
188        let name: String = rest
189            .chars()
190            .enumerate()
191            .take_while(|(position, character)| {
192                character.is_ascii_alphabetic()
193                    || *character == '_'
194                    || (*position > 0 && character.is_ascii_digit())
195            })
196            .map(|(_, character)| character)
197            .collect();
198        if !name.is_empty() {
199            return Some((start, start + 1 + name.len(), name));
200        }
201    }
202    None
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn parsed(text: &str) -> Vec<(String, String)> {
210        parse(text)
211    }
212
213    fn pairs(entries: &[(&str, &str)]) -> Vec<(String, String)> {
214        entries
215            .iter()
216            .map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
217            .collect()
218    }
219
220    #[test]
221    fn parses_as_npm_dotenv_does() {
222        assert_eq!(
223            parsed(
224                "# comment\nexport A=1\nB = two words # note\nC='single $X'\nD=\"line\\nbreak\"\nE=`tick`\nF=\n  G=x\nnot a line\nH: yaml\nB=again\n"
225            ),
226            pairs(&[
227                ("A", "1"),
228                ("B", "again"),
229                ("C", "single $X"),
230                ("D", "line\nbreak"),
231                ("E", "tick"),
232                ("F", ""),
233                ("G", "x"),
234                ("H", "yaml"),
235            ])
236        );
237        // Quoted values span lines; `\"` stays as written.
238        assert_eq!(
239            parsed("A=\"one\ntwo\"\nB=\"say \\\"hi\\\"\"\nC=a#b\n"),
240            pairs(&[("A", "one\ntwo"), ("B", "say \\\"hi\\\""), ("C", "a")])
241        );
242    }
243
244    fn env(entries: &[(&str, &str)]) -> BTreeMap<OsString, OsString> {
245        entries
246            .iter()
247            .map(|(key, value)| ((*key).into(), (*value).into()))
248            .collect()
249    }
250
251    #[test]
252    fn expands_once_across_every_file() {
253        let directory = tempfile::tempdir().unwrap();
254        let first = directory.path().join(".env.build");
255        let second = directory.path().join(".env");
256        std::fs::write(&first, "URL=${HOST}:${PORT}\nNAME=first\n").unwrap();
257        std::fs::write(
258            &second,
259            "HOST=localhost\nPORT=${DEFAULT_PORT:-80}\nNAME=second\nSHELL_SET=file\nEMPTY=filled\nLITERAL=\\$HOME\nALT=${HOST:+yes}\n",
260        )
261        .unwrap();
262        let mut variables = env(&[("SHELL_SET", "shell"), ("EMPTY", "")]);
263        load(&[&first, &second], &mut variables).unwrap();
264        let get = |name: &str| variables[OsStr::new(name)].to_str().unwrap().to_owned();
265        assert_eq!(get("URL"), "localhost:80");
266        assert_eq!(get("NAME"), "first");
267        assert_eq!(get("SHELL_SET"), "shell");
268        // dotenv-expand fills a variable set but empty.
269        assert_eq!(get("EMPTY"), "filled");
270        assert_eq!(get("LITERAL"), "$HOME");
271        assert_eq!(get("ALT"), "yes");
272    }
273
274    #[test]
275    fn a_reference_to_itself_ends() {
276        let mut variables = env(&[]);
277        let directory = tempfile::tempdir().unwrap();
278        let file = directory.path().join(".env");
279        std::fs::write(&file, "A=${A}\nB=x${B:-fallback}\n").unwrap();
280        load(&[&file], &mut variables).unwrap();
281        assert_eq!(variables[OsStr::new("A")], "");
282        assert_eq!(variables[OsStr::new("B")], "xfallback");
283    }
284}