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