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