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///
16/// # Examples
17///
18/// ```rust
19/// use tanzim_source::{Source, ParseError};
20///
21/// let error: ParseError = Source::parse("").unwrap_err();
22/// assert_eq!(
23///     error.to_string(),
24///     "invalid configuration source at column 1: configuration source is required"
25/// );
26/// ```
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum ParseError {
29    /// No source identifier (empty input or invalid start).
30    MissingSource {
31        /// The full input string being parsed.
32        input: String,
33        /// Byte offset into `input` where the error occurred.
34        at: usize,
35    },
36    /// Input ended before a required token.
37    UnexpectedEnd {
38        /// The full input string being parsed.
39        input: String,
40        /// Byte offset into `input` where the error occurred.
41        at: usize,
42        /// What was expected at this position.
43        expected: &'static str,
44    },
45    /// Unexpected character at the current position.
46    UnexpectedChar {
47        /// The full input string being parsed.
48        input: String,
49        /// Byte offset into `input` where the error occurred.
50        at: usize,
51        /// The character that was found instead.
52        found: char,
53        /// What was expected at this position.
54        expected: &'static str,
55    },
56    /// Option or map key is not a valid identifier.
57    InvalidIdentifier {
58        /// The full input string being parsed.
59        input: String,
60        /// Byte offset into `input` where the error occurred.
61        at: usize,
62        /// The invalid identifier text that was found.
63        found: String,
64    },
65    /// Option or map key is empty.
66    EmptyKey {
67        /// The full input string being parsed.
68        input: String,
69        /// Byte offset into `input` where the error occurred.
70        at: usize,
71    },
72    /// Option value is empty; use `""` for an empty string.
73    EmptyValue {
74        /// The full input string being parsed.
75        input: String,
76        /// Byte offset into `input` where the error occurred.
77        at: usize,
78    },
79    /// Invalid escape sequence inside a quoted string.
80    InvalidEscape {
81        /// The full input string being parsed.
82        input: String,
83        /// Byte offset into `input` where the error occurred.
84        at: usize,
85    },
86    /// Quoted string has no closing `"`.
87    UnclosedString {
88        /// The full input string being parsed.
89        input: String,
90        /// Byte offset into `input` where the error occurred.
91        at: usize,
92    },
93    /// List has no closing `]`.
94    UnclosedList {
95        /// The full input string being parsed.
96        input: String,
97        /// Byte offset into `input` where the error occurred.
98        at: usize,
99    },
100    /// Map or options block has no closing `)`.
101    UnclosedMap {
102        /// The full input string being parsed.
103        input: String,
104        /// Byte offset into `input` where the error occurred.
105        at: usize,
106    },
107    /// Comma with no following entry.
108    TrailingComma {
109        /// The full input string being parsed.
110        input: String,
111        /// Byte offset into `input` where the error occurred.
112        at: usize,
113    },
114    /// Token looks like a number but is not valid.
115    InvalidNumber {
116        /// The full input string being parsed.
117        input: String,
118        /// Byte offset into `input` where the error occurred.
119        at: usize,
120        /// The invalid numeric token that was found.
121        found: String,
122    },
123    /// Non-empty input after a complete configuration source.
124    TrailingInput {
125        /// The full input string being parsed.
126        input: String,
127        /// Byte offset into `input` where the error occurred.
128        at: usize,
129        /// The unparsed remainder of the input.
130        rest: String,
131    },
132    /// The reserved `on_error` option is malformed (bad shape, unknown stage, or bad value).
133    InvalidOnError {
134        /// The full input string being parsed.
135        input: String,
136        /// Byte offset into `input` where the error occurred.
137        at: usize,
138        /// Description of what is wrong with the `on_error` option.
139        message: String,
140    },
141}
142
143impl Display for ParseError {
144    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
145        let (input, at, message) = match self {
146            Self::MissingSource { input, at, .. } => (
147                input.as_str(),
148                *at,
149                "configuration source is required".to_string(),
150            ),
151            Self::UnexpectedEnd {
152                input,
153                at,
154                expected,
155                ..
156            } => (
157                input.as_str(),
158                *at,
159                format!("configuration source: expected {expected}, found end of input"),
160            ),
161            Self::UnexpectedChar {
162                input,
163                at,
164                found,
165                expected,
166                ..
167            } => (
168                input.as_str(),
169                *at,
170                format!("configuration source: expected {expected}, found `{found}`"),
171            ),
172            Self::InvalidIdentifier {
173                input, at, found, ..
174            } => (
175                input.as_str(),
176                *at,
177                format!("configuration source: invalid identifier `{found}`"),
178            ),
179            Self::EmptyKey { input, at, .. } => (
180                input.as_str(),
181                *at,
182                "configuration source option key cannot be empty".to_string(),
183            ),
184            Self::EmptyValue { input, at, .. } => (
185                input.as_str(),
186                *at,
187                "configuration source option value cannot be empty; use \"\"".to_string(),
188            ),
189            Self::InvalidEscape { input, at, .. } => (
190                input.as_str(),
191                *at,
192                "configuration source: invalid escape sequence in string".to_string(),
193            ),
194            Self::UnclosedString { input, at, .. } => (
195                input.as_str(),
196                *at,
197                "configuration source: unclosed string".to_string(),
198            ),
199            Self::UnclosedList { input, at, .. } => (
200                input.as_str(),
201                *at,
202                "configuration source: unclosed list".to_string(),
203            ),
204            Self::UnclosedMap { input, at, .. } => (
205                input.as_str(),
206                *at,
207                "configuration source: unclosed map".to_string(),
208            ),
209            Self::TrailingComma { input, at, .. } => (
210                input.as_str(),
211                *at,
212                "configuration source: trailing comma".to_string(),
213            ),
214            Self::InvalidNumber {
215                input, at, found, ..
216            } => (
217                input.as_str(),
218                *at,
219                format!("configuration source: invalid number `{found}`"),
220            ),
221            Self::TrailingInput {
222                input, at, rest, ..
223            } => (
224                input.as_str(),
225                *at,
226                format!("configuration source: unexpected trailing input `{rest}`"),
227            ),
228            Self::InvalidOnError { input, at, message } => (
229                input.as_str(),
230                *at,
231                format!("configuration source: {message}"),
232            ),
233        };
234        write!(
235            f,
236            "invalid configuration source at column {}: {}",
237            at + 1,
238            message
239        )?;
240        if f.alternate() {
241            write!(f, "\n  {}\n  ", input)?;
242            for _ in 0..at {
243                write!(f, " ")?;
244            }
245            write!(f, "^")?;
246        }
247        Ok(())
248    }
249}
250
251impl std::error::Error for ParseError {}
252
253/// Parse a `SOURCE[(OPTIONS)][:RESOURCE]` configuration source string.
254///
255/// Equivalent to [`Source::parse`]. See the [crate-level documentation](crate) for the format
256/// and rules.
257///
258/// # Examples
259///
260/// ```rust
261/// use tanzim_source::parse;
262///
263/// let source = parse("env(prefix=APP_)")?;
264/// assert_eq!(source.source(), "env");
265/// assert_eq!(source.resource(), "");
266///
267/// let file = parse("file:app.toml")?;
268/// assert_eq!(file.source(), "file");
269/// assert_eq!(file.resource(), "app.toml");
270/// # Ok::<(), tanzim_source::ParseError>(())
271/// ```
272///
273/// A malformed source string returns a [`ParseError`]. Use `{error:#}` to render the input
274/// snippet with a caret pointing at the offending column:
275///
276/// ```rust
277/// use tanzim_source::parse;
278///
279/// let error = parse("file(bad").unwrap_err();
280/// println!("{error:#}");
281/// ```
282///
283/// which prints:
284///
285/// ```text
286/// invalid configuration source at column 9: configuration source: expected option value after `=`, found end of input
287///   file(bad
288///          ^
289/// ```
290pub fn parse(input: &str) -> Result<Source, ParseError> {
291    Parser::new(input).parse()
292}
293
294impl Display for Source {
295    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
296        write!(f, "{}", self.source())?;
297        if !self.options().is_empty() {
298            write!(f, "(")?;
299            for (index, (key, value)) in self.options().entries().iter().enumerate() {
300                if index > 0 {
301                    write!(f, ",")?;
302                }
303                write!(f, "{key}=")?;
304                write_option_value(f, value)?;
305            }
306            write!(f, ")")?;
307        }
308        if self.resource_colon() || !self.resource().is_empty() {
309            write!(f, ":{}", self.resource())?;
310        }
311        Ok(())
312    }
313}
314
315fn write_option_value(f: &mut Formatter<'_>, value: &OptionValue) -> fmt::Result {
316    match value {
317        OptionValue::Bool(value) => write!(f, "{value}"),
318        OptionValue::Integer(value) => write!(f, "{value}"),
319        OptionValue::Float(value) => {
320            if value.is_finite() && value.fract() == 0.0 {
321                write!(f, "{value:.1}")
322            } else {
323                write!(f, "{value}")
324            }
325        }
326        OptionValue::String(value) => {
327            let needs_quotes = value.is_empty()
328                || !value
329                    .chars()
330                    .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
331                || value.eq_ignore_ascii_case("true")
332                || value.eq_ignore_ascii_case("false")
333                || is_int_token(value)
334                || is_float_token(value);
335            if needs_quotes {
336                write!(f, "\"")?;
337                for ch in value.chars() {
338                    match ch {
339                        '"' => write!(f, "\\\"")?,
340                        '\\' => write!(f, "\\\\")?,
341                        '\n' => write!(f, "\\n")?,
342                        '\r' => write!(f, "\\r")?,
343                        '\t' => write!(f, "\\t")?,
344                        ch => write!(f, "{ch}")?,
345                    }
346                }
347                write!(f, "\"")
348            } else {
349                write!(f, "{value}")
350            }
351        }
352        OptionValue::List(values) => {
353            write!(f, "[")?;
354            for (index, item) in values.iter().enumerate() {
355                if index > 0 {
356                    write!(f, ",")?;
357                }
358                write_option_value(f, item)?;
359            }
360            write!(f, "]")
361        }
362        OptionValue::Map(options) => {
363            write!(f, "(")?;
364            for (index, (key, item)) in options.entries().iter().enumerate() {
365                if index > 0 {
366                    write!(f, ",")?;
367                }
368                write!(f, "{key}=")?;
369                write_option_value(f, item)?;
370            }
371            write!(f, ")")
372        }
373    }
374}
375
376struct Parser<'a> {
377    input: &'a str,
378    pos: usize,
379}
380
381impl<'a> Parser<'a> {
382    fn new(input: &'a str) -> Self {
383        Self { input, pos: 0 }
384    }
385
386    fn owned_input(&self) -> String {
387        self.input.to_string()
388    }
389
390    fn parse(mut self) -> Result<Source, ParseError> {
391        let source = self.parse_source()?;
392        let options_at = self.pos;
393        let options = if self.peek() == Some('(') {
394            self.parse_options_block()?
395        } else {
396            Options::default()
397        };
398        let (resource_colon, resource) = if self.peek() == Some(':') {
399            self.bump();
400            let resource = self.input[self.pos..].to_string();
401            self.pos = self.input.len();
402            (true, resource)
403        } else {
404            (false, String::new())
405        };
406        if self.pos < self.input.len() {
407            return Err(ParseError::TrailingInput {
408                input: self.owned_input(),
409                at: self.pos,
410                rest: self.input[self.pos..].to_string(),
411            });
412        }
413        if let Some(message) = validate_on_error(&options) {
414            return Err(ParseError::InvalidOnError {
415                input: self.owned_input(),
416                at: options_at,
417                message,
418            });
419        }
420        Ok(Source {
421            source,
422            options,
423            resource,
424            resource_colon,
425        })
426    }
427
428    fn parse_source(&mut self) -> Result<String, ParseError> {
429        let start = self.pos;
430        if !self
431            .peek()
432            .is_some_and(|ch| is_ident_char(ch) && !ch.is_ascii_digit())
433        {
434            if self.pos >= self.input.len() {
435                return Err(ParseError::MissingSource {
436                    input: self.owned_input(),
437                    at: self.pos,
438                });
439            }
440            let found = self.peek().unwrap();
441            return Err(ParseError::UnexpectedChar {
442                input: self.owned_input(),
443                at: self.pos,
444                found,
445                expected: "source identifier",
446            });
447        }
448        while self.peek().is_some_and(is_ident_char) {
449            self.bump();
450        }
451        if self.pos == start {
452            return Err(ParseError::MissingSource {
453                input: self.owned_input(),
454                at: self.pos,
455            });
456        }
457        Ok(self.input[start..self.pos].to_string())
458    }
459
460    fn parse_options_block(&mut self) -> Result<Options, ParseError> {
461        self.expect_char('(', "opening `(` for options")?;
462        let mut options = Options::default();
463        if self.peek() == Some(')') {
464            self.bump();
465            return Ok(options);
466        }
467        loop {
468            let key = self.parse_key()?;
469            self.expect_char('=', "option value after `=`")?;
470            let value = self.parse_value()?;
471            options.insert(key, value);
472            match self.peek() {
473                Some(',') => {
474                    self.bump();
475                    if matches!(self.peek(), Some(')' | ']' | ',')) {
476                        return Err(ParseError::TrailingComma {
477                            input: self.owned_input(),
478                            at: self.pos,
479                        });
480                    }
481                }
482                Some(')') => {
483                    self.bump();
484                    break;
485                }
486                None => {
487                    return Err(ParseError::UnclosedMap {
488                        input: self.owned_input(),
489                        at: self.pos,
490                    });
491                }
492                Some(found) => {
493                    return Err(ParseError::UnexpectedChar {
494                        input: self.owned_input(),
495                        at: self.pos,
496                        found,
497                        expected: "`,` or `)`",
498                    });
499                }
500            }
501        }
502        Ok(options)
503    }
504
505    fn parse_map_value(&mut self) -> Result<OptionValue, ParseError> {
506        self.expect_char('(', "opening `(` for map")?;
507        let mut options = Options::default();
508        if self.peek() == Some(')') {
509            self.bump();
510            return Ok(OptionValue::Map(options));
511        }
512        loop {
513            let key = self.parse_key()?;
514            self.expect_char('=', "map value after `=`")?;
515            let value = self.parse_value()?;
516            options.insert(key, value);
517            match self.peek() {
518                Some(',') => {
519                    self.bump();
520                    if matches!(self.peek(), Some(')' | ']' | ',')) {
521                        return Err(ParseError::TrailingComma {
522                            input: self.owned_input(),
523                            at: self.pos,
524                        });
525                    }
526                }
527                Some(')') => {
528                    self.bump();
529                    break;
530                }
531                None => {
532                    return Err(ParseError::UnclosedMap {
533                        input: self.owned_input(),
534                        at: self.pos,
535                    });
536                }
537                Some(found) => {
538                    return Err(ParseError::UnexpectedChar {
539                        input: self.owned_input(),
540                        at: self.pos,
541                        found,
542                        expected: "`,` or `)`",
543                    });
544                }
545            }
546        }
547        Ok(OptionValue::Map(options))
548    }
549
550    fn parse_list_value(&mut self) -> Result<OptionValue, ParseError> {
551        self.expect_char('[', "opening `[` for list")?;
552        let mut values = Vec::new();
553        if self.peek() == Some(']') {
554            self.bump();
555            return Ok(OptionValue::List(values));
556        }
557        loop {
558            values.push(self.parse_value()?);
559            match self.peek() {
560                Some(',') => {
561                    self.bump();
562                    if matches!(self.peek(), Some(']' | ',')) {
563                        return Err(ParseError::TrailingComma {
564                            input: self.owned_input(),
565                            at: self.pos,
566                        });
567                    }
568                }
569                Some(']') => {
570                    self.bump();
571                    break;
572                }
573                None => {
574                    return Err(ParseError::UnclosedList {
575                        input: self.owned_input(),
576                        at: self.pos,
577                    });
578                }
579                Some(found) => {
580                    return Err(ParseError::UnexpectedChar {
581                        input: self.owned_input(),
582                        at: self.pos,
583                        found,
584                        expected: "`,` or `]`",
585                    });
586                }
587            }
588        }
589        Ok(OptionValue::List(values))
590    }
591
592    fn parse_key(&mut self) -> Result<String, ParseError> {
593        let start = self.pos;
594        if !self
595            .peek()
596            .is_some_and(|ch| is_ident_char(ch) && !ch.is_ascii_digit())
597        {
598            if self.peek() == Some('=') {
599                return Err(ParseError::EmptyKey {
600                    input: self.owned_input(),
601                    at: self.pos,
602                });
603            }
604            let found = self
605                .peek()
606                .map(|ch| ch.to_string())
607                .unwrap_or_else(|| "end of input".to_string());
608            return if self.peek().is_some() {
609                Err(ParseError::UnexpectedChar {
610                    input: self.owned_input(),
611                    at: self.pos,
612                    found: self.peek().unwrap(),
613                    expected: "option key",
614                })
615            } else {
616                Err(ParseError::InvalidIdentifier {
617                    input: self.owned_input(),
618                    at: self.pos,
619                    found,
620                })
621            };
622        }
623        while self.peek().is_some_and(is_ident_char) {
624            self.bump();
625        }
626        if self.pos == start {
627            return Err(ParseError::EmptyKey {
628                input: self.owned_input(),
629                at: self.pos,
630            });
631        }
632        Ok(self.input[start..self.pos].to_string())
633    }
634
635    fn parse_value(&mut self) -> Result<OptionValue, ParseError> {
636        match self.peek() {
637            Some('"') => Ok(OptionValue::String(self.parse_quoted_string()?)),
638            Some('[') => self.parse_list_value(),
639            Some('(') => self.parse_map_value(),
640            Some('=') | Some(',') | Some(')') | Some(']') | Some(':') | Some('?') | None => {
641                Err(ParseError::EmptyValue {
642                    input: self.owned_input(),
643                    at: self.pos,
644                })
645            }
646            Some(_) => {
647                let token = self.parse_unquoted_token()?;
648                let at = self.pos - token.len();
649                let owned_input = self.input.to_string();
650                if token.eq_ignore_ascii_case("true") {
651                    Ok(OptionValue::Bool(true))
652                } else if token.eq_ignore_ascii_case("false") {
653                    Ok(OptionValue::Bool(false))
654                } else if token.contains('.') {
655                    if !is_float_token(&token) {
656                        Err(ParseError::InvalidNumber {
657                            input: owned_input,
658                            at,
659                            found: token,
660                        })
661                    } else {
662                        token.parse::<f64>().map(OptionValue::Float).map_err(|_| {
663                            ParseError::InvalidNumber {
664                                input: owned_input,
665                                at,
666                                found: token,
667                            }
668                        })
669                    }
670                } else if is_int_token(&token) {
671                    token.parse::<i64>().map(OptionValue::Integer).map_err(|_| {
672                        ParseError::InvalidNumber {
673                            input: owned_input,
674                            at,
675                            found: token,
676                        }
677                    })
678                } else {
679                    Ok(OptionValue::String(token))
680                }
681            }
682        }
683    }
684
685    fn parse_unquoted_token(&mut self) -> Result<String, ParseError> {
686        let start = self.pos;
687        while self
688            .peek()
689            .is_some_and(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
690        {
691            self.bump();
692        }
693        if self.pos == start {
694            let found = self.peek().unwrap();
695            return Err(ParseError::UnexpectedChar {
696                input: self.owned_input(),
697                at: self.pos,
698                found,
699                expected: "value",
700            });
701        }
702        Ok(self.input[start..self.pos].to_string())
703    }
704
705    fn parse_quoted_string(&mut self) -> Result<String, ParseError> {
706        self.expect_char('"', "opening `\"` for string")?;
707        let start = self.pos;
708        let mut value = String::new();
709        while let Some(ch) = self.peek() {
710            if ch == '"' {
711                self.bump();
712                return Ok(value);
713            }
714            if ch == '\\' {
715                self.bump();
716                let escaped = self.peek().ok_or(ParseError::UnclosedString {
717                    input: self.owned_input(),
718                    at: start,
719                })?;
720                value.push(match escaped {
721                    '"' => '"',
722                    '\\' => '\\',
723                    'n' => '\n',
724                    'r' => '\r',
725                    't' => '\t',
726                    _ => {
727                        return Err(ParseError::InvalidEscape {
728                            input: self.owned_input(),
729                            at: self.pos - 1,
730                        });
731                    }
732                });
733                self.bump();
734                continue;
735            }
736            self.bump();
737            value.push(ch);
738        }
739        Err(ParseError::UnclosedString {
740            input: self.owned_input(),
741            at: start,
742        })
743    }
744
745    fn expect_char(&mut self, expected: char, message: &'static str) -> Result<(), ParseError> {
746        match self.peek() {
747            Some(found) if found == expected => {
748                self.bump();
749                Ok(())
750            }
751            Some(found) => Err(ParseError::UnexpectedChar {
752                input: self.owned_input(),
753                at: self.pos,
754                found,
755                expected: message,
756            }),
757            None => Err(ParseError::UnexpectedEnd {
758                input: self.owned_input(),
759                at: self.pos,
760                expected: message,
761            }),
762        }
763    }
764
765    fn peek(&self) -> Option<char> {
766        self.input[self.pos..].chars().next()
767    }
768
769    fn bump(&mut self) -> Option<char> {
770        let ch = self.peek()?;
771        self.pos += ch.len_utf8();
772        Some(ch)
773    }
774}
775
776fn is_ident_char(ch: char) -> bool {
777    ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')
778}
779
780fn is_int_token(token: &str) -> bool {
781    let Some(body) = token.strip_prefix('-').or(Some(token)) else {
782        return false;
783    };
784    !body.is_empty() && body.chars().all(|ch| ch.is_ascii_digit())
785}
786
787fn is_float_token(token: &str) -> bool {
788    let token = token.strip_prefix('-').unwrap_or(token);
789    let Some((whole, fraction)) = token.split_once('.') else {
790        return false;
791    };
792    !whole.is_empty()
793        && !fraction.is_empty()
794        && whole.chars().all(|ch| ch.is_ascii_digit())
795        && fraction.chars().all(|ch| ch.is_ascii_digit())
796}
797
798/// Validate the reserved `on_error` option, returning a message when it is malformed.
799///
800/// Shape: `on_error=(<stage>=<policy>,…)` where `<stage>` is `load`/`parse`/`validate` and
801/// `<policy>` is `skip`/`fail` (case-insensitive). Absent option → `Ok`.
802fn validate_on_error(options: &Options) -> Option<String> {
803    let value = options.get("on_error")?;
804    let OptionValue::Map(map) = value else {
805        return Some(format!(
806            "`on_error` must be a map like `(load=skip)`, found {}",
807            value.type_name()
808        ));
809    };
810    for (stage, policy) in map.iter() {
811        if !matches!(stage, "load" | "parse" | "validate") {
812            return Some(format!(
813                "unknown `on_error` stage `{stage}`; expected load, parse, or validate"
814            ));
815        }
816        match policy {
817            OptionValue::String(text)
818                if text.eq_ignore_ascii_case("skip") || text.eq_ignore_ascii_case("fail") => {}
819            _ => {
820                return Some(format!(
821                    "`on_error` policy for `{stage}` must be `skip` or `fail`, found `{policy}`"
822                ));
823            }
824        }
825    }
826    None
827}