Skip to main content

yaml_edit/
migrate.rs

1//! Rewriting YAML 1.1 constructs into their YAML 1.2 spellings.
2//!
3//! Several plain-scalar forms changed meaning between the versions, all
4//! silently: none 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` (in any of their 1.1 capitalisations)
8//!   were booleans, and are now plain strings.
9//! - Binary (`0b1010`), signed hex (`-0x1F`), base 60 (`1:30`, `1:30.5`)
10//!   and numbers with underscores (`1_000`, `0x_1F`) were numbers, and are
11//!   now plain strings.
12//! - Leading-zero decimals with an 8 or 9 (`09`), `0o` octal (`0o17`) and
13//!   floats with an exponent but no `.` or no exponent sign (`1e3`,
14//!   `1.0e3`) were strings, and are now numbers.
15//!
16//! [`to_yaml_1_2`] rewrites them so the document keeps the value a YAML 1.1
17//! reader saw: `0755` becomes `0o755`, `yes` becomes `true`, `1:30`
18//! becomes `90`, `1_000` becomes `1000` and `09` becomes `'09'`. 1.2 has no
19//! signed octal, hex or binary, so `-0755` becomes the decimal `-493`.
20//! Everything else, formatting and comments included, is left alone.
21//!
22//! Mapping keys are rewritten too, so a 1.1 key `yes` becomes `true`.
23//!
24//! The single-letter booleans `y` and `n` are left alone: they are rarely
25//! meant as booleans, and 1.1 readers such as PyYAML treat them as strings.
26//!
27//! [`crate::validator::Rule::LegacyYaml11`] reports the same forms without
28//! changing anything, for callers who would rather decide per site.
29//!
30//! The output is meant for YAML 1.2 readers, and may itself be flagged by
31//! that rule: a 1.1 reader takes the `0o755` written for `0755` as a string.
32//! For a document that has to read the same in both versions, follow the
33//! rule's suggestions instead, which use the decimal there.
34
35use crate::lex::SyntaxKind;
36use crate::scalar::{places_to_decimal, signed_digits_to_decimal, CoreScalarType, ScalarValue};
37use crate::yaml::{Document, SyntaxNode};
38use rowan::ast::AstNode;
39
40/// A scalar that [`to_yaml_1_2`] rewrote.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct Rewrite {
43    /// The text as the document had it.
44    pub before: String,
45    /// The text it was replaced with.
46    pub after: String,
47}
48
49/// Rewrite the YAML 1.1 constructs in `doc` to their 1.2 spellings,
50/// returning what changed.
51///
52/// The value a YAML 1.1 reader saw is preserved, which is the reason to
53/// migrate at all: `mode: 0755` meant 493 and becomes `mode: 0o755`, which
54/// still means 493. `flag: yes` meant true and becomes `flag: true`.
55///
56/// A quoted scalar is already unambiguous, so it is never touched.
57///
58/// Run it once per document: the input is read as YAML 1.1, where the
59/// `0o755` it writes for `0755` is a string, so a second run quotes it.
60///
61/// # Example
62///
63/// ```rust
64/// # use std::str::FromStr;
65/// # use yaml_edit::Document;
66/// let doc = Document::from_str("mode: 0755\nflag: yes\n").unwrap();
67/// let rewrites = yaml_edit::migrate::to_yaml_1_2(&doc);
68///
69/// assert_eq!(doc.to_string(), "mode: 0o755\nflag: true\n");
70/// assert_eq!(rewrites.len(), 2);
71/// ```
72///
73/// Mutates in place despite `&doc` (see crate docs on interior mutability).
74pub fn to_yaml_1_2(doc: &Document) -> Vec<Rewrite> {
75    let mut rewrites = Vec::new();
76    rewrite_in(doc.syntax(), &mut rewrites);
77    rewrites
78}
79
80fn rewrite_in(node: &SyntaxNode, rewrites: &mut Vec<Rewrite>) {
81    for child in node.children() {
82        if child.kind() == SyntaxKind::SCALAR {
83            if let Some((after, kind)) = replacement_for(&child) {
84                let before = child.text().to_string();
85                let count = child.children_with_tokens().count();
86                crate::nodes::replace_children(
87                    &child,
88                    0..count,
89                    vec![crate::nodes::fresh_token(kind, &after).into()],
90                );
91                rewrites.push(Rewrite { before, after });
92            }
93            continue;
94        }
95        rewrite_in(&child, rewrites);
96    }
97}
98
99/// The 1.2 spelling of `scalar`, with the token kind it should carry, or
100/// `None` when the scalar is already unambiguous.
101fn replacement_for(scalar: &SyntaxNode) -> Option<(String, SyntaxKind)> {
102    // A scalar holding anything but its own value token (a block scalar's
103    // header, say) is not a plain scalar to reinterpret.
104    let mut tokens = scalar.children_with_tokens();
105    let token = tokens.next()?.into_token()?;
106    if tokens.next().is_some() {
107        return None;
108    }
109    if !matches!(
110        token.kind(),
111        SyntaxKind::INT | SyntaxKind::FLOAT | SyntaxKind::STRING | SyntaxKind::BOOL
112    ) {
113        return None;
114    }
115
116    let text = token.text();
117    // Quoting already settles the reading.
118    if text.starts_with(['"', '\'']) {
119        return None;
120    }
121
122    // So does an explicit tag: `!!str yes` is the string in both versions,
123    // and `!!int 0755` is whatever the tag says it is.
124    if scalar
125        .parent()
126        .is_some_and(|p| p.kind() == SyntaxKind::TAGGED_NODE)
127    {
128        return None;
129    }
130
131    if let Some(number) = yaml_1_1_number(text) {
132        return Some(number);
133    }
134
135    match text {
136        "yes" | "Yes" | "YES" | "on" | "On" | "ON" => Some(("true".into(), SyntaxKind::BOOL)),
137        "no" | "No" | "NO" | "off" | "Off" | "OFF" => Some(("false".into(), SyntaxKind::BOOL)),
138        _ if yaml_1_1_string(text) => Some((format!("'{text}'"), SyntaxKind::STRING)),
139        _ => None,
140    }
141}
142
143/// Whether a YAML 1.1 reader saw plain `text` as a string where the 1.2
144/// core schema resolves it to something else.
145///
146/// The 1.2 `!!bool` and `!!null` forms are all 1.1 booleans and nulls, and
147/// 1.2 hex, `.inf` and `.nan` are 1.1 numbers. That leaves, per the 1.1
148/// `!!int` and `!!float` regular expressions:
149///
150/// - a decimal with a leading zero and an 8 or 9 (`09`), which is not 1.1
151///   octal, while 1.1 decimal has no leading zero
152/// - `0o` octal, which 1.1 did not have
153/// - an exponent without a `.` (`1e3`) or without a sign (`1.0e3`), as 1.1
154///   floats need both
155pub(crate) fn yaml_1_1_string(text: &str) -> bool {
156    match ScalarValue::classify_plain(text) {
157        CoreScalarType::Integer => {
158            let body = text.strip_prefix(['+', '-']).unwrap_or(text);
159            body.starts_with("0o") || ScalarValue::is_leading_zero_decimal(text)
160        }
161        CoreScalarType::Float => text
162            .find(['e', 'E'])
163            .is_some_and(|i| !text[..i].contains('.') || !text[i + 1..].starts_with(['+', '-'])),
164        _ => false,
165    }
166}
167
168/// The 1.2 spelling of a YAML 1.1 number whose 1.2 reading differs, per the
169/// 1.1 `!!int` and `!!float` regular expressions.
170pub(crate) fn yaml_1_1_number(text: &str) -> Option<(String, SyntaxKind)> {
171    let (sign, body) = match text.strip_prefix(['+', '-']) {
172        Some(body) => (&text[..1], body),
173        None => ("", text),
174    };
175    let negative = sign == "-";
176    let decimal = |digits: &str, radix: u32| {
177        (
178            signed_digits_to_decimal(digits, radix, negative),
179            SyntaxKind::INT,
180        )
181    };
182
183    if let Some(digits) = body.strip_prefix("0b") {
184        return radix_digits(digits, 2).map(|d| decimal(&d, 2));
185    }
186    if let Some(written) = body.strip_prefix("0x") {
187        let digits = radix_digits(written, 16)?;
188        // 1.2 has no signed hex, and needs no rewrite for plain hex.
189        if !sign.is_empty() {
190            return Some(decimal(&digits, 16));
191        }
192        return written
193            .contains('_')
194            .then(|| (format!("0x{digits}"), SyntaxKind::INT));
195    }
196    if body.contains(':') {
197        return sexagesimal(sign, body);
198    }
199    // A bare leading zero was octal. 1.2 has no signed `0o`, so a negative
200    // one becomes decimal.
201    if let Some(digits) = body.strip_prefix('0').and_then(|d| radix_digits(d, 8)) {
202        if negative {
203            return Some(decimal(&digits, 8));
204        }
205        let digits = digits.trim_start_matches('0');
206        let digits = if digits.is_empty() { "0" } else { digits };
207        return Some((format!("0o{digits}"), SyntaxKind::INT));
208    }
209
210    if !body.contains('_') || !body.starts_with(|c: char| c.is_ascii_digit()) {
211        return None;
212    }
213    let (mantissa, exponent) = match body.find(['e', 'E']) {
214        Some(i) => body.split_at(i),
215        None => (body, ""),
216    };
217    let is_digits = |s: &str| s.bytes().all(|b| b.is_ascii_digit() || b == b'_');
218    let kind = match mantissa.split_once('.') {
219        None if exponent.is_empty() && is_digits(mantissa) && !mantissa.starts_with('0') => {
220            SyntaxKind::INT
221        }
222        Some((int, frac)) if is_digits(int) && is_digits(frac) => SyntaxKind::FLOAT,
223        _ => return None,
224    };
225    if !exponent.is_empty() {
226        let digits = exponent[1..].strip_prefix(['+', '-'])?;
227        if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
228            return None;
229        }
230    }
231    Some((format!("{sign}{}", body.replace('_', "")), kind))
232}
233
234/// The digits of a YAML 1.1 number in `radix` with its underscores
235/// removed, or `None` if it is not one.
236fn radix_digits(digits: &str, radix: u32) -> Option<String> {
237    if !digits.chars().all(|c| c == '_' || c.is_digit(radix)) {
238        return None;
239    }
240    let digits = digits.replace('_', "");
241    (!digits.is_empty()).then_some(digits)
242}
243
244/// Rewrite a 1.1 base 60 number such as `1:30` (90) or `1:30.5` (90.5) to
245/// decimal.
246fn sexagesimal(sign: &str, body: &str) -> Option<(String, SyntaxKind)> {
247    let mut parts = body.split(':');
248    let first = parts.next()?;
249    let mut rest: Vec<&str> = parts.collect();
250    let last = rest.pop()?;
251    let (last, frac) = match last.split_once('.') {
252        Some((last, frac)) => (last, Some(frac)),
253        None => (last, None),
254    };
255    // An integer may not start with 0; a float may.
256    if !first.starts_with(|c: char| c.is_ascii_digit())
257        || (frac.is_none() && first.starts_with('0'))
258    {
259        return None;
260    }
261    let mut places: Vec<(u32, u32)> = radix_digits(first, 10)?
262        .chars()
263        .map(|c| (10, c.to_digit(10).expect("checked by radix_digits")))
264        .collect();
265    for part in rest.into_iter().chain([last]) {
266        let digit = match part.as_bytes() {
267            [d @ b'0'..=b'9'] => d - b'0',
268            [t @ b'0'..=b'5', d @ b'0'..=b'9'] => (t - b'0') * 10 + (d - b'0'),
269            _ => return None,
270        };
271        places.push((60, digit.into()));
272    }
273    let value = places_to_decimal(places);
274    match frac {
275        None => Some((format!("{sign}{value}"), SyntaxKind::INT)),
276        Some(frac) if frac.bytes().all(|b| b.is_ascii_digit() || b == b'_') => {
277            let frac = frac.replace('_', "");
278            let frac = if frac.is_empty() { "0" } else { &frac };
279            Some((format!("{sign}{value}.{frac}"), SyntaxKind::FLOAT))
280        }
281        _ => None,
282    }
283}