use rlsp_fmt::{Doc, FormatOptions, concat, format as fmt_format, hard_line, indent, join, text};
use rlsp_yaml_parser::node::{Document, Node};
use rlsp_yaml_parser::{Chomp, ScalarStyle, Span};
use crate::server::YamlVersion;
#[derive(Debug, Clone)]
struct Comment {
line: usize,
text: String,
}
fn find_comment_on_line(line: &str) -> Option<(usize, String)> {
let mut in_single = false;
let mut in_double = false;
let mut chars = line.char_indices();
while let Some((byte_pos, c)) = chars.next() {
match c {
'\'' if !in_double => {
in_single = !in_single;
}
'"' if !in_single => {
in_double = !in_double;
}
'\\' if in_double => {
chars.next();
}
'#' if !in_single && !in_double => {
let before = &line[..byte_pos];
if before.trim_end().is_empty() || before.ends_with(|c: char| c.is_whitespace()) {
return Some((byte_pos, line[byte_pos..].to_string()));
}
}
_ => {}
}
}
None
}
fn content_signature(line: &str) -> String {
if let Some((byte_pos, _)) = find_comment_on_line(line) {
line[..byte_pos].trim().to_string()
} else {
line.trim().to_string()
}
}
struct ContentEntry {
signature: String,
blank_lines_before: usize,
leading: Vec<String>,
}
fn last_content_line_idx(
original: &str,
line_to_comment: &std::collections::HashMap<usize, &Comment>,
) -> Option<usize> {
original
.lines()
.enumerate()
.filter(|(idx, line)| {
!line.trim().is_empty()
&& (!line.trim_start().starts_with('#') || line_to_comment.contains_key(idx))
})
.map(|(idx, _)| idx)
.last()
}
fn attach_comments(original: &str, formatted: &str, comments: &[Comment]) -> String {
let line_to_comment: std::collections::HashMap<usize, &Comment> =
comments.iter().map(|c| (c.line, c)).collect();
let last_content_idx = last_content_line_idx(original, &line_to_comment);
let mut entries: Vec<ContentEntry> = Vec::new();
let mut pending_leading: Vec<String> = Vec::new();
let mut pending_blanks: usize = 0;
let mut first_entry = true;
for (idx, line) in original.lines().enumerate() {
if let Some(comment) = line_to_comment.get(&idx) {
if pending_blanks > 0 {
pending_leading.push(String::new());
}
pending_blanks = 0;
pending_leading.push(comment.text.clone());
} else if line.trim().is_empty() {
pending_blanks += 1;
} else if line.trim_start().starts_with('#')
&& last_content_idx.is_some_and(|last| idx > last)
{
if pending_blanks > 0 {
pending_leading.push(String::new());
}
pending_blanks = 0;
pending_leading.push(line.trim().to_string());
} else {
entries.push(ContentEntry {
signature: content_signature(line),
blank_lines_before: if first_entry {
0
} else {
pending_blanks.min(1)
},
leading: std::mem::take(&mut pending_leading),
});
first_entry = false;
pending_blanks = 0;
}
}
let trailing_leading = pending_leading;
let mut result_lines: Vec<String> = Vec::new();
let mut entry_iter = entries.iter();
let mut next_entry = entry_iter.next();
for fmt_line in formatted.lines() {
let fmt_sig = content_signature(fmt_line);
if fmt_sig.is_empty() {
if matches!(next_entry, Some(e) if e.signature.is_empty()) {
if let Some(e) = next_entry {
if e.blank_lines_before > 0 {
result_lines.push(String::new());
}
}
next_entry = entry_iter.next();
}
result_lines.push(fmt_line.to_string());
continue;
}
let mut carried_blanks = 0usize;
while matches!(next_entry, Some(e) if e.signature.is_empty()) {
if let Some(e) = next_entry {
carried_blanks = carried_blanks.max(e.blank_lines_before);
}
next_entry = entry_iter.next();
}
if let Some(entry) = next_entry {
if entry.signature == fmt_sig {
let indent_len = fmt_line.len() - fmt_line.trim_start().len();
let indent_str = " ".repeat(indent_len);
if entry.blank_lines_before > 0 || carried_blanks > 0 {
result_lines.push(String::new());
}
for lc in &entry.leading {
if lc.is_empty() {
result_lines.push(String::new());
} else {
result_lines.push(format!("{indent_str}{lc}"));
}
}
result_lines.push(fmt_line.to_string());
next_entry = entry_iter.next();
continue;
}
}
result_lines.push(fmt_line.to_string());
}
for lc in &trailing_leading {
if lc.is_empty() {
result_lines.push(String::new());
} else {
result_lines.push(lc.clone());
}
}
let mut out = result_lines.join("\n");
if !out.ends_with('\n') {
out.push('\n');
}
out
}
#[derive(Debug, Clone)]
pub struct YamlFormatOptions {
pub print_width: usize,
pub tab_width: usize,
pub use_tabs: bool,
pub single_quote: bool,
pub bracket_spacing: bool,
pub yaml_version: YamlVersion,
}
impl Default for YamlFormatOptions {
fn default() -> Self {
Self {
print_width: 80,
tab_width: 2,
use_tabs: false,
single_quote: false,
bracket_spacing: true,
yaml_version: YamlVersion::V1_2,
}
}
}
#[must_use]
pub fn format_yaml(text_input: &str, options: &YamlFormatOptions) -> String {
let documents: Vec<Document<Span>> = match rlsp_yaml_parser::load(text_input) {
Ok(docs) => docs,
Err(_) => return text_input.to_string(),
};
if documents.is_empty() {
return String::new();
}
let prefix_comments = extract_doc_prefix_comments(text_input);
let fmt_options = FormatOptions {
print_width: options.print_width,
tab_width: options.tab_width,
use_tabs: options.use_tabs,
};
let sep = text("---");
let mut parts: Vec<Doc> = Vec::new();
let mut iter = documents.iter().map(|doc| node_to_doc(&doc.root, options));
if let Some(first) = iter.next() {
parts.push(first);
}
for doc in iter {
parts.push(hard_line());
parts.push(sep.clone());
parts.push(hard_line());
parts.push(doc);
}
let joined = concat(parts);
let mut result = fmt_format(&joined, &fmt_options);
if !result.ends_with('\n') {
result.push('\n');
}
result = attach_comments(text_input, &result, &prefix_comments);
result
}
fn extract_doc_prefix_comments(text: &str) -> Vec<Comment> {
let mut comments = Vec::new();
for (line_idx, line) in text.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Some((byte_pos, comment_text)) = find_comment_on_line(line) {
let before = &line[..byte_pos];
if before.trim().is_empty() {
comments.push(Comment {
line: line_idx,
text: comment_text,
});
continue;
}
}
break;
}
comments
}
fn node_to_doc(node: &Node<Span>, options: &YamlFormatOptions) -> Doc {
match node {
Node::Scalar {
value, style, tag, ..
} => {
let tag_prefix = tag.as_ref().and_then(|t| {
if is_core_schema_tag(t) {
None
} else {
Some(format!("{t} "))
}
});
let scalar_doc = match style {
ScalarStyle::Literal(_) | ScalarStyle::Folded(_) => {
repr_block_to_doc(value, *style)
}
ScalarStyle::SingleQuoted | ScalarStyle::DoubleQuoted => {
if needs_quoting(value, options.yaml_version) {
if matches!(style, ScalarStyle::DoubleQuoted) {
text(format!("\"{}\"", escape_double_quoted(value)))
} else {
text(format!("'{value}'"))
}
} else {
string_to_doc(value, options)
}
}
ScalarStyle::Plain => {
if needs_quoting(value, options.yaml_version) {
text(value.clone())
} else {
string_to_doc(value, options)
}
}
};
if let Some(prefix) = tag_prefix {
concat(vec![text(prefix), scalar_doc])
} else {
scalar_doc
}
}
Node::Mapping { entries, .. } => mapping_to_doc(entries, options),
Node::Sequence { items, .. } => sequence_to_doc(items, options),
Node::Alias { name, .. } => text(format!("*{name}")),
}
}
fn is_core_schema_tag(tag: &str) -> bool {
tag.starts_with("tag:yaml.org,2002:")
}
fn string_to_doc(s: &str, options: &YamlFormatOptions) -> Doc {
if needs_quoting(s, options.yaml_version) {
if options.single_quote && !s.contains('\'') {
text(format!("'{s}'"))
} else {
text(format!("\"{}\"", escape_double_quoted(s)))
}
} else if options.single_quote {
text(format!("'{s}'"))
} else {
text(s.to_string())
}
}
fn needs_quoting(s: &str, version: YamlVersion) -> bool {
if s.is_empty() {
return true;
}
let always_reserved = matches!(
s,
"null" | "~" | "true" | "false" | "Null" | "NULL" | "True" | "TRUE" | "False" | "FALSE"
);
let v1_1_reserved = version == YamlVersion::V1_1
&& matches!(
s,
"yes" | "no" | "on" | "off" | "Yes" | "No" | "On" | "Off" | "YES" | "NO" | "ON" | "OFF"
);
always_reserved
|| v1_1_reserved
|| looks_like_number(s)
|| s.starts_with(|c: char| {
matches!(
c,
':' | '#'
| '&'
| '*'
| '?'
| '|'
| '-'
| '<'
| '>'
| '='
| '!'
| '%'
| '@'
| '`'
| '{'
| '}'
| '['
| ']'
)
})
|| s.contains(": ")
|| s.contains(" #")
|| s.starts_with("- ")
|| s.starts_with("--- ")
|| s == "---"
|| s == "..."
}
fn looks_like_number(s: &str) -> bool {
s.parse::<i64>().is_ok()
|| s.parse::<f64>().is_ok()
|| matches!(
s,
".inf" | ".Inf" | ".INF" | "+.inf" | "-.inf" | ".nan" | ".NaN" | ".NAN"
)
}
fn escape_double_quoted(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c => out.push(c),
}
}
out
}
fn repr_block_to_doc(s: &str, style: ScalarStyle) -> Doc {
let header = match style {
ScalarStyle::Literal(Chomp::Clip) => "|",
ScalarStyle::Literal(Chomp::Strip) => "|-",
ScalarStyle::Literal(Chomp::Keep) => "|+",
ScalarStyle::Folded(Chomp::Clip) => ">",
ScalarStyle::Folded(Chomp::Strip) => ">-",
ScalarStyle::Folded(Chomp::Keep) => ">+",
ScalarStyle::Plain | ScalarStyle::SingleQuoted | ScalarStyle::DoubleQuoted => "",
};
let mut parts = vec![text(header)];
for line_str in s.lines() {
parts.push(hard_line());
parts.push(text(line_str.to_string()));
}
concat(parts)
}
fn mapping_to_doc(entries: &[(Node<Span>, Node<Span>)], options: &YamlFormatOptions) -> Doc {
if entries.is_empty() {
return text("{}");
}
let pairs: Vec<Doc> = entries
.iter()
.map(|(key, value)| key_value_to_doc(key, value, options))
.collect();
let sep = hard_line();
join(&sep, pairs)
}
fn key_value_to_doc(key: &Node<Span>, value: &Node<Span>, options: &YamlFormatOptions) -> Doc {
let key_doc = node_to_doc(key, options);
let pair_doc = match value {
Node::Mapping { entries, .. } if !entries.is_empty() => concat(vec![
key_doc,
text(":"),
indent(concat(vec![hard_line(), mapping_to_doc(entries, options)])),
]),
Node::Sequence { items, .. } if !items.is_empty() => concat(vec![
key_doc,
text(":"),
indent(concat(vec![hard_line(), sequence_to_doc(items, options)])),
]),
Node::Scalar { .. } | Node::Mapping { .. } | Node::Sequence { .. } | Node::Alias { .. } => {
let value_doc = node_to_doc(value, options);
concat(vec![key_doc, text(": "), value_doc])
}
};
let pair_doc = if let Some(tc) = value.trailing_comment() {
concat(vec![pair_doc, text(format!(" {tc}"))])
} else {
pair_doc
};
let leading = key.leading_comments();
if leading.is_empty() {
pair_doc
} else {
let mut parts: Vec<Doc> = Vec::new();
for lc in leading {
parts.push(text(lc.clone()));
parts.push(hard_line());
}
parts.push(pair_doc);
concat(parts)
}
}
fn sequence_to_doc(seq: &[Node<Span>], options: &YamlFormatOptions) -> Doc {
if seq.is_empty() {
return text("[]");
}
let items: Vec<Doc> = seq
.iter()
.map(|item| sequence_item_to_doc(item, options))
.collect();
let sep = hard_line();
join(&sep, items)
}
fn sequence_item_to_doc(item: &Node<Span>, options: &YamlFormatOptions) -> Doc {
let item_doc = match item {
Node::Mapping { entries, .. } if !entries.is_empty() => {
let pairs: Vec<Doc> = entries
.iter()
.map(|(k, v)| key_value_to_doc(k, v, options))
.collect();
let sep = hard_line();
let inner = join(&sep, pairs);
concat(vec![text("- "), indent(inner)])
}
Node::Sequence { items, .. } if !items.is_empty() => concat(vec![
text("- "),
indent(concat(vec![hard_line(), sequence_to_doc(items, options)])),
]),
Node::Scalar { .. } | Node::Mapping { .. } | Node::Sequence { .. } | Node::Alias { .. } => {
concat(vec![text("- "), node_to_doc(item, options)])
}
};
let item_doc = if let Some(tc) = item.trailing_comment() {
concat(vec![item_doc, text(format!(" {tc}"))])
} else {
item_doc
};
let leading = item.leading_comments();
if leading.is_empty() {
item_doc
} else {
let mut parts: Vec<Doc> = Vec::new();
for lc in leading {
parts.push(text(lc.clone()));
parts.push(hard_line());
}
parts.push(item_doc);
concat(parts)
}
}
#[cfg(test)]
#[allow(clippy::indexing_slicing, clippy::expect_used, clippy::unwrap_used)]
mod tests {
use super::*;
fn default_opts() -> YamlFormatOptions {
YamlFormatOptions::default()
}
fn opts_with_version(v: YamlVersion) -> YamlFormatOptions {
YamlFormatOptions {
yaml_version: v,
..default_opts()
}
}
#[test]
fn simple_key_value() {
let result = format_yaml("key: value\n", &default_opts());
assert_eq!(result, "key: value\n");
}
#[test]
fn multiple_keys() {
let result = format_yaml("a: 1\nb: 2\nc: 3\n", &default_opts());
assert_eq!(result, "a: 1\nb: 2\nc: 3\n");
}
#[test]
fn nested_mapping() {
let input = "parent:\n child: value\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("parent:"), "missing parent key");
assert!(
result.contains(" child: value") || result.contains("\n child:"),
"child should be indented: {result:?}"
);
}
#[test]
fn deeply_nested() {
let input = "a:\n b:\n c: deep\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("a:"), "missing a");
assert!(result.contains("b:"), "missing b");
assert!(
result.contains("c: deep") || result.contains("c:"),
"missing c"
);
}
#[test]
fn block_sequence() {
let input = "items:\n - one\n - two\n - three\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("items:"), "missing items key");
assert!(result.contains("- one"), "missing - one");
assert!(result.contains("- two"), "missing - two");
}
#[test]
fn sequence_of_mappings() {
let input = "users:\n - name: Alice\n age: 30\n - name: Bob\n age: 25\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("users:"), "missing users: {result:?}");
assert!(
result.contains("- name: Alice"),
"first item first key missing: {result:?}"
);
assert!(
result.contains(" age: 30"),
"age should be indented under its sequence item: {result:?}"
);
assert!(
result.contains("- name: Bob"),
"second item first key missing: {result:?}"
);
assert!(
result.contains(" age: 25"),
"second item age should be indented: {result:?}"
);
}
#[test]
fn mapping_block_style() {
let input = "a: 1\nb: 2\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("a: 1"), "a missing: {result:?}");
assert!(result.contains("b: 2"), "b missing: {result:?}");
}
#[test]
fn flow_sequence_flat_when_fits() {
let input = "items:\n - a\n - b\n - c\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains('a'), "a missing: {result:?}");
assert!(result.contains('b'), "b missing: {result:?}");
assert!(result.contains('c'), "c missing: {result:?}");
}
#[test]
fn multi_document() {
let input = "key1: value1\n---\nkey2: value2\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("key1: value1"), "missing doc1: {result:?}");
assert!(result.contains("---"), "missing separator: {result:?}");
assert!(result.contains("key2: value2"), "missing doc2: {result:?}");
}
#[test]
fn null_values() {
let input = "key: null\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("null"), "null missing: {result:?}");
}
#[test]
fn boolean_values() {
let input = "enabled: true\ndisabled: false\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("true"), "true missing: {result:?}");
assert!(result.contains("false"), "false missing: {result:?}");
}
#[test]
fn numeric_values() {
let input = "port: 8080\nratio: 0.5\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("8080"), "integer missing: {result:?}");
assert!(result.contains("0.5"), "float missing: {result:?}");
}
#[test]
fn idempotent() {
let inputs = [
"key: value\n",
"a: 1\nb: 2\n",
"parent:\n child: value\n",
"items:\n - one\n - two\n",
];
for input in inputs {
let first = format_yaml(input, &default_opts());
let second = format_yaml(&first, &default_opts());
assert_eq!(
first, second,
"idempotency failed for {input:?}:\nfirst: {first:?}\nsecond: {second:?}"
);
}
}
#[test]
fn syntax_error_returns_original() {
let bad = "key: [unclosed\n";
let result = format_yaml(bad, &default_opts());
assert_eq!(result, bad, "should return original on parse error");
}
#[test]
fn string_quoting_ambiguous_values() {
let opts = YamlFormatOptions {
single_quote: false,
..Default::default()
};
let input = "key: some value\n";
let result = format_yaml(input, &opts);
assert!(result.contains("some value"), "result: {result:?}");
}
#[test]
fn single_quote_option() {
let opts = YamlFormatOptions {
single_quote: true,
..Default::default()
};
let input = "key: hello\n";
let result = format_yaml(input, &opts);
assert!(
result.contains("'hello'"),
"expected single-quoted: {result:?}"
);
}
#[test]
fn empty_document() {
let result = format_yaml("", &default_opts());
assert_eq!(result, "");
}
#[test]
fn trailing_comment_preserved() {
let input = "key: value # comment\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("key: value"), "content missing: {result:?}");
assert!(
result.contains("# comment"),
"trailing comment missing: {result:?}"
);
for line in result.lines() {
if line.contains("key: value") {
assert!(
line.contains("# comment"),
"trailing comment not on same line: {line:?}"
);
}
}
}
#[test]
fn leading_comment_preserved() {
let input = "# header\nkey: value\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains("# header"),
"leading comment missing: {result:?}"
);
assert!(result.contains("key: value"), "content missing: {result:?}");
let comment_pos = result.find("# header").unwrap();
let key_pos = result.find("key: value").unwrap();
assert!(
comment_pos < key_pos,
"leading comment should appear before key: {result:?}"
);
}
#[test]
fn multiple_leading_comments() {
let input = "# line one\n# line two\nkey: value\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains("# line one"),
"first comment missing: {result:?}"
);
assert!(
result.contains("# line two"),
"second comment missing: {result:?}"
);
assert!(result.contains("key: value"), "content missing: {result:?}");
let c1_pos = result.find("# line one").unwrap();
let c2_pos = result.find("# line two").unwrap();
let key_pos = result.find("key: value").unwrap();
assert!(c1_pos < key_pos, "first comment should precede key");
assert!(c2_pos < key_pos, "second comment should precede key");
assert!(c1_pos < c2_pos, "comments should be in original order");
}
#[test]
fn blank_line_between_sections() {
let input = "# section 1\nkey1: v1\n\n# section 2\nkey2: v2\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains("# section 1"),
"section 1 comment missing: {result:?}"
);
assert!(
result.contains("# section 2"),
"section 2 comment missing: {result:?}"
);
assert!(result.contains("key1: v1"), "key1 missing: {result:?}");
assert!(result.contains("key2: v2"), "key2 missing: {result:?}");
let s1_pos = result.find("# section 1").unwrap();
let k1_pos = result.find("key1: v1").unwrap();
let s2_pos = result.find("# section 2").unwrap();
let k2_pos = result.find("key2: v2").unwrap();
assert!(s1_pos < k1_pos, "section 1 comment should precede key1");
assert!(s2_pos < k2_pos, "section 2 comment should precede key2");
}
#[test]
fn comment_at_document_start() {
let input = "# top comment\nkey: value\n";
let result = format_yaml(input, &default_opts());
assert!(
result.starts_with("# top comment"),
"top comment should be first: {result:?}"
);
assert!(result.contains("key: value"), "content missing: {result:?}");
}
#[test]
fn comment_at_document_end() {
let input = "key: value\n# bottom comment\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("key: value"), "content missing: {result:?}");
assert!(
result.contains("# bottom comment"),
"bottom comment missing: {result:?}"
);
}
#[test]
fn comments_between_sequence_items() {
let input = "items:\n - item1\n # between\n - item2\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("- item1"), "item1 missing: {result:?}");
assert!(result.contains("- item2"), "item2 missing: {result:?}");
assert!(
result.contains("# between"),
"between comment missing: {result:?}"
);
let i1_pos = result.find("- item1").unwrap();
let bet_pos = result.find("# between").unwrap();
let i2_pos = result.find("- item2").unwrap();
assert!(i1_pos < bet_pos, "comment should be after item1");
assert!(bet_pos < i2_pos, "comment should be before item2");
}
#[test]
fn idempotent_with_comments() {
let inputs = [
"key: value # comment\n",
"# header\nkey: value\n",
"# section 1\nkey1: v1\n\n# section 2\nkey2: v2\n",
];
for input in inputs {
let first = format_yaml(input, &default_opts());
let second = format_yaml(&first, &default_opts());
assert_eq!(
first, second,
"idempotency failed for {input:?}:\nfirst: {first:?}\nsecond: {second:?}"
);
}
}
#[test]
fn no_comments_regression() {
let input = "a: 1\nb: 2\nc: 3\n";
let result = format_yaml(input, &default_opts());
assert_eq!(result, "a: 1\nb: 2\nc: 3\n", "regression: {result:?}");
}
#[test]
fn hash_inside_quoted_string_not_extracted() {
let input = "key: \"value # not a comment\"\n";
let result = format_yaml(input, &default_opts());
for line in result.lines() {
if line.contains("key:") {
assert!(
!line.trim_end().ends_with("# not a comment"),
"hash inside quoted string wrongly extracted as comment: {line:?}"
);
}
}
assert!(
result.contains("value") && result.contains("not a comment"),
"quoted string content should be present: {result:?}"
);
}
#[test]
fn needs_quoting_empty_string() {
assert!(
needs_quoting("", YamlVersion::V1_2),
"empty string must be quoted"
);
}
#[test]
fn needs_quoting_numeric_string() {
assert!(
needs_quoting("123", YamlVersion::V1_2),
"integer-looking string must be quoted"
);
assert!(
needs_quoting("3.14", YamlVersion::V1_2),
"float-looking string must be quoted"
);
}
#[test]
fn escape_double_quoted_control_chars() {
assert_eq!(escape_double_quoted("a\nb"), "a\\nb");
assert_eq!(escape_double_quoted("a\rb"), "a\\rb");
assert_eq!(escape_double_quoted("a\tb"), "a\\tb");
}
#[test]
fn escape_double_quoted_quote_and_backslash() {
assert_eq!(escape_double_quoted("say \"hi\""), "say \\\"hi\\\"");
assert_eq!(escape_double_quoted("a\\b"), "a\\\\b");
}
#[test]
fn tagged_node_preserves_tag() {
let input = "tagged: !mytag some_value\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains("!mytag"),
"tag prefix should be preserved: {result:?}"
);
assert!(
result.contains("some_value"),
"tagged value should be preserved: {result:?}"
);
}
#[test]
fn float_special_values_round_trip() {
let input = "nan_val: .nan\ninf_val: .inf\nneg_inf_val: -.inf\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains(".nan"),
".nan should be preserved: {result:?}"
);
assert!(
result.contains(".inf"),
".inf should be preserved: {result:?}"
);
assert!(
result.contains("-.inf"),
"-.inf should be preserved: {result:?}"
);
}
#[test]
fn whole_number_float_rendered_with_decimal() {
let input = "x: 42.0\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains("42.0"),
"whole-number float should retain decimal: {result:?}"
);
}
#[test]
fn empty_string_value_is_quoted() {
let input = "key: \"\"\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains("\"\"") || result.contains("''"),
"empty string should be quoted: {result:?}"
);
}
#[test]
fn numeric_looking_string_stays_quoted() {
let input = "version: \"123\"\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains("\"123\"") || result.contains("'123'"),
"numeric-looking string should be quoted: {result:?}"
);
}
#[test]
fn format_yaml_on_key_stays_unquoted() {
let result = format_yaml("on: push\n", &default_opts());
assert!(
result.contains("on:"),
"on: key should not be quoted: {result:?}"
);
assert!(
!result.contains("\"on\"") && !result.contains("'on'"),
"on: key must not be quoted: {result:?}"
);
}
#[test]
fn format_yaml_quoted_on_key_stays_quoted() {
let result = format_yaml("\"on\": push\n", &opts_with_version(YamlVersion::V1_1));
assert!(
result.contains("\"on\""),
"explicitly quoted on: key should stay quoted in V1.1: {result:?}"
);
}
#[test]
fn format_yaml_true_plain_scalar_preserved() {
let result = format_yaml("enabled: true\n", &default_opts());
assert!(
result.contains("true"),
"true should be preserved: {result:?}"
);
assert!(
!result.contains("\"true\"") && !result.contains("'true'"),
"true must not be quoted: {result:?}"
);
}
#[test]
fn format_yaml_false_plain_scalar_preserved() {
let result = format_yaml("active: false\n", &default_opts());
assert!(
result.contains("false"),
"false should be preserved: {result:?}"
);
assert!(
!result.contains("\"false\"") && !result.contains("'false'"),
"false must not be quoted: {result:?}"
);
}
#[test]
fn format_yaml_null_plain_scalar_preserved() {
let result = format_yaml("value: null\n", &default_opts());
assert!(
result.contains("null"),
"null should be preserved: {result:?}"
);
assert!(
!result.contains("\"null\"") && !result.contains("'null'"),
"null must not be quoted: {result:?}"
);
}
#[test]
fn format_yaml_integer_preserved() {
let result = format_yaml("port: 8080\n", &default_opts());
assert!(
result.contains("8080"),
"integer should be preserved: {result:?}"
);
}
#[test]
fn format_yaml_double_quoted_string_preserved() {
let result = format_yaml("greeting: \"hello\"\n", &default_opts());
assert!(
!result.contains("\"hello\""),
"unnecessary double quotes should be stripped: {result:?}"
);
assert!(
result.contains("hello"),
"value should still be present as plain: {result:?}"
);
}
#[test]
fn format_yaml_single_quoted_string_preserved() {
let result = format_yaml("greeting: 'hello'\n", &default_opts());
assert!(
!result.contains("'hello'"),
"unnecessary single quotes should be stripped: {result:?}"
);
assert!(
result.contains("hello"),
"value should still be present as plain: {result:?}"
);
}
#[test]
fn format_yaml_literal_block_scalar_preserved() {
let input = "body: |\n line one\n line two\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains('|'),
"literal block indicator missing: {result:?}"
);
assert!(
result.contains("line one"),
"block content missing: {result:?}"
);
assert!(
result.contains("line two"),
"block content missing: {result:?}"
);
}
#[test]
fn format_yaml_folded_block_scalar_preserved() {
let input = "body: >\n folded line\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains('>'),
"folded block indicator missing: {result:?}"
);
assert!(
result.contains("folded line"),
"block content missing: {result:?}"
);
}
#[test]
fn format_yaml_other_yaml11_booleans_unquoted() {
let result = format_yaml("yes: no\n", &default_opts());
assert!(
result.contains("yes:"),
"yes: key should not be quoted: {result:?}"
);
assert!(
result.contains("no"),
"no value should not be quoted: {result:?}"
);
}
#[test]
fn format_yaml_multi_document_round_trip_with_early_parse_false() {
let input = "a: 1\n---\nb: 2\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("a: 1"), "first doc missing: {result:?}");
assert!(
result.contains("---"),
"document separator missing: {result:?}"
);
assert!(result.contains("b: 2"), "second doc missing: {result:?}");
}
#[test]
fn format_yaml_blank_line_between_top_level_keys_preserved() {
let input = "on: push\n\npermissions:\n contents: read\n\njobs:\n build: {}\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains("on: push\n\npermissions:"),
"blank line between on: and permissions: missing: {result:?}"
);
assert!(result.contains("jobs:"), "jobs: key missing: {result:?}");
let on_pos = result.find("on: push").unwrap();
let jobs_pos = result.find("jobs:").unwrap();
let between = &result[on_pos..jobs_pos];
assert!(
between.contains("\n\n"),
"expected at least one blank line before jobs: {result:?}"
);
}
#[test]
fn format_yaml_blank_line_between_nested_keys_preserved() {
let input = "parent:\n a: 1\n\n b: 2\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains("a: 1\n\n") || result.contains("a: 1\n\n b:"),
"blank line between nested a and b missing: {result:?}"
);
}
#[test]
fn format_yaml_multiple_consecutive_blank_lines_collapsed_to_one() {
let input = "a: 1\n\n\nb: 2\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains("a: 1\n\nb: 2"),
"expected exactly one blank line: {result:?}"
);
assert!(
!result.contains("a: 1\n\n\nb: 2"),
"two consecutive blank lines should collapse to one: {result:?}"
);
}
#[test]
fn format_yaml_blank_line_preservation_is_idempotent() {
let input = "on: push\n\npermissions:\n contents: read\n\njobs:\n build: {}\n";
let first = format_yaml(input, &default_opts());
let second = format_yaml(&first, &default_opts());
assert_eq!(first, second, "blank line preservation is not idempotent");
}
#[test]
fn format_yaml_blank_line_between_sequence_items_preserved() {
let input = "items:\n - a: 1\n\n - b: 2\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains("\n\n"),
"blank line between sequence items missing: {result:?}"
);
}
#[test]
fn format_yaml_blank_lines_and_comments_coexist() {
let input = "# section one\na: 1\n\n# section two\nb: 2\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains("# section one"),
"first comment missing: {result:?}"
);
assert!(
result.contains("# section two"),
"second comment missing: {result:?}"
);
let first_pos = result.find("a: 1").unwrap();
let second_pos = result.find("# section two").unwrap();
let between = &result[first_pos..second_pos];
assert!(
between.contains("\n\n"),
"blank line between sections missing: {result:?}"
);
}
#[test]
fn format_yaml_blank_line_at_end_of_file_not_added() {
let result = format_yaml("a: 1\n\n", &default_opts());
assert_eq!(
result, "a: 1\n",
"trailing blank line should be stripped: {result:?}"
);
}
#[test]
fn format_yaml_blank_lines_inside_block_scalar_unaffected() {
let input = "body: |\n line one\n\n line three\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains("line one"),
"block content missing: {result:?}"
);
assert!(
result.contains("line three"),
"block content missing: {result:?}"
);
}
#[test]
fn format_yaml_no_blank_lines_not_added() {
let input = "a: 1\nb: 2\n";
let result = format_yaml(input, &default_opts());
assert_eq!(
result, "a: 1\nb: 2\n",
"no blank lines should be added: {result:?}"
);
}
#[test]
fn attach_comments_multiple_trailing_leading_comments_at_eof() {
let input = "key: value\n# first EOF comment\n\n# second EOF comment\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("key: value"), "content missing: {result:?}");
assert!(
result.contains("# first EOF comment"),
"first EOF comment missing: {result:?}"
);
assert!(
result.contains("# second EOF comment"),
"second EOF comment missing: {result:?}"
);
let content_pos = result.find("key: value").unwrap();
let first_pos = result.find("# first EOF comment").unwrap();
let second_pos = result.find("# second EOF comment").unwrap();
assert!(
first_pos > content_pos,
"first EOF comment should follow content"
);
assert!(
second_pos > first_pos,
"second EOF comment should follow first"
);
}
#[test]
fn empty_sequence_formats_as_brackets() {
let input = "empty_seq: []\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains("[]"),
"empty sequence should format as []: {result:?}"
);
}
#[test]
fn empty_mapping_formats_as_braces() {
let input = "empty_map: {}\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains("{}"),
"empty mapping should format as {{}}: {result:?}"
);
}
#[test]
fn nested_sequence_in_sequence() {
let input = "outer:\n - - inner1\n - inner2\n - simple\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("inner1"), "inner1 missing: {result:?}");
assert!(result.contains("inner2"), "inner2 missing: {result:?}");
assert!(result.contains("simple"), "simple missing: {result:?}");
let outer_pos = result.find("outer:").unwrap();
let inner1_pos = result.find("inner1").unwrap();
assert!(
inner1_pos > outer_pos,
"inner1 should appear after outer key: {result:?}"
);
}
#[test]
fn nested_sequence_idempotent() {
let input = "outer:\n - - inner1\n - inner2\n - simple\n";
let first = format_yaml(input, &default_opts());
let second = format_yaml(&first, &default_opts());
assert_eq!(
first, second,
"nested sequence formatting should be idempotent:\nfirst: {first:?}\nsecond: {second:?}"
);
}
#[test]
fn document_end_terminator_content_preserved() {
let input = "key1: value1\n...\n---\nkey2: value2\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains("key1: value1"),
"doc1 content missing: {result:?}"
);
assert!(
result.contains("key2: value2"),
"doc2 content missing: {result:?}"
);
assert!(
result.contains("---"),
"document separator missing: {result:?}"
);
}
#[test]
fn three_document_mixed_separators() {
let input = "key: value\n...\nkey2: value2\n---\nkey3: value3\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("key: value"), "doc1 missing: {result:?}");
assert!(result.contains("key2: value2"), "doc2 missing: {result:?}");
assert!(result.contains("key3: value3"), "doc3 missing: {result:?}");
}
#[test]
fn single_document_with_dot_terminator() {
let input = "key: value\n...\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("key: value"), "content missing: {result:?}");
assert!(
!result.contains("---"),
"no separator expected for single doc: {result:?}"
);
}
fn leading_spaces(line: &str) -> usize {
line.len() - line.trim_start().len()
}
#[test]
fn format_yaml_flow_sequence_in_mapping_in_sequence_item() {
let input = "spec:\n containers:\n - name: test\n command: [\"python\", \"-m\", \"http.server\", \"5000\"]\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains("command:"),
"command: key missing: {result:?}"
);
let command_pos = result.find("command:").expect("command: not found");
let command_line = result[..command_pos].lines().count().saturating_sub(1);
let lines: Vec<&str> = result.lines().collect();
let command_indent = leading_spaces(lines[command_line]);
let item_lines: Vec<&str> = lines[command_line + 1..]
.iter()
.take_while(|l| l.trim_start().starts_with('-') || l.trim().is_empty())
.filter(|l| l.trim_start().starts_with('-'))
.copied()
.collect();
assert!(
!item_lines.is_empty(),
"no sequence items found after command: in {result:?}"
);
for item in &item_lines {
assert!(
leading_spaces(item) > command_indent,
"item {item:?} not indented deeper than command: (indent {command_indent}): {result:?}"
);
}
}
#[test]
fn format_yaml_flow_sequence_in_nested_mapping() {
let input = "job:\n run:\n command: [\"echo\", \"hello\"]\n";
let result = format_yaml(input, &default_opts());
assert!(
result.contains("command:"),
"command: key missing: {result:?}"
);
let command_pos = result.find("command:").expect("command: not found");
let command_line = result[..command_pos].lines().count().saturating_sub(1);
let lines: Vec<&str> = result.lines().collect();
let command_indent = leading_spaces(lines[command_line]);
let item_lines: Vec<&str> = lines[command_line + 1..]
.iter()
.take_while(|l| l.trim_start().starts_with('-') || l.trim().is_empty())
.filter(|l| l.trim_start().starts_with('-'))
.copied()
.collect();
assert!(
!item_lines.is_empty(),
"no items found after command: in {result:?}"
);
for item in &item_lines {
assert!(
leading_spaces(item) > command_indent,
"item {item:?} not deeper than command: (indent {command_indent}): {result:?}"
);
}
}
#[test]
fn format_yaml_single_element_flow_sequence() {
let input = "args: [\"--verbose\"]\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("args:"), "args: key missing: {result:?}");
let args_pos = result.find("args:").expect("args: not found");
let args_line = result[..args_pos].lines().count().saturating_sub(1);
let lines: Vec<&str> = result.lines().collect();
let args_indent = leading_spaces(lines[args_line]);
let item_lines: Vec<&str> = lines[args_line + 1..]
.iter()
.take_while(|l| l.trim_start().starts_with('-') || l.trim().is_empty())
.filter(|l| l.trim_start().starts_with('-'))
.copied()
.collect();
assert!(
!item_lines.is_empty(),
"no items found after args: in {result:?}"
);
for item in &item_lines {
assert!(
leading_spaces(item) > args_indent,
"item {item:?} not deeper than args: (indent {args_indent}): {result:?}"
);
}
}
#[test]
fn format_yaml_deeply_nested_flow_sequence() {
let input = "jobs:\n build:\n steps:\n - name: run\n run: [\"bash\", \"-c\", \"echo hi\"]\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("run:"), "run: key missing: {result:?}");
let run_pos = result.rfind("run:").expect("run: not found");
let run_line = result[..run_pos].lines().count().saturating_sub(1);
let lines: Vec<&str> = result.lines().collect();
let run_indent = leading_spaces(lines[run_line]);
let after_run: Vec<&str> = lines[run_line + 1..]
.iter()
.take_while(|l| l.trim_start().starts_with('-') || l.trim().is_empty())
.filter(|l| l.trim_start().starts_with('-'))
.copied()
.collect();
if !after_run.is_empty() {
for item in &after_run {
assert!(
leading_spaces(item) > run_indent,
"item {item:?} not deeper than run: (indent {run_indent}): {result:?}"
);
}
}
assert!(
result.contains("bash") || result.contains("echo"),
"sequence content missing: {result:?}"
);
}
#[test]
fn format_yaml_top_level_flow_sequence_correct_indent() {
let input = "items: [\"a\", \"b\", \"c\"]\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("items:"), "items: key missing: {result:?}");
let items_pos = result.find("items:").expect("items: not found");
let items_line = result[..items_pos].lines().count().saturating_sub(1);
let lines: Vec<&str> = result.lines().collect();
let items_indent = leading_spaces(lines[items_line]);
let item_lines: Vec<&str> = lines[items_line + 1..]
.iter()
.take_while(|l| l.trim_start().starts_with('-') || l.trim().is_empty())
.filter(|l| l.trim_start().starts_with('-'))
.copied()
.collect();
assert!(
!item_lines.is_empty(),
"no items found after items: in {result:?}"
);
for item in &item_lines {
assert!(
leading_spaces(item) > items_indent,
"item {item:?} not indented deeper than items: (indent {items_indent}): {result:?}"
);
}
}
#[test]
fn format_yaml_flow_to_block_sequence_is_idempotent() {
let input = "spec:\n containers:\n - name: test\n command: [\"python\", \"-m\", \"http.server\", \"5000\"]\n";
let first = format_yaml(input, &default_opts());
let second = format_yaml(&first, &default_opts());
assert_eq!(first, second, "flow-to-block conversion is not idempotent");
}
#[test]
fn format_yaml_double_quoted_safe_string_stripped_to_plain() {
let result = format_yaml("value: \"python\"\n", &default_opts());
assert!(
!result.contains("\"python\""),
"unnecessary double quotes should be stripped: {result:?}"
);
assert!(
result.contains("python"),
"value should be present as plain: {result:?}"
);
}
#[test]
fn format_yaml_single_quoted_safe_string_stripped_to_plain() {
let result = format_yaml("value: 'hello'\n", &default_opts());
assert!(
!result.contains("'hello'"),
"unnecessary single quotes should be stripped: {result:?}"
);
assert!(
result.contains("hello"),
"value should be present as plain: {result:?}"
);
}
#[test]
fn format_yaml_double_quoted_number_like_string_kept_quoted() {
let result = format_yaml("value: \"5000\"\n", &default_opts());
assert!(
result.contains("\"5000\""),
"quotes on number-like string must be preserved: {result:?}"
);
}
#[test]
fn format_yaml_double_quoted_boolean_like_string_kept_quoted() {
let result = format_yaml("value: \"true\"\n", &default_opts());
assert!(
result.contains("\"true\""),
"quotes on boolean keyword must be preserved: {result:?}"
);
}
#[test]
fn format_yaml_double_quoted_on_keyword_kept_quoted() {
let result = format_yaml("value: \"on\"\n", &opts_with_version(YamlVersion::V1_1));
assert!(
result.contains("\"on\""),
"quotes on YAML 1.1 keyword must be preserved in V1.1 mode: {result:?}"
);
}
#[test]
fn format_yaml_double_quoted_string_with_colon_space_kept_quoted() {
let result = format_yaml("value: \"key: value\"\n", &default_opts());
assert!(
result.contains("key:"),
"value content should be present: {result:?}"
);
}
#[test]
fn format_yaml_double_quoted_string_with_special_char_kept_quoted() {
let result = format_yaml("value: \"#comment\"\n", &default_opts());
assert!(
result.contains("\"#comment\""),
"quotes on string starting with '#' must be preserved: {result:?}"
);
}
#[test]
fn format_yaml_quoted_string_in_block_sequence_stripped() {
let result = format_yaml(
"args: [\"python\", \"-m\", \"http.server\"]\n",
&default_opts(),
);
assert!(
!result.contains("\"python\""),
"\"python\" quotes should be stripped: {result:?}"
);
assert!(
!result.contains("\"http.server\""),
"\"http.server\" quotes should be stripped: {result:?}"
);
assert!(
result.contains("\"-m\""),
"\"-m\" quotes must be preserved (starts with '-'): {result:?}"
);
}
#[test]
fn format_yaml_quote_stripping_is_idempotent() {
let input = "value: \"python\"\n";
let first = format_yaml(input, &default_opts());
let second = format_yaml(&first, &default_opts());
assert_eq!(first, second, "quote stripping is not idempotent");
}
#[test]
fn format_yaml_quote_stripping_respects_single_quote_option() {
let opts = YamlFormatOptions {
single_quote: true,
..default_opts()
};
let result = format_yaml("value: \"python\"\n", &opts);
assert!(
result.contains("'python'"),
"single_quote option should apply single quotes: {result:?}"
);
assert!(
!result.contains("\"python\""),
"original double quotes should not be preserved: {result:?}"
);
}
#[test]
fn needs_quoting_on_is_false_in_v1_2() {
assert!(!needs_quoting("on", YamlVersion::V1_2));
}
#[test]
fn needs_quoting_on_is_true_in_v1_1() {
assert!(needs_quoting("on", YamlVersion::V1_1));
}
#[test]
fn needs_quoting_yes_is_false_in_v1_2() {
assert!(!needs_quoting("yes", YamlVersion::V1_2));
}
#[test]
fn needs_quoting_yes_is_true_in_v1_1() {
assert!(needs_quoting("yes", YamlVersion::V1_1));
}
#[test]
fn needs_quoting_off_is_false_in_v1_2() {
assert!(!needs_quoting("off", YamlVersion::V1_2));
}
#[test]
fn needs_quoting_off_is_true_in_v1_1() {
assert!(needs_quoting("off", YamlVersion::V1_1));
}
#[test]
fn needs_quoting_no_is_false_in_v1_2() {
assert!(!needs_quoting("no", YamlVersion::V1_2));
}
#[test]
fn needs_quoting_no_is_true_in_v1_1() {
assert!(needs_quoting("no", YamlVersion::V1_1));
}
#[test]
fn needs_quoting_true_is_true_in_v1_2() {
assert!(needs_quoting("true", YamlVersion::V1_2));
}
#[test]
fn needs_quoting_true_is_true_in_v1_1() {
assert!(needs_quoting("true", YamlVersion::V1_1));
}
#[test]
fn needs_quoting_null_is_true_in_v1_2() {
assert!(needs_quoting("null", YamlVersion::V1_2));
}
#[test]
fn needs_quoting_null_is_true_in_v1_1() {
assert!(needs_quoting("null", YamlVersion::V1_1));
}
#[test]
fn needs_quoting_uppercase_yes_is_false_in_v1_2() {
assert!(!needs_quoting("YES", YamlVersion::V1_2));
}
#[test]
fn needs_quoting_uppercase_yes_is_true_in_v1_1() {
assert!(needs_quoting("YES", YamlVersion::V1_1));
}
#[test]
fn needs_quoting_empty_string_is_true_in_both_versions() {
assert!(needs_quoting("", YamlVersion::V1_2));
assert!(needs_quoting("", YamlVersion::V1_1));
}
#[test]
fn needs_quoting_numeric_string_is_true_in_both_versions() {
assert!(needs_quoting("123", YamlVersion::V1_2));
assert!(needs_quoting("123", YamlVersion::V1_1));
}
#[test]
fn format_yaml_v1_1_double_quoted_on_value_stays_quoted() {
let result = format_yaml("value: \"on\"\n", &opts_with_version(YamlVersion::V1_1));
assert!(
result.contains("\"on\""),
"already-quoted on must stay quoted in V1.1: {result:?}"
);
}
#[test]
fn format_yaml_v1_2_double_quoted_on_value_stripped_to_plain() {
let result = format_yaml("value: \"on\"\n", &opts_with_version(YamlVersion::V1_2));
assert!(
!result.contains("\"on\"") && !result.contains("'on'"),
"on is not reserved in V1.2; quotes should be stripped: {result:?}"
);
assert!(
result.contains("on"),
"on value must appear as plain: {result:?}"
);
}
#[test]
fn format_yaml_v1_1_double_quoted_yes_value_stays_quoted() {
let result = format_yaml("value: \"yes\"\n", &opts_with_version(YamlVersion::V1_1));
assert!(
result.contains("\"yes\""),
"already-quoted yes must stay quoted in V1.1: {result:?}"
);
}
#[test]
fn format_yaml_v1_2_double_quoted_yes_value_stripped_to_plain() {
let result = format_yaml("value: \"yes\"\n", &opts_with_version(YamlVersion::V1_2));
assert!(
!result.contains("\"yes\"") && !result.contains("'yes'"),
"yes is not reserved in V1.2; quotes should be stripped: {result:?}"
);
assert!(
result.contains("yes"),
"yes value must appear as plain: {result:?}"
);
}
#[test]
fn format_yaml_true_value_quoted_in_both_versions() {
let r1 = format_yaml("value: \"true\"\n", &opts_with_version(YamlVersion::V1_1));
let r2 = format_yaml("value: \"true\"\n", &opts_with_version(YamlVersion::V1_2));
assert!(
r1.contains("\"true\""),
"true must stay quoted in V1.1: {r1:?}"
);
assert!(
r2.contains("\"true\""),
"true must stay quoted in V1.2: {r2:?}"
);
}
#[test]
fn format_yaml_on_key_stays_unquoted_in_v1_2() {
let result = format_yaml("on: push\n", &opts_with_version(YamlVersion::V1_2));
assert!(
result.contains("on:"),
"on: key should not be quoted in V1.2: {result:?}"
);
assert!(
!result.contains("\"on\"") && !result.contains("'on'"),
"on: key must not be quoted in V1.2: {result:?}"
);
}
#[test]
fn format_yaml_on_key_stays_unquoted_in_v1_1() {
let result = format_yaml("on: push\n", &opts_with_version(YamlVersion::V1_1));
assert!(
result.contains("on:"),
"on: key should remain as plain in V1.1 (plain scalars are emitted verbatim): {result:?}"
);
assert!(
!result.contains("\"on\"") && !result.contains("'on'"),
"on: plain key must not gain quotes even in V1.1: {result:?}"
);
}
#[test]
fn format_yaml_plain_scalar_roundtrips() {
let result = format_yaml("key: plain_value\n", &default_opts());
assert!(
result.contains("key: plain_value"),
"plain scalar missing: {result:?}"
);
}
#[test]
fn format_yaml_literal_block_scalar_chomp_clip() {
let result = format_yaml("key: |\n line one\n line two\n", &default_opts());
assert!(
result.contains('|'),
"literal block indicator missing: {result:?}"
);
}
#[test]
fn format_yaml_folded_block_scalar_chomp_strip() {
let result = format_yaml("key: >-\n content\n", &default_opts());
assert!(
result.contains(">-"),
"folded-strip indicator missing: {result:?}"
);
}
#[test]
fn format_yaml_single_quoted_scalar() {
let result = format_yaml("key: 'quoted value'\n", &default_opts());
assert!(
result.contains("quoted value"),
"single-quoted scalar content missing: {result:?}"
);
assert!(result.contains("key:"), "key missing: {result:?}");
}
#[test]
fn format_yaml_double_quoted_scalar() {
let result = format_yaml("key: \"quoted value\"\n", &default_opts());
assert!(
result.contains("quoted value"),
"double-quoted scalar content missing: {result:?}"
);
assert!(result.contains("key:"), "key missing: {result:?}");
}
#[test]
fn format_yaml_invalid_input_returns_input_unchanged() {
let input = "key: [bad\n";
let result = format_yaml(input, &default_opts());
assert_eq!(
result, input,
"invalid input should be returned unchanged: {result:?}"
);
}
}