Skip to main content

okf_core/yaml/
parser.rs

1//! Recursive parser for the OKF YAML subset. See the [module docs](super) for
2//! the supported grammar and intentional limitations.
3
4use super::{Mapping, Value};
5use std::fmt;
6
7/// An error produced while parsing YAML frontmatter.
8#[derive(Clone, Debug, PartialEq, Eq)]
9pub struct YamlError {
10    /// 1-based source line where the problem was detected (0 if not known).
11    pub line: usize,
12    /// Human-readable description.
13    pub message: String,
14}
15
16impl fmt::Display for YamlError {
17    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
18        if self.line > 0 {
19            write!(f, "YAML error at line {}: {}", self.line, self.message)
20        } else {
21            write!(f, "YAML error: {}", self.message)
22        }
23    }
24}
25
26impl std::error::Error for YamlError {}
27
28/// Parses a YAML document (the OKF subset) into a [`Value`].
29///
30/// Empty or comment/whitespace-only input parses to [`Value::Null`], mirroring
31/// `PyYAML`'s `safe_load("") is None`.
32pub fn parse(text: &str) -> Result<Value, YamlError> {
33    let lines: Vec<String> = text.lines().map(std::string::ToString::to_string).collect();
34    let mut p = Parser { lines, pos: 0 };
35    p.skip_blank_and_comments();
36    if p.pos >= p.lines.len() {
37        return Ok(Value::Null);
38    }
39    let base = p.current_indent()?;
40    let value = p.parse_node(base)?;
41    p.skip_blank_and_comments();
42    if p.pos < p.lines.len() {
43        return Err(p.err("unexpected trailing content"));
44    }
45    Ok(value)
46}
47
48struct Parser {
49    lines: Vec<String>,
50    pos: usize,
51}
52
53impl Parser {
54    fn err(&self, msg: impl Into<String>) -> YamlError {
55        YamlError {
56            line: self.pos + 1,
57            message: msg.into(),
58        }
59    }
60
61    fn is_blank_or_comment(line: &str) -> bool {
62        let t = line.trim_start();
63        t.is_empty() || t.starts_with('#')
64    }
65
66    fn skip_blank_and_comments(&mut self) {
67        while self.pos < self.lines.len() && Self::is_blank_or_comment(&self.lines[self.pos]) {
68            self.pos += 1;
69        }
70    }
71
72    /// Indentation (count of leading spaces) of the current line. Errors if the
73    /// leading whitespace contains a tab (YAML forbids tab indentation).
74    fn current_indent(&self) -> Result<usize, YamlError> {
75        indent_of(&self.lines[self.pos]).ok_or_else(|| self.err("tab character in indentation"))
76    }
77
78    /// Parses a node whose block items begin at column `indent`.
79    fn parse_node(&mut self, indent: usize) -> Result<Value, YamlError> {
80        let line = &self.lines[self.pos];
81        let content = &line[indent.min(line.len())..];
82        let trimmed = content.trim_start();
83
84        if trimmed == "-" || trimmed.starts_with("- ") {
85            self.parse_sequence(indent)
86        } else if split_key_value(trimmed).is_some() {
87            self.parse_mapping(indent)
88        } else {
89            // A bare scalar or flow collection. A plain scalar may continue on
90            // the following lines at this same indent.
91            let first = trimmed.to_string();
92            let entry_line = self.pos;
93            self.pos += 1;
94            self.read_scalar(&first, indent, entry_line)
95        }
96    }
97
98    fn parse_mapping(&mut self, indent: usize) -> Result<Value, YamlError> {
99        let mut map = Mapping::new();
100        loop {
101            self.skip_blank_and_comments();
102            if self.pos >= self.lines.len() {
103                break;
104            }
105            let ind = self.current_indent()?;
106            if ind < indent {
107                break;
108            }
109            if ind > indent {
110                return Err(self.err("unexpected indentation in mapping"));
111            }
112            let line = self.lines[self.pos].clone();
113            let content = line[indent..].to_string();
114            let trimmed = content.trim_start();
115            if trimmed == "-" || trimmed.starts_with("- ") {
116                break; // sequence at the same level: not part of this mapping
117            }
118            let (key_str, rest) = split_key_value(trimmed)
119                .ok_or_else(|| self.err("expected 'key: value' mapping entry"))?;
120            let key = parse_scalar(&key_str, self.pos)?;
121            let entry_line = self.pos;
122            self.pos += 1;
123
124            let value = match rest {
125                Some(r) if r.starts_with('|') || r.starts_with('>') => {
126                    self.parse_block_scalar(indent, &r)?
127                }
128                Some(r) => self.read_scalar(&r, indent + 1, entry_line)?,
129                None => {
130                    // Nested block on the following more-indented lines, else null.
131                    self.parse_nested(indent)?
132                }
133            };
134            map.push_raw(key, value);
135        }
136        Ok(Value::Mapping(map))
137    }
138
139    fn parse_sequence(&mut self, indent: usize) -> Result<Value, YamlError> {
140        let mut seq = Vec::new();
141        loop {
142            self.skip_blank_and_comments();
143            if self.pos >= self.lines.len() {
144                break;
145            }
146            let ind = self.current_indent()?;
147            if ind < indent {
148                break;
149            }
150            if ind > indent {
151                return Err(self.err("unexpected indentation in sequence"));
152            }
153            let line = self.lines[self.pos].clone();
154            let content = &line[indent..];
155            if !(content == "-" || content.starts_with("- ")) {
156                break;
157            }
158            // Column at which the item payload starts.
159            let dash_rest = &content[1..]; // after '-'
160            let item_offset = indent + 1 + (dash_rest.len() - dash_rest.trim_start().len());
161            let item_text = content[1..].trim_start().to_string();
162            let entry_line = self.pos;
163
164            if item_text.is_empty() {
165                // Nested block belonging to this item.
166                self.pos += 1;
167                let v = self.parse_nested(indent)?;
168                seq.push(v);
169            } else if item_text.starts_with('|') || item_text.starts_with('>') {
170                self.pos += 1;
171                let v = self.parse_block_scalar(indent, &item_text)?;
172                seq.push(v);
173            } else if split_key_value(&item_text).is_some() {
174                // Inline-started mapping element ("- key: value"). Rewrite the
175                // dash to whitespace so the payload aligns at `item_offset`,
176                // then parse a mapping at that deeper indent.
177                let mut rewritten = " ".repeat(item_offset);
178                rewritten.push_str(&item_text);
179                self.lines[entry_line] = rewritten;
180                let v = self.parse_mapping(item_offset)?;
181                seq.push(v);
182            } else {
183                self.pos += 1;
184                let v = self.read_scalar(&item_text, indent + 1, entry_line)?;
185                seq.push(v);
186            }
187        }
188        Ok(Value::Sequence(seq))
189    }
190
191    /// Reads a scalar that starts as `first` and may continue on the following
192    /// lines, folding each line break into a single space.
193    ///
194    /// YAML lets both plain and quoted scalars span lines (line folding),
195    /// and `PyYAML`'s `safe_dump` leans on it: any value longer than its 80-column
196    /// line width comes out wrapped. The reference implementation dumps with
197    /// `safe_dump`, so its own published bundles carry wrapped `description` and
198    /// `title` values, and a parser that rejects them cannot read
199    /// reference-produced OKF at all.
200    ///
201    /// A following line continues the scalar when it reaches `min_indent` and,
202    /// outside an open quote, does not itself open a mapping entry, a sequence
203    /// item, or a comment. Inside an unclosed quote every line belongs to the
204    /// scalar until the closing quote, since a wrapped quoted value may contain
205    /// anything, `key: value` shapes included. Flow collections are returned as
206    /// they stand: nothing emits one across lines.
207    fn read_scalar(
208        &mut self,
209        first: &str,
210        min_indent: usize,
211        line: usize,
212    ) -> Result<Value, YamlError> {
213        if first.starts_with('[') || first.starts_with('{') {
214            return parse_inline_value(first, line);
215        }
216
217        let mut text = first.to_string();
218        let mut open_quote = unclosed_quote(first);
219        while self.pos < self.lines.len() {
220            let raw = &self.lines[self.pos];
221            if raw.trim().is_empty() {
222                break;
223            }
224            let ind = indent_of(raw).ok_or_else(|| self.err("tab character in indentation"))?;
225            if ind < min_indent {
226                break;
227            }
228            let content = raw.trim();
229            if open_quote.is_none() && starts_a_new_node(content) {
230                break;
231            }
232            text.push(' ');
233            text.push_str(content);
234            if let Some(quote) = open_quote
235                && closes_quote(content, quote, 0)
236            {
237                open_quote = None;
238            }
239            self.pos += 1;
240        }
241
242        parse_scalar(&text, line)
243    }
244
245    /// Parses a nested block following a `key:` with no inline value.
246    ///
247    /// A nested *mapping* must be indented deeper than `parent_indent`. A nested
248    /// block *sequence*, however, is also permitted at exactly `parent_indent`
249    /// which is YAML's standard "indentation-relaxed" block sequence, and it is
250    /// what `PyYAML`'s `safe_dump` (used by the reference implementation) emits for
251    /// list values such as `tags`. Returns [`Value::Null`] when no block
252    /// follows.
253    fn parse_nested(&mut self, parent_indent: usize) -> Result<Value, YamlError> {
254        self.skip_blank_and_comments();
255        if self.pos >= self.lines.len() {
256            return Ok(Value::Null);
257        }
258        let ind = self.current_indent()?;
259        if ind > parent_indent {
260            self.parse_node(ind)
261        } else if ind == parent_indent && self.line_is_sequence_item(ind) {
262            self.parse_sequence(ind)
263        } else {
264            Ok(Value::Null)
265        }
266    }
267
268    /// Whether the current line, taken from column `indent`, begins a block
269    /// sequence item (`-` alone or `- …`).
270    fn line_is_sequence_item(&self, indent: usize) -> bool {
271        let line = &self.lines[self.pos];
272        let content = &line[indent.min(line.len())..];
273        content == "-" || content.starts_with("- ")
274    }
275
276    /// Parses a `|` (literal) or `>` (folded) block scalar. The header (`r`)
277    /// is the text after the `key:` (e.g. `|`, `|-`, `>+`).
278    fn parse_block_scalar(
279        &mut self,
280        parent_indent: usize,
281        header: &str,
282    ) -> Result<Value, YamlError> {
283        let style = header.as_bytes()[0]; // b'|' or b'>'
284        let chomp = header[1..].chars().find(|c| *c == '+' || *c == '-');
285
286        // Collect body lines: blanks, or lines indented deeper than the parent.
287        let mut body: Vec<String> = Vec::new();
288        let mut block_indent: Option<usize> = None;
289        while self.pos < self.lines.len() {
290            let line = &self.lines[self.pos];
291            if line.trim().is_empty() {
292                body.push(String::new());
293                self.pos += 1;
294                continue;
295            }
296            let ind = indent_of(line).ok_or_else(|| self.err("tab in block scalar indentation"))?;
297            if ind <= parent_indent {
298                break;
299            }
300            if block_indent.is_none() {
301                block_indent = Some(ind);
302            }
303            let bi = block_indent.unwrap();
304            let stripped = if line.len() >= bi {
305                line[bi..].to_string()
306            } else {
307                String::new()
308            };
309            body.push(stripped);
310            self.pos += 1;
311        }
312
313        // Drop trailing blank lines for accounting, remember how many there were.
314        let mut trailing_blanks = 0;
315        while body.last().is_some_and(std::string::String::is_empty) {
316            body.pop();
317            trailing_blanks += 1;
318        }
319
320        let mut text = if style == b'|' {
321            body.join("\n")
322        } else {
323            fold_lines(&body)
324        };
325
326        match chomp {
327            Some('-') => {} // strip: no trailing newline
328            Some('+') => {
329                // keep: restore all trailing blank lines + one newline for content
330                text.push('\n');
331                for _ in 0..trailing_blanks {
332                    text.push('\n');
333                }
334            }
335            _ => {
336                // clip: exactly one trailing newline if there was any content
337                if !text.is_empty() || trailing_blanks > 0 {
338                    text.push('\n');
339                }
340            }
341        }
342        Ok(Value::String(text))
343    }
344}
345
346/// Folds a literal block's lines per YAML's folded (`>`) rules: runs of
347/// non-empty lines join with a single space; blank lines become newlines.
348fn fold_lines(lines: &[String]) -> String {
349    let mut out = String::new();
350    let mut prev_nonempty = false;
351    for line in lines {
352        if line.is_empty() {
353            out.push('\n');
354            prev_nonempty = false;
355        } else {
356            if prev_nonempty {
357                out.push(' ');
358            }
359            out.push_str(line);
360            prev_nonempty = true;
361        }
362    }
363    out
364}
365
366/// Whether a (trimmed) line opens a node of its own rather than continuing the
367/// scalar above it: a comment, a sequence item, or a mapping entry.
368fn starts_a_new_node(content: &str) -> bool {
369    content.starts_with('#')
370        || content == "-"
371        || content.starts_with("- ")
372        || split_key_value(content).is_some()
373}
374
375/// The quote character of a quoted scalar that `s` opens but does not close, or
376/// `None` when `s` is plain or self-contained.
377fn unclosed_quote(s: &str) -> Option<char> {
378    match s.chars().next()? {
379        q @ ('\'' | '"') if !closes_quote(s, q, 1) => Some(q),
380        _ => None,
381    }
382}
383
384/// Whether the closing `quote` of an already-open quoted scalar appears in `s`
385/// at or after character index `from`, honouring `''` and `\"` escapes.
386fn closes_quote(s: &str, quote: char, from: usize) -> bool {
387    let chars: Vec<char> = s.chars().collect();
388    let mut i = from;
389    while i < chars.len() {
390        let c = chars[i];
391        if quote == '"' && c == '\\' {
392            i += 2;
393            continue;
394        }
395        if c == quote {
396            if quote == '\'' && chars.get(i + 1) == Some(&'\'') {
397                i += 2;
398                continue;
399            }
400            return true;
401        }
402        i += 1;
403    }
404    false
405}
406
407/// Leading-space count, or `None` if the indentation contains a tab.
408fn indent_of(line: &str) -> Option<usize> {
409    let mut n = 0;
410    for c in line.chars() {
411        match c {
412            ' ' => n += 1,
413            '\t' => return None,
414            _ => break,
415        }
416    }
417    Some(n)
418}
419
420/// Splits a (left-trimmed) line into a `key` and optional rest at the first
421/// top-level `:` that is followed by a space or end-of-line. Returns `None`
422/// when the line is not a mapping entry.
423fn split_key_value(s: &str) -> Option<(String, Option<String>)> {
424    let chars: Vec<char> = s.chars().collect();
425    let mut i = 0;
426    let mut quote: Option<char> = None;
427    let mut depth: i32 = 0;
428    while i < chars.len() {
429        let c = chars[i];
430        if let Some(q) = quote {
431            if q == '"' && c == '\\' {
432                i += 2;
433                continue;
434            }
435            if c == q {
436                if q == '\'' && chars.get(i + 1) == Some(&'\'') {
437                    i += 2;
438                    continue;
439                }
440                quote = None;
441            }
442            i += 1;
443            continue;
444        }
445        match c {
446            '\'' | '"' => quote = Some(c),
447            '[' | '{' => depth += 1,
448            ']' | '}' => {
449                if depth > 0 {
450                    depth -= 1;
451                }
452            }
453            '#' if depth == 0 && i > 0 && (chars[i - 1] == ' ' || chars[i - 1] == '\t') => {
454                break; // comment region without a preceding separator
455            }
456            ':' if depth == 0 => {
457                let next = chars.get(i + 1).copied();
458                if next.is_none() || next == Some(' ') || next == Some('\t') {
459                    let key: String = chars[..i].iter().collect();
460                    let rest: String = chars[i + 1..].iter().collect();
461                    let rest = rest.trim();
462                    let rest_opt = if rest.is_empty() || rest.starts_with('#') {
463                        None
464                    } else {
465                        Some(rest.to_string())
466                    };
467                    return Some((key.trim().to_string(), rest_opt));
468                }
469            }
470            _ => {}
471        }
472        i += 1;
473    }
474    None
475}
476
477/// Parses a single-line value: a flow collection or a scalar.
478fn parse_inline_value(s: &str, line: usize) -> Result<Value, YamlError> {
479    let t = s.trim();
480    if t.starts_with('[') || t.starts_with('{') {
481        let mut fp = FlowParser {
482            chars: t.chars().collect(),
483            pos: 0,
484            line,
485        };
486        let v = fp.parse_value()?;
487        fp.skip_ws();
488        // Allow a trailing comment after the flow collection.
489        if fp.pos < fp.chars.len() && fp.chars[fp.pos] != '#' {
490            return Err(YamlError {
491                line: line + 1,
492                message: "unexpected content after flow collection".into(),
493            });
494        }
495        Ok(v)
496    } else {
497        parse_scalar(t, line)
498    }
499}
500
501/// Interprets a scalar token (possibly quoted) into a typed [`Value`].
502fn parse_scalar(token: &str, line: usize) -> Result<Value, YamlError> {
503    let t = token.trim();
504    if t.is_empty() {
505        return Ok(Value::Null);
506    }
507    if t.starts_with('"') {
508        return parse_double_quoted(t, line).map(Value::String);
509    }
510    if t.starts_with('\'') {
511        return parse_single_quoted(t, line).map(Value::String);
512    }
513    // Plain scalar: strip a trailing " #" comment.
514    let plain = strip_trailing_comment(t);
515    Ok(interpret_plain(plain))
516}
517
518/// Strips a trailing ` #...` comment from a plain scalar.
519fn strip_trailing_comment(s: &str) -> &str {
520    let bytes = s.as_bytes();
521    let mut i = 0;
522    while i < bytes.len() {
523        if bytes[i] == b'#' && i > 0 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
524            return s[..i].trim_end();
525        }
526        i += 1;
527    }
528    s.trim_end()
529}
530
531/// Resolves a plain (unquoted) scalar to null/bool/int/float/string.
532///
533/// Number resolution is intentionally conservative to avoid silently coercing
534/// identifier-like values: integers must have no redundant leading zero (so a
535/// zero-padded code such as `007` stays a string), and floats must contain a
536/// decimal point (so `1e3` stays a string). This matches the safe, predictable
537/// end of YAML scalar resolution rather than `PyYAML`'s legacy octal/sexagesimal
538/// quirks. The special float tokens `.inf`, `-.inf`, and `.nan` are recognized
539/// so non-finite floats produced by the emitter round-trip.
540fn interpret_plain(s: &str) -> Value {
541    match s {
542        "" | "~" | "null" | "Null" | "NULL" => return Value::Null,
543        "true" | "True" | "TRUE" => return Value::Bool(true),
544        "false" | "False" | "FALSE" => return Value::Bool(false),
545        ".inf" | ".Inf" | ".INF" | "+.inf" => return Value::Float(f64::INFINITY),
546        "-.inf" | "-.Inf" | "-.INF" => return Value::Float(f64::NEG_INFINITY),
547        ".nan" | ".NaN" | ".NAN" => return Value::Float(f64::NAN),
548        _ => {}
549    }
550    if is_canonical_int(s)
551        && let Ok(i) = s.parse::<i64>()
552    {
553        return Value::Int(i);
554    }
555    if is_canonical_float(s)
556        && let Ok(f) = s.parse::<f64>()
557    {
558        return Value::Float(f);
559    }
560    Value::String(s.to_string())
561}
562
563/// `[-+]?(0|[1-9][0-9]*)`: a decimal integer with no redundant leading zero.
564fn is_canonical_int(s: &str) -> bool {
565    let digits = s.strip_prefix(['+', '-']).unwrap_or(s);
566    if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
567        return false;
568    }
569    digits == "0" || !digits.starts_with('0')
570}
571
572/// A float that contains a decimal point and a digit (optionally with an
573/// exponent), e.g. `0.1`, `-3.5`, `1.0e9`. Bare-exponent forms like `1e3` are
574/// deliberately treated as strings.
575fn is_canonical_float(s: &str) -> bool {
576    if !s.contains('.') || !s.bytes().any(|b| b.is_ascii_digit()) {
577        return false;
578    }
579    s.parse::<f64>().is_ok()
580}
581
582fn parse_double_quoted(s: &str, line: usize) -> Result<String, YamlError> {
583    let chars: Vec<char> = s.chars().collect();
584    debug_assert_eq!(chars[0], '"');
585    let mut out = String::new();
586    let mut i = 1;
587    while i < chars.len() {
588        let c = chars[i];
589        if c == '"' {
590            validate_quoted_tail(&chars, i, line, "double-quoted")?;
591            return Ok(out);
592        }
593        if c == '\\' {
594            i += 1;
595            let e = *chars.get(i).ok_or_else(|| YamlError {
596                line: line + 1,
597                message: "dangling escape in double-quoted string".into(),
598            })?;
599            match e {
600                'n' => out.push('\n'),
601                't' => out.push('\t'),
602                'r' => out.push('\r'),
603                '"' => out.push('"'),
604                '\\' => out.push('\\'),
605                '/' => out.push('/'),
606                '0' => out.push('\0'),
607                'b' => out.push('\u{0008}'),
608                'f' => out.push('\u{000C}'),
609                'u' => {
610                    let start = i + 1;
611                    let end = start + 4;
612                    let hex = chars.get(start..end).ok_or_else(|| YamlError {
613                        line: line + 1,
614                        message: "truncated Unicode escape in double-quoted string".into(),
615                    })?;
616                    let hex: String = hex.iter().collect();
617                    let cp = u32::from_str_radix(&hex, 16).map_err(|_| YamlError {
618                        line: line + 1,
619                        message: "malformed Unicode escape in double-quoted string".into(),
620                    })?;
621                    let ch = char::from_u32(cp).ok_or_else(|| YamlError {
622                        line: line + 1,
623                        message: "invalid Unicode scalar value in double-quoted string".into(),
624                    })?;
625                    out.push(ch);
626                    i = end - 1;
627                }
628                other => {
629                    return Err(YamlError {
630                        line: line + 1,
631                        message: format!(
632                            "unknown escape sequence \\{other} in double-quoted string"
633                        ),
634                    });
635                }
636            }
637            i += 1;
638            continue;
639        }
640        out.push(c);
641        i += 1;
642    }
643    Err(YamlError {
644        line: line + 1,
645        message: "unterminated double-quoted string".into(),
646    })
647}
648
649fn parse_single_quoted(s: &str, line: usize) -> Result<String, YamlError> {
650    let chars: Vec<char> = s.chars().collect();
651    debug_assert_eq!(chars[0], '\'');
652    let mut out = String::new();
653    let mut i = 1;
654    while i < chars.len() {
655        let c = chars[i];
656        if c == '\'' {
657            if chars.get(i + 1) == Some(&'\'') {
658                out.push('\'');
659                i += 2;
660                continue;
661            }
662            validate_quoted_tail(&chars, i, line, "single-quoted")?;
663            return Ok(out);
664        }
665        out.push(c);
666        i += 1;
667    }
668    Err(YamlError {
669        line: line + 1,
670        message: "unterminated single-quoted string".into(),
671    })
672}
673
674/// Ensures that only whitespace or a trailing comment follows a quoted scalar.
675fn validate_quoted_tail(
676    chars: &[char],
677    close: usize,
678    line: usize,
679    quote_name: &str,
680) -> Result<(), YamlError> {
681    for &c in &chars[close + 1..] {
682        if c.is_whitespace() {
683            continue;
684        }
685        if c == '#' {
686            return Ok(());
687        }
688        return Err(YamlError {
689            line: line + 1,
690            message: format!("unexpected content after closing {quote_name} quote"),
691        });
692    }
693    Ok(())
694}
695
696/// A recursive parser for flow collections (`[...]`, `{...}`).
697struct FlowParser {
698    chars: Vec<char>,
699    pos: usize,
700    line: usize,
701}
702
703impl FlowParser {
704    fn skip_ws(&mut self) {
705        while self.pos < self.chars.len() && self.chars[self.pos].is_whitespace() {
706            self.pos += 1;
707        }
708    }
709
710    fn err(&self, msg: impl Into<String>) -> YamlError {
711        YamlError {
712            line: self.line + 1,
713            message: msg.into(),
714        }
715    }
716
717    fn parse_value(&mut self) -> Result<Value, YamlError> {
718        self.skip_ws();
719        match self.chars.get(self.pos) {
720            Some('[') => self.parse_seq(),
721            Some('{') => self.parse_map(),
722            Some(_) => self.parse_flow_scalar(),
723            None => Ok(Value::Null),
724        }
725    }
726
727    fn parse_seq(&mut self) -> Result<Value, YamlError> {
728        self.pos += 1; // consume '['
729        let mut seq = Vec::new();
730        loop {
731            self.skip_ws();
732            match self.chars.get(self.pos) {
733                Some(']') => {
734                    self.pos += 1;
735                    break;
736                }
737                None => return Err(self.err("unterminated flow sequence")),
738                _ => {}
739            }
740            seq.push(self.parse_value()?);
741            self.skip_ws();
742            match self.chars.get(self.pos) {
743                Some(',') => self.pos += 1,
744                Some(']') => {
745                    self.pos += 1;
746                    break;
747                }
748                _ => return Err(self.err("expected ',' or ']' in flow sequence")),
749            }
750        }
751        Ok(Value::Sequence(seq))
752    }
753
754    fn parse_map(&mut self) -> Result<Value, YamlError> {
755        self.pos += 1; // consume '{'
756        let mut map = Mapping::new();
757        loop {
758            self.skip_ws();
759            match self.chars.get(self.pos) {
760                Some('}') => {
761                    self.pos += 1;
762                    break;
763                }
764                None => return Err(self.err("unterminated flow mapping")),
765                _ => {}
766            }
767            let key = self.parse_flow_scalar()?;
768            self.skip_ws();
769            // A key with no `: value` is a null-valued entry, as in YAML.
770            let value = if self.chars.get(self.pos) == Some(&':') {
771                self.pos += 1;
772                self.parse_value()?
773            } else {
774                Value::Null
775            };
776            map.push_raw(key, value);
777            self.skip_ws();
778            match self.chars.get(self.pos) {
779                Some(',') => self.pos += 1,
780                Some('}') => {
781                    self.pos += 1;
782                    break;
783                }
784                _ => return Err(self.err("expected ',' or '}' in flow mapping")),
785            }
786        }
787        Ok(Value::Mapping(map))
788    }
789
790    fn parse_flow_scalar(&mut self) -> Result<Value, YamlError> {
791        self.skip_ws();
792        let c = *self
793            .chars
794            .get(self.pos)
795            .ok_or_else(|| self.err("expected scalar"))?;
796        if c == '"' || c == '\'' {
797            let start = self.pos;
798            self.pos += 1;
799            while self.pos < self.chars.len() {
800                let cur = self.chars[self.pos];
801                if c == '"' && cur == '\\' {
802                    if self.pos + 1 >= self.chars.len() {
803                        return Err(self.err("dangling escape in double-quoted string"));
804                    }
805                    self.pos += 2;
806                    continue;
807                }
808                if cur == c {
809                    if c == '\'' && self.chars.get(self.pos + 1) == Some(&'\'') {
810                        self.pos += 2;
811                        continue;
812                    }
813                    self.pos += 1;
814                    break;
815                }
816                self.pos += 1;
817            }
818            let raw: String = self.chars[start..self.pos].iter().collect();
819            let s = if c == '"' {
820                parse_double_quoted(&raw, self.line)?
821            } else {
822                parse_single_quoted(&raw, self.line)?
823            };
824            return Ok(Value::String(s));
825        }
826        // Plain flow scalar: read until `,`, `]`, `}`, or a `:` acting as a
827        // key/value separator.
828        let start = self.pos;
829        while self.pos < self.chars.len() {
830            match self.chars[self.pos] {
831                ',' | ']' | '}' => break,
832                ':' if is_separator_colon(&self.chars, self.pos) => break,
833                _ => self.pos += 1,
834            }
835        }
836        let raw: String = self.chars[start..self.pos].iter().collect();
837        Ok(interpret_plain(raw.trim()))
838    }
839}
840
841/// Whether the `:` at `i` separates a flow mapping's key from its value, rather
842/// than being an ordinary character inside a plain scalar.
843///
844/// YAML only treats `:` as a separator in flow context when it is followed by
845/// whitespace, a flow indicator, or the end of the collection. OKF v0.2 relies
846/// on this: `{ by: human:walter, at: 2026-06-25T09:00:00Z }` is one mapping
847/// of two entries, not a parse error: the colons in `human:walter` and
848/// `09:00:00` are content.
849fn is_separator_colon(chars: &[char], i: usize) -> bool {
850    chars
851        .get(i + 1)
852        .is_none_or(|c| c.is_whitespace() || matches!(c, ',' | '[' | ']' | '{' | '}'))
853}