Skip to main content

runx_parser/
yaml.rs

1// rust-style-allow: large-file the QuoteScanner state machine and its
2// quote-aware scanners belong next to the parity-subset rules they enforce;
3// splitting the scanner from the rules trades clarity for two-file traversal.
4use std::collections::HashSet;
5
6use serde::de::DeserializeOwned;
7
8use crate::ParseError;
9
10const DIVERGENT_BOOLISH: &[&str] = &["yes", "no", "on", "off"];
11const LEFT_BRACE_BYTE: u8 = b'{';
12
13pub fn parse_yaml_document<T>(source: &str) -> Result<T, ParseError>
14where
15    T: DeserializeOwned,
16{
17    assert_yaml_parity_subset("yaml", source)?;
18    serde_norway::from_str(source).map_err(|error| ParseError::InvalidYaml {
19        field: "yaml".to_owned(),
20        message: error.to_string(),
21    })
22}
23
24pub fn assert_yaml_parity_subset(field: &str, source: &str) -> Result<(), ParseError> {
25    let mut block_scalar_indent = None;
26    for (line_index, line) in source.lines().enumerate() {
27        let line_number = line_index + 1;
28        let Some(content) = strip_yaml_comment(line) else {
29            continue;
30        };
31        let trimmed = content.trim();
32        if let Some(indent) = block_scalar_indent {
33            if trimmed.is_empty() || leading_spaces(content) > indent {
34                continue;
35            }
36            block_scalar_indent = None;
37        }
38        if trimmed.is_empty() || trimmed.starts_with("---") || trimmed.starts_with("...") {
39            continue;
40        }
41        reject_explicit_mapping_key(field, line_number, trimmed)?;
42        reject_embedded_colon_key(field, line_number, trimmed)?;
43        reject_colon_space_plain_scalar(field, line_number, content)?;
44        block_scalar_indent = block_scalar_indent_after(content).or(block_scalar_indent);
45    }
46    Ok(())
47}
48
49pub fn assert_execution_profile_yaml_subset(field: &str, source: &str) -> Result<(), ParseError> {
50    assert_yaml_parity_subset(field, source)?;
51    let mut mapping_stack = Vec::new();
52    let mut block_scalar_indent = None;
53    for (line_index, line) in source.lines().enumerate() {
54        let line_number = line_index + 1;
55        let Some(content) = strip_yaml_comment(line) else {
56            continue;
57        };
58        let trimmed = content.trim();
59        if let Some(indent) = block_scalar_indent {
60            if trimmed.is_empty() || leading_spaces(content) > indent {
61                continue;
62            }
63            block_scalar_indent = None;
64        }
65        if trimmed.is_empty() {
66            continue;
67        }
68        reject_document_marker(field, line_number, trimmed)?;
69        reject_yaml_reference_syntax(field, line_number, content)?;
70        reject_duplicate_mapping_key(field, line_number, content, &mut mapping_stack)?;
71        block_scalar_indent = block_scalar_indent_after(content).or(block_scalar_indent);
72    }
73    Ok(())
74}
75
76/// `false` for scalars YAML would coerce to a non-string (booleans, numbers,
77/// dates, special floats), so callers know to keep them quoted.
78///
79/// # Examples
80///
81/// ```
82/// use runx_parser::yaml::yaml_scalar_subset_allows;
83///
84/// assert!(yaml_scalar_subset_allows("echo")); // plain string: safe
85/// assert!(!yaml_scalar_subset_allows("yes")); // YAML boolean: ambiguous
86/// ```
87#[must_use]
88pub fn yaml_scalar_subset_allows(literal: &str) -> bool {
89    let trimmed = literal.trim();
90    !is_boolish(trimmed)
91        && !is_base_prefixed_number(trimmed)
92        && !is_sexagesimal_like(trimmed)
93        && !is_date_like(trimmed)
94        && !is_special_float(trimmed)
95}
96
97pub fn assert_yaml_scalar_subset(field: &str, literal: &str) -> Result<(), ParseError> {
98    if yaml_scalar_subset_allows(literal) {
99        return Ok(());
100    }
101    Err(ParseError::UnsupportedScalar {
102        field: field.to_owned(),
103        literal: literal.to_owned(),
104    })
105}
106
107fn strip_yaml_comment(line: &str) -> Option<&str> {
108    let mut scanner = QuoteScanner::new();
109    for (index, char) in line.char_indices() {
110        if scanner.is_plain_at(char) && char == '#' && is_comment_start(line, index) {
111            return Some(&line[..index]);
112        }
113        scanner.consume(char);
114    }
115    Some(line)
116}
117
118fn is_comment_start(line: &str, index: usize) -> bool {
119    index == 0 || line[..index].ends_with(char::is_whitespace)
120}
121
122fn reject_explicit_mapping_key(
123    field: &str,
124    line_number: usize,
125    trimmed: &str,
126) -> Result<(), ParseError> {
127    if trimmed == "?" || trimmed.starts_with("? ") {
128        return Err(ambiguous_yaml(field, line_number, trimmed));
129    }
130    Ok(())
131}
132
133fn reject_embedded_colon_key(
134    field: &str,
135    line_number: usize,
136    trimmed: &str,
137) -> Result<(), ParseError> {
138    let Some((key, _)) = top_level_plain_key(trimmed) else {
139        return Ok(());
140    };
141    if key.contains(':') {
142        return Err(ambiguous_yaml(field, line_number, trimmed));
143    }
144    Ok(())
145}
146
147fn top_level_plain_key(trimmed: &str) -> Option<(&str, usize)> {
148    let bytes = trimmed.as_bytes();
149    if bytes
150        .first()
151        .is_some_and(|byte| matches!(*byte, b'-' | b'?' | LEFT_BRACE_BYTE | b'[' | b'"' | b'\''))
152    {
153        return None;
154    }
155    let mut scanner = QuoteScanner::new();
156    for (index, char) in trimmed.char_indices() {
157        if scanner.is_plain_at(char) && char == ':' && is_mapping_delimiter(trimmed, index) {
158            return Some((trimmed[..index].trim(), index));
159        }
160        scanner.consume(char);
161    }
162    None
163}
164
165/// YAML quoted-scalar state machine used by the parity-subset scanners.
166///
167/// The earlier ad-hoc `previous != '\\'` toggle failed on YAML's double-quote
168/// escape rule (`\\` is one escape pair producing a literal `\`; the following
169/// byte is not the escape target) and on single-quote escapes (`''` is the
170/// escape, not a pair of toggles). Both shapes let `:`-bearing keys past the
171/// validator under specific escape patterns. This scanner consumes escape
172/// units in one step so the inside/outside-quote signal matches YAML's reader.
173#[derive(Clone, Copy)]
174enum QuoteState {
175    Plain,
176    InDouble,
177    /// Single-quote scanner saw a `'` and must decide on the next char whether
178    /// it was an escape (`''` -> stay in single) or a terminator.
179    InSinglePendingApostrophe,
180    InSingle,
181    /// Double-quote scanner saw a `\` and must consume the next char as the
182    /// escape target without inspecting it.
183    InDoubleEscape,
184}
185
186struct QuoteScanner {
187    state: QuoteState,
188}
189
190impl QuoteScanner {
191    fn new() -> Self {
192        Self {
193            state: QuoteState::Plain,
194        }
195    }
196
197    fn is_plain_at(&self, char: char) -> bool {
198        // PendingApostrophe means the prior `'` could be either a terminator
199        // or the first half of a `''` escape. The current char decides which:
200        // another `'` keeps us in the single-quoted scalar; anything else is
201        // plain text after the closed scalar.
202        match self.state {
203            QuoteState::Plain => true,
204            QuoteState::InSinglePendingApostrophe => char != '\'',
205            QuoteState::InDouble | QuoteState::InDoubleEscape | QuoteState::InSingle => false,
206        }
207    }
208
209    fn consume(&mut self, char: char) {
210        self.state = match self.state {
211            QuoteState::Plain => Self::plain_state_after(char),
212            QuoteState::InDouble => match char {
213                '\\' => QuoteState::InDoubleEscape,
214                '"' => QuoteState::Plain,
215                _ => QuoteState::InDouble,
216            },
217            QuoteState::InDoubleEscape => QuoteState::InDouble,
218            QuoteState::InSingle => match char {
219                '\'' => QuoteState::InSinglePendingApostrophe,
220                _ => QuoteState::InSingle,
221            },
222            // Resolve the prior `'` as either an escape pair (consume `''`
223            // and stay in single-quote) or a terminator (now plain, plus
224            // route the current char through the Plain transition table).
225            QuoteState::InSinglePendingApostrophe => match char {
226                '\'' => QuoteState::InSingle,
227                _ => Self::plain_state_after(char),
228            },
229        };
230    }
231
232    fn plain_state_after(char: char) -> QuoteState {
233        match char {
234            '\'' => QuoteState::InSingle,
235            '"' => QuoteState::InDouble,
236            _ => QuoteState::Plain,
237        }
238    }
239}
240
241fn is_mapping_delimiter(value: &str, index: usize) -> bool {
242    value[index + 1..]
243        .chars()
244        .next()
245        .is_none_or(char::is_whitespace)
246}
247
248fn reject_colon_space_plain_scalar(
249    field: &str,
250    line_number: usize,
251    content: &str,
252) -> Result<(), ParseError> {
253    let Some((_, value)) = split_plain_mapping_value(content) else {
254        return Ok(());
255    };
256    if plain_scalar_contains_colon_space(value) {
257        return Err(ambiguous_yaml(field, line_number, value.trim()));
258    }
259    Ok(())
260}
261
262fn reject_document_marker(
263    field: &str,
264    line_number: usize,
265    trimmed: &str,
266) -> Result<(), ParseError> {
267    if trimmed == "---"
268        || trimmed == "..."
269        || trimmed.starts_with("--- ")
270        || trimmed.starts_with("... ")
271    {
272        return Err(ParseError::InvalidYaml {
273            field: field.to_owned(),
274            message: format!(
275                "YAML document markers are not supported in X.yaml at line {line_number}; use one plain profile document."
276            ),
277        });
278    }
279    Ok(())
280}
281
282fn reject_yaml_reference_syntax(
283    field: &str,
284    line_number: usize,
285    content: &str,
286) -> Result<(), ParseError> {
287    for token in [": &", ": *", ": !", "- &", "- *", "- !"] {
288        if contains_plain_token(content, token) {
289            return Err(ParseError::InvalidYaml {
290                field: field.to_owned(),
291                message: format!(
292                    "YAML anchors, aliases, and tags are not supported in X.yaml at line {line_number}; write the profile explicitly."
293                ),
294            });
295        }
296    }
297    let trimmed = content.trim_start();
298    if trimmed.starts_with(['&', '*', '!']) {
299        return Err(ParseError::InvalidYaml {
300            field: field.to_owned(),
301            message: format!(
302                "YAML anchors, aliases, and tags are not supported in X.yaml at line {line_number}; write the profile explicitly."
303            ),
304        });
305    }
306    Ok(())
307}
308
309fn contains_plain_token(content: &str, token: &str) -> bool {
310    let mut scanner = QuoteScanner::new();
311    for (index, char) in content.char_indices() {
312        if scanner.is_plain_at(char) && content[index..].starts_with(token) {
313            return true;
314        }
315        scanner.consume(char);
316    }
317    false
318}
319
320struct MappingFrame {
321    indent: usize,
322    keys: HashSet<String>,
323}
324
325fn reject_duplicate_mapping_key(
326    field: &str,
327    line_number: usize,
328    content: &str,
329    stack: &mut Vec<MappingFrame>,
330) -> Result<(), ParseError> {
331    let indent = leading_spaces(content);
332    let trimmed = content.trim_start();
333    let (key_indent, key, sequence_item) = match sequence_item_key(trimmed, indent) {
334        Some(value) => value,
335        None => {
336            let Some((key, _)) = top_level_plain_key(trimmed) else {
337                return Ok(());
338            };
339            (indent, key, false)
340        }
341    };
342    if key == "<<" {
343        return Err(ParseError::InvalidYaml {
344            field: field.to_owned(),
345            message: format!(
346                "YAML merge keys are not supported in X.yaml at line {line_number}; write the profile explicitly."
347            ),
348        });
349    }
350    if sequence_item {
351        while stack.last().is_some_and(|frame| frame.indent >= key_indent) {
352            stack.pop();
353        }
354    } else {
355        while stack.last().is_some_and(|frame| frame.indent > key_indent) {
356            stack.pop();
357        }
358    }
359    if stack.last().is_none_or(|frame| frame.indent != key_indent) {
360        stack.push(MappingFrame {
361            indent: key_indent,
362            keys: HashSet::new(),
363        });
364    }
365    let Some(frame) = stack.last_mut() else {
366        return Err(ParseError::InvalidYaml {
367            field: field.to_owned(),
368            message: format!("could not track mapping key {key:?} in X.yaml at line {line_number}"),
369        });
370    };
371    if !frame.keys.insert(key.to_owned()) {
372        return Err(ParseError::InvalidYaml {
373            field: field.to_owned(),
374            message: format!(
375                "duplicate mapping key {key:?} in X.yaml at line {line_number}; keep profile keys unique."
376            ),
377        });
378    }
379    Ok(())
380}
381
382fn sequence_item_key(trimmed: &str, indent: usize) -> Option<(usize, &str, bool)> {
383    let rest = trimmed.strip_prefix("- ")?;
384    let item = rest.trim_start();
385    let leading = rest.len() - item.len();
386    let (key, _) = top_level_plain_key(item)?;
387    Some((indent + 2 + leading, key, true))
388}
389
390fn leading_spaces(content: &str) -> usize {
391    content.bytes().take_while(|byte| *byte == b' ').count()
392}
393
394fn block_scalar_indent_after(content: &str) -> Option<usize> {
395    block_scalar_value_candidates(content)
396        .iter()
397        .any(|value| is_block_scalar_header(value))
398        .then(|| leading_spaces(content))
399}
400
401fn block_scalar_value_candidates(content: &str) -> Vec<&str> {
402    let mut candidates = Vec::new();
403    if let Some((_, value)) = split_plain_mapping_value(content) {
404        candidates.push(value);
405    }
406    let trimmed = content.trim_start();
407    if let Some(rest) = trimmed.strip_prefix("- ") {
408        let item = rest.trim_start();
409        candidates.push(item);
410        if let Some((_, value)) = split_plain_mapping_value(item) {
411            candidates.push(value);
412        }
413    }
414    candidates
415}
416
417fn is_block_scalar_header(value: &str) -> bool {
418    let trimmed = value.trim();
419    let mut chars = trimmed.chars();
420    let Some(first) = chars.next() else {
421        return false;
422    };
423    if !matches!(first, '|' | '>') {
424        return false;
425    }
426    let mut seen_chomp = false;
427    let mut seen_indent = false;
428    for char in chars {
429        if matches!(char, '+' | '-') && !seen_chomp {
430            seen_chomp = true;
431        } else if char.is_ascii_digit() && !seen_indent {
432            seen_indent = true;
433        } else {
434            return false;
435        }
436    }
437    true
438}
439
440fn split_plain_mapping_value(content: &str) -> Option<(&str, &str)> {
441    let trimmed = content.trim_start();
442    let (key, delimiter_index) = top_level_plain_key(trimmed)?;
443    Some((key, &trimmed[delimiter_index + 1..]))
444}
445
446// rust-style-allow: long-function because the scalar exemptions and
447// quote-aware colon scanner are one validation rule.
448fn plain_scalar_contains_colon_space(value: &str) -> bool {
449    let trimmed = value.trim_start();
450    if trimmed.is_empty()
451        || trimmed.starts_with(['"', '\'', '|', '>', '{', '['])
452        || trimmed == "null"
453        || matches!(trimmed, "true" | "false")
454    {
455        return false;
456    }
457    contains_unquoted_colon_space(trimmed)
458}
459
460fn contains_unquoted_colon_space(value: &str) -> bool {
461    let mut scanner = QuoteScanner::new();
462    for (index, char) in value.char_indices() {
463        if scanner.is_plain_at(char) && char == ':' && is_mapping_delimiter(value, index) {
464            return true;
465        }
466        scanner.consume(char);
467    }
468    false
469}
470
471fn ambiguous_yaml(field: &str, line_number: usize, literal: &str) -> ParseError {
472    ParseError::InvalidYaml {
473        field: field.to_owned(),
474        message: format!(
475            "ambiguous YAML construct at line {line_number}; quote the value or key: {literal}"
476        ),
477    }
478}
479
480fn is_boolish(value: &str) -> bool {
481    DIVERGENT_BOOLISH
482        .iter()
483        .any(|candidate| value.eq_ignore_ascii_case(candidate))
484}
485
486fn is_base_prefixed_number(value: &str) -> bool {
487    let unsigned = value.strip_prefix(['+', '-']).unwrap_or(value);
488    unsigned.starts_with("0x") || unsigned.starts_with("0X") || unsigned.starts_with("0o")
489}
490
491fn is_sexagesimal_like(value: &str) -> bool {
492    let unsigned = value.strip_prefix(['+', '-']).unwrap_or(value);
493    let mut parts = unsigned.split(':');
494    let Some(first) = parts.next() else {
495        return false;
496    };
497    first.chars().all(|char| char.is_ascii_digit())
498        && parts.clone().count() > 0
499        && parts.all(|part| !part.is_empty() && part.chars().all(|char| char.is_ascii_digit()))
500}
501
502fn is_date_like(value: &str) -> bool {
503    let bytes = value.as_bytes();
504    bytes.len() >= 10
505        && bytes[0..4].iter().all(u8::is_ascii_digit)
506        && bytes[4] == b'-'
507        && bytes[5..7].iter().all(u8::is_ascii_digit)
508        && bytes[7] == b'-'
509        && bytes[8..10].iter().all(u8::is_ascii_digit)
510}
511
512fn is_special_float(value: &str) -> bool {
513    matches!(
514        value.to_ascii_lowercase().as_str(),
515        ".nan" | ".inf" | "+.inf" | "-.inf"
516    )
517}
518
519#[cfg(test)]
520mod tests {
521    use super::{
522        assert_execution_profile_yaml_subset, assert_yaml_parity_subset, assert_yaml_scalar_subset,
523        yaml_scalar_subset_allows,
524    };
525
526    #[test]
527    fn scalar_subset_rejects_divergent_forms() {
528        for literal in ["yes", "ON", "0x10", "0o10", "12:34", "2026-05-18", ".nan"] {
529            assert!(!yaml_scalar_subset_allows(literal), "{literal}");
530        }
531    }
532
533    #[test]
534    fn scalar_subset_allows_explicit_json_like_scalars() -> Result<(), crate::ParseError> {
535        for literal in ["true", "false", "1", "1.5", "plain text", "\"yes\""] {
536            assert_yaml_scalar_subset("fixture", literal)?;
537        }
538        Ok(())
539    }
540
541    // Regression cases for the double-quote-escape state machine. The earlier
542    // `previous != '\\'` toggle misread `\\` as still-escaped, so the closing
543    // `"` was missed and the scanner over-stayed inside quotes, masking a
544    // following ambiguous colon. The new state machine consumes `\` plus the
545    // next byte as one escape unit and resolves the close correctly.
546    #[test]
547    fn parity_subset_accepts_backslash_escape_in_double_quote() -> Result<(), crate::ParseError> {
548        for literal in [
549            "key: \"a\\\\b\"",
550            "key: \"trailing\\\\\"",
551            "key: \"mid\\\\\"",
552            "key: \"\\\\\"",
553        ] {
554            assert_yaml_parity_subset("fixture", literal)?;
555        }
556        Ok(())
557    }
558
559    #[test]
560    fn parity_subset_rejects_colon_space_after_closed_double_quote_with_escapes() {
561        // The value `plain "escaped\\" trailing: oops` is ambiguous: the
562        // quoted region terminates after `escaped\` (the `\\` is one escape
563        // unit, then `"` closes), and the trailing plain text contains
564        // `trailing: oops` which is a mapping delimiter. The old scanner
565        // stayed inside the quote forever because `previous != '\\'` at the
566        // close-quote position incorrectly suppressed the toggle, so it
567        // missed the trailing colon-space. The state machine correctly
568        // exits the quote at the close and flags the colon-space.
569        let result =
570            assert_yaml_parity_subset("fixture", "key: plain \"escaped\\\\\" trailing: oops");
571        assert!(result.is_err(), "expected rejection, got {result:?}");
572    }
573
574    #[test]
575    fn parity_subset_rejects_explicit_mapping_keys() {
576        let result = assert_yaml_parity_subset("fixture", "? >\r 2>-: ");
577        assert!(result.is_err(), "expected rejection, got {result:?}");
578    }
579
580    #[test]
581    fn execution_profile_subset_rejects_yaml_references_and_document_markers() {
582        for literal in [
583            "---\nskill: example",
584            "runners:\n  one:\n    outputs: &shared\n      result: string",
585            "runners:\n  one:\n    outputs: *shared",
586            "runners:\n  one:\n    runx:\n      <<: *shared",
587            "runners:\n  one:\n    type: !custom graph",
588        ] {
589            let result = assert_execution_profile_yaml_subset("runner_manifest", literal);
590            assert!(result.is_err(), "expected rejection, got {result:?}");
591        }
592    }
593
594    #[test]
595    fn execution_profile_subset_rejects_duplicate_keys_but_allows_sequence_reuse() {
596        let result =
597            assert_execution_profile_yaml_subset("runner_manifest", "skill: one\nskill: two\n");
598        assert!(
599            result.is_err(),
600            "expected duplicate key rejection, got {result:?}"
601        );
602
603        let sequence_result = assert_execution_profile_yaml_subset(
604            "runner_manifest",
605            r#"
606runners:
607  demo:
608    type: graph
609    graph:
610      name: demo
611      steps:
612        - id: first
613          tool: one.tool
614        - id: second
615          tool: two.tool
616"#,
617        );
618        assert!(
619            sequence_result.is_ok(),
620            "sequence item maps may reuse keys in separate items: {sequence_result:?}"
621        );
622    }
623
624    // Regression cases for the single-quote `''` escape. The earlier toggle
625    // flipped on every `'`, so `'it''s'` mis-segmented into three scalars and
626    // any `:` after byte 4 was treated as still-quoted.
627    #[test]
628    fn parity_subset_handles_single_quote_double_escape() -> Result<(), crate::ParseError> {
629        for literal in ["key: 'it''s'", "key: 'a''b''c'", "key: ''"] {
630            assert_yaml_parity_subset("fixture", literal)?;
631        }
632        Ok(())
633    }
634
635    #[test]
636    fn parity_subset_keeps_unicode_mapping_delimiter_on_char_boundary()
637    -> Result<(), crate::ParseError> {
638        assert_yaml_parity_subset("fixture", "\0\0\0'\0\0\0\0\u{8}'|\u{85}:")?;
639        Ok(())
640    }
641
642    #[test]
643    fn parity_subset_rejects_colon_space_after_closed_single_quote_with_escapes() {
644        // The value `plain 'it''s' trailing: oops` is ambiguous: the single
645        // quote terminates after `it's` (the `''` is one escape unit), and
646        // the trailing `trailing: oops` is a mapping delimiter. The old
647        // scanner toggled on every `'` so it mis-segmented the quoted run
648        // and could leave itself inside an apparent quote when the trailing
649        // colon-space appeared. The state machine resolves the `''` escape
650        // and flags the trailing colon-space.
651        let result = assert_yaml_parity_subset("fixture", "key: plain 'it''s' trailing: oops");
652        assert!(result.is_err(), "expected rejection, got {result:?}");
653    }
654}