Skip to main content

yaml_edit/
migrate.rs

1//! Rewriting YAML 1.1 constructs into their YAML 1.2 spellings.
2//!
3//! Two plain-scalar forms changed meaning between the versions, and both
4//! change it silently: neither is a syntax error under either version.
5//!
6//! - A bare leading zero was octal, so `0755` was 493 and is now 755.
7//! - `yes`, `no`, `on` and `off` were booleans, and are now plain strings.
8//!
9//! [`to_yaml_1_2`] rewrites both so the document keeps the value a YAML 1.1
10//! reader saw: `0755` becomes `0o755` and `yes` becomes `true`. Everything
11//! else, formatting and comments included, is left alone.
12//!
13//! [`crate::validator::Rule::LegacyYaml11`] reports the same constructs
14//! without changing anything, for callers who would rather decide per site.
15
16use crate::lex::SyntaxKind;
17use crate::yaml::{Document, SyntaxNode};
18use rowan::ast::AstNode;
19
20/// A scalar that [`to_yaml_1_2`] rewrote.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct Rewrite {
23    /// The text as the document had it.
24    pub before: String,
25    /// The text it was replaced with.
26    pub after: String,
27}
28
29/// Rewrite the YAML 1.1 constructs in `doc` to their 1.2 spellings,
30/// returning what changed.
31///
32/// The value a YAML 1.1 reader saw is preserved, which is the reason to
33/// migrate at all: `mode: 0755` meant 493 and becomes `mode: 0o755`, which
34/// still means 493. `flag: yes` meant true and becomes `flag: true`.
35///
36/// A quoted scalar is already unambiguous, so it is never touched.
37///
38/// # Example
39///
40/// ```rust
41/// # use std::str::FromStr;
42/// # use yaml_edit::Document;
43/// let doc = Document::from_str("mode: 0755\nflag: yes\n").unwrap();
44/// let rewrites = yaml_edit::migrate::to_yaml_1_2(&doc);
45///
46/// assert_eq!(doc.to_string(), "mode: 0o755\nflag: true\n");
47/// assert_eq!(rewrites.len(), 2);
48/// ```
49///
50/// Mutates in place despite `&doc` (see crate docs on interior mutability).
51pub fn to_yaml_1_2(doc: &Document) -> Vec<Rewrite> {
52    let mut rewrites = Vec::new();
53    rewrite_in(doc.syntax(), &mut rewrites);
54    rewrites
55}
56
57fn rewrite_in(node: &SyntaxNode, rewrites: &mut Vec<Rewrite>) {
58    for child in node.children() {
59        if child.kind() == SyntaxKind::SCALAR {
60            if let Some((after, kind)) = replacement_for(&child) {
61                let before = child.text().to_string();
62                let count = child.children_with_tokens().count();
63                child.splice_children(
64                    0..count,
65                    vec![crate::nodes::fresh_token(kind, &after).into()],
66                );
67                rewrites.push(Rewrite { before, after });
68            }
69            continue;
70        }
71        rewrite_in(&child, rewrites);
72    }
73}
74
75/// The 1.2 spelling of `scalar`, with the token kind it should carry, or
76/// `None` when the scalar is already unambiguous.
77fn replacement_for(scalar: &SyntaxNode) -> Option<(String, SyntaxKind)> {
78    // A scalar holding anything but its own value token (a block scalar's
79    // header, say) is not a plain scalar to reinterpret.
80    let mut tokens = scalar.children_with_tokens();
81    let token = tokens.next()?.into_token()?;
82    if tokens.next().is_some() {
83        return None;
84    }
85    if !matches!(
86        token.kind(),
87        SyntaxKind::INT | SyntaxKind::STRING | SyntaxKind::BOOL
88    ) {
89        return None;
90    }
91
92    let text = token.text();
93    // Quoting already settles the reading.
94    if text.starts_with(['"', '\'']) {
95        return None;
96    }
97
98    // So does an explicit tag: `!!str yes` is the string in both versions,
99    // and `!!int 0755` is whatever the tag says it is.
100    if scalar
101        .parent()
102        .is_some_and(|p| p.kind() == SyntaxKind::TAGGED_NODE)
103    {
104        return None;
105    }
106
107    if crate::ScalarValue::is_legacy_octal(text) {
108        let sign = if text.starts_with('-') { "-" } else { "" };
109        let digits = text.strip_prefix(['+', '-']).unwrap_or(text);
110        // `08` is no octal at all, so there is no 1.1 reading to preserve.
111        i64::from_str_radix(digits, 8).ok()?;
112        return Some((
113            format!("{sign}0o{}", digits.trim_start_matches('0')),
114            SyntaxKind::INT,
115        ));
116    }
117
118    match text {
119        "yes" | "Yes" | "YES" | "on" | "On" | "ON" => Some(("true".into(), SyntaxKind::BOOL)),
120        "no" | "No" | "NO" | "off" | "Off" | "OFF" => Some(("false".into(), SyntaxKind::BOOL)),
121        _ => None,
122    }
123}