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}