Skip to main content

dioxus_mdx/parser/
yaml_lite.rs

1//! Minimal YAML-subset parser for MDX and blog frontmatter.
2//!
3//! Frontmatter is a flat mapping of scalars and string sequences, so a full
4//! YAML engine (and the deprecated `serde_yaml` / `unsafe-libyaml` stack it
5//! drags into every wasm bundle) is more machinery than the job needs.
6//!
7//! # Supported subset
8//!
9//! - `key: value` scalars — plain, `'single-quoted'` (`''` escapes a quote), or
10//!   `"double-quoted"` (with `\"`, `\\`, `\n` and `\t` escapes)
11//! - unquoted `true` / `false` booleans (also `True`/`False`, `TRUE`/`FALSE`)
12//! - block sequences: `key:` followed by `- item` lines
13//! - flow sequences: `key: [a, "b"]`
14//! - blank lines, `# comment` lines, and trailing ` # comment` after a value
15//!
16//! Everything else is a parse error: nested mappings, block scalars (`|`, `>`),
17//! flow mappings, anchors/aliases/tags, multi-line values, and duplicate keys.
18
19/// A frontmatter block used a shape outside the [supported subset](self).
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct YamlLiteError {
22    /// Human-readable description, prefixed with the source line where known.
23    pub message: String,
24}
25
26impl std::fmt::Display for YamlLiteError {
27    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28        f.write_str(&self.message)
29    }
30}
31
32impl std::error::Error for YamlLiteError {}
33
34fn err(line: usize, message: impl std::fmt::Display) -> YamlLiteError {
35    YamlLiteError {
36        message: format!("line {line}: {message}"),
37    }
38}
39
40/// A value in a [`YamlMap`].
41#[derive(Debug, Clone, PartialEq, Eq)]
42#[non_exhaustive]
43pub enum YamlValue {
44    /// `key:` with nothing after it (and no sequence items following).
45    Null,
46    /// A string scalar.
47    Scalar(String),
48    /// An unquoted `true` / `false`.
49    Bool(bool),
50    /// A block or flow sequence of scalars.
51    Sequence(Vec<String>),
52}
53
54impl YamlValue {
55    fn kind(&self) -> &'static str {
56        match self {
57            Self::Null => "null",
58            Self::Scalar(_) => "a string",
59            Self::Bool(_) => "a boolean",
60            Self::Sequence(_) => "a sequence",
61        }
62    }
63}
64
65/// A flat frontmatter mapping, in source order.
66#[derive(Debug, Clone, Default, PartialEq, Eq)]
67pub struct YamlMap {
68    entries: Vec<(String, YamlValue)>,
69}
70
71impl YamlMap {
72    /// Look up a key.
73    pub fn get(&self, key: &str) -> Option<&YamlValue> {
74        self.entries.iter().find(|(k, _)| k == key).map(|(_, v)| v)
75    }
76
77    /// A string field, or `None` when the key is absent or null.
78    pub fn optional_str(&self, key: &str) -> Result<Option<String>, YamlLiteError> {
79        match self.get(key) {
80            None | Some(YamlValue::Null) => Ok(None),
81            Some(YamlValue::Scalar(s)) => Ok(Some(s.clone())),
82            Some(other) => Err(type_error(key, "a string", other)),
83        }
84    }
85
86    /// A string field that must be present and non-null.
87    pub fn require_str(&self, key: &str) -> Result<String, YamlLiteError> {
88        self.optional_str(key)?.ok_or_else(|| YamlLiteError {
89            message: format!("missing field `{key}`"),
90        })
91    }
92
93    /// A boolean field, defaulting to `false` when absent or null.
94    pub fn optional_bool(&self, key: &str) -> Result<bool, YamlLiteError> {
95        match self.get(key) {
96            None | Some(YamlValue::Null) => Ok(false),
97            Some(YamlValue::Bool(b)) => Ok(*b),
98            Some(other) => Err(type_error(key, "a boolean", other)),
99        }
100    }
101
102    /// A sequence of scalars, empty when the key is absent or null.
103    pub fn optional_str_seq(&self, key: &str) -> Result<Vec<String>, YamlLiteError> {
104        match self.get(key) {
105            None | Some(YamlValue::Null) => Ok(Vec::new()),
106            Some(YamlValue::Sequence(items)) => Ok(items.clone()),
107            Some(other) => Err(type_error(key, "a sequence", other)),
108        }
109    }
110}
111
112fn type_error(key: &str, expected: &str, found: &YamlValue) -> YamlLiteError {
113    YamlLiteError {
114        message: format!("field `{key}`: expected {expected}, found {}", found.kind()),
115    }
116}
117
118/// Parse a frontmatter block (delimiters already stripped) into a flat mapping.
119///
120/// See the [module docs](self) for the accepted subset; anything else is an error.
121pub fn parse_yaml_lite(input: &str) -> Result<YamlMap, YamlLiteError> {
122    let mut map = YamlMap::default();
123    // Index of the entry that `- item` lines would extend.
124    let mut pending: Option<usize> = None;
125
126    for (idx, raw_line) in input.lines().enumerate() {
127        let line = idx + 1;
128        let trimmed = raw_line.trim();
129        if trimmed.is_empty() || trimmed.starts_with('#') {
130            continue;
131        }
132
133        if trimmed == "-" {
134            return Err(err(line, "sequence items must be inline scalars"));
135        }
136        if let Some(item) = trimmed.strip_prefix("- ") {
137            let Some(entry) = pending else {
138                return Err(err(line, "sequence item outside a `key:` block"));
139            };
140            let value = parse_scalar(item.trim(), line)?;
141            let slot = &mut map.entries[entry].1;
142            match slot {
143                YamlValue::Null => *slot = YamlValue::Sequence(vec![value]),
144                YamlValue::Sequence(items) => items.push(value),
145                _ => return Err(err(line, "sequence item cannot follow a scalar value")),
146            }
147            continue;
148        }
149
150        if raw_line.starts_with([' ', '\t']) {
151            return Err(err(
152                line,
153                "nested mappings and multi-line values are not supported",
154            ));
155        }
156
157        let Some((key, rest)) = trimmed.split_once(':') else {
158            return Err(err(line, "expected `key: value`"));
159        };
160        if !rest.is_empty() && !rest.starts_with([' ', '\t']) {
161            return Err(err(line, "expected a space after `key:`"));
162        }
163        let key = key.trim();
164        if key.is_empty() {
165            return Err(err(line, "empty key"));
166        }
167        if key.starts_with(['"', '\'']) {
168            return Err(err(line, "quoted keys are not supported"));
169        }
170        if map.get(key).is_some() {
171            return Err(err(line, format!("duplicate key `{key}`")));
172        }
173
174        let value = parse_value(rest.trim(), line)?;
175        pending = matches!(value, YamlValue::Null).then_some(map.entries.len());
176        map.entries.push((key.to_string(), value));
177    }
178
179    Ok(map)
180}
181
182/// Parse the text after `key:` into a value.
183fn parse_value(raw: &str, line: usize) -> Result<YamlValue, YamlLiteError> {
184    let Some(first) = raw.chars().next() else {
185        return Ok(YamlValue::Null);
186    };
187    match first {
188        '#' => Ok(YamlValue::Null),
189        '[' => parse_flow_sequence(raw, line).map(YamlValue::Sequence),
190        '{' => Err(err(line, "flow mappings are not supported")),
191        '|' | '>' => Err(err(line, "block scalars (`|`, `>`) are not supported")),
192        '&' | '*' | '!' => Err(err(
193            line,
194            "anchors, aliases and tags (`&`, `*`, `!`) are not supported",
195        )),
196        _ => {
197            let quoted = first == '"' || first == '\'';
198            let scalar = parse_scalar(raw, line)?;
199            if !quoted {
200                match scalar.as_str() {
201                    "true" | "True" | "TRUE" => return Ok(YamlValue::Bool(true)),
202                    "false" | "False" | "FALSE" => return Ok(YamlValue::Bool(false)),
203                    "" => return Ok(YamlValue::Null),
204                    _ => {}
205                }
206            }
207            Ok(YamlValue::Scalar(scalar))
208        }
209    }
210}
211
212/// Parse a single scalar: plain, single-quoted or double-quoted.
213fn parse_scalar(raw: &str, line: usize) -> Result<String, YamlLiteError> {
214    let raw = raw.trim();
215    if raw.starts_with(['"', '\'']) {
216        return parse_quoted(raw, line);
217    }
218    // In a plain scalar, ` #` starts a trailing comment.
219    let value = match raw.find(" #") {
220        Some(i) => &raw[..i],
221        None => raw,
222    };
223    Ok(value.trim_end().to_string())
224}
225
226fn parse_quoted(raw: &str, line: usize) -> Result<String, YamlLiteError> {
227    let quote = if raw.starts_with('"') { '"' } else { '\'' };
228    let body = &raw[1..];
229    let mut out = String::new();
230    let mut chars = body.char_indices();
231
232    while let Some((i, ch)) = chars.next() {
233        if quote == '"' && ch == '\\' {
234            let Some((_, esc)) = chars.next() else {
235                break;
236            };
237            out.push(match esc {
238                '"' => '"',
239                '\\' => '\\',
240                'n' => '\n',
241                't' => '\t',
242                other => {
243                    return Err(err(
244                        line,
245                        format!("unsupported escape `\\{other}` in a double-quoted string"),
246                    ));
247                }
248            });
249            continue;
250        }
251        if ch == quote {
252            // YAML escapes a quote inside a single-quoted string by doubling it.
253            if quote == '\'' && body[i + 1..].starts_with('\'') {
254                out.push('\'');
255                chars.next();
256                continue;
257            }
258            let tail = body[i + 1..].trim();
259            if !tail.is_empty() && !tail.starts_with('#') {
260                return Err(err(line, "trailing content after a quoted string"));
261            }
262            return Ok(out);
263        }
264        out.push(ch);
265    }
266
267    Err(err(line, "unterminated quoted string"))
268}
269
270fn parse_flow_sequence(raw: &str, line: usize) -> Result<Vec<String>, YamlLiteError> {
271    let close = raw
272        .rfind(']')
273        .ok_or_else(|| err(line, "unterminated flow sequence (expected a closing `]`)"))?;
274    let tail = raw[close + 1..].trim();
275    if !tail.is_empty() && !tail.starts_with('#') {
276        return Err(err(line, "trailing content after a flow sequence"));
277    }
278
279    let inner = raw[1..close].trim();
280    if inner.is_empty() {
281        return Ok(Vec::new());
282    }
283
284    // Split on top-level commas, respecting quotes.
285    let mut pieces: Vec<&str> = Vec::new();
286    let mut start = 0usize;
287    let mut quote: Option<char> = None;
288    let mut escaped = false;
289    for (i, ch) in inner.char_indices() {
290        if escaped {
291            escaped = false;
292            continue;
293        }
294        match quote {
295            Some(q) => {
296                if q == '"' && ch == '\\' {
297                    escaped = true;
298                } else if ch == q {
299                    quote = None;
300                }
301            }
302            None => match ch {
303                '"' | '\'' => quote = Some(ch),
304                '[' | '{' => {
305                    return Err(err(line, "nested flow collections are not supported"));
306                }
307                ',' => {
308                    pieces.push(&inner[start..i]);
309                    start = i + 1;
310                }
311                _ => {}
312            },
313        }
314    }
315    if quote.is_some() {
316        return Err(err(line, "unterminated quoted string in a flow sequence"));
317    }
318    pieces.push(&inner[start..]);
319
320    pieces
321        .into_iter()
322        .map(|piece| {
323            let piece = piece.trim();
324            if piece.is_empty() {
325                return Err(err(line, "empty item in a flow sequence"));
326            }
327            parse_scalar(piece, line)
328        })
329        .collect()
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    fn parse(input: &str) -> YamlMap {
337        parse_yaml_lite(input).expect("expected the input to parse")
338    }
339
340    #[test]
341    fn parses_plain_quoted_and_escaped_scalars() {
342        let map = parse(
343            "title: Plain Value\nsingle: 'it''s here'\ndouble: \"a \\\"quoted\\\" \\\\ path\"",
344        );
345        assert_eq!(
346            map.optional_str("title").unwrap().as_deref(),
347            Some("Plain Value")
348        );
349        assert_eq!(
350            map.optional_str("single").unwrap().as_deref(),
351            Some("it's here")
352        );
353        assert_eq!(
354            map.optional_str("double").unwrap().as_deref(),
355            Some("a \"quoted\" \\ path")
356        );
357    }
358
359    #[test]
360    fn skips_comment_lines_and_trailing_comments() {
361        let map = parse("# leading comment\n\ntitle: Hello # trailing\nicon: \"book\" # also\n");
362        assert_eq!(map.optional_str("title").unwrap().as_deref(), Some("Hello"));
363        assert_eq!(map.optional_str("icon").unwrap().as_deref(), Some("book"));
364    }
365
366    #[test]
367    fn hash_without_leading_space_stays_in_a_plain_scalar() {
368        let map = parse("color: \"#ff0000\"\nanchor: intro#section");
369        assert_eq!(
370            map.optional_str("color").unwrap().as_deref(),
371            Some("#ff0000")
372        );
373        assert_eq!(
374            map.optional_str("anchor").unwrap().as_deref(),
375            Some("intro#section")
376        );
377    }
378
379    #[test]
380    fn parses_block_and_flow_sequences() {
381        let block = parse("tags:\n  - rust\n  - \"dioxus\"\ntitle: T");
382        assert_eq!(block.optional_str_seq("tags").unwrap(), ["rust", "dioxus"]);
383        assert_eq!(block.optional_str("title").unwrap().as_deref(), Some("T"));
384
385        let flow = parse("tags: [\"announcement\", dioxus, 'x, y']");
386        assert_eq!(
387            flow.optional_str_seq("tags").unwrap(),
388            ["announcement", "dioxus", "x, y"]
389        );
390
391        assert_eq!(
392            parse("tags: []").optional_str_seq("tags").unwrap(),
393            Vec::<String>::new()
394        );
395    }
396
397    #[test]
398    fn unindented_block_sequences_are_accepted() {
399        let map = parse("tags:\n- rust\n- wasm");
400        assert_eq!(map.optional_str_seq("tags").unwrap(), ["rust", "wasm"]);
401    }
402
403    #[test]
404    fn parses_booleans_only_when_unquoted() {
405        let map = parse("draft: true\nfeatured: False\nlabel: \"true\"");
406        assert!(map.optional_bool("draft").unwrap());
407        assert!(!map.optional_bool("featured").unwrap());
408        assert_eq!(map.optional_str("label").unwrap().as_deref(), Some("true"));
409        // A quoted "true" is a string, not a boolean.
410        assert!(map.optional_bool("label").is_err());
411        // Absent booleans default to false.
412        assert!(!map.optional_bool("missing").unwrap());
413    }
414
415    #[test]
416    fn required_and_typed_field_errors() {
417        let map = parse("title: Hi\ntags: nope");
418        let err = map.require_str("date").unwrap_err();
419        assert!(err.to_string().contains("date"), "got: {err}");
420        let err = map.optional_str_seq("tags").unwrap_err();
421        assert!(
422            err.to_string().contains("expected a sequence"),
423            "got: {err}"
424        );
425    }
426
427    #[test]
428    fn empty_input_and_null_values() {
429        let map = parse("");
430        assert_eq!(map.optional_str("title").unwrap(), None);
431
432        let map = parse("description:\ntitle: Hi");
433        assert_eq!(map.get("description"), Some(&YamlValue::Null));
434        assert_eq!(map.optional_str("description").unwrap(), None);
435        assert_eq!(
436            map.optional_str_seq("description").unwrap(),
437            Vec::<String>::new()
438        );
439    }
440
441    #[test]
442    fn unsupported_shapes_are_errors() {
443        for input in [
444            "author:\n  name: Jane",           // nested mapping
445            "body: |\n  line one\n  line two", // block scalar
446            "body: >\n  folded",               // folded scalar
447            "base: &anchor value",             // anchor
448            "copy: *anchor",                   // alias
449            "meta: { a: 1 }",                  // flow mapping
450            "- a\n- b",                        // top-level sequence
451            "Just a fenced paragraph.",        // not a mapping
452            "title: A\ntitle: B",              // duplicate key
453            "title: \"unterminated",           // unterminated quote
454            "tags: [a, [b]]",                  // nested flow collection
455            "tags: [a",                        // unterminated flow sequence
456            "title:Hello",                     // missing space after the colon
457        ] {
458            assert!(
459                parse_yaml_lite(input).is_err(),
460                "expected an error for: {input:?}"
461            );
462        }
463    }
464}