1use 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
13pub fn parse(text: &str) -> Vec<(String, String)> {
18 static LINE: OnceLock<Regex> = OnceLock::new();
19 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
49pub 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 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
99pub 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
107fn 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 .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
167fn 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 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 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}