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/// Serialize a [`Value`] map into dotenv / env-file `KEY=VALUE` lines.
297///
298/// Accepts a [`Value`], `&Value`, [`LocatedValue`], or `&LocatedValue`; the root must be
299/// a [`Value::Map`]. Nested maps are flattened using the `separator` option carried by
300/// `source` (the same option [`Env::parse`] reads); a nested map with no separator
301/// configured is an error, as are lists (env has no list representation).
302///
303/// ```
304/// use tanzim_parse::env::unparse;
305/// use tanzim_source::SourceBuilder;
306/// use tanzim_value::{Map, LocatedValue, Location, Value};
307///
308/// let source = SourceBuilder::new().with_source("env").build().unwrap();
309/// let mut map = Map::new();
310/// map.insert("port".into(), LocatedValue {
311///     value: Value::String("8080".into()),
312///     location: Location::at("env", "", None, None, None),
313/// });
314/// assert_eq!(unparse(&source, Value::Map(map)).unwrap(), "port=8080\n");
315/// ```
316pub fn unparse<V: AsRef<Value>>(
317    source: &Source,
318    value: V,
319) -> Result<String, Box<dyn std::error::Error + Send + Sync + 'static>> {
320    let value = value.as_ref();
321    let map = match value.as_map() {
322        Some(map) => map,
323        None => {
324            return Err(format!("env root must be a map, found {}", value.type_name()).into());
325        }
326    };
327    let separator = source
328        .options()
329        .get("separator")
330        .and_then(|value| value.as_string().cloned());
331    let mut out = String::new();
332    write_env(&mut out, map, "", separator.as_deref())?;
333    Ok(out)
334}
335
336fn write_env(
337    out: &mut String,
338    map: &Map,
339    prefix: &str,
340    separator: Option<&str>,
341) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
342    for (key, item) in map.entries() {
343        let full_key = format!("{prefix}{key}");
344        match &item.value {
345            Value::Map(inner) => {
346                let separator = match separator {
347                    Some(separator) => separator,
348                    None => {
349                        return Err(format!(
350                            "cannot serialize nested map at key {full_key:?} to env without a separator option"
351                        )
352                        .into());
353                    }
354                };
355                write_env(
356                    out,
357                    inner,
358                    &format!("{full_key}{separator}"),
359                    Some(separator),
360                )?;
361            }
362            Value::List(_) => {
363                return Err(format!(
364                    "cannot serialize list at key {full_key:?} to env: env has no list representation"
365                )
366                .into());
367            }
368            scalar => {
369                out.push_str(&full_key);
370                out.push('=');
371                match scalar {
372                    Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
373                    Value::Int(value) => out.push_str(&value.to_string()),
374                    Value::Float(value) => out.push_str(&format!("{value:?}")),
375                    Value::String(value) => {
376                        let needs_quote = value.is_empty()
377                            || value.contains(|ch: char| {
378                                ch.is_whitespace() || matches!(ch, '"' | '\'' | '#' | '=')
379                            });
380                        if needs_quote {
381                            out.push('"');
382                            for ch in value.chars() {
383                                match ch {
384                                    '"' => out.push_str("\\\""),
385                                    '\\' => out.push_str("\\\\"),
386                                    '\n' => out.push_str("\\n"),
387                                    '\r' => out.push_str("\\r"),
388                                    '\t' => out.push_str("\\t"),
389                                    other => out.push(other),
390                                }
391                            }
392                            out.push('"');
393                        } else {
394                            out.push_str(value);
395                        }
396                    }
397                    // Maps and lists are handled by the arms above.
398                    Value::List(_) | Value::Map(_) => {}
399                }
400                out.push('\n');
401            }
402        }
403    }
404    Ok(())
405}
406
407#[cfg(all(test, feature = "env"))]
408mod tests {
409    use super::*;
410    use tanzim_source::{OptionValue, SourceBuilder};
411
412    fn file_source(resource: &str) -> Source {
413        SourceBuilder::new()
414            .with_source("file")
415            .with_resource(resource)
416            .build()
417            .unwrap()
418    }
419
420    fn loc(value: Value) -> LocatedValue {
421        LocatedValue {
422            value,
423            location: Location::at("env", "test", None, None, None),
424        }
425    }
426
427    #[test]
428    fn unparses_complex_env() {
429        let source = SourceBuilder::new()
430            .with_source("env")
431            .with_option("separator", OptionValue::String("__".into()))
432            .build()
433            .unwrap();
434        let mut database = Map::new();
435        database.insert("host".into(), loc(Value::String("localhost".into())));
436        database.insert("port".into(), loc(Value::Int(5432)));
437        let mut map = Map::new();
438        map.insert("database".into(), loc(Value::Map(database)));
439        map.insert("debug".into(), loc(Value::Bool(true)));
440        map.insert("note".into(), loc(Value::String("has space".into())));
441
442        let text = unparse(&source, Value::Map(map)).unwrap();
443        assert_eq!(
444            text,
445            "database__host=localhost\ndatabase__port=5432\ndebug=true\nnote=\"has space\"\n"
446        );
447    }
448
449    #[test]
450    fn unparse_list_is_error() {
451        let source = file_source(".env");
452        let mut map = Map::new();
453        map.insert("items".into(), loc(Value::List(vec![loc(Value::Int(1))])));
454        assert!(unparse(&source, Value::Map(map)).is_err());
455    }
456
457    #[test]
458    fn parses_dotenv_contents() {
459        let source = file_source(".env");
460        let parsed = Env::new().parse(&source, b"FOO=bar\nBAZ=qux\n").unwrap();
461        let map = parsed.value.as_map().unwrap();
462        assert_eq!(map.get("foo").unwrap().value.as_string().unwrap(), "bar");
463        assert_eq!(map.get("baz").unwrap().value.as_string().unwrap(), "qux");
464    }
465
466    #[test]
467    fn parses_env_with_line_numbers() {
468        let source = file_source(".env");
469        let root = Env::new().parse(&source, b"FOO=bar\nBAZ=qux\n").unwrap();
470        let map = root.value.as_map().unwrap();
471        let foo = map.get("foo").unwrap();
472        assert_eq!(foo.value.as_string().unwrap(), "bar");
473        assert_eq!(foo.location.line, std::num::NonZeroU32::new(1));
474        let baz = map.get("baz").unwrap();
475        assert_eq!(baz.location.line, std::num::NonZeroU32::new(2));
476    }
477
478    #[test]
479    fn parses_nested_keys_with_separator() {
480        let source = SourceBuilder::new()
481            .with_source("env")
482            .with_option("separator", OptionValue::String("__".into()))
483            .build()
484            .unwrap();
485        let parsed = Env::new().parse(&source, b"BAR__BAZ=val\n").unwrap();
486        let map = parsed.value.as_map().unwrap();
487        let bar = map.get("bar").unwrap();
488        let nested = bar.value.as_map().unwrap();
489        assert_eq!(nested.get("baz").unwrap().value.as_string().unwrap(), "val");
490    }
491}