1use crate::span::{is_single_line, line_column_from_line};
44use crate::{Parse, Source};
45use cfg_if::cfg_if;
46use tanzim_value::{Comment, Error, LocatedValue, Location, Map, Value};
47
48#[derive(Clone, Copy, Default)]
70pub struct Env;
71
72impl Env {
73 pub fn new() -> Self {
75 Self
76 }
77}
78
79fn hash_comment_body(line: &str) -> Option<String> {
80 let trimmed = line.trim();
81 if !trimmed.starts_with('#') {
82 return None;
83 }
84 Some(trimmed[1..].trim_start().to_string())
85}
86
87fn parse_double_quoted_value(input: &str) -> Option<(String, &str)> {
88 let mut out = String::new();
89 let mut index = 1usize;
90 while index < input.len() {
91 let ch = input[index..].chars().next()?;
92 let ch_len = ch.len_utf8();
93 if ch == '"' {
94 return Some((out, &input[index + ch_len..]));
95 }
96 if ch == '\\' {
97 index += ch_len;
98 if index >= input.len() {
99 out.push('\\');
100 break;
101 }
102 let next = input[index..].chars().next()?;
103 let next_len = next.len_utf8();
104 match next {
105 'n' => out.push('\n'),
106 'r' => out.push('\r'),
107 't' => out.push('\t'),
108 '"' => out.push('"'),
109 '\\' => out.push('\\'),
110 other => {
111 out.push('\\');
112 out.push(other);
113 }
114 }
115 index += next_len;
116 } else {
117 out.push(ch);
118 index += ch_len;
119 }
120 }
121 None
122}
123
124fn parse_single_quoted_value(input: &str) -> Option<(String, &str)> {
125 let mut index = 1usize;
126 while index < input.len() {
127 let ch = input[index..].chars().next()?;
128 if ch == '\'' {
129 return Some((input[1..index].to_string(), &input[index + ch.len_utf8()..]));
130 }
131 index += ch.len_utf8();
132 }
133 None
134}
135
136fn parse_env_value_and_comment(value_part: &str) -> (String, Option<String>) {
137 let trimmed = value_part.trim_start();
138 if trimmed.starts_with('"')
139 && let Some((value, rest)) = parse_double_quoted_value(trimmed)
140 {
141 return (value, hash_comment_body(rest));
142 } else if trimmed.starts_with('\'')
143 && let Some((value, rest)) = parse_single_quoted_value(trimmed)
144 {
145 return (value, hash_comment_body(rest));
146 }
147 if let Some(space_index) = trimmed.find(" #") {
148 let value = trimmed[..space_index].trim_end();
149 let comment = trimmed[space_index + 1..].trim();
150 if comment.starts_with('#') {
151 return (value.to_string(), hash_comment_body(comment));
152 }
153 }
154 (trimmed.to_string(), None)
155}
156
157impl Parse for Env {
158 fn name(&self) -> &str {
159 "Environment-Variables"
160 }
161
162 fn supported_format_list(&self) -> Vec<String> {
163 vec!["env".into()]
164 }
165
166 fn parse(
167 &self,
168 source: &Source,
169 bytes: &[u8],
170 other_source_list: &[Source],
171 ) -> Result<LocatedValue, Error> {
172 fn insert_nested(map: &mut Map, parts: &[String], value: LocatedValue) {
173 if parts.is_empty() {
174 return;
175 }
176 if parts.len() == 1 {
177 map.insert(parts[0].clone(), value);
178 return;
179 }
180 let head = parts[0].clone();
181 let rest = &parts[1..];
182 match map.get_mut(&head) {
183 Some(existing) => {
184 if let Value::Map(inner) = existing.value_mut() {
185 insert_nested(inner, rest, value);
186 return;
187 }
188 let loc = value.location().clone();
189 let mut inner = Map::new();
190 insert_nested(&mut inner, rest, value);
191 existing.set_value(Value::Map(inner));
192 existing.set_location(loc);
193 }
194 None => {
195 let loc = value.location().clone();
196 let mut inner = Map::new();
197 insert_nested(&mut inner, rest, value);
198 map.insert(head, LocatedValue::new(Value::Map(inner), loc));
199 }
200 }
201 }
202
203 #[cfg(any(feature = "tracing", feature = "logging"))]
204 let source_name = source.source();
205 #[cfg(any(feature = "tracing", feature = "logging"))]
206 let resource = source.resource();
207 cfg_if! {
208 if #[cfg(feature = "tracing")] {
209 tracing::debug!(msg = "Parsing env-format configuration", source = source_name, resource = resource, bytes = bytes.len());
210 } else if #[cfg(feature = "logging")] {
211 log::debug!("msg=\"Parsing env-format configuration\" source={source_name} resource={resource} bytes={}", bytes.len());
212 }
213 }
214
215 let (separator, lowercase) = if source.source() == "env" {
216 let separator = match source.options().get("separator") {
217 None => None,
218 Some(value) => value.as_string().cloned(),
219 };
220 let lowercase = match source.options().get("lowercase") {
221 None => true,
222 Some(value) => value.as_bool().unwrap_or(true),
223 };
224 (separator, lowercase)
225 } else {
226 let mut env_sources: Vec<&Source> = Vec::new();
227 for other in other_source_list {
228 if other.source() == "env" {
229 env_sources.push(other);
230 }
231 }
232 if env_sources.is_empty() {
233 let separator = match source.options().get("separator") {
234 None => None,
235 Some(value) => value.as_string().cloned(),
236 };
237 let lowercase = match source.options().get("lowercase") {
238 None => true,
239 Some(value) => value.as_bool().unwrap_or(true),
240 };
241 (separator, lowercase)
242 } else {
243 let mut first_separator: Option<Option<String>> = None;
244 for env_source in &env_sources {
245 let sep = match env_source.options().get("separator") {
246 None => None,
247 Some(value) => value.as_string().cloned(),
248 };
249 match &first_separator {
250 None => first_separator = Some(sep),
251 Some(expected) => {
252 if *expected != sep {
253 return Err(Error::Parse {
254 text: String::new(),
255 location: Some(Box::new(Location::in_source(
256 source.clone(),
257 None,
258 None,
259 None,
260 ))),
261 message: "cannot determine env separator: multiple env sources with different separator options".to_string(),
262 });
263 }
264 }
265 }
266 }
267 let separator = first_separator.unwrap_or(None);
268 let lowercase = match env_sources[0].options().get("lowercase") {
269 None => true,
270 Some(value) => value.as_bool().unwrap_or(true),
271 };
272 (separator, lowercase)
273 }
274 };
275
276 let text = match std::str::from_utf8(bytes) {
277 Ok(value) => value,
278 Err(_) => {
279 return Err(Error::InvalidUtf8 {
280 location: Box::new(Location::in_source(source.clone(), None, None, None)),
281 });
282 }
283 };
284 let single_line = is_single_line(bytes);
285 let mut map = Map::new();
286 let mut pending_before: Vec<String> = Vec::new();
287 let mut line_number = 0usize;
288 let mut offset = 0usize;
289 while offset < text.len() {
290 let rest = &text[offset..];
291 let line_end = match rest.find('\n') {
292 Some(index) => index,
293 None => rest.len(),
294 };
295 let line = &rest[..line_end];
296 line_number += 1;
297 let trimmed = line.trim();
298 if trimmed.is_empty() {
299 offset += line_end;
300 if offset < text.len() {
301 offset += 1;
302 }
303 continue;
304 }
305 if let Some(body) = hash_comment_body(trimmed) {
306 pending_before.push(body);
307 } else {
308 let mut line_body = trimmed;
309 if line_body.starts_with("export ") {
310 line_body = line_body["export ".len()..].trim_start();
311 }
312 if let Some(equal_index) = line_body.find('=') {
313 let key = line_body[..equal_index].trim();
314 let value_part = line_body[equal_index + 1..].trim();
315 if !key.is_empty() {
316 let key_start = line.find(key).unwrap_or(0);
317 let column = line_column_from_line(line, 1, key_start);
318 let (value, after_comment) = parse_env_value_and_comment(value_part);
319 let location = if single_line {
320 Location::in_source(source.clone(), None, None, None)
321 } else {
322 Location::in_source(
323 source.clone(),
324 Some(line_number),
325 Some(column),
326 None,
327 )
328 };
329 let final_key = if lowercase {
330 key.to_lowercase()
331 } else {
332 key.to_string()
333 };
334 let mut located_value = LocatedValue::new(Value::String(value), location);
335 let mut comment = Comment::new();
336 if !pending_before.is_empty() {
337 comment.set_before(pending_before.clone());
338 pending_before.clear();
339 }
340 if let Some(after) = after_comment {
341 comment.set_after(Some(after));
342 }
343 if !comment.before().is_empty() || comment.after().is_some() {
344 located_value = located_value.with_comment(comment);
345 }
346 match &separator {
347 None => {
348 map.insert(final_key, located_value);
349 }
350 Some(sep) => {
351 let mut part_list: Vec<String> = Vec::new();
352 let mut remaining = final_key.as_str();
353 loop {
354 if let Some(index) = remaining.find(sep.as_str()) {
355 part_list.push(remaining[..index].to_string());
356 remaining = &remaining[index + sep.len()..];
357 } else {
358 part_list.push(remaining.to_string());
359 break;
360 }
361 }
362 if part_list.len() == 1 {
363 map.insert(part_list[0].clone(), located_value);
364 } else {
365 insert_nested(&mut map, &part_list, located_value);
366 }
367 }
368 }
369 }
370 }
371 }
372 offset += line_end;
373 if offset < text.len() {
374 offset += 1;
375 }
376 }
377 cfg_if! {
378 if #[cfg(feature = "tracing")] {
379 tracing::trace!(msg = "Parsed env-format configuration", source = source_name, resource = resource, key_count = map.len());
380 } else if #[cfg(feature = "logging")] {
381 log::trace!("msg=\"Parsed env-format configuration\" source={source_name} resource={resource} key_count={}", map.len());
382 }
383 }
384 Ok(LocatedValue::new(
385 Value::Map(map),
386 Location::in_source(source.clone(), None, None, None),
387 ))
388 }
389
390 fn is_format_supported(&self, bytes: &[u8]) -> Option<bool> {
391 let text = std::str::from_utf8(bytes).ok()?;
392 for line in text.split('\n') {
393 let line = line.trim();
394 if !line.is_empty() && !line.starts_with('#') && line.contains('=') {
395 return Some(true);
396 }
397 }
398 Some(false)
399 }
400}
401
402pub fn unparse<V: AsRef<Value>>(
423 source: &Source,
424 value: V,
425) -> Result<String, Box<dyn std::error::Error + Send + Sync + 'static>> {
426 let value = value.as_ref();
427 let map = match value.as_map() {
428 Some(map) => map,
429 None => {
430 return Err(format!("env root must be a map, found {}", value.type_name()).into());
431 }
432 };
433 let separator = source
434 .options()
435 .get("separator")
436 .and_then(|value| value.as_string().cloned());
437 let mut out = String::new();
438 write_env(&mut out, map, "", separator.as_deref())?;
439 Ok(out)
440}
441
442fn write_env(
443 out: &mut String,
444 map: &Map,
445 prefix: &str,
446 separator: Option<&str>,
447) -> Result<(), Box<dyn std::error::Error + Send + Sync + 'static>> {
448 for (key, item) in map.entries() {
449 if matches!(item.value(), Value::Null) {
450 continue;
451 }
452 let full_key = format!("{prefix}{key}");
453 match item.value() {
454 Value::Map(inner) => {
455 let separator = match separator {
456 Some(separator) => separator,
457 None => {
458 return Err(format!(
459 "cannot serialize nested map at key {full_key:?} to env without a separator option"
460 )
461 .into());
462 }
463 };
464 write_env(
465 out,
466 inner,
467 &format!("{full_key}{separator}"),
468 Some(separator),
469 )?;
470 }
471 Value::List(_) => {
472 return Err(format!(
473 "cannot serialize list at key {full_key:?} to env: env has no list representation"
474 )
475 .into());
476 }
477 scalar => {
478 for before in item.comment().before() {
479 out.push_str("# ");
480 out.push_str(before);
481 if !before.ends_with('\n') {
482 out.push('\n');
483 }
484 }
485 out.push_str(&full_key);
486 out.push('=');
487 match scalar {
488 Value::Bool(value) => out.push_str(if *value { "true" } else { "false" }),
489 Value::Int(value) => out.push_str(&value.to_string()),
490 Value::Float(value) => out.push_str(&format!("{value:?}")),
491 Value::String(value) => {
492 let needs_quote = value.is_empty()
493 || value.contains(|ch: char| {
494 ch.is_whitespace() || matches!(ch, '"' | '\'' | '#' | '=')
495 });
496 if needs_quote {
497 out.push('"');
498 for ch in value.chars() {
499 match ch {
500 '"' => out.push_str("\\\""),
501 '\\' => out.push_str("\\\\"),
502 '\n' => out.push_str("\\n"),
503 '\r' => out.push_str("\\r"),
504 '\t' => out.push_str("\\t"),
505 other => out.push(other),
506 }
507 }
508 out.push('"');
509 } else {
510 out.push_str(value);
511 }
512 }
513 Value::Null => {}
514 Value::List(_) | Value::Map(_) => {}
516 }
517 if let Some(after) = item.comment().after() {
518 out.push_str(" # ");
519 out.push_str(after);
520 }
521 out.push('\n');
522 }
523 }
524 }
525 Ok(())
526}
527
528#[cfg(all(test, feature = "env"))]
529mod tests {
530 use super::*;
531 use std::path::PathBuf;
532 use tanzim_source::{OptionValue, SourceBuilder};
533
534 fn file_source(resource: &str) -> Source {
535 SourceBuilder::new()
536 .with_source("file")
537 .with_resource(resource)
538 .build()
539 .unwrap()
540 }
541
542 fn loc(value: Value) -> LocatedValue {
543 LocatedValue::new(value, Location::at("env", "test", None, None, None))
544 }
545
546 #[test]
547 fn unparses_complex_env() {
548 let source = SourceBuilder::new()
549 .with_source("env")
550 .with_option("separator", OptionValue::String("__".into()))
551 .build()
552 .unwrap();
553 let mut database = Map::new();
554 database.insert("host".into(), loc(Value::String("localhost".into())));
555 database.insert("port".into(), loc(Value::Int(5432)));
556 let mut map = Map::new();
557 map.insert("database".into(), loc(Value::Map(database)));
558 map.insert("debug".into(), loc(Value::Bool(true)));
559 map.insert("note".into(), loc(Value::String("has space".into())));
560
561 let text = unparse(&source, Value::Map(map)).unwrap();
562 assert_eq!(
563 text,
564 "database__host=localhost\ndatabase__port=5432\ndebug=true\nnote=\"has space\"\n"
565 );
566 }
567
568 #[test]
569 fn unparse_list_is_error() {
570 let source = file_source(".env");
571 let mut map = Map::new();
572 map.insert("items".into(), loc(Value::List(vec![loc(Value::Int(1))])));
573 assert!(unparse(&source, Value::Map(map)).is_err());
574 }
575
576 #[test]
577 fn parses_dotenv_contents() {
578 let source = file_source(".env");
579 let parsed = Env::new()
580 .parse(&source, b"FOO=bar\nBAZ=qux\n", &[])
581 .unwrap();
582 let map = parsed.value().as_map().unwrap();
583 assert_eq!(map.get("foo").unwrap().value().as_string().unwrap(), "bar");
584 assert_eq!(map.get("baz").unwrap().value().as_string().unwrap(), "qux");
585 }
586
587 #[test]
588 fn parses_env_with_line_numbers() {
589 let source = file_source(".env");
590 let root = Env::new()
591 .parse(&source, b"FOO=bar\nBAZ=qux\n", &[])
592 .unwrap();
593 let map = root.value().as_map().unwrap();
594 let foo = map.get("foo").unwrap();
595 assert_eq!(foo.value().as_string().unwrap(), "bar");
596 assert_eq!(foo.location().line, std::num::NonZeroU32::new(1));
597 let baz = map.get("baz").unwrap();
598 assert_eq!(baz.location().line, std::num::NonZeroU32::new(2));
599 }
600
601 #[test]
602 fn parses_nested_keys_with_separator() {
603 let source = SourceBuilder::new()
604 .with_source("env")
605 .with_option("separator", OptionValue::String("__".into()))
606 .build()
607 .unwrap();
608 let parsed = Env::new().parse(&source, b"BAR__BAZ=val\n", &[]).unwrap();
609 let map = parsed.value().as_map().unwrap();
610 let bar = map.get("bar").unwrap();
611 let nested = bar.value().as_map().unwrap();
612 assert_eq!(
613 nested.get("baz").unwrap().value().as_string().unwrap(),
614 "val"
615 );
616 }
617
618 #[test]
619 fn parses_prefix_and_suffix_comments() {
620 let text = b"# top comment\n# second line\nPORT=8080 # listen port\n";
621 let parsed = Env::new().parse(&file_source(".env"), text, &[]).unwrap();
622 let port = parsed.value().as_map().unwrap().get("port").unwrap();
623 assert_eq!(port.comment().before(), &["top comment", "second line"]);
624 assert_eq!(port.comment().after(), Some("listen port"));
625 assert_eq!(port.value().as_string().unwrap(), "8080");
626 }
627
628 #[test]
629 fn parses_quoted_value_with_suffix_comment() {
630 let source = SourceBuilder::new()
631 .with_source("env")
632 .with_option("separator", OptionValue::String("__".into()))
633 .build()
634 .unwrap();
635 let parsed = Env::new()
636 .parse(&source, b"SERVER__PORT=\"8080\" # listen port\n", &[])
637 .unwrap();
638 let server = parsed.value().as_map().unwrap().get("server").unwrap();
639 let port = server.value().as_map().unwrap().get("port").unwrap();
640 assert_eq!(port.value().as_string().unwrap(), "8080");
641 assert_eq!(port.comment().after(), Some("listen port"));
642 }
643
644 #[test]
645 fn unparses_prefix_and_suffix_comments() {
646 let source = file_source(".env");
647 let mut map = Map::new();
648 map.insert(
649 "port".into(),
650 loc(Value::String("8080".into())).with_comment(
651 Comment::new()
652 .with_before(["top comment"])
653 .with_after(Some("listen port")),
654 ),
655 );
656 let text = unparse(&source, Value::Map(map)).unwrap();
657 assert_eq!(text, "# top comment\nport=8080 # listen port\n");
658 }
659
660 #[test]
661 fn parses_and_unparses_foo_env_comments() {
662 let path =
663 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples/full/etc/foo.env");
664 let text = std::fs::read_to_string(&path).unwrap();
665 let source = SourceBuilder::new()
666 .with_source("env")
667 .with_option("separator", OptionValue::String(".".into()))
668 .build()
669 .unwrap();
670 let parsed = Env::new().parse(&source, text.as_bytes(), &[]).unwrap();
671 let port = parsed
672 .value()
673 .as_map()
674 .unwrap()
675 .get("server")
676 .unwrap()
677 .value()
678 .as_map()
679 .unwrap()
680 .get("port")
681 .unwrap();
682 assert_eq!(port.value().as_string().unwrap(), "8080");
683 assert_eq!(port.comment().after(), Some("listen port"));
684
685 let reparsed = unparse(&source, parsed.into_value()).unwrap();
686 assert_eq!(reparsed, "server.port=8080 # listen port\n");
687
688 let reparsed_again = Env::new().parse(&source, reparsed.as_bytes(), &[]).unwrap();
689 let port_again = reparsed_again
690 .value()
691 .as_map()
692 .unwrap()
693 .get("server")
694 .unwrap()
695 .value()
696 .as_map()
697 .unwrap()
698 .get("port")
699 .unwrap();
700 assert_eq!(port_again.value().as_string().unwrap(), "8080");
701 assert_eq!(port_again.comment().after(), Some("listen port"));
702 }
703
704 #[test]
705 fn parses_file_env_inheriting_separator() {
706 let env_source = SourceBuilder::new()
707 .with_source("env")
708 .with_option("separator", OptionValue::String(".".into()))
709 .build()
710 .unwrap();
711 let file_source = file_source("foo.env");
712 let other_sources = [env_source];
713 let parsed = Env::new()
714 .parse(
715 &file_source,
716 b"SERVER.PORT=8080\n",
717 other_sources.as_slice(),
718 )
719 .unwrap();
720 let server = parsed.value().as_map().unwrap().get("server").unwrap();
721 let port = server.value().as_map().unwrap().get("port").unwrap();
722 assert_eq!(port.value().as_string().unwrap(), "8080");
723 }
724
725 #[test]
726 fn rejects_conflicting_env_separators() {
727 let env_dot = SourceBuilder::new()
728 .with_source("env")
729 .with_option("separator", OptionValue::String(".".into()))
730 .build()
731 .unwrap();
732 let env_underscore = SourceBuilder::new()
733 .with_source("env")
734 .with_option("separator", OptionValue::String("__".into()))
735 .build()
736 .unwrap();
737 let other_sources = [env_dot, env_underscore];
738 let error = Env::new()
739 .parse(
740 &file_source("foo.env"),
741 b"SERVER.PORT=8080\n",
742 other_sources.as_slice(),
743 )
744 .unwrap_err();
745 assert!(matches!(error, Error::Parse { .. }));
746 assert!(error.to_string().contains("separator"));
747 }
748}