use std::fmt::Write as _;
use rlsp_fmt::{Doc, FormatOptions, concat, format as fmt_format, hard_line, text};
use rlsp_yaml_parser::Span;
use rlsp_yaml_parser::node::{Document, Node};
use crate::editing::editor_config::LineEnding;
mod comment_preservation;
mod content_tracking;
mod dedup;
mod mapping_render;
mod node_to_doc;
pub mod options;
mod scalar_render;
mod sequence_render;
pub use options::YamlFormatOptions;
#[must_use]
pub fn format_subtree(
node: &Node<Span>,
options: &YamlFormatOptions,
base_indent: usize,
) -> String {
let doc = node_to_doc::node_to_doc(node, options, false);
let fmt_options = FormatOptions {
print_width: options.print_width,
tab_width: options.tab_width,
use_tabs: false,
};
let rendered = fmt_format(&doc, &fmt_options);
let text = rendered.trim_end_matches('\n');
if base_indent == 0 {
return text.to_string();
}
let indent_str = " ".repeat(base_indent);
let mut lines = text.lines();
lines.next().map_or_else(String::new, |first| {
let rest = lines.fold(String::new(), |mut acc, l| {
let _ = write!(acc, "\n{indent_str}{l}");
acc
});
format!("{first}{rest}")
})
}
#[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 = comment_preservation::extract_doc_prefix_comments(text_input);
let fmt_options = FormatOptions {
print_width: options.print_width,
tab_width: options.tab_width,
use_tabs: false,
};
let documents: Vec<Document<Span>> = if options.format_remove_duplicate_keys {
documents
.into_iter()
.map(|mut doc| {
dedup::dedup_mapping_keys(&mut doc.root);
doc
})
.collect()
} else {
documents
};
let doc_marker = text("---");
let end_marker = text("...");
let mut parts: Vec<Doc> = Vec::new();
for (i, doc) in documents.iter().enumerate() {
let is_first = i == 0;
let needs_start_marker = !is_first || doc.explicit_start;
if needs_start_marker {
if !parts.is_empty() {
parts.push(hard_line());
}
parts.push(doc_marker.clone());
parts.push(hard_line());
}
parts.push(node_to_doc::node_to_doc(&doc.root, options, false));
if doc.explicit_end {
parts.push(hard_line());
parts.push(end_marker.clone());
}
}
let joined = concat(parts);
let mut result = fmt_format(&joined, &fmt_options);
if !result.ends_with('\n') {
result.push('\n');
}
let last_content_hint = content_tracking::last_content_line_from_ast(&documents);
result = comment_preservation::attach_comments(
text_input,
&result,
&prefix_comments,
last_content_hint,
);
match options.line_ending {
LineEnding::Lf => {}
LineEnding::Crlf => {
result = result.replace('\n', "\r\n");
}
LineEnding::Cr => {
result = result.replace('\n', "\r");
}
}
if !options.insert_final_newline {
match options.line_ending {
LineEnding::Lf => {
if result.ends_with('\n') {
result.pop();
}
}
LineEnding::Crlf => {
if result.ends_with("\r\n") {
result.truncate(result.len() - 2);
}
}
LineEnding::Cr => {
if result.ends_with('\r') {
result.pop();
}
}
}
}
result
}
#[cfg(test)]
mod tests {
use rlsp_yaml_parser::CollectionStyle;
use rstest::rstest;
use super::*;
fn default_opts() -> YamlFormatOptions {
YamlFormatOptions::default()
}
#[rstest]
#[case::boolean_values("enabled: true\ndisabled: false\n", &["true", "false"] as &[&str])]
#[case::numeric_values("port: 8080\nratio: 0.5\n", &["8080", "0.5"])]
#[case::mapping_block_style("a: 1\nb: 2\n", &["a: 1", "b: 2"])]
#[case::flow_sequence_items("items:\n - a\n - b\n - c\n", &["a", "b", "c"])]
#[case::multi_document(
"key1: value1\n---\nkey2: value2\n",
&["key1: value1", "---", "key2: value2"]
)]
#[case::float_special_values(
"nan_val: .nan\ninf_val: .inf\nneg_inf_val: -.inf\n",
&[".nan", ".inf", "-.inf"]
)]
#[case::tagged_node("tagged: !mytag some_value\n", &["!mytag", "some_value"])]
#[case::literal_block_scalar(
"body: |\n line one\n line two\n",
&["|", "line one", "line two"]
)]
#[case::folded_block_scalar("body: >\n folded line\n", &[">", "folded line"])]
#[case::single_quoted_scalar_content("key: 'quoted value'\n", &["quoted value", "key:"])]
#[case::double_quoted_scalar_content("key: \"quoted value\"\n", &["quoted value", "key:"])]
fn format_yaml_multi_contains(#[case] input: &str, #[case] expected: &[&str]) {
let result = format_yaml(input, &default_opts());
for &s in expected {
assert!(result.contains(s), "{s:?} missing: {result:?}");
}
}
#[test]
fn anchor_scalar_preserved() {
let result = format_yaml("key: &anchor value\n", &default_opts());
assert_eq!(result, "key: &anchor value\n");
}
#[test]
fn anchor_block_mapping_preserved() {
let result = format_yaml("defaults: &defaults\n timeout: 30\n", &default_opts());
assert_eq!(result, "defaults: &defaults\n timeout: 30\n");
}
#[test]
fn anchor_block_sequence_preserved() {
let result = format_yaml("items: &mylist\n - a\n - b\n", &default_opts());
assert_eq!(result, "items: &mylist\n - a\n - b\n");
}
#[test]
fn anchor_flow_mapping_preserved() {
let result = format_yaml("key: &anchor {a: 1}\n", &default_opts());
assert!(result.contains("&anchor"), "anchor missing: {result:?}");
}
#[test]
fn anchor_flow_sequence_preserved() {
let result = format_yaml("key: &anchor [a, b]\n", &default_opts());
assert_eq!(result, "key: &anchor [a, b]\n");
}
#[test]
fn anchor_sequence_item_block_mapping_preserved() {
let result = format_yaml("items:\n - &item\n key: val\n", &default_opts());
assert_eq!(result, "items:\n - &item\n key: val\n");
}
#[test]
fn alias_reference_preserved() {
let result = format_yaml(
"defaults: &defaults\n timeout: 30\nservice:\n <<: *defaults\n",
&default_opts(),
);
assert!(result.contains("&defaults"), "anchor missing: {result:?}");
assert!(result.contains("*defaults"), "alias missing: {result:?}");
}
#[test]
fn anchor_alias_idempotent() {
let input = "defaults: &defaults\n timeout: 30\nservice:\n <<: *defaults\n";
let first = format_yaml(input, &default_opts());
let second = format_yaml(&first, &default_opts());
assert_eq!(first, second, "anchor/alias not idempotent: {first:?}");
}
#[test]
fn anchor_on_top_level_scalar_preserved() {
let result = format_yaml("&doc hello\n", &default_opts());
assert_eq!(result, "&doc hello\n");
}
#[test]
fn anchor_and_alias_round_trip_sequence() {
let input = "base: &base\n - x\n - y\nextended:\n - *base\n";
let result = format_yaml(input, &default_opts());
assert!(result.contains("&base"), "anchor missing: {result:?}");
assert!(result.contains("- x"), "sequence item missing: {result:?}");
assert!(result.contains("*base"), "alias missing: {result:?}");
}
#[test]
fn anchor_before_tag_on_scalar() {
let result = format_yaml("item: &myanchor !mytag value\n", &default_opts());
assert!(result.contains("&myanchor"), "anchor missing: {result:?}");
assert!(result.contains("!mytag"), "tag missing: {result:?}");
assert!(result.contains("value"), "value missing: {result:?}");
let before_tag = result.split("!mytag").next().unwrap_or("");
assert!(
before_tag.contains("&myanchor"),
"anchor must precede tag per YAML spec §6.8.1: {result:?}"
);
}
#[test]
fn anchor_with_trailing_comment_preserved() {
let result = format_yaml("key: &anchor value # inline comment\n", &default_opts());
assert!(
result.contains("&anchor value"),
"anchor+value missing: {result:?}"
);
assert!(
result.contains("# inline comment"),
"comment missing: {result:?}"
);
}
#[test]
fn anchor_on_empty_flow_mapping_preserved() {
let result = format_yaml("empty: &empty {}\n", &default_opts());
assert_eq!(result, "empty: &empty {}\n");
}
#[test]
fn anchor_on_empty_flow_sequence_preserved() {
let result = format_yaml("empty: &empty []\n", &default_opts());
assert_eq!(result, "empty: &empty []\n");
}
#[test]
fn no_spurious_anchor_when_none() {
let result = format_yaml("key: value\n", &default_opts());
assert!(
!result.contains('&'),
"spurious anchor in output: {result:?}"
);
}
#[test]
fn bare_document_emits_no_markers() {
let result = format_yaml("key: value\n", &default_opts());
assert!(result.contains("key: value"), "content missing: {result:?}");
assert!(
!result.contains("---"),
"unexpected `---` in output: {result:?}"
);
assert!(
!result.contains("..."),
"unexpected `...` in output: {result:?}"
);
}
#[test]
fn explicit_start_marker_preserved() {
let result = format_yaml("---\nkey: value\n", &default_opts());
assert!(
result.contains("---"),
"`---` missing from output: {result:?}"
);
}
#[test]
fn explicit_end_marker_preserved() {
let result = format_yaml("key: value\n...\n", &default_opts());
assert!(
result.contains("..."),
"`...` missing from output: {result:?}"
);
}
#[test]
fn both_markers_preserved() {
let result = format_yaml("---\nkey: value\n...\n", &default_opts());
assert!(
result.contains("---"),
"`---` missing from output: {result:?}"
);
assert!(
result.contains("..."),
"`...` missing from output: {result:?}"
);
}
#[test]
fn multi_document_separator_always_emitted() {
let result = format_yaml("doc1: a\n---\ndoc2: b\n", &default_opts());
assert!(
result.contains("---"),
"`---` separator missing: {result:?}"
);
assert!(
result.contains("doc1: a"),
"doc1 content missing: {result:?}"
);
assert!(
result.contains("doc2: b"),
"doc2 content missing: {result:?}"
);
}
#[test]
fn explicit_end_only_on_first_document() {
let result = format_yaml("doc1: a\n...\n---\ndoc2: b\n", &default_opts());
assert!(
result.contains("---"),
"`---` separator missing: {result:?}"
);
assert!(
result.contains("..."),
"`...` missing from output: {result:?}"
);
assert!(
result.find("...") < result.find("doc2: b"),
"`...` should appear before doc2, got: {result:?}"
);
let after_doc2 = result.find("doc2: b").map_or("", |pos| &result[pos..]);
assert!(
!after_doc2.contains("..."),
"unexpected `...` after doc2: {result:?}"
);
}
#[test]
fn explicit_end_on_all_documents_preserved() {
let result = format_yaml("doc1: a\n...\n---\ndoc2: b\n...\n", &default_opts());
let count = result.matches("...").count();
assert_eq!(
count, 2,
"expected 2 `...` markers, got {count}: {result:?}"
);
}
fn parse_root(src: &str) -> Node<Span> {
rlsp_yaml_parser::load(src)
.expect("test input must parse")
.remove(0)
.root
}
#[test]
fn format_subtree_scalar_base_indent_zero() {
let node = parse_root("hello");
let result = format_subtree(&node, &default_opts(), 0);
assert_eq!(result, "hello");
}
#[test]
fn format_subtree_scalar_base_indent_never_indents_first_line() {
let node = parse_root("hello");
let result = format_subtree(&node, &default_opts(), 4);
assert_eq!(result, "hello");
}
#[test]
fn format_subtree_empty_mapping_emits_inline() {
let node = parse_root("{}");
let result = format_subtree(&node, &default_opts(), 0);
assert_eq!(result, "{}");
}
#[test]
fn format_subtree_empty_sequence_emits_inline() {
let node = parse_root("[]");
let result = format_subtree(&node, &default_opts(), 0);
assert_eq!(result, "[]");
}
#[rstest]
#[case::indent_zero(0, "a: 1", "b: 2")]
#[case::indent_two(2, "a: 1", " b: 2")]
#[case::indent_eight(8, "a: 1", " b: 2")]
fn format_subtree_block_mapping_base_indent(
#[case] base_indent: usize,
#[case] expected_line0: &str,
#[case] expected_line1: &str,
) {
let node = parse_root("a: 1\nb: 2\n");
let result = format_subtree(&node, &default_opts(), base_indent);
match result.lines().collect::<Vec<_>>().as_slice() {
[line0, line1, ..] => {
assert_eq!(*line0, expected_line0, "line 0 mismatch: {result:?}");
assert_eq!(*line1, expected_line1, "line 1 mismatch: {result:?}");
}
other => panic!("expected at least 2 lines, got: {other:?}"),
}
}
#[test]
fn format_subtree_block_sequence_continuation_indented() {
let node = parse_root("- x\n- y\n");
let result = format_subtree(&node, &default_opts(), 2);
match result.lines().collect::<Vec<_>>().as_slice() {
[line0, line1, ..] => {
assert_eq!(*line0, "- x", "line 0 mismatch: {result:?}");
assert_eq!(*line1, " - y", "line 1 mismatch: {result:?}");
}
other => panic!("expected at least 2 lines, got: {other:?}"),
}
}
#[test]
fn format_subtree_nested_mapping_in_sequence_base_indent() {
let node = parse_root("- a: 1\n b: 2\n- c: 3\n");
let result = format_subtree(&node, &default_opts(), 2);
let lines: Vec<&str> = result.lines().collect();
let first = lines.first().expect("output must have at least one line");
assert!(
first.starts_with("- a: 1"),
"first line should start with `- a: 1`: {result:?}"
);
let c_line = lines
.iter()
.find(|l| l.contains("c: 3"))
.copied()
.expect("output must contain `c: 3`");
assert!(
c_line.starts_with(" - c: 3"),
"`- c: 3` line should have two leading spaces: {result:?}"
);
}
#[test]
fn format_subtree_enforce_block_style_option_converts_flow_to_block() {
let node = parse_root("{a: 1, b: 2}");
let opts = YamlFormatOptions {
format_enforce_block_style: true,
..YamlFormatOptions::default()
};
let result = format_subtree(&node, &opts, 2);
match result.lines().collect::<Vec<_>>().as_slice() {
[line0, line1, ..] => {
assert_eq!(*line0, "a: 1", "line 0 mismatch: {result:?}");
assert_eq!(*line1, " b: 2", "line 1 mismatch: {result:?}");
}
other => panic!("expected at least 2 lines, got: {other:?}"),
}
}
#[test]
fn format_subtree_flow_mapping_style_mutation_to_block() {
let mut node = parse_root("{a: 1, b: 2}");
if let Node::Mapping { style, .. } = &mut node {
*style = CollectionStyle::Block;
}
let result = format_subtree(&node, &default_opts(), 2);
match result.lines().collect::<Vec<_>>().as_slice() {
[line0, line1, ..] => {
assert_eq!(*line0, "a: 1", "line 0 mismatch: {result:?}");
assert_eq!(*line1, " b: 2", "line 1 mismatch: {result:?}");
}
other => panic!("expected at least 2 lines, got: {other:?}"),
}
}
#[test]
fn format_subtree_flow_sequence_style_mutation_to_block() {
let mut node = parse_root("[a, b, c]");
if let Node::Sequence { style, .. } = &mut node {
*style = CollectionStyle::Block;
}
let result = format_subtree(&node, &default_opts(), 2);
match result.lines().collect::<Vec<_>>().as_slice() {
[line0, line1, line2, ..] => {
assert_eq!(*line0, "- a", "line 0 mismatch: {result:?}");
assert_eq!(*line1, " - b", "line 1 mismatch: {result:?}");
assert_eq!(*line2, " - c", "line 2 mismatch: {result:?}");
}
other => panic!("expected at least 3 lines, got: {other:?}"),
}
}
#[test]
fn format_subtree_nested_flow_in_flow_sequence_to_block() {
let mut node = parse_root("[{a: 1}, {b: 2}]");
if let Node::Sequence { style, items, .. } = &mut node {
*style = CollectionStyle::Block;
for item in items.iter_mut() {
if let Node::Mapping { style: ms, .. } = item {
*ms = CollectionStyle::Block;
}
}
}
let result = format_subtree(&node, &default_opts(), 2);
assert!(result.contains("a: 1"), "a: 1 missing: {result:?}");
assert!(result.contains("b: 2"), "b: 2 missing: {result:?}");
let second_item_line = result
.lines()
.find(|l| l.contains("b: 2"))
.expect("line with b: 2 must exist");
assert!(
second_item_line.starts_with(" "),
"second item line must be indented by 2: {result:?}"
);
}
#[test]
fn format_subtree_multiline_flow_mapping_to_block() {
let mut node = parse_root("{\n a: 1,\n b: 2,\n}");
if let Node::Mapping { style, .. } = &mut node {
*style = CollectionStyle::Block;
}
let result = format_subtree(&node, &default_opts(), 2);
match result.lines().collect::<Vec<_>>().as_slice() {
[line0, line1, ..] => {
assert_eq!(*line0, "a: 1", "line 0 mismatch: {result:?}");
assert_eq!(*line1, " b: 2", "line 1 mismatch: {result:?}");
}
other => panic!("expected at least 2 lines, got: {other:?}"),
}
}
#[test]
fn line_ending_crlf_replaces_all_newlines() {
let opts = YamlFormatOptions {
line_ending: LineEnding::Crlf,
..default_opts()
};
let output = format_yaml("a: 1\nb: 2\n", &opts);
assert!(output.contains("\r\n"), "output should contain CRLF");
for (i, ch) in output.char_indices() {
if ch == '\n' {
assert!(
i > 0 && output.as_bytes()[i - 1] == b'\r',
"bare LF at byte {i}"
);
}
}
}
#[test]
fn line_ending_cr_replaces_all_newlines() {
let opts = YamlFormatOptions {
line_ending: LineEnding::Cr,
..default_opts()
};
let output = format_yaml("a: 1\nb: 2\n", &opts);
assert!(!output.contains('\n'), "output should have no LF");
assert!(output.contains('\r'), "output should have at least one CR");
assert!(!output.contains("\r\n"), "output should have no CRLF");
}
#[test]
fn line_ending_lf_leaves_output_unchanged() {
let opts = YamlFormatOptions {
line_ending: LineEnding::Lf,
..default_opts()
};
let output = format_yaml("a: 1\nb: 2\n", &opts);
assert!(!output.contains('\r'), "LF mode should produce no CR");
assert!(output.ends_with('\n'), "LF mode should end with LF");
}
#[test]
fn insert_final_newline_false_strips_trailing_newline() {
let opts = YamlFormatOptions {
insert_final_newline: false,
..default_opts()
};
let output = format_yaml("key: value\n", &opts);
assert_eq!(
output, "key: value",
"trailing newline should be stripped; got: {output:?}"
);
}
#[test]
fn insert_final_newline_true_leaves_trailing_newline() {
let opts = YamlFormatOptions {
insert_final_newline: true,
..default_opts()
};
let output = format_yaml("key: value\n", &opts);
assert!(
output.ends_with('\n'),
"trailing newline should be preserved; got: {output:?}"
);
}
#[test]
fn insert_final_newline_false_with_crlf_strips_crlf_terminator() {
let opts = YamlFormatOptions {
line_ending: LineEnding::Crlf,
insert_final_newline: false,
..default_opts()
};
let output = format_yaml("key: value\n", &opts);
assert!(
!output.ends_with("\r\n") && !output.ends_with('\n') && !output.ends_with('\r'),
"trailing CRLF terminator should be stripped; got: {output:?}"
);
assert!(
output.ends_with("value"),
"content should be intact; got: {output:?}"
);
}
#[test]
fn format_yaml_default_options_still_ends_with_newline() {
let output = format_yaml("key: value\n", &default_opts());
assert!(
output.ends_with('\n'),
"default options should preserve trailing newline; got: {output:?}"
);
}
}