use rlsp_fmt::{Doc, FormatOptions, concat, format as fmt_format, hard_line, indent, join, text};
use saphyr::{LoadableYamlNode, ScalarOwned, ScalarStyle, YamlOwned};
#[derive(Debug, Clone, PartialEq, Eq)]
enum CommentKind {
Trailing,
Leading,
}
#[derive(Debug, Clone)]
struct Comment {
line: usize,
text: String,
kind: CommentKind,
}
fn extract_comments(text: &str) -> Vec<Comment> {
let mut comments = Vec::new();
for (line_idx, line) in text.lines().enumerate() {
if let Some((byte_pos, comment_text)) = find_comment_on_line(line) {
let before = &line[..byte_pos];
let kind = if before.trim().is_empty() {
CommentKind::Leading
} else {
CommentKind::Trailing
};
comments.push(Comment {
line: line_idx,
text: comment_text,
kind,
});
}
}
comments
}
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,
leading: Vec<String>,
trailing: Option<String>,
}
fn attach_comments(original: &str, formatted: &str, comments: &[Comment]) -> String {
if comments.is_empty() {
return formatted.to_string();
}
let line_to_comment: std::collections::HashMap<usize, &Comment> =
comments.iter().map(|c| (c.line, c)).collect();
let mut entries: Vec<ContentEntry> = Vec::new();
let mut pending_leading: Vec<String> = Vec::new();
let mut pending_blanks: usize = 0;
for (idx, line) in original.lines().enumerate() {
if let Some(comment) = line_to_comment.get(&idx) {
match comment.kind {
CommentKind::Leading => {
if pending_blanks > 0 && !pending_leading.is_empty() {
pending_leading.push(String::new());
}
pending_blanks = 0;
pending_leading.push(comment.text.clone());
}
CommentKind::Trailing => {
entries.push(ContentEntry {
signature: content_signature(line),
leading: std::mem::take(&mut pending_leading),
trailing: Some(comment.text.clone()),
});
pending_blanks = 0;
}
}
} else if line.trim().is_empty() {
pending_blanks += 1;
} else {
entries.push(ContentEntry {
signature: content_signature(line),
leading: std::mem::take(&mut pending_leading),
trailing: None,
});
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 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);
for lc in &entry.leading {
if lc.is_empty() {
result_lines.push(String::new());
} else {
result_lines.push(format!("{indent_str}{lc}"));
}
}
if let Some(tc) = &entry.trailing {
result_lines.push(format!("{fmt_line} {tc}"));
} else {
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,
}
impl Default for YamlFormatOptions {
fn default() -> Self {
Self {
print_width: 80,
tab_width: 2,
use_tabs: false,
single_quote: false,
bracket_spacing: true,
}
}
}
#[must_use]
pub fn format_yaml(text_input: &str, options: &YamlFormatOptions) -> String {
let Ok(documents) = YamlOwned::load_from_str(text_input) else {
return text_input.to_string();
};
if documents.is_empty() {
return String::new();
}
let comments = extract_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, 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');
}
if !comments.is_empty() {
result = attach_comments(text_input, &result, &comments);
}
result
}
fn node_to_doc(node: &YamlOwned, options: &YamlFormatOptions) -> Doc {
match node {
YamlOwned::Value(scalar) => scalar_to_doc(scalar, options),
YamlOwned::Representation(s, style, _tag) => {
match style {
ScalarStyle::Literal | ScalarStyle::Folded => {
repr_block_to_doc(s, *style)
}
ScalarStyle::SingleQuoted => text(format!("'{s}'")),
ScalarStyle::DoubleQuoted => text(format!("\"{s}\"")),
ScalarStyle::Plain => text(s.clone()),
}
}
YamlOwned::Mapping(map) => mapping_to_doc(map, options),
YamlOwned::Sequence(seq) => sequence_to_doc(seq, options),
YamlOwned::Tagged(tag, inner) => {
let tag_text = format!("!{} ", tag.suffix);
concat(vec![text(tag_text), node_to_doc(inner, options)])
}
YamlOwned::Alias(idx) => text(format!("*alias{idx}")),
YamlOwned::BadValue => text("null"),
}
}
fn scalar_to_doc(scalar: &ScalarOwned, options: &YamlFormatOptions) -> Doc {
match scalar {
ScalarOwned::Null => text("null"),
ScalarOwned::Boolean(b) => text(if *b { "true" } else { "false" }),
ScalarOwned::Integer(i) => text(i.to_string()),
ScalarOwned::FloatingPoint(f) => text(format_float(**f)),
ScalarOwned::String(s) => string_to_doc(s, options),
}
}
fn format_float(f: f64) -> String {
if f.is_nan() {
".nan".to_string()
} else if f.is_infinite() {
if f > 0.0 {
".inf".to_string()
} else {
"-.inf".to_string()
}
} else {
let s = f.to_string();
if s.contains('.') || s.contains('e') {
s
} else {
format!("{s}.0")
}
}
}
fn string_to_doc(s: &str, options: &YamlFormatOptions) -> Doc {
if needs_quoting(s) {
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) -> bool {
if s.is_empty() {
return true;
}
matches!(
s,
"null"
| "~"
| "true"
| "false"
| "yes"
| "no"
| "on"
| "off"
| "True"
| "False"
| "Yes"
| "No"
| "On"
| "Off"
| "TRUE"
| "FALSE"
| "YES"
| "NO"
| "ON"
| "OFF"
| "NULL"
| "Null"
) || 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 => "|",
ScalarStyle::Folded => ">",
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(map: &saphyr::MappingOwned, options: &YamlFormatOptions) -> Doc {
if map.is_empty() {
return text("{}");
}
let pairs: Vec<Doc> = map
.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: &YamlOwned, value: &YamlOwned, options: &YamlFormatOptions) -> Doc {
let key_doc = node_to_doc(key, options);
match value {
YamlOwned::Mapping(map) if !map.is_empty() => concat(vec![
key_doc,
text(":"),
indent(concat(vec![hard_line(), mapping_to_doc(map, options)])),
]),
YamlOwned::Sequence(seq) if !seq.is_empty() => concat(vec![
key_doc,
text(":"),
indent(concat(vec![hard_line(), sequence_to_doc(seq, options)])),
]),
YamlOwned::Value(_)
| YamlOwned::Representation(..)
| YamlOwned::Mapping(_)
| YamlOwned::Sequence(_)
| YamlOwned::Tagged(..)
| YamlOwned::Alias(_)
| YamlOwned::BadValue => {
let value_doc = node_to_doc(value, options);
concat(vec![key_doc, text(": "), value_doc])
}
}
}
fn sequence_to_doc(seq: &[YamlOwned], 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: &YamlOwned, options: &YamlFormatOptions) -> Doc {
match item {
YamlOwned::Mapping(map) if !map.is_empty() => {
let pairs: Vec<Doc> = map
.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)])
}
YamlOwned::Sequence(seq) if !seq.is_empty() => concat(vec![
text("- "),
indent(concat(vec![hard_line(), sequence_to_doc(seq, options)])),
]),
YamlOwned::Value(_)
| YamlOwned::Representation(..)
| YamlOwned::Mapping(_)
| YamlOwned::Sequence(_)
| YamlOwned::Tagged(..)
| YamlOwned::Alias(_)
| YamlOwned::BadValue => concat(vec![text("- "), node_to_doc(item, options)]),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn default_opts() -> YamlFormatOptions {
YamlFormatOptions::default()
}
#[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("not a comment"),
"quoted string content should be preserved: {result:?}"
);
}
#[test]
fn extract_comments_empty_input() {
let comments = extract_comments("");
assert!(comments.is_empty(), "expected no comments: {comments:?}");
}
#[test]
fn extract_comments_no_comments() {
let comments = extract_comments("key: value\n");
assert!(comments.is_empty(), "expected no comments: {comments:?}");
}
#[test]
fn extract_comments_trailing_comment() {
let comments = extract_comments("key: value # my comment\n");
assert_eq!(comments.len(), 1, "expected one comment: {comments:?}");
assert_eq!(comments[0].kind, CommentKind::Trailing);
assert_eq!(comments[0].text, "# my comment");
}
#[test]
fn extract_comments_leading_comment() {
let comments = extract_comments("# my comment\nkey: value\n");
assert_eq!(comments.len(), 1, "expected one comment: {comments:?}");
assert_eq!(comments[0].kind, CommentKind::Leading);
assert_eq!(comments[0].text, "# my comment");
assert_eq!(comments[0].line, 0);
}
#[test]
fn extract_comments_leading_comment_indented() {
let comments = extract_comments(" # indented comment\n key: value\n");
assert_eq!(comments.len(), 1, "expected one comment: {comments:?}");
assert_eq!(comments[0].kind, CommentKind::Leading);
assert_eq!(comments[0].text, "# indented comment");
}
#[test]
fn extract_comments_no_space_after_hash() {
let comments = extract_comments("key: value #comment\n");
assert_eq!(comments.len(), 1, "expected one comment: {comments:?}");
assert_eq!(comments[0].kind, CommentKind::Trailing);
assert_eq!(comments[0].text, "#comment");
}
#[test]
fn extract_comments_empty_comment() {
let comments = extract_comments("key: value #\n");
assert_eq!(comments.len(), 1, "expected one comment: {comments:?}");
assert_eq!(comments[0].kind, CommentKind::Trailing);
assert_eq!(comments[0].text, "#");
}
#[test]
fn extract_comments_hash_in_quoted_string_not_extracted() {
let comments_double = extract_comments("key: \"value # not a comment\"\n");
assert!(
comments_double.is_empty(),
"double-quoted hash should not be extracted: {comments_double:?}"
);
let comments_single = extract_comments("key: 'value # not a comment'\n");
assert!(
comments_single.is_empty(),
"single-quoted hash should not be extracted: {comments_single:?}"
);
}
#[test]
fn extract_comments_multiple_trailing_on_consecutive_lines() {
let comments = extract_comments("a: 1 # first\nb: 2 # second\n");
assert_eq!(comments.len(), 2, "expected two comments: {comments:?}");
assert_eq!(comments[0].kind, CommentKind::Trailing);
assert_eq!(comments[0].text, "# first");
assert_eq!(comments[0].line, 0);
assert_eq!(comments[1].kind, CommentKind::Trailing);
assert_eq!(comments[1].text, "# second");
assert_eq!(comments[1].line, 1);
}
#[test]
fn extract_comments_consecutive_leading_comments() {
let comments = extract_comments("# first\n# second\nkey: value\n");
assert_eq!(comments.len(), 2, "expected two comments: {comments:?}");
assert_eq!(comments[0].kind, CommentKind::Leading);
assert_eq!(comments[0].text, "# first");
assert_eq!(comments[0].line, 0);
assert_eq!(comments[1].kind, CommentKind::Leading);
assert_eq!(comments[1].text, "# second");
assert_eq!(comments[1].line, 1);
}
#[test]
fn extract_comments_comment_at_document_start() {
let comments = extract_comments("# preamble\nkey: value\n");
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].kind, CommentKind::Leading);
assert_eq!(comments[0].line, 0);
assert_eq!(comments[0].text, "# preamble");
}
#[test]
fn extract_comments_comment_at_document_end() {
let comments = extract_comments("key: value\n# trailing\n");
assert_eq!(comments.len(), 1);
assert_eq!(comments[0].kind, CommentKind::Leading);
assert_eq!(comments[0].line, 1);
assert_eq!(comments[0].text, "# trailing");
}
#[test]
fn attach_comments_trailing_reattached_by_signature() {
let original = "key: value # comment\n";
let formatted = "key: value\n";
let comments = extract_comments(original);
let result = attach_comments(original, formatted, &comments);
assert!(result.contains("key: value"), "content missing: {result:?}");
assert!(result.contains("# comment"), "comment missing: {result:?}");
for line in result.lines() {
if line.contains("key: value") {
assert!(
line.contains("# comment"),
"comment should be on same line: {line:?}"
);
}
}
}
#[test]
fn attach_comments_leading_reattached_before_target_line() {
let original = "# heading\nkey: value\n";
let formatted = "key: value\n";
let comments = extract_comments(original);
let result = attach_comments(original, formatted, &comments);
assert!(result.contains("# heading"), "comment missing: {result:?}");
let comment_pos = result.find("# heading").unwrap();
let key_pos = result.find("key: value").unwrap();
assert!(
comment_pos < key_pos,
"comment should precede content: {result:?}"
);
}
#[test]
fn attach_comments_unmatched_trailing_dropped() {
let original = "old_key: v # orphan\n";
let formatted = "new_key: v\n";
let comments = extract_comments(original);
let result = attach_comments(original, formatted, &comments);
assert_eq!(
result, "new_key: v\n",
"unmatched comment should be dropped: {result:?}"
);
}
#[test]
fn attach_comments_no_comments_returns_formatted_unchanged() {
let original = "key: value\n";
let formatted = "key: value\n";
let result = attach_comments(original, formatted, &[]);
assert_eq!(result, formatted);
}
#[test]
fn format_yaml_comment_on_reformatted_line() {
let input = "key: value # note\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("key: value"), "content missing: {result:?}");
assert!(
result.contains("# note"),
"comment should be preserved when signature matches: {result:?}"
);
}
}