1use 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#[derive(Clone, Copy, Default)]
67pub struct Env;
68
69impl Env {
70 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
297pub 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 if let Value::Comment(text) = &item.value {
345 out.push_str(text);
346 if !text.ends_with('\n') {
347 out.push('\n');
348 }
349 continue;
350 }
351 if matches!(item.value, Value::Null) {
352 continue;
353 }
354 let full_key = format!("{prefix}{key}");
355 match &item.value {
356 Value::Map(inner) => {
357 let separator = match separator {
358 Some(separator) => separator,
359 None => {
360 return Err(format!(
361 "cannot serialize nested map at key {full_key:?} to env without a separator option"
362 )
363 .into());
364 }
365 };
366 write_env(
367 out,
368 inner,
369 &format!("{full_key}{separator}"),
370 Some(separator),
371 )?;
372 }
373 Value::List(_) => {
374 return Err(format!(
375 "cannot serialize list at key {full_key:?} to env: env has no list representation"
376 )
377 .into());
378 }
379 scalar => {
380 out.push_str(&full_key);
381 out.push('=');
382 match scalar {
383 Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
384 Value::Int(value) => out.push_str(&value.to_string()),
385 Value::Float(value) => out.push_str(&format!("{value:?}")),
386 Value::String(value) => {
387 let needs_quote = value.is_empty()
388 || value.contains(|ch: char| {
389 ch.is_whitespace() || matches!(ch, '"' | '\'' | '#' | '=')
390 });
391 if needs_quote {
392 out.push('"');
393 for ch in value.chars() {
394 match ch {
395 '"' => out.push_str("\\\""),
396 '\\' => out.push_str("\\\\"),
397 '\n' => out.push_str("\\n"),
398 '\r' => out.push_str("\\r"),
399 '\t' => out.push_str("\\t"),
400 other => out.push(other),
401 }
402 }
403 out.push('"');
404 } else {
405 out.push_str(value);
406 }
407 }
408 Value::Null | Value::Comment(_) => {}
409 Value::List(_) | Value::Map(_) => {}
411 }
412 out.push('\n');
413 }
414 }
415 }
416 Ok(())
417}
418
419#[cfg(all(test, feature = "env"))]
420mod tests {
421 use super::*;
422 use tanzim_source::{OptionValue, SourceBuilder};
423
424 fn file_source(resource: &str) -> Source {
425 SourceBuilder::new()
426 .with_source("file")
427 .with_resource(resource)
428 .build()
429 .unwrap()
430 }
431
432 fn loc(value: Value) -> LocatedValue {
433 LocatedValue {
434 value,
435 location: Location::at("env", "test", None, None, None),
436 }
437 }
438
439 #[test]
440 fn unparses_complex_env() {
441 let source = SourceBuilder::new()
442 .with_source("env")
443 .with_option("separator", OptionValue::String("__".into()))
444 .build()
445 .unwrap();
446 let mut database = Map::new();
447 database.insert("host".into(), loc(Value::String("localhost".into())));
448 database.insert("port".into(), loc(Value::Int(5432)));
449 let mut map = Map::new();
450 map.insert("database".into(), loc(Value::Map(database)));
451 map.insert("debug".into(), loc(Value::Bool(true)));
452 map.insert("note".into(), loc(Value::String("has space".into())));
453
454 let text = unparse(&source, Value::Map(map)).unwrap();
455 assert_eq!(
456 text,
457 "database__host=localhost\ndatabase__port=5432\ndebug=true\nnote=\"has space\"\n"
458 );
459 }
460
461 #[test]
462 fn unparse_list_is_error() {
463 let source = file_source(".env");
464 let mut map = Map::new();
465 map.insert("items".into(), loc(Value::List(vec![loc(Value::Int(1))])));
466 assert!(unparse(&source, Value::Map(map)).is_err());
467 }
468
469 #[test]
470 fn parses_dotenv_contents() {
471 let source = file_source(".env");
472 let parsed = Env::new().parse(&source, b"FOO=bar\nBAZ=qux\n").unwrap();
473 let map = parsed.value.as_map().unwrap();
474 assert_eq!(map.get("foo").unwrap().value.as_string().unwrap(), "bar");
475 assert_eq!(map.get("baz").unwrap().value.as_string().unwrap(), "qux");
476 }
477
478 #[test]
479 fn parses_env_with_line_numbers() {
480 let source = file_source(".env");
481 let root = Env::new().parse(&source, b"FOO=bar\nBAZ=qux\n").unwrap();
482 let map = root.value.as_map().unwrap();
483 let foo = map.get("foo").unwrap();
484 assert_eq!(foo.value.as_string().unwrap(), "bar");
485 assert_eq!(foo.location.line, std::num::NonZeroU32::new(1));
486 let baz = map.get("baz").unwrap();
487 assert_eq!(baz.location.line, std::num::NonZeroU32::new(2));
488 }
489
490 #[test]
491 fn parses_nested_keys_with_separator() {
492 let source = SourceBuilder::new()
493 .with_source("env")
494 .with_option("separator", OptionValue::String("__".into()))
495 .build()
496 .unwrap();
497 let parsed = Env::new().parse(&source, b"BAR__BAZ=val\n").unwrap();
498 let map = parsed.value.as_map().unwrap();
499 let bar = map.get("bar").unwrap();
500 let nested = bar.value.as_map().unwrap();
501 assert_eq!(nested.get("baz").unwrap().value.as_string().unwrap(), "val");
502 }
503}