Skip to main content

tf_types/
yaml.rs

1//! TF-YAML: the strict YAML subset TrustForge parses and emits — in-house
2//! codec (see `docs/dependency-audit.md`), mirror of
3//! `tools/tf-types-ts/src/core/yaml.ts`. Read that file's doc comment for
4//! the full subset definition; the two implementations must stay
5//! semantically identical (verified by parsing every `.yaml` in the repo
6//! plus the conformance suites in both languages).
7//!
8//! Values parse into `serde_json::Value`: mapping keys are always
9//! strings, integers restrict to the ±2^53-1 safe range (larger digit
10//! runs stay strings, matching the TS/JSON number model), and the
11//! non-JSON floats `.inf`/`.nan` are rejected.
12//!
13//! Deliberately rejected (out of subset): anchors & aliases, tags,
14//! multi-document streams, complex (`? `) keys.
15
16use serde_json::{Map, Number, Value};
17use std::fmt;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct YamlError {
21    message: String,
22    /// 0-based raw line, when known.
23    line: Option<usize>,
24}
25
26impl YamlError {
27    fn new(message: impl Into<String>, line: Option<usize>) -> Self {
28        Self {
29            message: message.into(),
30            line,
31        }
32    }
33}
34
35impl fmt::Display for YamlError {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        match self.line {
38            Some(l) => write!(f, "line {}: {}", l + 1, self.message),
39            None => write!(f, "{}", self.message),
40        }
41    }
42}
43
44impl std::error::Error for YamlError {}
45
46const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; // 2^53 - 1
47
48/* ------------------------------------------------------------------ */
49/*  Scalar resolution (YAML 1.2 core schema)                           */
50/* ------------------------------------------------------------------ */
51
52fn resolve_scalar(text: &str) -> Result<Value, YamlError> {
53    match text {
54        "" | "~" | "null" | "Null" | "NULL" => return Ok(Value::Null),
55        "true" | "True" | "TRUE" => return Ok(Value::Bool(true)),
56        "false" | "False" | "FALSE" => return Ok(Value::Bool(false)),
57        ".nan" | ".NaN" | ".NAN" => {
58            return Err(YamlError::new("non-finite floats are not supported", None))
59        }
60        _ => {}
61    }
62    let bytes = text.as_bytes();
63    let digits = |s: &[u8]| !s.is_empty() && s.iter().all(u8::is_ascii_digit);
64    let unsigned = bytes.strip_prefix(b"-").or_else(|| bytes.strip_prefix(b"+"));
65    let body = unsigned.unwrap_or(bytes);
66    if digits(body) {
67        if let Ok(n) = text.parse::<i64>() {
68            if n.abs() <= MAX_SAFE_INTEGER {
69                return Ok(Value::Number(n.into()));
70            }
71        }
72        return Ok(Value::String(text.to_string())); // overflow-sized digit runs
73    }
74    if let Some(hex) = text.strip_prefix("0x") {
75        if !hex.is_empty() && hex.bytes().all(|b| b.is_ascii_hexdigit()) {
76            if let Ok(n) = i64::from_str_radix(hex, 16) {
77                if n <= MAX_SAFE_INTEGER {
78                    return Ok(Value::Number(n.into()));
79                }
80            }
81            return Ok(Value::String(text.to_string()));
82        }
83    }
84    if let Some(oct) = text.strip_prefix("0o") {
85        if !oct.is_empty() && oct.bytes().all(|b| (b'0'..=b'7').contains(&b)) {
86            if let Ok(n) = i64::from_str_radix(oct, 8) {
87                if n <= MAX_SAFE_INTEGER {
88                    return Ok(Value::Number(n.into()));
89                }
90            }
91            return Ok(Value::String(text.to_string()));
92        }
93    }
94    if is_float_syntax(text) {
95        if let Ok(f) = text.parse::<f64>() {
96            if f.is_finite() {
97                if let Some(n) = Number::from_f64(f) {
98                    return Ok(Value::Number(n));
99                }
100            }
101        }
102    }
103    if matches!(
104        text,
105        ".inf" | ".Inf" | ".INF" | "-.inf" | "-.Inf" | "-.INF" | "+.inf" | "+.Inf" | "+.INF"
106    ) {
107        return Err(YamlError::new("non-finite floats are not supported", None));
108    }
109    Ok(Value::String(text.to_string()))
110}
111
112/// `[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?`
113fn is_float_syntax(text: &str) -> bool {
114    let s = text
115        .strip_prefix('-')
116        .or_else(|| text.strip_prefix('+'))
117        .unwrap_or(text);
118    let (mantissa, exponent) = match s.find(['e', 'E']) {
119        Some(i) => (&s[..i], Some(&s[i + 1..])),
120        None => (s, None),
121    };
122    let mantissa_ok = if let Some(frac) = mantissa.strip_prefix('.') {
123        !frac.is_empty() && frac.bytes().all(|b| b.is_ascii_digit())
124    } else if let Some(dot) = mantissa.find('.') {
125        let (int, frac) = (&mantissa[..dot], &mantissa[dot + 1..]);
126        !int.is_empty()
127            && int.bytes().all(|b| b.is_ascii_digit())
128            && frac.bytes().all(|b| b.is_ascii_digit())
129    } else {
130        // Pure integers are handled earlier; only exponent forms remain.
131        exponent.is_some() && !mantissa.is_empty() && mantissa.bytes().all(|b| b.is_ascii_digit())
132    };
133    if !mantissa_ok {
134        return false;
135    }
136    match exponent {
137        None => true,
138        Some(e) => {
139            let e = e.strip_prefix('-').or_else(|| e.strip_prefix('+')).unwrap_or(e);
140            !e.is_empty() && e.bytes().all(|b| b.is_ascii_digit())
141        }
142    }
143}
144
145/* ------------------------------------------------------------------ */
146/*  Parser                                                             */
147/* ------------------------------------------------------------------ */
148
149#[derive(Clone)]
150struct Line {
151    indent: usize,
152    content: String,
153    raw: usize,
154}
155
156struct Parser {
157    lines: Vec<String>,
158    items: Vec<Line>,
159    pos: usize,
160}
161
162impl Parser {
163    fn new(input: &str) -> Result<Self, YamlError> {
164        let lines: Vec<String> = input.split('\n').map(|l| l.trim_end_matches('\r').to_string()).collect();
165        let mut items = Vec::new();
166        for (i, raw) in lines.iter().enumerate() {
167            let trimmed = raw.trim_end();
168            let indent = count_indent(trimmed);
169            if raw[..indent.min(raw.len())].contains('\t') || raw.trim_start_matches(' ').starts_with('\t') && raw.trim().is_empty() {
170                return Err(YamlError::new("tabs are not allowed in indentation", Some(i)));
171            }
172            let content = &trimmed[indent..];
173            if content.is_empty() || content.starts_with('#') {
174                continue;
175            }
176            if content == "---" {
177                if items.is_empty() {
178                    continue; // leading document marker
179                }
180                return Err(YamlError::new(
181                    "multi-document streams are not supported",
182                    Some(i),
183                ));
184            }
185            if content == "..." {
186                continue;
187            }
188            items.push(Line {
189                indent,
190                content: content.to_string(),
191                raw: i,
192            });
193        }
194        Ok(Self {
195            lines,
196            items,
197            pos: 0,
198        })
199    }
200
201    fn parse(mut self) -> Result<Value, YamlError> {
202        if self.items.is_empty() {
203            return Ok(Value::Null);
204        }
205        let value = self.parse_node(0)?;
206        if self.pos < self.items.len() {
207            return Err(YamlError::new(
208                "unexpected content",
209                Some(self.items[self.pos].raw),
210            ));
211        }
212        Ok(value)
213    }
214
215    fn peek(&self) -> Option<&Line> {
216        self.items.get(self.pos)
217    }
218
219    fn parse_node(&mut self, min_indent: usize) -> Result<Value, YamlError> {
220        let Some(line) = self.peek() else {
221            return Ok(Value::Null);
222        };
223        if line.indent < min_indent {
224            return Ok(Value::Null);
225        }
226        if line.content == "-" || line.content.starts_with("- ") {
227            let indent = line.indent;
228            return self.parse_sequence(indent);
229        }
230        if find_key(&line.content).is_some() {
231            let indent = line.indent;
232            return self.parse_mapping(indent);
233        }
234        self.parse_scalar_lines()
235    }
236
237    fn parse_sequence(&mut self, indent: usize) -> Result<Value, YamlError> {
238        let mut out = Vec::new();
239        loop {
240            let Some(line) = self.peek() else { break };
241            if line.indent != indent {
242                break;
243            }
244            if line.content == "-" {
245                self.pos += 1;
246                let deeper = self.peek().map(|n| n.indent > indent).unwrap_or(false);
247                out.push(if deeper {
248                    self.parse_node(indent + 1)?
249                } else {
250                    Value::Null
251                });
252                continue;
253            }
254            if !line.content.starts_with("- ") {
255                break;
256            }
257            // Rewrite `- rest` in place as deeper-indented content so
258            // nested structures parse naturally with true columns.
259            let rest = line.content[2..].to_string();
260            let raw = line.raw;
261            let extra = count_indent(&rest);
262            self.items[self.pos] = Line {
263                indent: indent + 2 + extra,
264                content: rest[extra..].to_string(),
265                raw,
266            };
267            out.push(self.parse_node(indent + 1)?);
268        }
269        Ok(Value::Array(out))
270    }
271
272    fn parse_mapping(&mut self, indent: usize) -> Result<Value, YamlError> {
273        let mut out = Map::new();
274        loop {
275            let Some(line) = self.peek() else { break };
276            if line.indent != indent {
277                break;
278            }
279            if line.content == "-" || line.content.starts_with("- ") {
280                break;
281            }
282            let Some((key, rest)) = find_key(&line.content) else {
283                break;
284            };
285            let raw = line.raw;
286            self.pos += 1;
287            let value = if rest.is_empty() {
288                let deeper = self.peek().map(|n| n.indent > indent).unwrap_or(false);
289                if deeper {
290                    self.parse_node(indent + 1)?
291                } else {
292                    Value::Null
293                }
294            } else if rest.starts_with('|') || rest.starts_with('>') {
295                Value::String(self.parse_block_scalar(&rest, indent, raw)?)
296            } else {
297                self.parse_inline_value(&rest, indent, raw)?
298            };
299            if out.contains_key(&key) {
300                return Err(YamlError::new(
301                    format!("duplicate mapping key {key:?}"),
302                    Some(raw),
303                ));
304            }
305            out.insert(key, value);
306        }
307        Ok(Value::Object(out))
308    }
309
310    fn parse_inline_value(
311        &mut self,
312        rest: &str,
313        indent: usize,
314        raw_line: usize,
315    ) -> Result<Value, YamlError> {
316        if rest.starts_with('[') || rest.starts_with('{') {
317            let text = self.collect_flow(rest, raw_line)?;
318            let mut flow = FlowParser {
319                s: text.as_bytes(),
320                text: &text,
321                i: 0,
322                raw_line,
323            };
324            let value = flow.parse_value()?;
325            flow.expect_end()?;
326            return Ok(value);
327        }
328        if rest.starts_with('"') || rest.starts_with('\'') {
329            let (value, end) = parse_quoted(rest)
330                .ok_or_else(|| YamlError::new("unterminated quoted scalar", Some(raw_line)))?;
331            let after = strip_comment(rest[end..].trim());
332            if !after.is_empty() {
333                return Err(YamlError::new(
334                    "unexpected content after quoted scalar",
335                    Some(raw_line),
336                ));
337            }
338            return Ok(Value::String(value));
339        }
340        if rest.starts_with('&') || rest.starts_with('*') {
341            return Err(YamlError::new(
342                "anchors and aliases are not supported (TF-YAML subset)",
343                Some(raw_line),
344            ));
345        }
346        if rest.starts_with('!') {
347            return Err(YamlError::new(
348                "tags are not supported (TF-YAML subset)",
349                Some(raw_line),
350            ));
351        }
352        // Plain scalar with folded continuation lines.
353        let mut text = strip_comment(rest).to_string();
354        loop {
355            let Some(next) = self.peek() else { break };
356            if next.indent <= indent {
357                break;
358            }
359            if next.content == "-" || next.content.starts_with("- ") {
360                break;
361            }
362            if find_key(&next.content).is_some() {
363                break;
364            }
365            text.push(' ');
366            text.push_str(strip_comment(&next.content));
367            self.pos += 1;
368        }
369        resolve_scalar(text.trim()).map_err(|e| YamlError::new(e.message, Some(raw_line)))
370    }
371
372    fn parse_scalar_lines(&mut self) -> Result<Value, YamlError> {
373        let first = self.peek().expect("caller checked").clone();
374        self.pos += 1;
375        self.parse_inline_value(&first.content, first.indent.saturating_sub(1), first.raw)
376    }
377
378    fn collect_flow(&mut self, first: &str, raw_line: usize) -> Result<String, YamlError> {
379        let mut text = strip_comment(first.trim()).to_string();
380        loop {
381            if flow_balanced(&text) {
382                return Ok(text);
383            }
384            let Some(next) = self.peek() else {
385                return Err(YamlError::new("unterminated flow collection", Some(raw_line)));
386            };
387            let chunk = strip_comment(next.content.trim()).to_string();
388            self.pos += 1;
389            text.push(' ');
390            text.push_str(&chunk);
391        }
392    }
393
394    fn parse_block_scalar(
395        &mut self,
396        header: &str,
397        key_indent: usize,
398        raw_line: usize,
399    ) -> Result<String, YamlError> {
400        let folded = header.starts_with('>');
401        #[derive(PartialEq)]
402        enum Chomp {
403            Clip,
404            Strip,
405            Keep,
406        }
407        let mut chomp = Chomp::Clip;
408        let mut explicit_indent = None;
409        for c in strip_comment(header[1..].trim()).chars() {
410            match c {
411                '-' => chomp = Chomp::Strip,
412                '+' => chomp = Chomp::Keep,
413                '1'..='9' => explicit_indent = Some(key_indent + c.to_digit(10).unwrap() as usize),
414                _ => {
415                    return Err(YamlError::new(
416                        format!("bad block scalar header {header:?}"),
417                        Some(raw_line),
418                    ))
419                }
420            }
421        }
422
423        let start_raw = raw_line + 1;
424        let mut end_raw = start_raw;
425        for i in start_raw..self.lines.len() {
426            let l = &self.lines[i];
427            if l.trim().is_empty() {
428                continue;
429            }
430            if count_indent(l) <= key_indent {
431                break;
432            }
433            end_raw = i + 1;
434        }
435        let raw: Vec<&str> = (start_raw..end_raw)
436            .map(|i| self.lines[i].as_str())
437            .collect();
438        while self.pos < self.items.len() && self.items[self.pos].raw < end_raw {
439            self.pos += 1;
440        }
441
442        let mut block_indent = explicit_indent;
443        if block_indent.is_none() {
444            for l in &raw {
445                if !l.trim().is_empty() {
446                    block_indent = Some(count_indent(l));
447                    break;
448                }
449            }
450        }
451        let block_indent = block_indent.unwrap_or(key_indent + 1);
452        if block_indent <= key_indent {
453            return Err(YamlError::new(
454                "block scalar body must be indented past its key",
455                Some(raw_line),
456            ));
457        }
458
459        let body: Vec<String> = raw
460            .iter()
461            .map(|l| {
462                if l.trim().is_empty() {
463                    String::new()
464                } else {
465                    l[block_indent.min(count_indent(l))..].to_string()
466                }
467            })
468            .collect();
469        let mut end = body.len();
470        while end > 0 && body[end - 1].is_empty() {
471            end -= 1;
472        }
473        let kept = &body[..end];
474        let trailing_blank = body.len() - end;
475
476        let text = if !folded {
477            kept.join("\n")
478        } else {
479            let mut text = String::new();
480            let mut prev_was_text = false;
481            let mut prev_was_literal = false;
482            for l in kept {
483                let literal = !l.is_empty() && (l.starts_with(' ') || l.starts_with('\t'));
484                if l.is_empty() {
485                    text.push('\n');
486                    prev_was_text = false;
487                    prev_was_literal = false;
488                    continue;
489                }
490                if prev_was_text && !literal && !prev_was_literal {
491                    text.push(' ');
492                } else if prev_was_literal || (prev_was_text && literal) {
493                    text.push('\n');
494                }
495                text.push_str(l);
496                prev_was_text = true;
497                prev_was_literal = literal;
498            }
499            text
500        };
501        Ok(match chomp {
502            Chomp::Strip => text.trim_end_matches('\n').to_string(),
503            Chomp::Keep => {
504                if kept.is_empty() && trailing_blank == 0 {
505                    text
506                } else {
507                    text + &"\n".repeat(trailing_blank + 1)
508                }
509            }
510            Chomp::Clip => {
511                if text.is_empty() && trailing_blank == 0 {
512                    text
513                } else {
514                    text + "\n"
515                }
516            }
517        })
518    }
519}
520
521fn count_indent(s: &str) -> usize {
522    s.bytes().take_while(|&b| b == b' ').count()
523}
524
525/// Split a mapping line into key and remainder (comment-stripped).
526fn find_key(content: &str) -> Option<(String, String)> {
527    if content.starts_with('"') || content.starts_with('\'') {
528        let (key, end) = parse_quoted(content)?;
529        let after = content[end..].trim_start();
530        let rest = after.strip_prefix(':')?;
531        if !rest.is_empty() && !rest.starts_with(' ') {
532            return None;
533        }
534        return Some((key, strip_comment(rest.trim()).to_string()));
535    }
536    let bytes = content.as_bytes();
537    let mut depth = 0i32;
538    for i in 0..bytes.len() {
539        match bytes[i] {
540            b'[' | b'{' => depth += 1,
541            b']' | b'}' => depth -= 1,
542            b':' if depth == 0 => {
543                if i + 1 == bytes.len() || bytes[i + 1] == b' ' {
544                    let key = content[..i].trim();
545                    if key.is_empty() || key.starts_with('#') {
546                        return None;
547                    }
548                    return Some((
549                        key.to_string(),
550                        strip_comment(content[i + 1..].trim()).to_string(),
551                    ));
552                }
553            }
554            b'#' if i > 0 && bytes[i - 1] == b' ' => return None,
555            _ => {}
556        }
557    }
558    None
559}
560
561/// Strip a ` #comment` suffix outside quotes.
562fn strip_comment(s: &str) -> &str {
563    if s.starts_with('#') {
564        return "";
565    }
566    let bytes = s.as_bytes();
567    let mut in_single = false;
568    let mut in_double = false;
569    let mut i = 0;
570    while i < bytes.len() {
571        match bytes[i] {
572            b'\'' if !in_double => in_single = !in_single,
573            b'"' if !in_single && (i == 0 || bytes[i - 1] != b'\\') => in_double = !in_double,
574            b'#' if !in_single && !in_double && i > 0 && (bytes[i - 1] == b' ' || bytes[i - 1] == b'\t') => {
575                return s[..i].trim_end();
576            }
577            _ => {}
578        }
579        i += 1;
580    }
581    s
582}
583
584fn flow_balanced(s: &str) -> bool {
585    let bytes = s.as_bytes();
586    let mut depth = 0i32;
587    let mut in_single = false;
588    let mut in_double = false;
589    let mut i = 0;
590    while i < bytes.len() {
591        let b = bytes[i];
592        if in_single {
593            if b == b'\'' {
594                in_single = false;
595            }
596        } else if in_double {
597            if b == b'\\' {
598                i += 1;
599            } else if b == b'"' {
600                in_double = false;
601            }
602        } else {
603            match b {
604                b'\'' => in_single = true,
605                b'"' => in_double = true,
606                b'[' | b'{' => depth += 1,
607                b']' | b'}' => depth -= 1,
608                _ => {}
609            }
610        }
611        i += 1;
612    }
613    depth <= 0 && !in_single && !in_double
614}
615
616/// Parse a quoted scalar at the start of `s`; returns (value, end byte offset).
617fn parse_quoted(s: &str) -> Option<(String, usize)> {
618    let mut chars = s.char_indices();
619    let (_, quote) = chars.next()?;
620    if quote == '\'' {
621        let mut out = String::new();
622        let mut iter = chars.peekable();
623        while let Some((i, c)) = iter.next() {
624            if c == '\'' {
625                if matches!(iter.peek(), Some((_, '\''))) {
626                    out.push('\'');
627                    iter.next();
628                } else {
629                    return Some((out, i + 1));
630                }
631            } else {
632                out.push(c);
633            }
634        }
635        return None;
636    }
637    if quote == '"' {
638        let bytes = s.as_bytes();
639        let mut out = String::new();
640        let mut i = 1;
641        while i < s.len() {
642            let c = s[i..].chars().next()?;
643            if c == '"' {
644                return Some((out, i + 1));
645            }
646            if c == '\\' {
647                let esc = s[i + 1..].chars().next()?;
648                i += 1 + esc.len_utf8();
649                match esc {
650                    'n' => out.push('\n'),
651                    't' => out.push('\t'),
652                    'r' => out.push('\r'),
653                    '0' => out.push('\0'),
654                    'a' => out.push('\x07'),
655                    'b' => out.push('\x08'),
656                    'f' => out.push('\x0c'),
657                    'v' => out.push('\x0b'),
658                    'e' => out.push('\x1b'),
659                    '"' | '\\' | '/' => out.push(esc),
660                    'x' => {
661                        let h = s.get(i..i + 2)?;
662                        out.push(char::from_u32(u32::from_str_radix(h, 16).ok()?)?);
663                        i += 2;
664                    }
665                    'u' => {
666                        let h = s.get(i..i + 4)?;
667                        let cp = u32::from_str_radix(h, 16).ok()?;
668                        // Surrogates in \u escapes: TS uses UTF-16 code
669                        // units; TF-YAML content never uses them, so
670                        // reject rather than mis-handle.
671                        out.push(char::from_u32(cp)?);
672                        i += 4;
673                    }
674                    'U' => {
675                        let h = s.get(i..i + 8)?;
676                        out.push(char::from_u32(u32::from_str_radix(h, 16).ok()?)?);
677                        i += 8;
678                    }
679                    _ => return None,
680                }
681                let _ = bytes;
682                continue;
683            }
684            out.push(c);
685            i += c.len_utf8();
686        }
687        return None;
688    }
689    None
690}
691
692/* ------------------------------------------------------------------ */
693/*  Flow-collection parser                                             */
694/* ------------------------------------------------------------------ */
695
696struct FlowParser<'a> {
697    s: &'a [u8],
698    text: &'a str,
699    i: usize,
700    raw_line: usize,
701}
702
703impl<'a> FlowParser<'a> {
704    fn ws(&mut self) {
705        while self.i < self.s.len() && (self.s[self.i] == b' ' || self.s[self.i] == b'\t') {
706            self.i += 1;
707        }
708    }
709
710    fn fail(&self, msg: &str) -> YamlError {
711        YamlError::new(format!("{msg} in flow collection"), Some(self.raw_line))
712    }
713
714    fn parse_value(&mut self) -> Result<Value, YamlError> {
715        self.ws();
716        let Some(&c) = self.s.get(self.i) else {
717            return Err(self.fail("unexpected end"));
718        };
719        match c {
720            b'[' => self.parse_array(),
721            b'{' => self.parse_map(),
722            b'"' | b'\'' => {
723                let (value, end) = parse_quoted(&self.text[self.i..])
724                    .ok_or_else(|| self.fail("unterminated quoted scalar"))?;
725                self.i += end;
726                Ok(Value::String(value))
727            }
728            b'&' | b'*' => Err(self.fail("anchors/aliases are not supported")),
729            b'!' => Err(self.fail("tags are not supported")),
730            _ => {
731                let start = self.i;
732                while self.i < self.s.len() {
733                    let ch = self.s[self.i];
734                    if ch == b',' || ch == b']' || ch == b'}' {
735                        break;
736                    }
737                    if ch == b':' && (self.i + 1 == self.s.len() || self.s[self.i + 1] == b' ') {
738                        break;
739                    }
740                    self.i += 1;
741                }
742                resolve_scalar(self.text[start..self.i].trim())
743                    .map_err(|e| YamlError::new(e.message, Some(self.raw_line)))
744            }
745        }
746    }
747
748    fn parse_array(&mut self) -> Result<Value, YamlError> {
749        self.i += 1;
750        let mut out = Vec::new();
751        self.ws();
752        if self.s.get(self.i) == Some(&b']') {
753            self.i += 1;
754            return Ok(Value::Array(out));
755        }
756        loop {
757            out.push(self.parse_value()?);
758            self.ws();
759            match self.s.get(self.i) {
760                Some(b',') => {
761                    self.i += 1;
762                    self.ws();
763                    if self.s.get(self.i) == Some(&b']') {
764                        self.i += 1;
765                        return Ok(Value::Array(out));
766                    }
767                }
768                Some(b']') => {
769                    self.i += 1;
770                    return Ok(Value::Array(out));
771                }
772                _ => return Err(self.fail("expected , or ]")),
773            }
774        }
775    }
776
777    fn parse_map(&mut self) -> Result<Value, YamlError> {
778        self.i += 1;
779        let mut out = Map::new();
780        self.ws();
781        if self.s.get(self.i) == Some(&b'}') {
782            self.i += 1;
783            return Ok(Value::Object(out));
784        }
785        loop {
786            self.ws();
787            let key = match self.s.get(self.i) {
788                Some(b'"') | Some(b'\'') => {
789                    let (k, end) = parse_quoted(&self.text[self.i..])
790                        .ok_or_else(|| self.fail("unterminated quoted key"))?;
791                    self.i += end;
792                    k
793                }
794                _ => {
795                    let start = self.i;
796                    while self.i < self.s.len()
797                        && self.s[self.i] != b':'
798                        && self.s[self.i] != b','
799                        && self.s[self.i] != b'}'
800                    {
801                        self.i += 1;
802                    }
803                    self.text[start..self.i].trim().to_string()
804                }
805            };
806            self.ws();
807            let mut value = Value::Null;
808            if self.s.get(self.i) == Some(&b':') {
809                self.i += 1;
810                value = self.parse_value()?;
811            }
812            if out.contains_key(&key) {
813                return Err(self.fail(&format!("duplicate key {key:?}")));
814            }
815            out.insert(key, value);
816            self.ws();
817            match self.s.get(self.i) {
818                Some(b',') => {
819                    self.i += 1;
820                    self.ws();
821                    if self.s.get(self.i) == Some(&b'}') {
822                        self.i += 1;
823                        return Ok(Value::Object(out));
824                    }
825                }
826                Some(b'}') => {
827                    self.i += 1;
828                    return Ok(Value::Object(out));
829                }
830                _ => return Err(self.fail("expected , or }")),
831            }
832        }
833    }
834
835    fn expect_end(&mut self) -> Result<(), YamlError> {
836        self.ws();
837        if self.i < self.s.len() {
838            return Err(self.fail("trailing content"));
839        }
840        Ok(())
841    }
842}
843
844/* ------------------------------------------------------------------ */
845/*  Public API                                                         */
846/* ------------------------------------------------------------------ */
847
848/// Parse TF-YAML into a JSON value tree.
849pub fn parse(input: &str) -> Result<Value, YamlError> {
850    Parser::new(input)?.parse()
851}
852
853/// Parse TF-YAML directly into a typed struct.
854pub fn from_str<T: serde::de::DeserializeOwned>(input: &str) -> Result<T, YamlError> {
855    let value = parse(input)?;
856    serde_json::from_value(value).map_err(|e| YamlError::new(e.to_string(), None))
857}
858
859/// Serialize a value as block-style TF-YAML.
860pub fn to_string<T: serde::Serialize>(value: &T) -> Result<String, YamlError> {
861    let json = serde_json::to_value(value).map_err(|e| YamlError::new(e.to_string(), None))?;
862    Ok(emit(&json))
863}
864
865/* ------------------------------------------------------------------ */
866/*  Emitter                                                            */
867/* ------------------------------------------------------------------ */
868
869fn plain_safe(s: &str) -> bool {
870    let mut chars = s.chars();
871    match chars.next() {
872        Some(c) if c.is_ascii_alphanumeric() || c == '_' => {}
873        _ => return false,
874    }
875    s.chars().all(|c| {
876        c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/' | '@' | ' ')
877    })
878}
879
880fn needs_quoting(s: &str) -> bool {
881    if s.is_empty() || !plain_safe(s) || s != s.trim() {
882        return true;
883    }
884    match resolve_scalar(s) {
885        Ok(Value::String(_)) => {}
886        _ => return true,
887    }
888    // Syntactically number-like strings must be quoted regardless of our
889    // own resolution — see the TS mirror.
890    let body = s.strip_prefix('-').or_else(|| s.strip_prefix('+')).unwrap_or(s);
891    if !body.is_empty() && body.bytes().all(|b| b.is_ascii_digit() || b == b'_') && body.bytes().next().unwrap().is_ascii_digit() {
892        return true;
893    }
894    if is_float_syntax(s) {
895        return true;
896    }
897    false
898}
899
900fn quote(s: &str) -> String {
901    serde_json::to_string(s).expect("string serializes")
902}
903
904fn format_scalar(v: &Value) -> String {
905    match v {
906        Value::Null => "null".to_string(),
907        Value::Bool(true) => "true".to_string(),
908        Value::Bool(false) => "false".to_string(),
909        Value::Number(n) => n.to_string(),
910        Value::String(s) => {
911            if needs_quoting(s) {
912                quote(s)
913            } else {
914                s.clone()
915            }
916        }
917        _ => unreachable!("caller checked scalar"),
918    }
919}
920
921fn is_scalar(v: &Value) -> bool {
922    !matches!(v, Value::Array(_) | Value::Object(_))
923}
924
925fn format_key(k: &str) -> String {
926    if needs_quoting(k) || k.contains(':') || k.contains('#') {
927        quote(k)
928    } else {
929        k.to_string()
930    }
931}
932
933/// Multi-line strings become literal block scalars when safe.
934fn format_multiline(v: &Value, indent: usize) -> String {
935    if let Value::String(s) = v {
936        let ok = s.contains('\n')
937            && !s.starts_with(char::is_whitespace)
938            && !s.ends_with(char::is_whitespace)
939            && !s.contains("\n\n\n");
940        if ok {
941            let pad = "  ".repeat(indent);
942            let body: Vec<String> = s
943                .split('\n')
944                .map(|l| {
945                    if l.is_empty() {
946                        String::new()
947                    } else {
948                        format!("{pad}{l}")
949                    }
950                })
951                .collect();
952            return format!("|-\n{}", body.join("\n"));
953        }
954    }
955    format_scalar(v)
956}
957
958fn emit_entry(prefix: &str, key: &str, val: &Value, child_indent: usize, out: &mut Vec<String>) {
959    if is_scalar(val) {
960        out.push(format!(
961            "{prefix}{}: {}",
962            format_key(key),
963            format_multiline(val, child_indent)
964        ));
965    } else {
966        out.push(format!("{prefix}{}:", format_key(key)));
967        emit_node(val, child_indent, out);
968    }
969}
970
971fn emit_node(v: &Value, indent: usize, out: &mut Vec<String>) {
972    let pad = "  ".repeat(indent);
973    match v {
974        Value::Array(items) => {
975            if items.is_empty() {
976                let last = out.last_mut().expect("array attaches to a line");
977                last.push_str(" []");
978                return;
979            }
980            for item in items {
981                if is_scalar(item) {
982                    out.push(format!("{pad}- {}", format_scalar(item)));
983                } else if let Value::Array(_) = item {
984                    out.push(format!("{pad}-"));
985                    emit_node(item, indent + 1, out);
986                } else if let Value::Object(map) = item {
987                    if map.is_empty() {
988                        out.push(format!("{pad}- {{}}"));
989                        continue;
990                    }
991                    let mut first = true;
992                    for (k, val) in map {
993                        let prefix = if first {
994                            format!("{pad}- ")
995                        } else {
996                            format!("{pad}  ")
997                        };
998                        first = false;
999                        emit_entry(&prefix, k, val, indent + 2, out);
1000                    }
1001                }
1002            }
1003        }
1004        Value::Object(map) => {
1005            if map.is_empty() {
1006                let last = out.last_mut().expect("map attaches to a line");
1007                last.push_str(" {}");
1008                return;
1009            }
1010            for (k, val) in map {
1011                emit_entry(&pad, k, val, indent + 1, out);
1012            }
1013        }
1014        _ => unreachable!("caller checked collection"),
1015    }
1016}
1017
1018fn emit(v: &Value) -> String {
1019    if is_scalar(v) {
1020        return format!("{}\n", format_scalar(v));
1021    }
1022    // Top-level empty collections have no key line to attach to.
1023    match v {
1024        Value::Array(items) if items.is_empty() => return "[]\n".to_string(),
1025        Value::Object(map) if map.is_empty() => return "{}\n".to_string(),
1026        _ => {}
1027    }
1028    let mut out = Vec::new();
1029    emit_node(v, 0, &mut out);
1030    format!("{}\n", out.join("\n"))
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use super::*;
1036    use serde_json::json;
1037
1038    #[test]
1039    fn scalars_resolve_like_yaml12_core() {
1040        assert_eq!(parse("x: null").unwrap(), json!({"x": null}));
1041        assert_eq!(parse("x: ~").unwrap(), json!({"x": null}));
1042        assert_eq!(parse("x:").unwrap(), json!({"x": null}));
1043        assert_eq!(parse("x: true").unwrap(), json!({"x": true}));
1044        assert_eq!(parse("x: False").unwrap(), json!({"x": false}));
1045        assert_eq!(parse("x: 42").unwrap(), json!({"x": 42}));
1046        assert_eq!(parse("x: -7").unwrap(), json!({"x": -7}));
1047        assert_eq!(parse("x: 0x1f").unwrap(), json!({"x": 31}));
1048        assert_eq!(parse("x: 3.5").unwrap(), json!({"x": 3.5}));
1049        assert_eq!(parse("x: 1e3").unwrap(), json!({"x": 1000.0}));
1050        assert_eq!(parse("x: yes").unwrap(), json!({"x": "yes"})); // 1.2, not 1.1
1051        assert_eq!(parse("x: on").unwrap(), json!({"x": "on"}));
1052        // Overflow-sized digit runs stay strings (JS parity).
1053        assert_eq!(
1054            parse("x: 070000004041424344454647").unwrap(),
1055            json!({"x": "070000004041424344454647"})
1056        );
1057    }
1058
1059    #[test]
1060    fn block_structures() {
1061        let doc = "top:\n  list:\n    - a\n    - name: n1\n      value: 1\n    - - nested\n  map:\n    k: v\n";
1062        assert_eq!(
1063            parse(doc).unwrap(),
1064            json!({"top": {"list": ["a", {"name": "n1", "value": 1}, ["nested"]], "map": {"k": "v"}}})
1065        );
1066    }
1067
1068    #[test]
1069    fn flow_structures() {
1070        assert_eq!(
1071            parse("x: [1, two, {k: v}, [3]]").unwrap(),
1072            json!({"x": [1, "two", {"k": "v"}, [3]]})
1073        );
1074        assert_eq!(parse("x: {a: 1, b: [2]}").unwrap(), json!({"x": {"a": 1, "b": [2]}}));
1075        // Multi-line flow.
1076        assert_eq!(
1077            parse("x: [1,\n   2,\n   3]").unwrap(),
1078            json!({"x": [1, 2, 3]})
1079        );
1080    }
1081
1082    #[test]
1083    fn quoted_scalars() {
1084        assert_eq!(parse("x: 'it''s'").unwrap(), json!({"x": "it's"}));
1085        assert_eq!(parse("x: \"a\\nb\"").unwrap(), json!({"x": "a\nb"}));
1086        assert_eq!(parse("x: \"42\"").unwrap(), json!({"x": "42"}));
1087        assert_eq!(parse("\"a: b\": 1").unwrap(), json!({"a: b": 1}));
1088    }
1089
1090    #[test]
1091    fn block_scalars() {
1092        assert_eq!(
1093            parse("x: |\n  line1\n  line2\ny: 1").unwrap(),
1094            json!({"x": "line1\nline2\n", "y": 1})
1095        );
1096        assert_eq!(
1097            parse("x: |-\n  line1\n  line2").unwrap(),
1098            json!({"x": "line1\nline2"})
1099        );
1100        assert_eq!(
1101            parse("x: >-\n  fold\n  ed\n\n  para").unwrap(),
1102            json!({"x": "fold ed\npara"})
1103        );
1104        // Body lines that look like structure stay text.
1105        assert_eq!(
1106            parse("x: |\n  key: value\n  - item\ny: 2").unwrap(),
1107            json!({"x": "key: value\n- item\n", "y": 2})
1108        );
1109    }
1110
1111    #[test]
1112    fn comments_and_blanks() {
1113        let doc = "# header\nx: 1 # trailing\n\ny: \"# not a comment\"\nz: a#b\n";
1114        assert_eq!(
1115            parse(doc).unwrap(),
1116            json!({"x": 1, "y": "# not a comment", "z": "a#b"})
1117        );
1118    }
1119
1120    #[test]
1121    fn subset_violations_rejected() {
1122        assert!(parse("x: &a 1").is_err());
1123        assert!(parse("x: *a").is_err());
1124        assert!(parse("x: !!str 1").is_err());
1125        assert!(parse("a: 1\n---\nb: 2").is_err());
1126        assert!(parse("x: 1\nx: 2").is_err());
1127    }
1128
1129    #[test]
1130    fn emitter_round_trips() {
1131        let doc = json!({
1132            "name": "test",
1133            "count": 42,
1134            "pi": 3.5,
1135            "flag": true,
1136            "nothing": null,
1137            "digits": "0123456789012345678",
1138            "multiline": "first\nsecond",
1139            "list": [1, "two", {"k": "v", "nested": {"deep": [1, 2]}}],
1140            "looks_like_bool": "true",
1141            "empty_list": [],
1142            "empty_map": {},
1143            "weird key: yes": "value",
1144        });
1145        let text = to_string(&doc).unwrap();
1146        assert_eq!(parse(&text).unwrap(), doc, "emitted:\n{text}");
1147    }
1148
1149    #[test]
1150    fn typed_from_str() {
1151        #[derive(serde::Deserialize, PartialEq, Debug)]
1152        struct T {
1153            name: String,
1154            values: Vec<i64>,
1155        }
1156        let t: T = from_str("name: x\nvalues: [1, 2]\n").unwrap();
1157        assert_eq!(
1158            t,
1159            T {
1160                name: "x".into(),
1161                values: vec![1, 2]
1162            }
1163        );
1164    }
1165}