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 flat [`Value::Map`] of
12//!   [`Value::String`]s — env input has no nested or typed values.
13//! - Each key carries its line/column [`Location`]; for single-line input the line/column are
14//!   omitted. The root map has no line.
15//! - Non-UTF-8 input fails with [`Error::InvalidUtf8`]; there are
16//!   no syntax errors otherwise. [`is_format_supported`](crate::Parse::is_format_supported)
17//!   returns `Some(true)` when any non-comment line contains `=`, else `Some(false)`.
18//!
19//! # Example
20//!
21//! ```
22//! use tanzim_parse::{Parse, env::Env};
23//!
24//! let value = Env::new().parse("file", ".env", b"SERVER_HOST=\"127.0.0.1\"\n").unwrap();
25//! assert_eq!(
26//!     value.value.as_map().unwrap().get("SERVER_HOST").unwrap().value.as_string().unwrap(),
27//!     "127.0.0.1"
28//! );
29//! ```
30
31use crate::Parse;
32use crate::span::{is_single_line, line_column_from_line};
33use cfg_if::cfg_if;
34use tanzim_value::{Error, LocatedValue, Location, Map, Value};
35
36/// Parser for the `env` format: dotenv / env-file `KEY=VALUE` lines into a flat string map.
37///
38/// Skips blank lines and `#` comments, supports quoted values, and records each key's line number
39/// as a [`Location`]. The result is always a map of strings. Stateless — construct with
40/// [`Env::new`].
41///
42/// ```
43/// use tanzim_parse::{Parse, env::Env};
44///
45/// let value = Env::new().parse("file", ".env", b"# comment\nPORT=8080\n").unwrap();
46/// let port = value.value.as_map().unwrap().get("PORT").unwrap();
47/// assert_eq!(port.value.as_string().unwrap(), "8080");
48/// ```
49#[derive(Clone, Copy, Default)]
50pub struct Env;
51
52impl Env {
53    /// Create an env-format parser.
54    pub fn new() -> Self {
55        Self
56    }
57}
58
59impl Parse for Env {
60    fn name(&self) -> &str {
61        "Environment-Variables"
62    }
63
64    fn supported_format_list(&self) -> Vec<String> {
65        vec!["env".into()]
66    }
67
68    fn parse(&self, source: &str, resource: &str, bytes: &[u8]) -> Result<LocatedValue, Error> {
69        cfg_if! {
70            if #[cfg(feature = "tracing")] {
71                tracing::debug!(msg = "Parsing env-format configuration", source = source, resource = resource, bytes = bytes.len());
72            } else if #[cfg(feature = "logging")] {
73                log::debug!("msg=\"Parsing env-format configuration\" source={source} resource={resource} bytes={}", bytes.len());
74            }
75        }
76        let text = match std::str::from_utf8(bytes) {
77            Ok(value) => value,
78            Err(_) => {
79                return Err(Error::InvalidUtf8 {
80                    location: Location::at(source, resource, None, None, None),
81                });
82            }
83        };
84        let single_line = is_single_line(bytes);
85        let mut map = Map::new();
86        let mut line_number = 0usize;
87        let mut offset = 0usize;
88        while offset < text.len() {
89            let rest = &text[offset..];
90            let line_end = match rest.find('\n') {
91                Some(index) => index,
92                None => rest.len(),
93            };
94            let line = &rest[..line_end];
95            line_number += 1;
96            let trimmed = line.trim();
97            if !trimmed.is_empty() && !trimmed.starts_with('#') {
98                let mut line_body = trimmed;
99                if line_body.starts_with("export ") {
100                    line_body = line_body["export ".len()..].trim_start();
101                }
102                if let Some(equal_index) = line_body.find('=') {
103                    let key = line_body[..equal_index].trim();
104                    let value_part = line_body[equal_index + 1..].trim();
105                    if !key.is_empty() {
106                        let key_start = line.find(key).unwrap_or(0);
107                        let column = line_column_from_line(line, 1, key_start);
108                        let value = if value_part.starts_with('"')
109                            && value_part.ends_with('"')
110                            && value_part.len() >= 2
111                        {
112                            let inner = &value_part[1..value_part.len() - 1];
113                            let mut out = String::new();
114                            let mut index = 0usize;
115                            while index < inner.len() {
116                                let ch = inner[index..].chars().next().expect("valid utf-8");
117                                let ch_len = ch.len_utf8();
118                                if ch == '\\' {
119                                    index += ch_len;
120                                    if index < inner.len() {
121                                        let next =
122                                            inner[index..].chars().next().expect("valid utf-8");
123                                        let next_len = next.len_utf8();
124                                        match next {
125                                            'n' => out.push('\n'),
126                                            'r' => out.push('\r'),
127                                            't' => out.push('\t'),
128                                            '"' => out.push('"'),
129                                            '\\' => out.push('\\'),
130                                            other => {
131                                                out.push('\\');
132                                                out.push(other);
133                                            }
134                                        }
135                                        index += next_len;
136                                    } else {
137                                        out.push('\\');
138                                    }
139                                } else {
140                                    out.push(ch);
141                                    index += ch_len;
142                                }
143                            }
144                            out
145                        } else if value_part.starts_with('\'')
146                            && value_part.ends_with('\'')
147                            && value_part.len() >= 2
148                        {
149                            value_part[1..value_part.len() - 1].to_string()
150                        } else {
151                            value_part.to_string()
152                        };
153                        let location = if single_line {
154                            Location::at(source, resource, None, None, None)
155                        } else {
156                            Location::at(source, resource, Some(line_number), Some(column), None)
157                        };
158                        map.insert(
159                            key.to_string(),
160                            LocatedValue {
161                                value: Value::String(value),
162                                location,
163                            },
164                        );
165                    }
166                }
167            }
168            offset += line_end;
169            if offset < text.len() {
170                offset += 1;
171            }
172        }
173        cfg_if! {
174            if #[cfg(feature = "tracing")] {
175                tracing::trace!(msg = "Parsed env-format configuration", source = source, resource = resource, key_count = map.len());
176            } else if #[cfg(feature = "logging")] {
177                log::trace!("msg=\"Parsed env-format configuration\" source={source} resource={resource} key_count={}", map.len());
178            }
179        }
180        Ok(LocatedValue {
181            value: Value::Map(map),
182            location: Location::at(source, resource, None, None, None),
183        })
184    }
185
186    fn is_format_supported(&self, bytes: &[u8]) -> Option<bool> {
187        let text = std::str::from_utf8(bytes).ok()?;
188        for line in text.split('\n') {
189            let line = line.trim();
190            if !line.is_empty() && !line.starts_with('#') && line.contains('=') {
191                return Some(true);
192            }
193        }
194        Some(false)
195    }
196}
197
198#[cfg(all(test, feature = "env"))]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn parses_dotenv_contents() {
204        let parsed = Env::new()
205            .parse("file", ".env", b"FOO=bar\nBAZ=qux\n")
206            .unwrap();
207        let map = parsed.value.as_map().unwrap();
208        assert_eq!(map.get("FOO").unwrap().value.as_string().unwrap(), "bar");
209        assert_eq!(map.get("BAZ").unwrap().value.as_string().unwrap(), "qux");
210    }
211
212    #[test]
213    fn parses_env_with_line_numbers() {
214        let root = Env::new()
215            .parse("file", ".env", b"FOO=bar\nBAZ=qux\n")
216            .unwrap();
217        let map = root.value.as_map().unwrap();
218        let foo = map.get("FOO").unwrap();
219        assert_eq!(foo.value.as_string().unwrap(), "bar");
220        assert_eq!(foo.location.line, std::num::NonZeroU32::new(1));
221        let baz = map.get("BAZ").unwrap();
222        assert_eq!(baz.location.line, std::num::NonZeroU32::new(2));
223    }
224}