Skip to main content

tanzim_source/
parse.rs

1//! Parse and format configuration source strings.
2//!
3//! Format: `SOURCE [(OPTIONS)] [?] [:RESOURCE]` — see crate README for rules.
4//!
5//! Use [`parse`] or [`Source::parse`] to parse; [`Source`] [`Display`] writes the canonical form.
6
7use crate::{OptionValue, Options, Source};
8use std::fmt::{self, Display, Formatter};
9
10/// Error while parsing a configuration source string.
11///
12/// Format: `SOURCE [(OPTIONS)] [?] [:RESOURCE]` — see the crate README for rules.
13///
14/// [`Display`] is one line by default; use `{error:#}` for the input snippet and caret.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum ParseError {
17    /// No source identifier (empty input or invalid start).
18    MissingSource { input: String, at: usize },
19    /// Input ended before a required token.
20    UnexpectedEnd {
21        input: String,
22        at: usize,
23        expected: &'static str,
24    },
25    /// Unexpected character at the current position.
26    UnexpectedChar {
27        input: String,
28        at: usize,
29        found: char,
30        expected: &'static str,
31    },
32    /// Option or map key is not a valid identifier.
33    InvalidIdentifier {
34        input: String,
35        at: usize,
36        found: String,
37    },
38    /// Option or map key is empty.
39    EmptyKey { input: String, at: usize },
40    /// Option value is empty; use `""` for an empty string.
41    EmptyValue { input: String, at: usize },
42    /// Invalid escape sequence inside a quoted string.
43    InvalidEscape { input: String, at: usize },
44    /// Quoted string has no closing `"`.
45    UnclosedString { input: String, at: usize },
46    /// List has no closing `]`.
47    UnclosedList { input: String, at: usize },
48    /// Map or options block has no closing `)`.
49    UnclosedMap { input: String, at: usize },
50    /// Comma with no following entry.
51    TrailingComma { input: String, at: usize },
52    /// Token looks like a number but is not valid.
53    InvalidNumber {
54        input: String,
55        at: usize,
56        found: String,
57    },
58    /// Non-empty input after a complete configuration source.
59    TrailingInput {
60        input: String,
61        at: usize,
62        rest: String,
63    },
64    /// The reserved `on_error` option is malformed (bad shape, unknown stage, or bad value).
65    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
182/// Parse a configuration source string.
183pub 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
691/// Validate the reserved `on_error` option, returning a message when it is malformed.
692///
693/// Shape: `on_error=(<stage>=<policy>,…)` where `<stage>` is `load`/`parse`/`validate` and
694/// `<policy>` is `skip`/`fail` (case-insensitive). Absent option → `Ok`.
695fn 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        // The legacy `?` ignore-errors marker is gone; it is now trailing input.
795        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}