Skip to main content

agentd/config/
yaml.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! A hand-rolled **YAML subset** reader for config files → `serde_json::Value`.
3//!
4//! agentd's config file may be YAML (`--config agentd.yaml`) — but the
5//! minimalism moat forbids a YAML *crate* (`serde_yaml` is also unmaintained),
6//! so, like the cron parser, the HTTP client, and the JSON-with-comments
7//! stripper, this is written on `std` alone and produces the same
8//! `serde_json::Value` tree the JSON path yields. One document model, two
9//! surface syntaxes; everything downstream (typed deserialization, schema
10//! validation, hot reload) is format-agnostic.
11//!
12//! ## What is supported (the config-file subset)
13//!
14//! - block mappings (`key: value`, nesting by indentation) and block sequences
15//!   (`- item`, including `- key: value` items and nested sequences, and a
16//!   sequence at the same indent as its key);
17//! - flow collections (`[a, b]`, `{k: v}`), nested and spanning lines;
18//! - scalars: plain, single-quoted (`''` escape), double-quoted (JSON escapes +
19//!   `\xHH`/`\uHHHH`/`\UHHHHHHHH`), and block scalars `|` / `>` with `-`/`+`
20//!   chomping and an explicit indentation indicator;
21//! - multi-line plain scalars (continuation lines fold with a space);
22//! - comments (`#` at line start or after whitespace), blank lines, `---` /
23//!   `...` document markers, `%` directives, a UTF-8 BOM, CRLF line endings;
24//! - YAML 1.2 **core-schema** typing of plain scalars: `null`/`~`/empty → null,
25//!   `true`/`false` (any case) → bool, decimal/`0x`/`0o` integers, floats;
26//!   everything else is a string (`yes`/`no`/`on`/`off` are STRINGS — the
27//!   1.1 footgun is deliberately absent).
28//!
29//! ## What is rejected, loudly
30//!
31//! Anchors/aliases (`&a`/`*a`), tags (`!!str`), merge keys (`<<`), complex keys
32//! (`? `), multiple documents, tab indentation, multi-line quoted scalars, and
33//! duplicate mapping keys — each is a parse error naming the line and column,
34//! never a silent guess. Non-finite floats (`.inf`/`.nan`) are rejected because
35//! JSON cannot carry them.
36
37use serde_json::{Map, Number, Value};
38
39/// A YAML parse error with a 1-based line and column.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct YamlError {
42    pub line: usize,
43    pub col: usize,
44    pub msg: String,
45}
46
47impl std::fmt::Display for YamlError {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        write!(f, "line {}, column {}: {}", self.line, self.col, self.msg)
50    }
51}
52
53impl std::error::Error for YamlError {}
54
55/// Parse one YAML document into a JSON value. An empty document is `null`.
56pub fn parse(src: &str) -> Result<Value, YamlError> {
57    let src = src.strip_prefix('\u{feff}').unwrap_or(src);
58    let raw: Vec<&str> = src
59        .split('\n')
60        .map(|l| l.strip_suffix('\r').unwrap_or(l))
61        .collect();
62    let lines = logical_lines(&raw)?;
63    let mut p = Parser { raw, lines, pos: 0 };
64    let Some(first) = p.peek().cloned() else {
65        return Ok(Value::Null);
66    };
67    let v = p.parse_node(first.indent, first.indent)?;
68    if let Some(l) = p.peek() {
69        return Err(err(l, 0, "unexpected content after the document root"));
70    }
71    Ok(v)
72}
73
74/// Parse a single inline YAML value (a scalar or a flow collection) — the shape
75/// an env var or a `--flag` value carries: `12`, `true`, `[a, b]`, `{k: v}`,
76/// `"quoted"`. A plain scalar that types as a string comes back verbatim
77/// (trimmed).
78pub fn parse_inline(src: &str) -> Result<Value, YamlError> {
79    let line = Line {
80        no: 1,
81        indent: 0,
82        text: src.trim().to_string(),
83    };
84    let (v, rest, _plain) = parse_inline_value(&line, &line.text, 0)?;
85    if !rest.trim().is_empty() {
86        return Err(err(
87            &line,
88            line.text.len() - rest.len(),
89            "trailing characters after the value",
90        ));
91    }
92    Ok(v)
93}
94
95// ---------------------------------------------------------------------------
96// Logical lines
97// ---------------------------------------------------------------------------
98
99/// One significant source line: its 1-based number, indentation (spaces) and
100/// the text after the indentation with any trailing comment removed.
101#[derive(Debug, Clone)]
102struct Line {
103    no: usize,
104    indent: usize,
105    text: String,
106}
107
108fn err(l: &Line, col_in_text: usize, msg: impl Into<String>) -> YamlError {
109    YamlError {
110        line: l.no,
111        col: l.indent + col_in_text + 1,
112        msg: msg.into(),
113    }
114}
115
116/// Reduce the raw lines to significant ones: drop blank and comment-only lines,
117/// `%` directives and `---`/`...` markers (a second `---` is a multi-document
118/// stream — rejected), measure the indentation and strip trailing comments.
119/// Block-scalar bodies are re-read from the RAW lines by the parser (blank lines
120/// and `#` are content there), so what happens to them here is irrelevant.
121fn logical_lines(raw: &[&str]) -> Result<Vec<Line>, YamlError> {
122    let mut out = Vec::new();
123    let mut seen_doc_start = false;
124    for (i, &line) in raw.iter().enumerate() {
125        let no = i + 1;
126        let trimmed = line.trim_start_matches(' ');
127        let indent = line.len() - trimmed.len();
128        if trimmed.trim().is_empty() || trimmed.starts_with('#') {
129            continue;
130        }
131        if indent == 0 {
132            if trimmed.starts_with('%') {
133                continue; // %YAML / %TAG directives: ignored
134            }
135            if trimmed == "---" || trimmed.starts_with("--- ") {
136                if seen_doc_start || !out.is_empty() {
137                    return Err(YamlError {
138                        line: no,
139                        col: 1,
140                        msg: "multiple YAML documents are not supported (one document per file)"
141                            .into(),
142                    });
143                }
144                seen_doc_start = true;
145                let rest = trimmed[3..].trim();
146                if rest.is_empty() {
147                    continue;
148                }
149                // `--- {inline: doc}` — the rest IS the document root.
150                out.push(Line {
151                    no,
152                    indent: 4,
153                    text: strip_comment(rest),
154                });
155                continue;
156            }
157            if trimmed == "..." {
158                break; // document end marker: nothing after it is content
159            }
160        }
161        out.push(Line {
162            no,
163            indent,
164            text: strip_comment(trimmed),
165        });
166    }
167    Ok(out)
168}
169
170/// Strip a trailing ` # comment` (a `#` at the start or after whitespace, and
171/// not inside single/double quotes), then trailing whitespace.
172fn strip_comment(text: &str) -> String {
173    let bytes = text.as_bytes();
174    let mut i = 0;
175    let mut in_single = false;
176    let mut in_double = false;
177    while i < bytes.len() {
178        let b = bytes[i];
179        if in_double {
180            if b == b'\\' {
181                i += 2;
182                continue;
183            }
184            if b == b'"' {
185                in_double = false;
186            }
187        } else if in_single {
188            if b == b'\'' {
189                if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
190                    i += 2; // `''` escape
191                    continue;
192                }
193                in_single = false;
194            }
195        } else if b == b'"' && quote_opens(bytes, i) {
196            in_double = true;
197        } else if b == b'\'' && quote_opens(bytes, i) {
198            in_single = true;
199        } else if b == b'#' && (i == 0 || bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') {
200            return text[..i].trim_end().to_string();
201        }
202        i += 1;
203    }
204    text.trim_end().to_string()
205}
206
207/// A quote character opens a quoted scalar only at a value/element start —
208/// after `: `, `- `, `[`, `{`, `,`, or at the line start. Elsewhere (`it's`,
209/// `5"`) it is plain-scalar content.
210fn quote_opens(bytes: &[u8], i: usize) -> bool {
211    let mut j = i;
212    while j > 0 && bytes[j - 1] == b' ' {
213        j -= 1;
214    }
215    j == 0 || matches!(bytes[j - 1], b':' | b'-' | b'[' | b'{' | b',')
216}
217
218// ---------------------------------------------------------------------------
219// The block parser
220// ---------------------------------------------------------------------------
221
222struct Parser<'s> {
223    /// Every source line, CR-stripped, by index (`Line::no - 1`) — the block
224    /// scalar reader needs the raw text (blank lines, `#`, trailing spaces).
225    raw: Vec<&'s str>,
226    lines: Vec<Line>,
227    pos: usize,
228}
229
230impl Parser<'_> {
231    fn peek(&self) -> Option<&Line> {
232        self.lines.get(self.pos)
233    }
234
235    /// Parse the node starting at the current line (which sits at `indent`).
236    /// `parent_indent` is the indentation of the enclosing block — a block
237    /// scalar's body must be indented deeper than THAT (relevant for `- |`,
238    /// where the virtual line's indent is deeper than the dash).
239    fn parse_node(&mut self, indent: usize, parent_indent: usize) -> Result<Value, YamlError> {
240        let Some(line) = self.peek().cloned() else {
241            return Ok(Value::Null);
242        };
243        check_tab(&line)?;
244        if is_seq_item(&line.text) {
245            return self.parse_sequence(indent);
246        }
247        if let Some(header) = line
248            .text
249            .strip_prefix('|')
250            .or_else(|| line.text.strip_prefix('>'))
251        {
252            let literal = line.text.starts_with('|');
253            self.pos += 1;
254            return self.parse_block_scalar(&line, parent_indent, literal, header, 0);
255        }
256        if split_key(&line)?.is_some() {
257            return self.parse_mapping(indent);
258        }
259        // A bare scalar / flow collection as the whole node (the document root,
260        // or a sequence item's value).
261        self.pos += 1;
262        let text = self.gather_flow(&line, &line.text, indent)?;
263        let (v, rest, plain) = parse_inline_value(&line, &text, 0)?;
264        if !rest.trim().is_empty() {
265            return Err(err(
266                &line,
267                text.len() - rest.len(),
268                "trailing characters after the value",
269            ));
270        }
271        if plain {
272            return self.fold_plain_continuation(v, indent);
273        }
274        Ok(v)
275    }
276
277    fn parse_sequence(&mut self, indent: usize) -> Result<Value, YamlError> {
278        let mut items = Vec::new();
279        while let Some(line) = self.peek().cloned() {
280            check_tab(&line)?;
281            if line.indent < indent {
282                break;
283            }
284            if line.indent > indent {
285                return Err(err(&line, 0, "unexpected indentation inside a sequence"));
286            }
287            if !is_seq_item(&line.text) {
288                break; // a sibling mapping key at this indent — the caller decides
289            }
290            let rest = line.text[1..].trim_start_matches(' ');
291            if rest.is_empty() {
292                // `-` alone: the item is the nested node on the following lines.
293                self.pos += 1;
294                match self.peek() {
295                    Some(next) if next.indent > indent => {
296                        let ni = next.indent;
297                        items.push(self.parse_node(ni, indent)?);
298                    }
299                    _ => items.push(Value::Null),
300                }
301                continue;
302            }
303            // `- key: v` / `- - x` / `- scalar` / `- |`: treat the rest as a
304            // virtual line starting at the column where it begins, and parse
305            // THAT as a node whose parent block is this sequence.
306            let inner_indent = indent + (line.text.len() - rest.len());
307            self.lines[self.pos] = Line {
308                no: line.no,
309                indent: inner_indent,
310                text: rest.to_string(),
311            };
312            items.push(self.parse_node(inner_indent, indent)?);
313        }
314        Ok(Value::Array(items))
315    }
316
317    fn parse_mapping(&mut self, indent: usize) -> Result<Value, YamlError> {
318        let mut map = Map::new();
319        while let Some(line) = self.peek().cloned() {
320            check_tab(&line)?;
321            if line.indent < indent {
322                break;
323            }
324            if line.indent > indent {
325                return Err(err(&line, 0, "unexpected indentation inside a mapping"));
326            }
327            if is_seq_item(&line.text) {
328                return Err(err(
329                    &line,
330                    0,
331                    "a sequence item is not allowed here (expected a `key: value`)",
332                ));
333            }
334            let Some((key, rest)) = split_key(&line)? else {
335                return Err(err(&line, 0, "expected a `key: value` mapping entry"));
336            };
337            if map.contains_key(&key) {
338                return Err(err(&line, 0, format!("duplicate mapping key {key:?}")));
339            }
340            let rest = rest.to_string();
341            self.pos += 1;
342            let value = self.parse_mapping_value(&line, indent, &rest)?;
343            map.insert(key, value);
344        }
345        Ok(Value::Object(map))
346    }
347
348    /// The value part of `key: <rest>` — inline, a nested block on the
349    /// following lines, or a block scalar.
350    fn parse_mapping_value(
351        &mut self,
352        line: &Line,
353        indent: usize,
354        rest: &str,
355    ) -> Result<Value, YamlError> {
356        let rest_col = line.text.len() - rest.len();
357        if rest.is_empty() {
358            // A nested block on the following more-indented lines — or a
359            // sequence at the SAME indent (`key:\n- a` is legal YAML) — or null.
360            return match self.peek() {
361                Some(next) if next.indent > indent => {
362                    let ni = next.indent;
363                    self.parse_node(ni, indent)
364                }
365                Some(next) if next.indent == indent && is_seq_item(&next.text) => {
366                    self.parse_sequence(indent)
367                }
368                _ => Ok(Value::Null),
369            };
370        }
371        if let Some(header) = rest.strip_prefix('|').or_else(|| rest.strip_prefix('>')) {
372            let literal = rest.starts_with('|');
373            return self.parse_block_scalar(line, indent, literal, header, rest_col);
374        }
375        let text = self.gather_flow(line, rest, indent)?;
376        let (v, tail, plain) = parse_inline_value(line, &text, rest_col)?;
377        if !tail.trim().is_empty() {
378            let col = rest_col + (text.len() - tail.len());
379            return Err(err(line, col, "trailing characters after the value"));
380        }
381        if plain {
382            return self.fold_plain_continuation(v, indent);
383        }
384        Ok(v)
385    }
386
387    /// A flow collection (`[`/`{`) may span lines: pull following lines that
388    /// are indented deeper than the owning block until the brackets balance.
389    fn gather_flow(
390        &mut self,
391        line: &Line,
392        start: &str,
393        indent: usize,
394    ) -> Result<String, YamlError> {
395        let t = start.trim_start();
396        if !(t.starts_with('[') || t.starts_with('{')) {
397            return Ok(start.to_string());
398        }
399        // Flow context is indentation-insensitive: continuation lines (and the
400        // closing bracket) may sit at any column, as they do in JSON-style YAML.
401        let _ = indent;
402        let mut buf = start.to_string();
403        while !flow_balanced(&buf) {
404            match self.peek() {
405                Some(next) => {
406                    buf.push(' ');
407                    buf.push_str(&next.text);
408                    self.pos += 1;
409                }
410                None => {
411                    return Err(err(
412                        line,
413                        line.text.len() - start.len(),
414                        "unterminated flow collection (missing `]` or `}`)",
415                    ));
416                }
417            }
418        }
419        Ok(buf)
420    }
421
422    /// A plain scalar continues on more-indented lines that are neither mapping
423    /// entries nor sequence items; the pieces fold with a single space.
424    fn fold_plain_continuation(&mut self, v: Value, indent: usize) -> Result<Value, YamlError> {
425        let mut pieces: Vec<String> = Vec::new();
426        while let Some(next) = self.peek() {
427            if next.indent <= indent || is_seq_item(&next.text) || split_key(next)?.is_some() {
428                break;
429            }
430            pieces.push(next.text.trim().to_string());
431            self.pos += 1;
432        }
433        if pieces.is_empty() {
434            return Ok(v);
435        }
436        // Folded text is always a string, whatever the first line typed as.
437        let mut s = match v {
438            Value::String(s) => s,
439            Value::Null => String::new(),
440            other => other.to_string(),
441        };
442        for p in pieces {
443            if !s.is_empty() {
444                s.push(' ');
445            }
446            s.push_str(&p);
447        }
448        Ok(Value::String(s))
449    }
450
451    /// `key: |` / `key: >` (or a bare `|`/`>` node). `header` is what follows
452    /// the indicator: `-`/`+` chomping and/or an explicit indentation digit.
453    /// The body is every RAW line after the header that is blank or indented
454    /// deeper than `parent_indent`.
455    fn parse_block_scalar(
456        &mut self,
457        line: &Line,
458        parent_indent: usize,
459        literal: bool,
460        header: &str,
461        header_col: usize,
462    ) -> Result<Value, YamlError> {
463        let mut chomp = Chomp::Clip;
464        let mut explicit_indent: Option<usize> = None;
465        for ch in header.trim().chars() {
466            match ch {
467                '-' => chomp = Chomp::Strip,
468                '+' => chomp = Chomp::Keep,
469                d @ '1'..='9' => {
470                    explicit_indent = Some(parent_indent + (d as usize - '0' as usize))
471                }
472                _ => {
473                    return Err(err(
474                        line,
475                        header_col,
476                        format!("bad block scalar header {:?}", header.trim()),
477                    ));
478                }
479            }
480        }
481        // Collect the raw body.
482        let mut body: Vec<&str> = Vec::new();
483        let mut idx = line.no; // raw index of the line AFTER the header
484        while idx < self.raw.len() {
485            let r = self.raw[idx];
486            let ind = r.len() - r.trim_start_matches(' ').len();
487            if r.trim().is_empty() {
488                body.push("");
489                idx += 1;
490                continue;
491            }
492            if ind <= parent_indent {
493                break;
494            }
495            body.push(r);
496            idx += 1;
497        }
498        // Skip the logical lines the body consumed.
499        while self.peek().is_some_and(|l| l.no <= idx) {
500            self.pos += 1;
501        }
502        // Block indentation: explicit, else the first non-blank body line's.
503        let block_indent = explicit_indent.or_else(|| {
504            body.iter()
505                .find(|l| !l.trim().is_empty())
506                .map(|l| l.len() - l.trim_start_matches(' ').len())
507        });
508        let Some(block_indent) = block_indent else {
509            // An empty block scalar.
510            return Ok(Value::String(match chomp {
511                Chomp::Keep => "\n".repeat(body.len()),
512                _ => String::new(),
513            }));
514        };
515        let mut content: Vec<String> = Vec::with_capacity(body.len());
516        for (k, l) in body.iter().enumerate() {
517            if l.trim().is_empty() {
518                content.push(String::new());
519                continue;
520            }
521            let ind = l.len() - l.trim_start_matches(' ').len();
522            if ind < block_indent {
523                let bad = Line {
524                    no: line.no + 1 + k,
525                    indent: ind,
526                    text: l.trim().to_string(),
527                };
528                return Err(err(
529                    &bad,
530                    0,
531                    "block scalar line is indented less than the block",
532                ));
533            }
534            content.push(l[block_indent..].to_string());
535        }
536        // Trailing blank lines are the chomping's business, not the text's.
537        let trailing = content.iter().rev().take_while(|l| l.is_empty()).count();
538        let text_lines = &content[..content.len() - trailing];
539        let mut text = if literal {
540            text_lines.join("\n")
541        } else {
542            fold_lines(text_lines)
543        };
544        match chomp {
545            Chomp::Strip => {}
546            Chomp::Clip => {
547                if !text.is_empty() {
548                    text.push('\n');
549                }
550            }
551            Chomp::Keep => {
552                if !text.is_empty() {
553                    text.push('\n');
554                }
555                text.push_str(&"\n".repeat(trailing));
556            }
557        }
558        Ok(Value::String(text))
559    }
560}
561
562/// Fold (`>`) block-scalar content lines: single line breaks between normal
563/// lines become a space; blank lines become newlines; "more-indented" lines
564/// (leading spaces beyond the block indent) keep their line breaks.
565fn fold_lines(lines: &[String]) -> String {
566    let mut out = String::new();
567    for (i, l) in lines.iter().enumerate() {
568        if i == 0 {
569            out.push_str(l);
570            continue;
571        }
572        let prev = &lines[i - 1];
573        if l.is_empty() {
574            out.push('\n');
575        } else if prev.is_empty() {
576            out.push_str(l);
577        } else if l.starts_with(' ') || prev.starts_with(' ') {
578            out.push('\n');
579            out.push_str(l);
580        } else {
581            out.push(' ');
582            out.push_str(l);
583        }
584    }
585    out
586}
587
588#[derive(Clone, Copy)]
589enum Chomp {
590    Clip,
591    Strip,
592    Keep,
593}
594
595/// A structural line (a mapping entry / sequence item / node start) may not be
596/// indented with tabs. Block-scalar bodies never reach this check (they are read
597/// raw), so a tab inside literal text is fine.
598fn check_tab(line: &Line) -> Result<(), YamlError> {
599    if line.text.starts_with('\t') {
600        return Err(err(
601            line,
602            0,
603            "tab indentation is not allowed in YAML (use spaces)",
604        ));
605    }
606    Ok(())
607}
608
609/// `- item` / `-` — a block sequence entry.
610fn is_seq_item(text: &str) -> bool {
611    text == "-" || text.starts_with("- ")
612}
613
614/// Do the flow brackets in `s` balance (outside quotes)? An empty/unbalanced
615/// text says `false`; a text with more closers than openers says `true` (the
616/// parser will then report the stray closer).
617fn flow_balanced(s: &str) -> bool {
618    let bytes = s.as_bytes();
619    let mut depth: i32 = 0;
620    let mut i = 0;
621    let mut in_single = false;
622    let mut in_double = false;
623    while i < bytes.len() {
624        let b = bytes[i];
625        if in_double {
626            if b == b'\\' {
627                i += 2;
628                continue;
629            }
630            if b == b'"' {
631                in_double = false;
632            }
633        } else if in_single {
634            if b == b'\'' {
635                if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
636                    i += 2;
637                    continue;
638                }
639                in_single = false;
640            }
641        } else {
642            match b {
643                b'"' => in_double = true,
644                b'\'' => in_single = true,
645                b'[' | b'{' => depth += 1,
646                b']' | b'}' => depth -= 1,
647                _ => {}
648            }
649        }
650        i += 1;
651    }
652    depth <= 0
653}
654
655/// If `line.text` is a `key: rest` mapping entry, return `(key, rest)`. The key
656/// is plain (up to the first `: ` / trailing `:` outside quotes) or quoted. A
657/// `:` not followed by a space (`http://x`, `12:30`) does not split.
658fn split_key(line: &Line) -> Result<Option<(String, &str)>, YamlError> {
659    let text = line.text.as_str();
660    if text.starts_with('[') || text.starts_with('{') || is_seq_item(text) {
661        return Ok(None);
662    }
663    if text.starts_with("? ") || text == "?" {
664        return Err(err(
665            line,
666            0,
667            "complex mapping keys (`? `) are not supported",
668        ));
669    }
670    if text.starts_with('"') || text.starts_with('\'') {
671        let (key, after) = parse_quoted(line, text, 0)?;
672        let after_trim = after.trim_start();
673        if let Some(rest) = after_trim.strip_prefix(':')
674            && (rest.is_empty() || rest.starts_with(' '))
675        {
676            return Ok(Some((key, rest.trim_start_matches(' '))));
677        }
678        return Ok(None);
679    }
680    let bytes = text.as_bytes();
681    for i in 0..bytes.len() {
682        if bytes[i] == b':' && (i + 1 == bytes.len() || bytes[i + 1] == b' ') {
683            let key = text[..i].trim_end();
684            if key.is_empty() {
685                return Err(err(line, i, "empty mapping key"));
686            }
687            if key.starts_with('&') || key.starts_with('*') || key.starts_with('!') {
688                return Err(err(line, 0, "anchors, aliases and tags are not supported"));
689            }
690            if key == "<<" {
691                return Err(err(line, 0, "merge keys (`<<`) are not supported"));
692            }
693            let rest = text[i + 1..].trim_start_matches(' ');
694            return Ok(Some((key.to_string(), rest)));
695        }
696    }
697    Ok(None)
698}
699
700// ---------------------------------------------------------------------------
701// Inline values: scalars + flow collections
702// ---------------------------------------------------------------------------
703
704/// Parse an inline value at the start of `s` (a slice of the line's text
705/// starting at column `col`). Returns the value, the unconsumed remainder, and
706/// whether the value was an unquoted PLAIN scalar (which may fold onto the
707/// following lines).
708fn parse_inline_value<'a>(
709    line: &Line,
710    s: &'a str,
711    col: usize,
712) -> Result<(Value, &'a str, bool), YamlError> {
713    let t = s.trim_start_matches(' ');
714    let col = col + (s.len() - t.len());
715    if t.is_empty() {
716        return Ok((Value::Null, t, false));
717    }
718    match t.as_bytes()[0] {
719        b'"' | b'\'' => {
720            let (v, rest) = parse_quoted(line, t, col)?;
721            Ok((Value::String(v), rest, false))
722        }
723        b'[' => parse_flow_seq(line, t, col).map(|(v, r)| (v, r, false)),
724        b'{' => parse_flow_map(line, t, col).map(|(v, r)| (v, r, false)),
725        b'&' | b'*' | b'!' => Err(err(
726            line,
727            col,
728            "anchors, aliases and tags are not supported",
729        )),
730        b'@' | b'`' => Err(err(
731            line,
732            col,
733            "reserved indicator at the start of a plain scalar",
734        )),
735        _ => Ok((type_plain(t, line, col)?, "", true)),
736    }
737}
738
739/// Plain scalar inside a flow collection: ends at `,`, `]`, `}` or (in a map,
740/// `:` followed by space/punctuation). Returns the raw text and the remainder.
741fn take_flow_plain(s: &str, in_map: bool) -> (&str, &str) {
742    let bytes = s.as_bytes();
743    let mut i = 0;
744    while i < bytes.len() {
745        let b = bytes[i];
746        if b == b',' || b == b']' || b == b'}' {
747            break;
748        }
749        if in_map
750            && b == b':'
751            && (i + 1 == bytes.len() || matches!(bytes[i + 1], b' ' | b',' | b'}' | b']'))
752        {
753            break;
754        }
755        i += 1;
756    }
757    (s[..i].trim(), &s[i..])
758}
759
760fn parse_flow_seq<'a>(line: &Line, s: &'a str, col: usize) -> Result<(Value, &'a str), YamlError> {
761    let mut rest = &s[1..];
762    let mut items = Vec::new();
763    loop {
764        rest = rest.trim_start();
765        if rest.is_empty() {
766            return Err(err(line, col, "unterminated flow sequence (missing `]`)"));
767        }
768        if let Some(r) = rest.strip_prefix(']') {
769            return Ok((Value::Array(items), r));
770        }
771        let (v, r) = parse_flow_element(line, rest, col + (s.len() - rest.len()), false)?;
772        items.push(v);
773        rest = r.trim_start();
774        if let Some(r) = rest.strip_prefix(',') {
775            rest = r;
776        } else if !rest.starts_with(']') {
777            return Err(err(
778                line,
779                col + (s.len() - rest.len()),
780                "expected `,` or `]` in a flow sequence",
781            ));
782        }
783    }
784}
785
786fn parse_flow_map<'a>(line: &Line, s: &'a str, col: usize) -> Result<(Value, &'a str), YamlError> {
787    let mut rest = &s[1..];
788    let mut map = Map::new();
789    loop {
790        rest = rest.trim_start();
791        if rest.is_empty() {
792            return Err(err(line, col, "unterminated flow mapping (missing `}`)"));
793        }
794        if let Some(r) = rest.strip_prefix('}') {
795            return Ok((Value::Object(map), r));
796        }
797        let key_col = col + (s.len() - rest.len());
798        let (key, r) = if rest.starts_with('"') || rest.starts_with('\'') {
799            parse_quoted(line, rest, key_col)?
800        } else {
801            let (k, r) = take_flow_plain(rest, true);
802            if k.is_empty() {
803                return Err(err(line, key_col, "empty key in a flow mapping"));
804            }
805            (k.to_string(), r)
806        };
807        if map.contains_key(&key) {
808            return Err(err(line, key_col, format!("duplicate mapping key {key:?}")));
809        }
810        rest = r.trim_start();
811        let value = if let Some(r) = rest.strip_prefix(':') {
812            let (v, r2) =
813                parse_flow_element(line, r.trim_start(), col + (s.len() - r.len()), true)?;
814            rest = r2;
815            v
816        } else {
817            Value::Null // `{a, b}` — a key with no value is null
818        };
819        map.insert(key, value);
820        rest = rest.trim_start();
821        if let Some(r) = rest.strip_prefix(',') {
822            rest = r;
823        } else if !rest.starts_with('}') {
824            return Err(err(
825                line,
826                col + (s.len() - rest.len()),
827                "expected `,` or `}` in a flow mapping",
828            ));
829        }
830    }
831}
832
833/// One element inside a flow collection: a nested collection, a quoted scalar,
834/// or a plain scalar terminated by the flow punctuation.
835fn parse_flow_element<'a>(
836    line: &Line,
837    s: &'a str,
838    col: usize,
839    in_map: bool,
840) -> Result<(Value, &'a str), YamlError> {
841    match s.as_bytes().first() {
842        None => Ok((Value::Null, s)),
843        Some(b'[') => parse_flow_seq(line, s, col),
844        Some(b'{') => parse_flow_map(line, s, col),
845        Some(b'"') | Some(b'\'') => {
846            let (v, r) = parse_quoted(line, s, col)?;
847            Ok((Value::String(v), r))
848        }
849        Some(b'&') | Some(b'*') | Some(b'!') => Err(err(
850            line,
851            col,
852            "anchors, aliases and tags are not supported",
853        )),
854        Some(_) => {
855            let (raw, r) = take_flow_plain(s, in_map);
856            Ok((type_plain(raw, line, col)?, r))
857        }
858    }
859}
860
861/// Parse a quoted scalar starting at `s[0]` (`"` or `'`). Returns the decoded
862/// text and the remainder after the closing quote.
863fn parse_quoted<'a>(line: &Line, s: &'a str, col: usize) -> Result<(String, &'a str), YamlError> {
864    let quote = s.as_bytes()[0];
865    let mut out = String::new();
866    let mut chars = s[1..].char_indices().peekable();
867    while let Some((i, c)) = chars.next() {
868        let abs = 1 + i;
869        if quote == b'\'' {
870            if c == '\'' {
871                if let Some(&(_, '\'')) = chars.peek() {
872                    chars.next();
873                    out.push('\'');
874                    continue;
875                }
876                return Ok((out, &s[abs + 1..]));
877            }
878            out.push(c);
879            continue;
880        }
881        if c == '"' {
882            return Ok((out, &s[abs + 1..]));
883        }
884        if c == '\\' {
885            let Some((_, e)) = chars.next() else {
886                return Err(err(
887                    line,
888                    col + abs,
889                    "unterminated escape in a double-quoted scalar",
890                ));
891            };
892            match e {
893                'n' => out.push('\n'),
894                't' => out.push('\t'),
895                'r' => out.push('\r'),
896                '0' => out.push('\0'),
897                'a' => out.push('\u{7}'),
898                'b' => out.push('\u{8}'),
899                'e' => out.push('\u{1b}'),
900                'f' => out.push('\u{c}'),
901                'v' => out.push('\u{b}'),
902                ' ' => out.push(' '),
903                '/' => out.push('/'),
904                '"' => out.push('"'),
905                '\\' => out.push('\\'),
906                'x' | 'u' | 'U' => {
907                    let n = match e {
908                        'x' => 2,
909                        'u' => 4,
910                        _ => 8,
911                    };
912                    let mut code = 0u32;
913                    for _ in 0..n {
914                        let Some((_, h)) = chars.next() else {
915                            return Err(err(line, col + abs, "truncated \\x/\\u escape"));
916                        };
917                        let Some(d) = h.to_digit(16) else {
918                            return Err(err(line, col + abs, "bad hex digit in \\x/\\u escape"));
919                        };
920                        code = code * 16 + d;
921                    }
922                    let Some(ch) = char::from_u32(code) else {
923                        return Err(err(line, col + abs, "escape is not a valid unicode scalar"));
924                    };
925                    out.push(ch);
926                }
927                other => {
928                    return Err(err(
929                        line,
930                        col + abs,
931                        format!("unknown escape `\\{other}` in a double-quoted scalar"),
932                    ));
933                }
934            }
935            continue;
936        }
937        out.push(c);
938    }
939    Err(err(
940        line,
941        col,
942        "unterminated quoted scalar (multi-line quoted scalars are not supported)",
943    ))
944}
945
946/// Type a plain scalar per the YAML 1.2 core schema.
947fn type_plain(raw: &str, line: &Line, col: usize) -> Result<Value, YamlError> {
948    let t = raw.trim();
949    if t.is_empty() || t == "~" || t == "null" || t == "Null" || t == "NULL" {
950        return Ok(Value::Null);
951    }
952    match t {
953        "true" | "True" | "TRUE" => return Ok(Value::Bool(true)),
954        "false" | "False" | "FALSE" => return Ok(Value::Bool(false)),
955        _ => {}
956    }
957    if let Some(n) = parse_int(t) {
958        return Ok(n);
959    }
960    if is_float(t) {
961        return match t.parse::<f64>() {
962            Ok(f) if f.is_finite() => Number::from_f64(f)
963                .map(Value::Number)
964                .ok_or_else(|| err(line, col, "float not representable")),
965            _ => Err(err(line, col, format!("float {t:?} is out of range"))),
966        };
967    }
968    let lower = t.trim_start_matches(['-', '+']).to_ascii_lowercase();
969    if lower == ".inf" || lower == ".nan" {
970        return Err(err(
971            line,
972            col,
973            format!("non-finite float {t:?} cannot be represented in JSON"),
974        ));
975    }
976    Ok(Value::String(t.to_string()))
977}
978
979/// `[-+]?[0-9]+`, `0x[0-9a-fA-F]+`, `0o[0-7]+` → a JSON number (i64, else u64).
980fn parse_int(t: &str) -> Option<Value> {
981    let (neg, body) = match t.strip_prefix('-') {
982        Some(b) => (true, b),
983        None => (false, t.strip_prefix('+').unwrap_or(t)),
984    };
985    let (radix, digits) = if let Some(h) = body.strip_prefix("0x") {
986        (16, h)
987    } else if let Some(o) = body.strip_prefix("0o") {
988        (8, o)
989    } else {
990        (10, body)
991    };
992    if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) {
993        return None;
994    }
995    let mag = u64::from_str_radix(digits, radix).ok()?;
996    if neg {
997        if mag <= i64::MAX as u64 + 1 {
998            return Some(Value::from(-(mag as i128) as i64));
999        }
1000        return None;
1001    }
1002    Some(Value::from(mag))
1003}
1004
1005/// `[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?` — a float candidate
1006/// (a bare integer is NOT one; `parse_int` runs first).
1007fn is_float(t: &str) -> bool {
1008    let s = t.strip_prefix(['-', '+']).unwrap_or(t);
1009    let (mant, exp) = match s.find(['e', 'E']) {
1010        Some(i) => (&s[..i], Some(&s[i + 1..])),
1011        None => (s, None),
1012    };
1013    if let Some(e) = exp {
1014        let e = e.strip_prefix(['-', '+']).unwrap_or(e);
1015        if e.is_empty() || !e.bytes().all(|b| b.is_ascii_digit()) {
1016            return false;
1017        }
1018    }
1019    let (int, frac) = match mant.split_once('.') {
1020        Some((i, f)) => (i, Some(f)),
1021        None => (mant, None),
1022    };
1023    let int_ok = int.bytes().all(|b| b.is_ascii_digit());
1024    match frac {
1025        Some(f) => {
1026            int_ok && f.bytes().all(|b| b.is_ascii_digit()) && !(int.is_empty() && f.is_empty())
1027        }
1028        None => exp.is_some() && !int.is_empty() && int_ok,
1029    }
1030}
1031
1032#[cfg(test)]
1033mod tests {
1034    use super::*;
1035    use serde_json::json;
1036
1037    fn y(src: &str) -> Value {
1038        parse(src).unwrap_or_else(|e| panic!("yaml parse failed: {e}\n---\n{src}"))
1039    }
1040
1041    fn e(src: &str) -> YamlError {
1042        parse(src).expect_err("expected a parse error")
1043    }
1044
1045    #[test]
1046    fn empty_and_scalar_documents() {
1047        assert_eq!(y(""), Value::Null);
1048        assert_eq!(y("# only a comment\n\n"), Value::Null);
1049        assert_eq!(y("42"), json!(42));
1050        assert_eq!(y("--- hello\n"), json!("hello"));
1051        assert_eq!(
1052            y("---\nkey: v\n...\nignored: after end\n"),
1053            json!({"key": "v"})
1054        );
1055    }
1056
1057    #[test]
1058    fn nested_mappings_and_sequences() {
1059        let v = y(r#"
1060model: gpt-5
1061limits:
1062  max_steps: 5
1063  max_depth: 2
1064mcp_servers:
1065  - name: fs
1066    endpoint: https://fs.example/mcp
1067    tags:
1068      "*": [untrusted_input, egress]
1069  - name: q
1070    endpoint: "https://q.example/mcp"
1071subscribe:
1072- queue://inbox
1073- queue://dead-letter
1074"#);
1075        assert_eq!(
1076            v,
1077            json!({
1078                "model": "gpt-5",
1079                "limits": {"max_steps": 5, "max_depth": 2},
1080                "mcp_servers": [
1081                    {"name": "fs", "endpoint": "https://fs.example/mcp",
1082                     "tags": {"*": ["untrusted_input", "egress"]}},
1083                    {"name": "q", "endpoint": "https://q.example/mcp"}
1084                ],
1085                "subscribe": ["queue://inbox", "queue://dead-letter"]
1086            })
1087        );
1088    }
1089
1090    #[test]
1091    fn nested_sequences_and_null_items() {
1092        assert_eq!(y("- - a\n  - b\n- - c\n"), json!([["a", "b"], ["c"]]));
1093        assert_eq!(y("-\n  x: 1\n- \n-\n"), json!([{"x": 1}, null, null]));
1094        assert_eq!(
1095            y("- x: 1\n  y: 2\n- z: 3\n"),
1096            json!([{"x": 1, "y": 2}, {"z": 3}])
1097        );
1098        // A sequence nested under a key at deeper indent.
1099        assert_eq!(y("k:\n  - a\n  - b\n"), json!({"k": ["a", "b"]}));
1100    }
1101
1102    #[test]
1103    fn scalar_typing_follows_the_core_schema() {
1104        let v = y(r#"
1105n: null
1106t: ~
1107e:
1108b1: true
1109b2: False
1110i1: 42
1111i2: -7
1112i3: 0x1F
1113i4: 0o17
1114f1: 1.5
1115f2: -2.
1116f3: .5
1117f4: 1e3
1118s1: yes
1119s2: on
1120s3: 12:30
1121s4: 1_000
1122s5: hello world
1123s6: 3 apples
1124big: 18446744073709551615
1125"#);
1126        assert_eq!(v["n"], Value::Null);
1127        assert_eq!(v["t"], Value::Null);
1128        assert_eq!(v["e"], Value::Null);
1129        assert_eq!(v["b1"], json!(true));
1130        assert_eq!(v["b2"], json!(false));
1131        assert_eq!(v["i1"], json!(42));
1132        assert_eq!(v["i2"], json!(-7));
1133        assert_eq!(v["i3"], json!(31));
1134        assert_eq!(v["i4"], json!(15));
1135        assert_eq!(v["f1"], json!(1.5));
1136        assert_eq!(v["f2"], json!(-2.0));
1137        assert_eq!(v["f3"], json!(0.5));
1138        assert_eq!(v["f4"], json!(1000.0));
1139        assert_eq!(v["s1"], json!("yes"));
1140        assert_eq!(v["s2"], json!("on"));
1141        assert_eq!(v["s3"], json!("12:30"));
1142        assert_eq!(v["s4"], json!("1_000"));
1143        assert_eq!(v["s5"], json!("hello world"));
1144        assert_eq!(v["s6"], json!("3 apples"));
1145        assert_eq!(v["big"], json!(18446744073709551615u64));
1146    }
1147
1148    #[test]
1149    fn quoted_scalars_and_escapes() {
1150        let v = y(r##"
1151a: "line\nbreak \"q\" \u00e9 \x41"
1152b: 'it''s # not a comment'
1153c: "#not a comment"
1154d: plain # a comment
1155e: "true"
1156f: '42'
1157"##);
1158        assert_eq!(v["a"], json!("line\nbreak \"q\" é A"));
1159        assert_eq!(v["b"], json!("it's # not a comment"));
1160        assert_eq!(v["c"], json!("#not a comment"));
1161        assert_eq!(v["d"], json!("plain"));
1162        assert_eq!(v["e"], json!("true"));
1163        assert_eq!(v["f"], json!("42"));
1164    }
1165
1166    #[test]
1167    fn urls_and_colons_do_not_split_keys() {
1168        let v = y("endpoint: https://host:8443/mcp\ntime: 12:30\nk: a:b\n");
1169        assert_eq!(v["endpoint"], json!("https://host:8443/mcp"));
1170        assert_eq!(v["time"], json!("12:30"));
1171        assert_eq!(v["k"], json!("a:b"));
1172    }
1173
1174    #[test]
1175    fn flow_collections_inline_and_multiline() {
1176        let v = y(r#"
1177a: [1, "two", three, [4, 5], {x: y}]
1178b: {k: v, n: 2, list: [a, b], "quoted key": 'q', empty}
1179c: [
1180  one,
1181  two,
1182]
1183d: []
1184e: {}
1185"#);
1186        assert_eq!(v["a"], json!([1, "two", "three", [4, 5], {"x": "y"}]));
1187        assert_eq!(
1188            v["b"],
1189            json!({"k": "v", "n": 2, "list": ["a", "b"], "quoted key": "q", "empty": null})
1190        );
1191        assert_eq!(v["c"], json!(["one", "two"]));
1192        assert_eq!(v["d"], json!([]));
1193        assert_eq!(v["e"], json!({}));
1194    }
1195
1196    #[test]
1197    fn block_scalars_literal_and_folded() {
1198        let v = y("lit: |\n  line one\n  line two\n\n  after blank\nnext: 1\n");
1199        assert_eq!(v["lit"], json!("line one\nline two\n\nafter blank\n"));
1200        assert_eq!(v["next"], json!(1));
1201
1202        let v = y("fold: >\n  a b\n  c d\n\n  e\n");
1203        assert_eq!(v["fold"], json!("a b c d\ne\n"));
1204
1205        // Chomping: strip / keep; explicit indentation indicator.
1206        let v = y("s: |-\n  x\n  y\n\n\nk: |+\n  z\n\n\nafter: 2\n");
1207        assert_eq!(v["s"], json!("x\ny"));
1208        assert_eq!(v["k"], json!("z\n\n\n"));
1209        assert_eq!(v["after"], json!(2));
1210
1211        let v = y("ind: |2\n    keep two extra\n   one extra\n");
1212        assert_eq!(v["ind"], json!("  keep two extra\n one extra\n"));
1213
1214        // `#` and blank lines inside a block scalar are content, not comments.
1215        let v = y("script: |\n  echo hi # not a comment\n\n  # nor this\n");
1216        assert_eq!(
1217            v["script"],
1218            json!("echo hi # not a comment\n\n# nor this\n")
1219        );
1220
1221        // A block scalar as a sequence item.
1222        let v = y("- |\n  first\n- second\n");
1223        assert_eq!(v, json!(["first\n", "second"]));
1224
1225        // Empty block scalar.
1226        assert_eq!(y("e: |\nnext: 1\n"), json!({"e": "", "next": 1}));
1227    }
1228
1229    #[test]
1230    fn plain_scalars_fold_over_continuation_lines() {
1231        let v =
1232            y("instruction: read the report\n  and summarize it\n  in three bullets\nmodel: m\n");
1233        assert_eq!(
1234            v["instruction"],
1235            json!("read the report and summarize it in three bullets")
1236        );
1237        assert_eq!(v["model"], json!("m"));
1238    }
1239
1240    #[test]
1241    fn comments_bom_crlf_and_directives() {
1242        let src =
1243            "\u{feff}%YAML 1.2\r\n---\r\n# comment\r\nkey: value # trailing\r\n\r\nother: 2\r\n";
1244        assert_eq!(y(src), json!({"key": "value", "other": 2}));
1245    }
1246
1247    #[test]
1248    fn errors_name_line_and_column() {
1249        let x = e("a: 1\n\tb: 2\n");
1250        assert_eq!(x.line, 2);
1251        assert!(x.msg.contains("tab"), "{x}");
1252
1253        let x = e("a: 1\na: 2\n");
1254        assert_eq!(x.line, 2);
1255        assert!(x.msg.contains("duplicate"), "{x}");
1256
1257        let x = e("a: &anchor 1\n");
1258        assert!(x.msg.contains("anchors"), "{x}");
1259        let x = e("a: *alias\n");
1260        assert!(x.msg.contains("anchors"), "{x}");
1261        let x = e("a: !!str 1\n");
1262        assert!(x.msg.contains("tags") || x.msg.contains("anchors"), "{x}");
1263        let x = e("<<: {a: 1}\n");
1264        assert!(x.msg.contains("merge"), "{x}");
1265        let x = e("? complex\n: key\n");
1266        assert!(x.msg.contains("complex"), "{x}");
1267
1268        let x = e("a: 1\n---\nb: 2\n");
1269        assert!(x.msg.contains("multiple"), "{x}");
1270
1271        let x = e("a: [1, 2\n");
1272        assert!(x.msg.contains("unterminated"), "{x}");
1273        let x = e("a: \"unterminated\n");
1274        assert!(x.msg.contains("unterminated"), "{x}");
1275
1276        let x = e("a: .inf\n");
1277        assert!(x.msg.contains("non-finite"), "{x}");
1278
1279        let x = e("a:\n  b: 1\n c: 2\n");
1280        assert_eq!(x.line, 3);
1281        assert!(x.msg.contains("indentation"), "{x}");
1282
1283        let x = e("- a\nb: 1\n");
1284        assert!(x.msg.contains("unexpected content"), "{x}");
1285    }
1286
1287    #[test]
1288    fn json_is_valid_yaml_for_the_flow_subset() {
1289        // A JSON document is (for our subset) a flow collection — parses the same.
1290        let src = r#"{"model": "m", "limits": {"max_steps": 3}, "subscribe": ["a", "b"], "flag": true, "n": null}"#;
1291        let from_yaml = y(src);
1292        let from_json: Value = serde_json::from_str(src).unwrap();
1293        assert_eq!(from_yaml, from_json);
1294    }
1295
1296    #[test]
1297    fn inline_values_for_env_and_flags() {
1298        assert_eq!(parse_inline("12").unwrap(), json!(12));
1299        assert_eq!(parse_inline(" true ").unwrap(), json!(true));
1300        assert_eq!(parse_inline("[a, b, 3]").unwrap(), json!(["a", "b", 3]));
1301        assert_eq!(parse_inline("{k: v}").unwrap(), json!({"k": "v"}));
1302        assert_eq!(parse_inline("plain text").unwrap(), json!("plain text"));
1303        assert_eq!(parse_inline("\"12\"").unwrap(), json!("12"));
1304        assert!(parse_inline("[1, 2").is_err());
1305        assert!(parse_inline("\"a\" tail").is_err());
1306    }
1307}