1use crate::{OptionValue, Options, Source};
8use std::fmt::{self, Display, Formatter};
9
10#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum ParseError {
17 MissingSource { input: String, at: usize },
19 UnexpectedEnd {
21 input: String,
22 at: usize,
23 expected: &'static str,
24 },
25 UnexpectedChar {
27 input: String,
28 at: usize,
29 found: char,
30 expected: &'static str,
31 },
32 InvalidIdentifier {
34 input: String,
35 at: usize,
36 found: String,
37 },
38 EmptyKey { input: String, at: usize },
40 EmptyValue { input: String, at: usize },
42 InvalidEscape { input: String, at: usize },
44 UnclosedString { input: String, at: usize },
46 UnclosedList { input: String, at: usize },
48 UnclosedMap { input: String, at: usize },
50 TrailingComma { input: String, at: usize },
52 InvalidNumber {
54 input: String,
55 at: usize,
56 found: String,
57 },
58 TrailingInput {
60 input: String,
61 at: usize,
62 rest: String,
63 },
64 InvalidOnError {
66 input: String,
67 at: usize,
68 message: String,
69 },
70}
71
72impl Display for ParseError {
73 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
74 let (input, at, message) = match self {
75 Self::MissingSource { input, at, .. } => (
76 input.as_str(),
77 *at,
78 "configuration source is required".to_string(),
79 ),
80 Self::UnexpectedEnd {
81 input,
82 at,
83 expected,
84 ..
85 } => (
86 input.as_str(),
87 *at,
88 format!("configuration source: expected {expected}, found end of input"),
89 ),
90 Self::UnexpectedChar {
91 input,
92 at,
93 found,
94 expected,
95 ..
96 } => (
97 input.as_str(),
98 *at,
99 format!("configuration source: expected {expected}, found `{found}`"),
100 ),
101 Self::InvalidIdentifier {
102 input, at, found, ..
103 } => (
104 input.as_str(),
105 *at,
106 format!("configuration source: invalid identifier `{found}`"),
107 ),
108 Self::EmptyKey { input, at, .. } => (
109 input.as_str(),
110 *at,
111 "configuration source option key cannot be empty".to_string(),
112 ),
113 Self::EmptyValue { input, at, .. } => (
114 input.as_str(),
115 *at,
116 "configuration source option value cannot be empty; use \"\"".to_string(),
117 ),
118 Self::InvalidEscape { input, at, .. } => (
119 input.as_str(),
120 *at,
121 "configuration source: invalid escape sequence in string".to_string(),
122 ),
123 Self::UnclosedString { input, at, .. } => (
124 input.as_str(),
125 *at,
126 "configuration source: unclosed string".to_string(),
127 ),
128 Self::UnclosedList { input, at, .. } => (
129 input.as_str(),
130 *at,
131 "configuration source: unclosed list".to_string(),
132 ),
133 Self::UnclosedMap { input, at, .. } => (
134 input.as_str(),
135 *at,
136 "configuration source: unclosed map".to_string(),
137 ),
138 Self::TrailingComma { input, at, .. } => (
139 input.as_str(),
140 *at,
141 "configuration source: trailing comma".to_string(),
142 ),
143 Self::InvalidNumber {
144 input, at, found, ..
145 } => (
146 input.as_str(),
147 *at,
148 format!("configuration source: invalid number `{found}`"),
149 ),
150 Self::TrailingInput {
151 input, at, rest, ..
152 } => (
153 input.as_str(),
154 *at,
155 format!("configuration source: unexpected trailing input `{rest}`"),
156 ),
157 Self::InvalidOnError { input, at, message } => (
158 input.as_str(),
159 *at,
160 format!("configuration source: {message}"),
161 ),
162 };
163 write!(
164 f,
165 "invalid configuration source at column {}: {}",
166 at + 1,
167 message
168 )?;
169 if f.alternate() {
170 write!(f, "\n {}\n ", input)?;
171 for _ in 0..at {
172 write!(f, " ")?;
173 }
174 write!(f, "^")?;
175 }
176 Ok(())
177 }
178}
179
180impl std::error::Error for ParseError {}
181
182pub fn parse(input: &str) -> Result<Source, ParseError> {
184 Parser::new(input).parse()
185}
186
187impl Display for Source {
188 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
189 write!(f, "{}", self.source())?;
190 if !self.options().is_empty() {
191 write!(f, "(")?;
192 for (index, (key, value)) in self.options().entries().iter().enumerate() {
193 if index > 0 {
194 write!(f, ",")?;
195 }
196 write!(f, "{key}=")?;
197 write_option_value(f, value)?;
198 }
199 write!(f, ")")?;
200 }
201 if self.resource_colon() || !self.resource().is_empty() {
202 write!(f, ":{}", self.resource())?;
203 }
204 Ok(())
205 }
206}
207
208fn write_option_value(f: &mut Formatter<'_>, value: &OptionValue) -> fmt::Result {
209 match value {
210 OptionValue::Bool(value) => write!(f, "{value}"),
211 OptionValue::Integer(value) => write!(f, "{value}"),
212 OptionValue::Float(value) => {
213 if value.is_finite() && value.fract() == 0.0 {
214 write!(f, "{value:.1}")
215 } else {
216 write!(f, "{value}")
217 }
218 }
219 OptionValue::String(value) => {
220 let needs_quotes = value.is_empty()
221 || !value
222 .chars()
223 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
224 || value.eq_ignore_ascii_case("true")
225 || value.eq_ignore_ascii_case("false")
226 || is_int_token(value)
227 || is_float_token(value);
228 if needs_quotes {
229 write!(f, "\"")?;
230 for ch in value.chars() {
231 match ch {
232 '"' => write!(f, "\\\"")?,
233 '\\' => write!(f, "\\\\")?,
234 '\n' => write!(f, "\\n")?,
235 '\r' => write!(f, "\\r")?,
236 '\t' => write!(f, "\\t")?,
237 ch => write!(f, "{ch}")?,
238 }
239 }
240 write!(f, "\"")
241 } else {
242 write!(f, "{value}")
243 }
244 }
245 OptionValue::List(values) => {
246 write!(f, "[")?;
247 for (index, item) in values.iter().enumerate() {
248 if index > 0 {
249 write!(f, ",")?;
250 }
251 write_option_value(f, item)?;
252 }
253 write!(f, "]")
254 }
255 OptionValue::Map(options) => {
256 write!(f, "(")?;
257 for (index, (key, item)) in options.entries().iter().enumerate() {
258 if index > 0 {
259 write!(f, ",")?;
260 }
261 write!(f, "{key}=")?;
262 write_option_value(f, item)?;
263 }
264 write!(f, ")")
265 }
266 }
267}
268
269struct Parser<'a> {
270 input: &'a str,
271 pos: usize,
272}
273
274impl<'a> Parser<'a> {
275 fn new(input: &'a str) -> Self {
276 Self { input, pos: 0 }
277 }
278
279 fn owned_input(&self) -> String {
280 self.input.to_string()
281 }
282
283 fn parse(mut self) -> Result<Source, ParseError> {
284 let source = self.parse_source()?;
285 let options_at = self.pos;
286 let options = if self.peek() == Some('(') {
287 self.parse_options_block()?
288 } else {
289 Options::default()
290 };
291 let (resource_colon, resource) = if self.peek() == Some(':') {
292 self.bump();
293 let resource = self.input[self.pos..].to_string();
294 self.pos = self.input.len();
295 (true, resource)
296 } else {
297 (false, String::new())
298 };
299 if self.pos < self.input.len() {
300 return Err(ParseError::TrailingInput {
301 input: self.owned_input(),
302 at: self.pos,
303 rest: self.input[self.pos..].to_string(),
304 });
305 }
306 if let Some(message) = validate_on_error(&options) {
307 return Err(ParseError::InvalidOnError {
308 input: self.owned_input(),
309 at: options_at,
310 message,
311 });
312 }
313 Ok(Source {
314 source,
315 options,
316 resource,
317 resource_colon,
318 })
319 }
320
321 fn parse_source(&mut self) -> Result<String, ParseError> {
322 let start = self.pos;
323 if !self
324 .peek()
325 .is_some_and(|ch| is_ident_char(ch) && !ch.is_ascii_digit())
326 {
327 if self.pos >= self.input.len() {
328 return Err(ParseError::MissingSource {
329 input: self.owned_input(),
330 at: self.pos,
331 });
332 }
333 let found = self.peek().unwrap();
334 return Err(ParseError::UnexpectedChar {
335 input: self.owned_input(),
336 at: self.pos,
337 found,
338 expected: "source identifier",
339 });
340 }
341 while self.peek().is_some_and(is_ident_char) {
342 self.bump();
343 }
344 if self.pos == start {
345 return Err(ParseError::MissingSource {
346 input: self.owned_input(),
347 at: self.pos,
348 });
349 }
350 Ok(self.input[start..self.pos].to_string())
351 }
352
353 fn parse_options_block(&mut self) -> Result<Options, ParseError> {
354 self.expect_char('(', "opening `(` for options")?;
355 let mut options = Options::default();
356 if self.peek() == Some(')') {
357 self.bump();
358 return Ok(options);
359 }
360 loop {
361 let key = self.parse_key()?;
362 self.expect_char('=', "option value after `=`")?;
363 let value = self.parse_value()?;
364 options.insert(key, value);
365 match self.peek() {
366 Some(',') => {
367 self.bump();
368 if matches!(self.peek(), Some(')' | ']' | ',')) {
369 return Err(ParseError::TrailingComma {
370 input: self.owned_input(),
371 at: self.pos,
372 });
373 }
374 }
375 Some(')') => {
376 self.bump();
377 break;
378 }
379 None => {
380 return Err(ParseError::UnclosedMap {
381 input: self.owned_input(),
382 at: self.pos,
383 });
384 }
385 Some(found) => {
386 return Err(ParseError::UnexpectedChar {
387 input: self.owned_input(),
388 at: self.pos,
389 found,
390 expected: "`,` or `)`",
391 });
392 }
393 }
394 }
395 Ok(options)
396 }
397
398 fn parse_map_value(&mut self) -> Result<OptionValue, ParseError> {
399 self.expect_char('(', "opening `(` for map")?;
400 let mut options = Options::default();
401 if self.peek() == Some(')') {
402 self.bump();
403 return Ok(OptionValue::Map(options));
404 }
405 loop {
406 let key = self.parse_key()?;
407 self.expect_char('=', "map value after `=`")?;
408 let value = self.parse_value()?;
409 options.insert(key, value);
410 match self.peek() {
411 Some(',') => {
412 self.bump();
413 if matches!(self.peek(), Some(')' | ']' | ',')) {
414 return Err(ParseError::TrailingComma {
415 input: self.owned_input(),
416 at: self.pos,
417 });
418 }
419 }
420 Some(')') => {
421 self.bump();
422 break;
423 }
424 None => {
425 return Err(ParseError::UnclosedMap {
426 input: self.owned_input(),
427 at: self.pos,
428 });
429 }
430 Some(found) => {
431 return Err(ParseError::UnexpectedChar {
432 input: self.owned_input(),
433 at: self.pos,
434 found,
435 expected: "`,` or `)`",
436 });
437 }
438 }
439 }
440 Ok(OptionValue::Map(options))
441 }
442
443 fn parse_list_value(&mut self) -> Result<OptionValue, ParseError> {
444 self.expect_char('[', "opening `[` for list")?;
445 let mut values = Vec::new();
446 if self.peek() == Some(']') {
447 self.bump();
448 return Ok(OptionValue::List(values));
449 }
450 loop {
451 values.push(self.parse_value()?);
452 match self.peek() {
453 Some(',') => {
454 self.bump();
455 if matches!(self.peek(), Some(']' | ',')) {
456 return Err(ParseError::TrailingComma {
457 input: self.owned_input(),
458 at: self.pos,
459 });
460 }
461 }
462 Some(']') => {
463 self.bump();
464 break;
465 }
466 None => {
467 return Err(ParseError::UnclosedList {
468 input: self.owned_input(),
469 at: self.pos,
470 });
471 }
472 Some(found) => {
473 return Err(ParseError::UnexpectedChar {
474 input: self.owned_input(),
475 at: self.pos,
476 found,
477 expected: "`,` or `]`",
478 });
479 }
480 }
481 }
482 Ok(OptionValue::List(values))
483 }
484
485 fn parse_key(&mut self) -> Result<String, ParseError> {
486 let start = self.pos;
487 if !self
488 .peek()
489 .is_some_and(|ch| is_ident_char(ch) && !ch.is_ascii_digit())
490 {
491 if self.peek() == Some('=') {
492 return Err(ParseError::EmptyKey {
493 input: self.owned_input(),
494 at: self.pos,
495 });
496 }
497 let found = self
498 .peek()
499 .map(|ch| ch.to_string())
500 .unwrap_or_else(|| "end of input".to_string());
501 return if self.peek().is_some() {
502 Err(ParseError::UnexpectedChar {
503 input: self.owned_input(),
504 at: self.pos,
505 found: self.peek().unwrap(),
506 expected: "option key",
507 })
508 } else {
509 Err(ParseError::InvalidIdentifier {
510 input: self.owned_input(),
511 at: self.pos,
512 found,
513 })
514 };
515 }
516 while self.peek().is_some_and(is_ident_char) {
517 self.bump();
518 }
519 if self.pos == start {
520 return Err(ParseError::EmptyKey {
521 input: self.owned_input(),
522 at: self.pos,
523 });
524 }
525 Ok(self.input[start..self.pos].to_string())
526 }
527
528 fn parse_value(&mut self) -> Result<OptionValue, ParseError> {
529 match self.peek() {
530 Some('"') => Ok(OptionValue::String(self.parse_quoted_string()?)),
531 Some('[') => self.parse_list_value(),
532 Some('(') => self.parse_map_value(),
533 Some('=') | Some(',') | Some(')') | Some(']') | Some(':') | Some('?') | None => {
534 Err(ParseError::EmptyValue {
535 input: self.owned_input(),
536 at: self.pos,
537 })
538 }
539 Some(_) => {
540 let token = self.parse_unquoted_token()?;
541 let at = self.pos - token.len();
542 let owned_input = self.input.to_string();
543 if token.eq_ignore_ascii_case("true") {
544 Ok(OptionValue::Bool(true))
545 } else if token.eq_ignore_ascii_case("false") {
546 Ok(OptionValue::Bool(false))
547 } else if token.contains('.') {
548 if !is_float_token(&token) {
549 Err(ParseError::InvalidNumber {
550 input: owned_input,
551 at,
552 found: token,
553 })
554 } else {
555 token.parse::<f64>().map(OptionValue::Float).map_err(|_| {
556 ParseError::InvalidNumber {
557 input: owned_input,
558 at,
559 found: token,
560 }
561 })
562 }
563 } else if is_int_token(&token) {
564 token.parse::<i64>().map(OptionValue::Integer).map_err(|_| {
565 ParseError::InvalidNumber {
566 input: owned_input,
567 at,
568 found: token,
569 }
570 })
571 } else {
572 Ok(OptionValue::String(token))
573 }
574 }
575 }
576 }
577
578 fn parse_unquoted_token(&mut self) -> Result<String, ParseError> {
579 let start = self.pos;
580 while self
581 .peek()
582 .is_some_and(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
583 {
584 self.bump();
585 }
586 if self.pos == start {
587 let found = self.peek().unwrap();
588 return Err(ParseError::UnexpectedChar {
589 input: self.owned_input(),
590 at: self.pos,
591 found,
592 expected: "value",
593 });
594 }
595 Ok(self.input[start..self.pos].to_string())
596 }
597
598 fn parse_quoted_string(&mut self) -> Result<String, ParseError> {
599 self.expect_char('"', "opening `\"` for string")?;
600 let start = self.pos;
601 let mut value = String::new();
602 while let Some(ch) = self.peek() {
603 if ch == '"' {
604 self.bump();
605 return Ok(value);
606 }
607 if ch == '\\' {
608 self.bump();
609 let escaped = self.peek().ok_or(ParseError::UnclosedString {
610 input: self.owned_input(),
611 at: start,
612 })?;
613 value.push(match escaped {
614 '"' => '"',
615 '\\' => '\\',
616 'n' => '\n',
617 'r' => '\r',
618 't' => '\t',
619 _ => {
620 return Err(ParseError::InvalidEscape {
621 input: self.owned_input(),
622 at: self.pos - 1,
623 });
624 }
625 });
626 self.bump();
627 continue;
628 }
629 self.bump();
630 value.push(ch);
631 }
632 Err(ParseError::UnclosedString {
633 input: self.owned_input(),
634 at: start,
635 })
636 }
637
638 fn expect_char(&mut self, expected: char, message: &'static str) -> Result<(), ParseError> {
639 match self.peek() {
640 Some(found) if found == expected => {
641 self.bump();
642 Ok(())
643 }
644 Some(found) => Err(ParseError::UnexpectedChar {
645 input: self.owned_input(),
646 at: self.pos,
647 found,
648 expected: message,
649 }),
650 None => Err(ParseError::UnexpectedEnd {
651 input: self.owned_input(),
652 at: self.pos,
653 expected: message,
654 }),
655 }
656 }
657
658 fn peek(&self) -> Option<char> {
659 self.input[self.pos..].chars().next()
660 }
661
662 fn bump(&mut self) -> Option<char> {
663 let ch = self.peek()?;
664 self.pos += ch.len_utf8();
665 Some(ch)
666 }
667}
668
669fn is_ident_char(ch: char) -> bool {
670 ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')
671}
672
673fn is_int_token(token: &str) -> bool {
674 let Some(body) = token.strip_prefix('-').or(Some(token)) else {
675 return false;
676 };
677 !body.is_empty() && body.chars().all(|ch| ch.is_ascii_digit())
678}
679
680fn is_float_token(token: &str) -> bool {
681 let token = token.strip_prefix('-').unwrap_or(token);
682 let Some((whole, fraction)) = token.split_once('.') else {
683 return false;
684 };
685 !whole.is_empty()
686 && !fraction.is_empty()
687 && whole.chars().all(|ch| ch.is_ascii_digit())
688 && fraction.chars().all(|ch| ch.is_ascii_digit())
689}
690
691fn validate_on_error(options: &Options) -> Option<String> {
696 let value = options.get("on_error")?;
697 let OptionValue::Map(map) = value else {
698 return Some(format!(
699 "`on_error` must be a map like `(load=skip)`, found {}",
700 value.type_name()
701 ));
702 };
703 for (stage, policy) in map.iter() {
704 if !matches!(stage, "load" | "parse" | "validate") {
705 return Some(format!(
706 "unknown `on_error` stage `{stage}`; expected load, parse, or validate"
707 ));
708 }
709 match policy {
710 OptionValue::String(text)
711 if text.eq_ignore_ascii_case("skip") || text.eq_ignore_ascii_case("fail") => {}
712 _ => {
713 return Some(format!(
714 "`on_error` policy for `{stage}` must be `skip` or `fail`, found `{policy}`"
715 ));
716 }
717 }
718 }
719 None
720}
721
722#[cfg(test)]
723mod tests {
724 use super::*;
725 use crate::OptionValue;
726
727 fn parsed(input: &str) -> Source {
728 parse(input).unwrap_or_else(|error| panic!("{error}"))
729 }
730
731 #[test]
732 fn parses_documented_examples() {
733 let env = parsed("env");
734 assert_eq!(env.source(), "env");
735 assert!(env.options().is_empty());
736 assert_eq!(env.resource(), "");
737 assert_eq!(env.on_error(crate::Stage::Load), crate::OnError::Fail);
738 assert!(!env.resource_colon());
739
740 let env_opts = parsed("env(prefix=APP_)");
741 assert_eq!(
742 env_opts.options().get("prefix"),
743 Some(&OptionValue::String("APP_".into()))
744 );
745
746 let file = parsed("file:/x/y/z");
747 assert_eq!(file.resource(), "/x/y/z");
748 assert_eq!(file.on_error(crate::Stage::Load), crate::OnError::Fail);
749
750 let file_skip = parsed("file(on_error=(load=skip)):.env");
751 assert_eq!(file_skip.on_error(crate::Stage::Load), crate::OnError::Skip);
752 assert_eq!(file_skip.resource(), ".env");
753
754 let http = parsed(
755 r#"http(headers=(Authorization="TOKEN"),timeout=3s,on_error=(load=skip)):https://domain.tld/my/config.yml"#,
756 );
757 assert_eq!(http.source(), "http");
758 assert_eq!(http.on_error(crate::Stage::Load), crate::OnError::Skip);
759 assert_eq!(http.resource(), "https://domain.tld/my/config.yml");
760 assert_eq!(
761 http.options().get("timeout"),
762 Some(&OptionValue::String("3s".into()))
763 );
764 }
765
766 #[test]
767 fn round_trips_examples() {
768 for input in [
769 "env",
770 "env(prefix=APP_)",
771 "file:/x/y/z",
772 "file(on_error=(load=skip)):.env",
773 "env:",
774 ] {
775 let source = parsed(input);
776 assert_eq!(source.to_string(), input, "round-trip failed for `{input}`");
777 }
778
779 let http = parsed(
780 r#"http(headers=(Authorization="TOKEN"),timeout=3s,on_error=(load=skip,validate=skip)):https://domain.tld/my/config.yml"#,
781 );
782 assert_eq!(parsed(&http.to_string()), http);
783 }
784
785 #[test]
786 fn parses_bool_case_insensitive() {
787 let source = parsed("env(on=TRUE,off=false)");
788 assert_eq!(source.options().get("on"), Some(&OptionValue::Bool(true)));
789 assert_eq!(source.options().get("off"), Some(&OptionValue::Bool(false)));
790 }
791
792 #[test]
793 fn old_skip_marker_now_errors() {
794 assert!(matches!(
796 parse("file?:.env"),
797 Err(ParseError::TrailingInput { .. })
798 ));
799 assert!(matches!(
800 parse("env?(kv=salam):oops"),
801 Err(ParseError::TrailingInput { .. })
802 ));
803 }
804
805 #[test]
806 fn rejects_malformed_on_error() {
807 assert!(matches!(
808 parse("file(on_error=skip):.env"),
809 Err(ParseError::InvalidOnError { .. })
810 ));
811 assert!(matches!(
812 parse("file(on_error=(bogus=skip)):.env"),
813 Err(ParseError::InvalidOnError { .. })
814 ));
815 assert!(matches!(
816 parse("file(on_error=(load=maybe)):.env"),
817 Err(ParseError::InvalidOnError { .. })
818 ));
819 }
820
821 #[test]
822 fn parses_complex_options_with_on_error() {
823 let source = parsed(r#"env(kv=salam,h=(o=b,z=[1,2,3.14,""]),on_error=(parse=skip)):oops"#);
824 assert_eq!(source.on_error(crate::Stage::Parse), crate::OnError::Skip);
825 assert_eq!(source.resource(), "oops");
826 assert_eq!(
827 source.options().get("kv"),
828 Some(&OptionValue::String("salam".into()))
829 );
830 }
831
832 #[test]
833 fn rejects_invalid_forms() {
834 assert!(matches!(parse(""), Err(ParseError::MissingSource { .. })));
835 assert!(matches!(
836 parse("env(a=)"),
837 Err(ParseError::EmptyValue { .. })
838 ));
839 assert!(matches!(
840 parse("env(a=1,)"),
841 Err(ParseError::TrailingComma { .. })
842 ));
843 assert!(matches!(
844 parse("env(a=.5)"),
845 Err(ParseError::InvalidNumber { .. })
846 ));
847 assert!(matches!(
848 parse("env(a=+5)"),
849 Err(ParseError::UnexpectedChar { .. })
850 ));
851 assert!(matches!(
852 parse("env()oops"),
853 Err(ParseError::TrailingInput { .. })
854 ));
855 }
856
857 #[test]
858 fn parse_error_alternate_includes_caret() {
859 let error = parse("env(prefix=)").unwrap_err();
860 let message = format!("{error:#}");
861 assert!(message.contains("column"));
862 assert!(message.contains('^'));
863 assert!(message.contains('\n'));
864 }
865
866 #[test]
867 fn parse_error_default_is_single_line() {
868 let error = parse("env(prefix=)").unwrap_err();
869 let message = error.to_string();
870 assert!(!message.contains('^'));
871 assert!(!message.contains('\n'));
872 }
873
874 #[test]
875 fn rejects_more_invalid_forms() {
876 assert!(matches!(parse("env(=1)"), Err(ParseError::EmptyKey { .. })));
877 assert!(matches!(
878 parse("env(@a=1)"),
879 Err(ParseError::UnexpectedChar { .. })
880 ));
881 assert!(matches!(
882 parse(r#"env(x="unclosed)"#),
883 Err(ParseError::UnclosedString { .. })
884 ));
885 assert!(matches!(
886 parse(r#"env(x="\q")"#),
887 Err(ParseError::InvalidEscape { .. })
888 ));
889 }
890
891 #[test]
892 fn parses_resource_colon_without_path() {
893 let source = parsed("env:");
894 assert!(source.resource_colon());
895 assert_eq!(source.resource(), "");
896 assert_eq!(source.to_string(), "env:");
897 }
898
899 #[test]
900 fn rejects_unclosed_list_and_map_forms() {
901 assert!(matches!(
902 parse("env(x=[1"),
903 Err(ParseError::UnclosedList { .. })
904 ));
905 assert!(matches!(
906 parse("env(a=1"),
907 Err(ParseError::UnclosedMap { .. })
908 ));
909 assert!(matches!(
910 parse("env(x=(a=1"),
911 Err(ParseError::UnclosedMap { .. })
912 ));
913 assert!(matches!(
914 parse("env("),
915 Err(ParseError::InvalidIdentifier { .. })
916 ));
917 assert!(matches!(
918 parse("env(a"),
919 Err(ParseError::UnexpectedEnd { .. })
920 ));
921 }
922
923 #[test]
924 fn parses_empty_options_list_and_map_values() {
925 let source = parsed("env()");
926 assert!(source.options().is_empty());
927
928 let source = parsed("env(items=[],nested=())");
929 assert_eq!(
930 source.options().get("items"),
931 Some(&OptionValue::List(Vec::new()))
932 );
933 assert!(source.options().get("nested").unwrap().is_map());
934 }
935
936 #[test]
937 fn parses_numeric_and_escaped_string_values() {
938 let source = parsed(r#"env(n=-7,pi=2.5,token="a\"b",nl="x\ny")"#);
939 assert_eq!(source.options().get("n"), Some(&OptionValue::Integer(-7)));
940 assert_eq!(source.options().get("pi"), Some(&OptionValue::Float(2.5)));
941 assert_eq!(
942 source.options().get("token"),
943 Some(&OptionValue::String("a\"b".into()))
944 );
945 assert_eq!(
946 source.options().get("nl"),
947 Some(&OptionValue::String("x\ny".into()))
948 );
949 }
950
951 #[test]
952 fn display_quotes_ambiguous_strings_and_formats_collections() {
953 let source = parsed(r#"env(empty="",name="007",items=[a,b],nested=(k=v))"#);
954 let text = source.to_string();
955 assert!(text.contains(r#"empty="""#));
956 assert!(text.contains(r#"name="007""#));
957 assert!(text.contains("items=[a,b]"));
958 assert!(text.contains("nested=(k=v)"));
959 assert_eq!(parsed(&text), source);
960 }
961
962 #[test]
963 fn display_renders_whole_number_floats_with_one_decimal_place() {
964 let source = parsed("env(n=2.0)");
965 assert_eq!(source.to_string(), "env(n=2.0)");
966 }
967
968 #[test]
969 fn list_and_map_reject_trailing_commas_and_bad_separators() {
970 assert!(matches!(
971 parse("env(x=[1,])"),
972 Err(ParseError::TrailingComma { .. })
973 ));
974 assert!(matches!(
975 parse("env(x=[1 2])"),
976 Err(ParseError::UnexpectedChar { .. })
977 ));
978 assert!(matches!(
979 parse("env(x=(a=1,))"),
980 Err(ParseError::TrailingComma { .. })
981 ));
982 }
983
984 #[test]
985 fn parse_error_variants_include_context_in_display() {
986 let error = parse("env()oops").unwrap_err();
987 let message = error.to_string();
988 assert!(message.contains("unexpected trailing input"));
989 assert!(message.contains("column"));
990
991 let error = parse("env(a=.5)").unwrap_err();
992 assert!(error.to_string().contains("invalid number"));
993 }
994}