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(inner) = existing.value_mut() {
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.set_value(Value::Map(inner));
106                    existing.set_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(head, LocatedValue::new(Value::Map(inner), loc));
113                }
114            }
115        }
116
117        #[cfg(any(feature = "tracing", feature = "logging"))]
118        let source_name = source.source();
119        #[cfg(any(feature = "tracing", feature = "logging"))]
120        let resource = source.resource();
121        cfg_if! {
122            if #[cfg(feature = "tracing")] {
123                tracing::debug!(msg = "Parsing env-format configuration", source = source_name, resource = resource, bytes = bytes.len());
124            } else if #[cfg(feature = "logging")] {
125                log::debug!("msg=\"Parsing env-format configuration\" source={source_name} resource={resource} bytes={}", bytes.len());
126            }
127        }
128
129        let separator = match source.options().get("separator") {
130            None => None,
131            Some(value) => value.as_string().cloned(),
132        };
133
134        let lowercase = match source.options().get("lowercase") {
135            None => true,
136            Some(value) => value.as_bool().unwrap_or(true),
137        };
138
139        let text = match std::str::from_utf8(bytes) {
140            Ok(value) => value,
141            Err(_) => {
142                return Err(Error::InvalidUtf8 {
143                    location: Box::new(Location::in_source(source.clone(), None, None, None)),
144                });
145            }
146        };
147        let single_line = is_single_line(bytes);
148        let mut map = Map::new();
149        let mut line_number = 0usize;
150        let mut offset = 0usize;
151        while offset < text.len() {
152            let rest = &text[offset..];
153            let line_end = match rest.find('\n') {
154                Some(index) => index,
155                None => rest.len(),
156            };
157            let line = &rest[..line_end];
158            line_number += 1;
159            let trimmed = line.trim();
160            if !trimmed.is_empty() && !trimmed.starts_with('#') {
161                let mut line_body = trimmed;
162                if line_body.starts_with("export ") {
163                    line_body = line_body["export ".len()..].trim_start();
164                }
165                if let Some(equal_index) = line_body.find('=') {
166                    let key = line_body[..equal_index].trim();
167                    let value_part = line_body[equal_index + 1..].trim();
168                    if !key.is_empty() {
169                        let key_start = line.find(key).unwrap_or(0);
170                        let column = line_column_from_line(line, 1, key_start);
171                        let value = if value_part.starts_with('"')
172                            && value_part.ends_with('"')
173                            && value_part.len() >= 2
174                        {
175                            let inner = &value_part[1..value_part.len() - 1];
176                            let mut out = String::new();
177                            let mut index = 0usize;
178                            while index < inner.len() {
179                                let ch = inner[index..].chars().next().expect("valid utf-8");
180                                let ch_len = ch.len_utf8();
181                                if ch == '\\' {
182                                    index += ch_len;
183                                    if index < inner.len() {
184                                        let next =
185                                            inner[index..].chars().next().expect("valid utf-8");
186                                        let next_len = next.len_utf8();
187                                        match next {
188                                            'n' => out.push('\n'),
189                                            'r' => out.push('\r'),
190                                            't' => out.push('\t'),
191                                            '"' => out.push('"'),
192                                            '\\' => out.push('\\'),
193                                            other => {
194                                                out.push('\\');
195                                                out.push(other);
196                                            }
197                                        }
198                                        index += next_len;
199                                    } else {
200                                        out.push('\\');
201                                    }
202                                } else {
203                                    out.push(ch);
204                                    index += ch_len;
205                                }
206                            }
207                            out
208                        } else if value_part.starts_with('\'')
209                            && value_part.ends_with('\'')
210                            && value_part.len() >= 2
211                        {
212                            value_part[1..value_part.len() - 1].to_string()
213                        } else {
214                            value_part.to_string()
215                        };
216                        let location = if single_line {
217                            Location::in_source(source.clone(), None, None, None)
218                        } else {
219                            Location::in_source(
220                                source.clone(),
221                                Some(line_number),
222                                Some(column),
223                                None,
224                            )
225                        };
226                        let final_key = if lowercase {
227                            key.to_lowercase()
228                        } else {
229                            key.to_string()
230                        };
231                        let located_value = LocatedValue::new(Value::String(value), location);
232                        match &separator {
233                            None => {
234                                map.insert(final_key, located_value);
235                            }
236                            Some(sep) => {
237                                let mut part_list: Vec<String> = Vec::new();
238                                let mut remaining = final_key.as_str();
239                                loop {
240                                    if let Some(index) = remaining.find(sep.as_str()) {
241                                        part_list.push(remaining[..index].to_string());
242                                        remaining = &remaining[index + sep.len()..];
243                                    } else {
244                                        part_list.push(remaining.to_string());
245                                        break;
246                                    }
247                                }
248                                if part_list.len() == 1 {
249                                    map.insert(part_list[0].clone(), located_value);
250                                } else {
251                                    insert_nested(&mut map, &part_list, located_value);
252                                }
253                            }
254                        }
255                    }
256                }
257            }
258            offset += line_end;
259            if offset < text.len() {
260                offset += 1;
261            }
262        }
263        cfg_if! {
264            if #[cfg(feature = "tracing")] {
265                tracing::trace!(msg = "Parsed env-format configuration", source = source_name, resource = resource, key_count = map.len());
266            } else if #[cfg(feature = "logging")] {
267                log::trace!("msg=\"Parsed env-format configuration\" source={source_name} resource={resource} key_count={}", map.len());
268            }
269        }
270        Ok(LocatedValue::new(
271            Value::Map(map),
272            Location::in_source(source.clone(), None, None, None),
273        ))
274    }
275
276    fn is_format_supported(&self, bytes: &[u8]) -> Option<bool> {
277        let text = std::str::from_utf8(bytes).ok()?;
278        for line in text.split('\n') {
279            let line = line.trim();
280            if !line.is_empty() && !line.starts_with('#') && line.contains('=') {
281                return Some(true);
282            }
283        }
284        Some(false)
285    }
286}
287
288/// Serialize a [`Value`] map into dotenv / env-file `KEY=VALUE` lines.
289///
290/// Accepts a [`Value`], `&Value`, [`LocatedValue`], or `&LocatedValue`; the root must be
291/// a [`Value::Map`]. Nested maps are flattened using the `separator` option carried by
292/// `source` (the same option [`Env::parse`] reads); a nested map with no separator
293/// configured is an error, as are lists (env has no list representation).
294///
295/// ```
296/// use tanzim_parse::env::unparse;
297/// use tanzim_source::SourceBuilder;
298/// use tanzim_value::{Map, LocatedValue, Location, Value};
299///
300/// let source = SourceBuilder::new().with_source("env").build().unwrap();
301/// let mut map = Map::new();
302/// map.insert("port".into(), LocatedValue::new(
303///     Value::String("8080".into()),
304///     Location::at("env", "", None, None, None),
305/// ));
306/// assert_eq!(unparse(&source, Value::Map(map)).unwrap(), "port=8080\n");
307/// ```
308pub fn unparse<V: AsRef<Value>>(
309    source: &Source,
310    value: V,
311) -> Result<String, Box<dyn std::error::Error + Send + Sync + 'static>> {
312    let value = value.as_ref();
313    let map = match value.as_map() {
314        Some(map) => map,
315        None => {
316            return Err(format!("env root must be a map, found {}", value.type_name()).into());
317        }
318    };
319    let separator = source
320        .options()
321        .get("separator")
322        .and_then(|value| value.as_string().cloned());
323    let mut out = String::new();
324    write_env(&mut out, map, "", separator.as_deref())?;
325    Ok(out)
326}
327
328fn write_env(
329    out: &mut String,
330    map: &Map,
331    prefix: &str,
332    separator: Option<&str>,
333) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
334    for (key, item) in map.entries() {
335        if matches!(item.value(), Value::Null) {
336            continue;
337        }
338        let full_key = format!("{prefix}{key}");
339        match item.value() {
340            Value::Map(inner) => {
341                let separator = match separator {
342                    Some(separator) => separator,
343                    None => {
344                        return Err(format!(
345                            "cannot serialize nested map at key {full_key:?} to env without a separator option"
346                        )
347                        .into());
348                    }
349                };
350                write_env(
351                    out,
352                    inner,
353                    &format!("{full_key}{separator}"),
354                    Some(separator),
355                )?;
356            }
357            Value::List(_) => {
358                return Err(format!(
359                    "cannot serialize list at key {full_key:?} to env: env has no list representation"
360                )
361                .into());
362            }
363            scalar => {
364                out.push_str(&full_key);
365                out.push('=');
366                match scalar {
367                    Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
368                    Value::Int(value) => out.push_str(&value.to_string()),
369                    Value::Float(value) => out.push_str(&format!("{value:?}")),
370                    Value::String(value) => {
371                        let needs_quote = value.is_empty()
372                            || value.contains(|ch: char| {
373                                ch.is_whitespace() || matches!(ch, '"' | '\'' | '#' | '=')
374                            });
375                        if needs_quote {
376                            out.push('"');
377                            for ch in value.chars() {
378                                match ch {
379                                    '"' => out.push_str("\\\""),
380                                    '\\' => out.push_str("\\\\"),
381                                    '\n' => out.push_str("\\n"),
382                                    '\r' => out.push_str("\\r"),
383                                    '\t' => out.push_str("\\t"),
384                                    other => out.push(other),
385                                }
386                            }
387                            out.push('"');
388                        } else {
389                            out.push_str(value);
390                        }
391                    }
392                    Value::Null => {}
393                    // Maps and lists are handled by the arms above.
394                    Value::List(_) | Value::Map(_) => {}
395                }
396                out.push('\n');
397            }
398        }
399    }
400    Ok(())
401}
402
403#[cfg(all(test, feature = "env"))]
404mod tests {
405    use super::*;
406    use tanzim_source::{OptionValue, SourceBuilder};
407
408    fn file_source(resource: &str) -> Source {
409        SourceBuilder::new()
410            .with_source("file")
411            .with_resource(resource)
412            .build()
413            .unwrap()
414    }
415
416    fn loc(value: Value) -> LocatedValue {
417        LocatedValue::new(value, Location::at("env", "test", None, None, None))
418    }
419
420    #[test]
421    fn unparses_complex_env() {
422        let source = SourceBuilder::new()
423            .with_source("env")
424            .with_option("separator", OptionValue::String("__".into()))
425            .build()
426            .unwrap();
427        let mut database = Map::new();
428        database.insert("host".into(), loc(Value::String("localhost".into())));
429        database.insert("port".into(), loc(Value::Int(5432)));
430        let mut map = Map::new();
431        map.insert("database".into(), loc(Value::Map(database)));
432        map.insert("debug".into(), loc(Value::Bool(true)));
433        map.insert("note".into(), loc(Value::String("has space".into())));
434
435        let text = unparse(&source, Value::Map(map)).unwrap();
436        assert_eq!(
437            text,
438            "database__host=localhost\ndatabase__port=5432\ndebug=true\nnote=\"has space\"\n"
439        );
440    }
441
442    #[test]
443    fn unparse_list_is_error() {
444        let source = file_source(".env");
445        let mut map = Map::new();
446        map.insert("items".into(), loc(Value::List(vec![loc(Value::Int(1))])));
447        assert!(unparse(&source, Value::Map(map)).is_err());
448    }
449
450    #[test]
451    fn parses_dotenv_contents() {
452        let source = file_source(".env");
453        let parsed = Env::new().parse(&source, b"FOO=bar\nBAZ=qux\n").unwrap();
454        let map = parsed.value().as_map().unwrap();
455        assert_eq!(map.get("foo").unwrap().value().as_string().unwrap(), "bar");
456        assert_eq!(map.get("baz").unwrap().value().as_string().unwrap(), "qux");
457    }
458
459    #[test]
460    fn parses_env_with_line_numbers() {
461        let source = file_source(".env");
462        let root = Env::new().parse(&source, b"FOO=bar\nBAZ=qux\n").unwrap();
463        let map = root.value().as_map().unwrap();
464        let foo = map.get("foo").unwrap();
465        assert_eq!(foo.value().as_string().unwrap(), "bar");
466        assert_eq!(foo.location().line, std::num::NonZeroU32::new(1));
467        let baz = map.get("baz").unwrap();
468        assert_eq!(baz.location().line, std::num::NonZeroU32::new(2));
469    }
470
471    #[test]
472    fn parses_nested_keys_with_separator() {
473        let source = SourceBuilder::new()
474            .with_source("env")
475            .with_option("separator", OptionValue::String("__".into()))
476            .build()
477            .unwrap();
478        let parsed = Env::new().parse(&source, b"BAR__BAZ=val\n").unwrap();
479        let map = parsed.value().as_map().unwrap();
480        let bar = map.get("bar").unwrap();
481        let nested = bar.value().as_map().unwrap();
482        assert_eq!(
483            nested.get("baz").unwrap().value().as_string().unwrap(),
484            "val"
485        );
486    }
487}