Skip to main content

tanzim_parse/
env.rs

1//! Dotenv / env-file parser (`env` feature).
2//!
3//! **Format:** `env`
4//!
5//! # Behaviour
6//!
7//! - Splits the UTF-8 input into lines; blank lines and `#` comments are ignored, and an optional
8//!   leading `export ` is stripped.
9//! - Each remaining `KEY=VALUE` line becomes a string entry. Values may be double-quoted (with
10//!   `\n`, `\r`, `\t`, `\"`, `\\` escapes), single-quoted (taken literally), or unquoted (used
11//!   verbatim). The result is always a [`Value::Map`] of [`Value::String`]s.
12//! - When the source carries a `separator` option, keys are split on that separator and nested
13//!   into sub-maps (e.g. `BAR__BAZ=val` with `separator=__` becomes `{bar: {baz: "val"}}`).
14//! - Each key carries its line/column [`Location`]; for single-line input the line/column are
15//!   omitted. The root map has no line.
16//! - Non-UTF-8 input fails with [`Error::InvalidUtf8`]; there are
17//!   no syntax errors otherwise. [`is_format_supported`](crate::Parse::is_format_supported)
18//!   returns `Some(true)` when any non-comment line contains `=`, else `Some(false)`.
19//!
20//! # Example
21//!
22//! ```
23//! use tanzim_parse::{Parse, env::Env};
24//! use tanzim_source::SourceBuilder;
25//!
26//! let source = SourceBuilder::new()
27//!     .with_source("file")
28//!     .with_resource(".env")
29//!     .build()
30//!     .unwrap();
31//! let value = Env::new()
32//!     .parse(&source, b"SERVER_HOST=\"127.0.0.1\"\n")
33//!     .unwrap();
34//! assert_eq!(
35//!     value.value.as_map().unwrap().get("server_host").unwrap().value.as_string().unwrap(),
36//!     "127.0.0.1"
37//! );
38//! ```
39
40use crate::span::{is_single_line, line_column_from_line};
41use crate::{Parse, Source};
42use cfg_if::cfg_if;
43use tanzim_value::{Error, LocatedValue, Location, Map, Value};
44
45/// Parser for the `env` format: dotenv / env-file `KEY=VALUE` lines into a string map.
46///
47/// Skips blank lines and `#` comments, supports quoted values, and records each key's line number
48/// as a [`Location`]. When the source carries a `separator` option, keys are nested into
49/// sub-maps. Stateless — construct with [`Env::new`].
50///
51/// ```
52/// use tanzim_parse::{Parse, env::Env};
53/// use tanzim_source::SourceBuilder;
54///
55/// let source = SourceBuilder::new()
56///     .with_source("file")
57///     .with_resource(".env")
58///     .build()
59///     .unwrap();
60/// let value = Env::new()
61///     .parse(&source, b"# comment\nPORT=8080\n")
62///     .unwrap();
63/// let port = value.value.as_map().unwrap().get("port").unwrap();
64/// assert_eq!(port.value.as_string().unwrap(), "8080");
65/// ```
66#[derive(Clone, Copy, Default)]
67pub struct Env;
68
69impl Env {
70    /// Create an env-format parser.
71    pub fn new() -> Self {
72        Self
73    }
74}
75
76impl Parse for Env {
77    fn name(&self) -> &str {
78        "Environment-Variables"
79    }
80
81    fn supported_format_list(&self) -> Vec<String> {
82        vec!["env".into()]
83    }
84
85    fn parse(&self, source: &Source, bytes: &[u8]) -> Result<LocatedValue, Error> {
86        fn insert_nested(map: &mut Map, parts: &[String], value: LocatedValue) {
87            if parts.is_empty() {
88                return;
89            }
90            if parts.len() == 1 {
91                map.insert(parts[0].clone(), value);
92                return;
93            }
94            let head = parts[0].clone();
95            let rest = &parts[1..];
96            match map.get_mut(&head) {
97                Some(existing) => {
98                    if let Value::Map(ref mut inner) = existing.value {
99                        insert_nested(inner, rest, value);
100                        return;
101                    }
102                    let loc = value.location.clone();
103                    let mut inner = Map::new();
104                    insert_nested(&mut inner, rest, value);
105                    existing.value = Value::Map(inner);
106                    existing.location = loc;
107                }
108                None => {
109                    let loc = value.location.clone();
110                    let mut inner = Map::new();
111                    insert_nested(&mut inner, rest, value);
112                    map.insert(
113                        head,
114                        LocatedValue {
115                            value: Value::Map(inner),
116                            location: loc,
117                        },
118                    );
119                }
120            }
121        }
122
123        let source_name = source.source();
124        let resource = source.resource();
125        cfg_if! {
126            if #[cfg(feature = "tracing")] {
127                tracing::debug!(msg = "Parsing env-format configuration", source = source_name, resource = resource, bytes = bytes.len());
128            } else if #[cfg(feature = "logging")] {
129                log::debug!("msg=\"Parsing env-format configuration\" source={source_name} resource={resource} bytes={}", bytes.len());
130            }
131        }
132
133        let separator = match source.options().get("separator") {
134            None => None,
135            Some(value) => value.as_string().cloned(),
136        };
137
138        let lowercase = match source.options().get("lowercase") {
139            None => true,
140            Some(value) => value.as_bool().unwrap_or(true),
141        };
142
143        let text = match std::str::from_utf8(bytes) {
144            Ok(value) => value,
145            Err(_) => {
146                return Err(Error::InvalidUtf8 {
147                    location: Location::at(source_name, resource, None, None, None),
148                });
149            }
150        };
151        let single_line = is_single_line(bytes);
152        let mut map = Map::new();
153        let mut line_number = 0usize;
154        let mut offset = 0usize;
155        while offset < text.len() {
156            let rest = &text[offset..];
157            let line_end = match rest.find('\n') {
158                Some(index) => index,
159                None => rest.len(),
160            };
161            let line = &rest[..line_end];
162            line_number += 1;
163            let trimmed = line.trim();
164            if !trimmed.is_empty() && !trimmed.starts_with('#') {
165                let mut line_body = trimmed;
166                if line_body.starts_with("export ") {
167                    line_body = line_body["export ".len()..].trim_start();
168                }
169                if let Some(equal_index) = line_body.find('=') {
170                    let key = line_body[..equal_index].trim();
171                    let value_part = line_body[equal_index + 1..].trim();
172                    if !key.is_empty() {
173                        let key_start = line.find(key).unwrap_or(0);
174                        let column = line_column_from_line(line, 1, key_start);
175                        let value = if value_part.starts_with('"')
176                            && value_part.ends_with('"')
177                            && value_part.len() >= 2
178                        {
179                            let inner = &value_part[1..value_part.len() - 1];
180                            let mut out = String::new();
181                            let mut index = 0usize;
182                            while index < inner.len() {
183                                let ch = inner[index..].chars().next().expect("valid utf-8");
184                                let ch_len = ch.len_utf8();
185                                if ch == '\\' {
186                                    index += ch_len;
187                                    if index < inner.len() {
188                                        let next =
189                                            inner[index..].chars().next().expect("valid utf-8");
190                                        let next_len = next.len_utf8();
191                                        match next {
192                                            'n' => out.push('\n'),
193                                            'r' => out.push('\r'),
194                                            't' => out.push('\t'),
195                                            '"' => out.push('"'),
196                                            '\\' => out.push('\\'),
197                                            other => {
198                                                out.push('\\');
199                                                out.push(other);
200                                            }
201                                        }
202                                        index += next_len;
203                                    } else {
204                                        out.push('\\');
205                                    }
206                                } else {
207                                    out.push(ch);
208                                    index += ch_len;
209                                }
210                            }
211                            out
212                        } else if value_part.starts_with('\'')
213                            && value_part.ends_with('\'')
214                            && value_part.len() >= 2
215                        {
216                            value_part[1..value_part.len() - 1].to_string()
217                        } else {
218                            value_part.to_string()
219                        };
220                        let location = if single_line {
221                            Location::at(source_name, resource, None, None, None)
222                        } else {
223                            Location::at(
224                                source_name,
225                                resource,
226                                Some(line_number),
227                                Some(column),
228                                None,
229                            )
230                        };
231                        let final_key = if lowercase {
232                            key.to_lowercase()
233                        } else {
234                            key.to_string()
235                        };
236                        let located_value = LocatedValue {
237                            value: Value::String(value),
238                            location,
239                        };
240                        match &separator {
241                            None => {
242                                map.insert(final_key, located_value);
243                            }
244                            Some(sep) => {
245                                let mut part_list: Vec<String> = Vec::new();
246                                let mut remaining = final_key.as_str();
247                                loop {
248                                    if let Some(index) = remaining.find(sep.as_str()) {
249                                        part_list.push(remaining[..index].to_string());
250                                        remaining = &remaining[index + sep.len()..];
251                                    } else {
252                                        part_list.push(remaining.to_string());
253                                        break;
254                                    }
255                                }
256                                if part_list.len() == 1 {
257                                    map.insert(part_list[0].clone(), located_value);
258                                } else {
259                                    insert_nested(&mut map, &part_list, located_value);
260                                }
261                            }
262                        }
263                    }
264                }
265            }
266            offset += line_end;
267            if offset < text.len() {
268                offset += 1;
269            }
270        }
271        cfg_if! {
272            if #[cfg(feature = "tracing")] {
273                tracing::trace!(msg = "Parsed env-format configuration", source = source_name, resource = resource, key_count = map.len());
274            } else if #[cfg(feature = "logging")] {
275                log::trace!("msg=\"Parsed env-format configuration\" source={source_name} resource={resource} key_count={}", map.len());
276            }
277        }
278        Ok(LocatedValue {
279            value: Value::Map(map),
280            location: Location::at(source_name, resource, None, None, None),
281        })
282    }
283
284    fn is_format_supported(&self, bytes: &[u8]) -> Option<bool> {
285        let text = std::str::from_utf8(bytes).ok()?;
286        for line in text.split('\n') {
287            let line = line.trim();
288            if !line.is_empty() && !line.starts_with('#') && line.contains('=') {
289                return Some(true);
290            }
291        }
292        Some(false)
293    }
294}
295
296#[cfg(all(test, feature = "env"))]
297mod tests {
298    use super::*;
299    use tanzim_source::{OptionValue, SourceBuilder};
300
301    fn file_source(resource: &str) -> Source {
302        SourceBuilder::new()
303            .with_source("file")
304            .with_resource(resource)
305            .build()
306            .unwrap()
307    }
308
309    #[test]
310    fn parses_dotenv_contents() {
311        let source = file_source(".env");
312        let parsed = Env::new().parse(&source, b"FOO=bar\nBAZ=qux\n").unwrap();
313        let map = parsed.value.as_map().unwrap();
314        assert_eq!(map.get("foo").unwrap().value.as_string().unwrap(), "bar");
315        assert_eq!(map.get("baz").unwrap().value.as_string().unwrap(), "qux");
316    }
317
318    #[test]
319    fn parses_env_with_line_numbers() {
320        let source = file_source(".env");
321        let root = Env::new().parse(&source, b"FOO=bar\nBAZ=qux\n").unwrap();
322        let map = root.value.as_map().unwrap();
323        let foo = map.get("foo").unwrap();
324        assert_eq!(foo.value.as_string().unwrap(), "bar");
325        assert_eq!(foo.location.line, std::num::NonZeroU32::new(1));
326        let baz = map.get("baz").unwrap();
327        assert_eq!(baz.location.line, std::num::NonZeroU32::new(2));
328    }
329
330    #[test]
331    fn parses_nested_keys_with_separator() {
332        let source = SourceBuilder::new()
333            .with_source("env")
334            .with_option("separator", OptionValue::String("__".into()))
335            .build()
336            .unwrap();
337        let parsed = Env::new().parse(&source, b"BAR__BAZ=val\n").unwrap();
338        let map = parsed.value.as_map().unwrap();
339        let bar = map.get("bar").unwrap();
340        let nested = bar.value.as_map().unwrap();
341        assert_eq!(nested.get("baz").unwrap().value.as_string().unwrap(), "val");
342    }
343}