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, PathBuf};
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. The files it read are returned.
55pub fn load(
56    paths: &[impl AsRef<Path>],
57    env: &mut BTreeMap<OsString, OsString>,
58) -> Result<Vec<PathBuf>> {
59    let mut parsed: Vec<(String, String)> = Vec::new();
60    let mut read = Vec::new();
61    for path in paths {
62        let path = path.as_ref();
63        let text = match std::fs::read(path) {
64            Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
65            Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
66            Err(error) => {
67                return Err(error).with_context(|| format!("cannot read {}", path.display()));
68            }
69        };
70        read.push(path.to_path_buf());
71        for (key, value) in parse(&text) {
72            if !parsed.iter().any(|(name, _)| *name == key) {
73                parsed.push((key, value));
74            }
75        }
76    }
77    for (key, value) in &parsed {
78        let name = crate::environment_key(env, key.into());
79        env.entry(name).or_insert_with(|| value.into());
80    }
81    // Expanded against `env` as populated, which takes the results only
82    // once every value is expanded, as dotenv-expand does.
83    let mut running: BTreeMap<String, String> = BTreeMap::new();
84    let mut expanded = Vec::new();
85    for (key, value) in &parsed {
86        let name = crate::environment_key(env, key.into());
87        let current = env
88            .get(&name)
89            .map(|value| value.to_string_lossy().into_owned());
90        let value = match current {
91            Some(current) if !current.is_empty() && current != *value => current,
92            _ => expand(value, env, &running),
93        };
94        let value = value.replace("\\$", "$");
95        running.insert(key.clone(), value.clone());
96        expanded.push((name, value));
97    }
98    for (name, value) in expanded {
99        env.insert(name, value.into());
100    }
101    Ok(read)
102}
103
104/// The variables of one file as `load` would set them in an empty
105/// environment: what Nx compares to unload a root dotenv file.
106pub fn read_alone(path: &Path) -> Result<BTreeMap<OsString, OsString>> {
107    let mut env = BTreeMap::new();
108    load(&[path], &mut env)?;
109    Ok(env)
110}
111
112/// `dotenv-expand`'s expansion of one value: `${KEY}`, `$KEY` and the
113/// `${KEY:-default}`, `${KEY-default}`, `${KEY:+alternate}` and
114/// `${KEY+alternate}` forms, not preceded by a backslash, the environment
115/// winning over values the files set before. A reference is expanded again
116/// until nothing changes, and one that comes back to an earlier result takes
117/// its default instead.
118fn expand(
119    value: &str,
120    env: &BTreeMap<OsString, OsString>,
121    running: &BTreeMap<String, String>,
122) -> String {
123    let lookup = |key: &str| -> Option<String> {
124        env.get(OsStr::new(key))
125            .map(|value| value.to_string_lossy().into_owned())
126            .or_else(|| running.get(key).cloned())
127            .filter(|value| !value.is_empty())
128    };
129    let mut result = value.to_owned();
130    let mut seen: Vec<String> = Vec::new();
131    while let Some((start, end, expression)) = reference(&result) {
132        seen.push(result.clone());
133        let (key, operator, rest) = match ["-", ":-", "+", ":+"]
134            .iter()
135            .filter_map(|operator| expression.find(operator).map(|index| (index, *operator)))
136            // The first operator found, the longer at the same place.
137            .min_by_key(|(index, operator)| (*index, std::cmp::Reverse(operator.len())))
138        {
139            Some((index, operator)) => (
140                &expression[..index],
141                Some(operator),
142                expression[index..]
143                    .split(operator)
144                    .skip(1)
145                    .collect::<Vec<_>>()
146                    .join(operator),
147            ),
148            None => (expression.as_str(), None, String::new()),
149        };
150        let replacement = match operator {
151            Some("+" | ":+") => {
152                if lookup(key).is_some() {
153                    rest
154                } else {
155                    String::new()
156                }
157            }
158            _ => match lookup(key) {
159                Some(found) if seen.contains(&found) => rest,
160                Some(found) => found,
161                None => rest,
162            },
163        };
164        result.replace_range(start..end, &replacement);
165        if running.get(key) == Some(&result) {
166            break;
167        }
168    }
169    result
170}
171
172/// The first `${...}` or `$NAME` in `text` not preceded by a backslash: its
173/// span and what it names.
174fn reference(text: &str) -> Option<(usize, usize, String)> {
175    let bytes = text.as_bytes();
176    let mut index = 0;
177    while let Some(offset) = text[index..].find('$') {
178        let start = index + offset;
179        index = start + 1;
180        if start > 0 && bytes[start - 1] == b'\\' {
181            continue;
182        }
183        let rest = &text[start + 1..];
184        if let Some(inner) = rest.strip_prefix('{') {
185            if let Some(close) = inner.find(['{', '}'])
186                && inner.as_bytes()[close] == b'}'
187                && close > 0
188            {
189                return Some((start, start + 2 + close + 1, inner[..close].to_owned()));
190            }
191            continue;
192        }
193        let name: String = rest
194            .chars()
195            .enumerate()
196            .take_while(|(position, character)| {
197                character.is_ascii_alphabetic()
198                    || *character == '_'
199                    || (*position > 0 && character.is_ascii_digit())
200            })
201            .map(|(_, character)| character)
202            .collect();
203        if !name.is_empty() {
204            return Some((start, start + 1 + name.len(), name));
205        }
206    }
207    None
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn parsed(text: &str) -> Vec<(String, String)> {
215        parse(text)
216    }
217
218    fn pairs(entries: &[(&str, &str)]) -> Vec<(String, String)> {
219        entries
220            .iter()
221            .map(|(key, value)| ((*key).to_owned(), (*value).to_owned()))
222            .collect()
223    }
224
225    #[test]
226    fn parses_as_npm_dotenv_does() {
227        assert_eq!(
228            parsed(
229                "# 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"
230            ),
231            pairs(&[
232                ("A", "1"),
233                ("B", "again"),
234                ("C", "single $X"),
235                ("D", "line\nbreak"),
236                ("E", "tick"),
237                ("F", ""),
238                ("G", "x"),
239                ("H", "yaml"),
240            ])
241        );
242        // Quoted values span lines; `\"` stays as written.
243        assert_eq!(
244            parsed("A=\"one\ntwo\"\nB=\"say \\\"hi\\\"\"\nC=a#b\n"),
245            pairs(&[("A", "one\ntwo"), ("B", "say \\\"hi\\\""), ("C", "a")])
246        );
247    }
248
249    fn env(entries: &[(&str, &str)]) -> BTreeMap<OsString, OsString> {
250        entries
251            .iter()
252            .map(|(key, value)| ((*key).into(), (*value).into()))
253            .collect()
254    }
255
256    #[test]
257    fn expands_once_across_every_file() {
258        let directory = tempfile::tempdir().unwrap();
259        let first = directory.path().join(".env.build");
260        let second = directory.path().join(".env");
261        std::fs::write(&first, "URL=${HOST}:${PORT}\nNAME=first\n").unwrap();
262        std::fs::write(
263            &second,
264            "HOST=localhost\nPORT=${DEFAULT_PORT:-80}\nNAME=second\nSHELL_SET=file\nEMPTY=filled\nLITERAL=\\$HOME\nALT=${HOST:+yes}\n",
265        )
266        .unwrap();
267        let mut variables = env(&[("SHELL_SET", "shell"), ("EMPTY", "")]);
268        load(&[&first, &second], &mut variables).unwrap();
269        let get = |name: &str| variables[OsStr::new(name)].to_str().unwrap().to_owned();
270        assert_eq!(get("URL"), "localhost:80");
271        assert_eq!(get("NAME"), "first");
272        assert_eq!(get("SHELL_SET"), "shell");
273        // dotenv-expand fills a variable set but empty.
274        assert_eq!(get("EMPTY"), "filled");
275        assert_eq!(get("LITERAL"), "$HOME");
276        assert_eq!(get("ALT"), "yes");
277    }
278
279    #[test]
280    fn a_reference_to_itself_ends() {
281        let mut variables = env(&[]);
282        let directory = tempfile::tempdir().unwrap();
283        let file = directory.path().join(".env");
284        std::fs::write(&file, "A=${A}\nB=x${B:-fallback}\n").unwrap();
285        load(&[&file], &mut variables).unwrap();
286        assert_eq!(variables[OsStr::new("A")], "");
287        assert_eq!(variables[OsStr::new("B")], "xfallback");
288    }
289}