use tower_lsp::lsp_types::{CodeAction, CodeActionKind, Diagnostic, Position, Range, TextEdit};
use rlsp_yaml_parser::node::Node;
use rlsp_yaml_parser::{CollectionStyle, Document, LineIndex, Span};
use crate::editing::formatter::YamlFormatOptions;
use super::{block_to_flow::block_text_and_start_col, make_action, span_matches_diag};
pub(super) fn flow_map_to_block(
docs: &[Document<Span>],
text: &str,
diag: &Diagnostic,
uri: &tower_lsp::lsp_types::Url,
options: &YamlFormatOptions,
) -> Option<CodeAction> {
let (node, idx) = find_flow_mapping(docs, diag)?;
let Node::Mapping { loc, .. } = node else {
return None;
};
let mut block_node = node.clone();
if let Node::Mapping {
style, tag, meta, ..
} = &mut block_node
{
*style = CollectionStyle::Block;
*tag = None;
if let Some(m) = meta.as_mut() {
m.anchor = None;
m.anchor_loc = None;
m.tag_loc = None;
}
}
let (new_text, edit_start_col) =
block_text_and_start_col(&block_node, *loc, text, idx, options);
if new_text.trim().is_empty() {
return None;
}
let (start_line, _) = idx.line_column(loc.start);
let (end_line, end_col) = idx.line_column(loc.end);
#[expect(
clippy::cast_possible_truncation,
reason = "edit_start_col is a byte column that always fits u32"
)]
let edit_range = Range::new(
Position::new(start_line.saturating_sub(1), edit_start_col as u32),
Position::new(end_line.saturating_sub(1), end_col + 1),
);
Some(make_action(
"Convert flow mapping to block style".to_string(),
uri,
vec![TextEdit {
range: edit_range,
new_text,
}],
CodeActionKind::REFACTOR_REWRITE,
Some(vec![diag.clone()]),
))
}
fn find_flow_mapping<'a>(
docs: &'a [Document<Span>],
diag: &Diagnostic,
) -> Option<(&'a Node<Span>, &'a LineIndex)> {
for doc in docs {
let idx = doc.line_index();
if let Some(node) = find_flow_mapping_in_node(&doc.root, diag, idx) {
return Some((node, idx));
}
}
None
}
fn find_flow_mapping_in_node<'a>(
node: &'a Node<Span>,
diag: &Diagnostic,
idx: &LineIndex,
) -> Option<&'a Node<Span>> {
match node {
Node::Mapping {
style: CollectionStyle::Flow,
loc,
entries,
..
} => {
if span_matches_diag(*loc, diag, idx) {
return Some(node);
}
for (k, v) in entries {
if let Some(found) = find_flow_mapping_in_node(k, diag, idx) {
return Some(found);
}
if let Some(found) = find_flow_mapping_in_node(v, diag, idx) {
return Some(found);
}
}
None
}
Node::Mapping { entries, .. } => {
for (k, v) in entries {
if let Some(found) = find_flow_mapping_in_node(k, diag, idx) {
return Some(found);
}
if let Some(found) = find_flow_mapping_in_node(v, diag, idx) {
return Some(found);
}
}
None
}
Node::Sequence { items, .. } => {
for item in items {
if let Some(found) = find_flow_mapping_in_node(item, diag, idx) {
return Some(found);
}
}
None
}
Node::Scalar { .. } | Node::Alias { .. } => None,
}
}
pub(super) fn flow_seq_to_block(
docs: &[Document<Span>],
text: &str,
diag: &Diagnostic,
uri: &tower_lsp::lsp_types::Url,
options: &YamlFormatOptions,
) -> Option<CodeAction> {
let (node, idx) = find_flow_sequence(docs, diag)?;
let Node::Sequence { loc, .. } = node else {
return None;
};
let mut block_node = node.clone();
if let Node::Sequence {
style, tag, meta, ..
} = &mut block_node
{
*style = CollectionStyle::Block;
*tag = None;
if let Some(m) = meta.as_mut() {
m.anchor = None;
m.anchor_loc = None;
m.tag_loc = None;
}
}
let (new_text, edit_start_col) =
block_text_and_start_col(&block_node, *loc, text, idx, options);
if new_text.trim().is_empty() {
return None;
}
let (start_line, _) = idx.line_column(loc.start);
let (end_line, end_col) = idx.line_column(loc.end);
#[expect(
clippy::cast_possible_truncation,
reason = "edit_start_col is a byte column that always fits u32"
)]
let edit_range = Range::new(
Position::new(start_line.saturating_sub(1), edit_start_col as u32),
Position::new(end_line.saturating_sub(1), end_col + 1),
);
Some(make_action(
"Convert flow sequence to block style".to_string(),
uri,
vec![TextEdit {
range: edit_range,
new_text,
}],
CodeActionKind::REFACTOR_REWRITE,
Some(vec![diag.clone()]),
))
}
fn find_flow_sequence<'a>(
docs: &'a [Document<Span>],
diag: &Diagnostic,
) -> Option<(&'a Node<Span>, &'a LineIndex)> {
for doc in docs {
let idx = doc.line_index();
if let Some(node) = find_flow_sequence_in_node(&doc.root, diag, idx) {
return Some((node, idx));
}
}
None
}
fn find_flow_sequence_in_node<'a>(
node: &'a Node<Span>,
diag: &Diagnostic,
idx: &LineIndex,
) -> Option<&'a Node<Span>> {
match node {
Node::Sequence {
style: CollectionStyle::Flow,
loc,
items,
..
} => {
if span_matches_diag(*loc, diag, idx) {
return Some(node);
}
for item in items {
if let Some(found) = find_flow_sequence_in_node(item, diag, idx) {
return Some(found);
}
}
None
}
Node::Sequence { items, .. } => {
for item in items {
if let Some(found) = find_flow_sequence_in_node(item, diag, idx) {
return Some(found);
}
}
None
}
Node::Mapping { entries, .. } => {
for (k, v) in entries {
if let Some(found) = find_flow_sequence_in_node(k, diag, idx) {
return Some(found);
}
if let Some(found) = find_flow_sequence_in_node(v, diag, idx) {
return Some(found);
}
}
None
}
Node::Scalar { .. } | Node::Alias { .. } => None,
}
}
#[cfg(test)]
#[expect(
clippy::indexing_slicing,
clippy::unwrap_used,
clippy::expect_used,
clippy::items_after_statements,
reason = "test code"
)]
mod tests {
use rstest::rstest;
use rlsp_yaml_parser::Span;
use rlsp_yaml_parser::node::Node;
use tower_lsp::lsp_types::{CodeActionKind, Position, Range};
use super::super::code_actions;
use super::super::test_helpers::{
cursor_range, docs_for, flow_diags_for, flow_map_action, flow_seq_action, line_range,
make_diagnostic, make_flow_diag, new_text_for,
};
use crate::editing::formatter::YamlFormatOptions;
use crate::test_utils::test_uri;
fn count(haystack: &str, needle: &str) -> usize {
let mut count = 0;
let mut start = 0;
while let Some(pos) = haystack[start..].find(needle) {
count += 1;
start += pos + needle.len();
}
count
}
fn apply_flow_map_edit(yaml: &str) -> (String, String) {
let action = flow_map_action(yaml).expect("expected flow-map action");
let new_text = new_text_for(&action);
let edit = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()[&test_uri()][0];
let source_lines: Vec<&str> = yaml.lines().collect();
let start_line = edit.range.start.line as usize;
let end_line = edit.range.end.line as usize;
let start_col = edit.range.start.character as usize;
let end_col = edit.range.end.character as usize;
let mut result = String::new();
for (i, line) in source_lines.iter().enumerate() {
if i < start_line || i > end_line {
result.push_str(line);
result.push('\n');
} else if i == start_line && i == end_line {
result.push_str(&line[..start_col]);
result.push_str(&new_text);
result.push_str(&line[end_col..]);
result.push('\n');
} else if i == start_line {
result.push_str(&line[..start_col]);
result.push_str(&new_text);
} else if i == end_line {
result.push_str(&line[end_col..]);
result.push('\n');
}
}
(result, new_text)
}
fn apply_flow_seq_edit(yaml: &str) -> (String, String) {
let action = flow_seq_action(yaml).expect("expected flow-seq action");
let new_text = new_text_for(&action);
let edit = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()[&test_uri()][0];
let source_lines: Vec<&str> = yaml.lines().collect();
let start_line = edit.range.start.line as usize;
let end_line = edit.range.end.line as usize;
let start_col = edit.range.start.character as usize;
let end_col = edit.range.end.character as usize;
let mut result = String::new();
for (i, line) in source_lines.iter().enumerate() {
if i < start_line || i > end_line {
result.push_str(line);
result.push('\n');
} else if i == start_line && i == end_line {
result.push_str(&line[..start_col]);
result.push_str(&new_text);
result.push_str(&line[end_col..]);
result.push('\n');
} else if i == start_line {
result.push_str(&line[..start_col]);
result.push_str(&new_text);
} else if i == end_line {
result.push_str(&line[end_col..]);
result.push('\n');
}
}
(result, new_text)
}
#[test]
fn flow_map_to_block_new_text_does_not_duplicate_anchor() {
let text = "config: &myanchor {a: 1, b: 2}\n";
let (result, new_text) = apply_flow_map_edit(text);
assert_eq!(
count(&new_text, "&myanchor"),
0,
"new_text must not contain the anchor (source buffer preserves it): {new_text:?}"
);
assert_eq!(
count(&result, "&myanchor"),
1,
"final document must contain the anchor exactly once: {result:?}"
);
}
#[test]
fn flow_map_to_block_new_text_does_not_duplicate_user_tag() {
let text = "config: !mytag {a: 1, b: 2}\n";
let (result, new_text) = apply_flow_map_edit(text);
assert_eq!(
count(&new_text, "!mytag"),
0,
"new_text must not contain the user tag (source buffer preserves it): {new_text:?}"
);
assert_eq!(
count(&result, "!mytag"),
1,
"final document must contain the user tag exactly once: {result:?}"
);
}
#[test]
fn flow_map_to_block_new_text_does_not_duplicate_anchor_or_tag() {
let text = "config: &a !mytag {a: 1, b: 2}\n";
let (result, new_text) = apply_flow_map_edit(text);
assert_eq!(
count(&new_text, "&a"),
0,
"new_text must not contain the anchor (source buffer preserves it): {new_text:?}"
);
assert_eq!(
count(&new_text, "!mytag"),
0,
"new_text must not contain the user tag (source buffer preserves it): {new_text:?}"
);
assert_eq!(
count(&result, "&a"),
1,
"final document must contain the anchor exactly once: {result:?}"
);
assert_eq!(
count(&result, "!mytag"),
1,
"final document must contain the user tag exactly once: {result:?}"
);
}
#[test]
fn flow_seq_to_block_new_text_does_not_duplicate_anchor() {
let text = "items: &myanchor [a, b, c]\n";
let (result, new_text) = apply_flow_seq_edit(text);
assert_eq!(
count(&new_text, "&myanchor"),
0,
"new_text must not contain the anchor (source buffer preserves it): {new_text:?}"
);
assert_eq!(
count(&result, "&myanchor"),
1,
"final document must contain the anchor exactly once: {result:?}"
);
}
#[test]
fn flow_seq_to_block_new_text_does_not_duplicate_user_tag() {
let text = "items: !mytag [a, b, c]\n";
let (result, new_text) = apply_flow_seq_edit(text);
assert_eq!(
count(&new_text, "!mytag"),
0,
"new_text must not contain the user tag (source buffer preserves it): {new_text:?}"
);
assert_eq!(
count(&result, "!mytag"),
1,
"final document must contain the user tag exactly once: {result:?}"
);
}
#[test]
fn flow_seq_to_block_new_text_does_not_duplicate_anchor_or_tag() {
let text = "items: &a !mytag [a, b, c]\n";
let (result, new_text) = apply_flow_seq_edit(text);
assert_eq!(
count(&new_text, "&a"),
0,
"new_text must not contain the anchor (source buffer preserves it): {new_text:?}"
);
assert_eq!(
count(&new_text, "!mytag"),
0,
"new_text must not contain the user tag (source buffer preserves it): {new_text:?}"
);
assert_eq!(
count(&result, "&a"),
1,
"final document must contain the anchor exactly once: {result:?}"
);
assert_eq!(
count(&result, "!mytag"),
1,
"final document must contain the user tag exactly once: {result:?}"
);
}
#[test]
fn should_convert_simple_flow_map_to_block() {
let text = "config: {a: 1, b: 2}\n";
let actions = code_actions(
&docs_for(text),
text,
line_range(0),
&flow_diags_for(text),
&test_uri(),
&YamlFormatOptions::default(),
);
let action = actions
.iter()
.find(|a| a.title.contains("flow mapping"))
.unwrap();
let edit = action.edit.as_ref().unwrap();
let changes = edit.changes.as_ref().unwrap();
let edits = &changes[&test_uri()];
assert!(edits[0].new_text.contains("a: 1"));
assert!(edits[0].new_text.contains("b: 2"));
assert!(!edits[0].new_text.contains('{'));
}
#[rstest]
#[case::flow_map_invalid_range("config: {a: 1}\n", "flowMap", "flow mapping")]
#[case::flow_seq_invalid_range("items: [a]\n", "flowSeq", "flow sequence")]
#[case::unused_anchor_invalid_range("data: &unused\n", "unusedAnchor", "unused anchor")]
fn invalid_range_produces_no_action(
#[case] text: &str,
#[case] code: &str,
#[case] title_fragment: &str,
) {
let diag = make_diagnostic(0, 100, 200, code);
let actions = code_actions(
&docs_for(text),
text,
line_range(0),
&[diag],
&test_uri(),
&YamlFormatOptions::default(),
);
assert!(actions.iter().all(|a| !a.title.contains(title_fragment)));
}
#[test]
fn should_convert_simple_flow_seq_to_block() {
let text = "items: [one, two, three]\n";
let actions = code_actions(
&docs_for(text),
text,
line_range(0),
&flow_diags_for(text),
&test_uri(),
&YamlFormatOptions::default(),
);
let action = actions
.iter()
.find(|a| a.title.contains("flow sequence"))
.unwrap();
let edit = action.edit.as_ref().unwrap();
let changes = edit.changes.as_ref().unwrap();
let edits = &changes[&test_uri()];
assert!(edits[0].new_text.contains("- one"));
assert!(edits[0].new_text.contains("- two"));
assert!(edits[0].new_text.contains("- three"));
assert!(!edits[0].new_text.contains('['));
}
#[test]
fn should_indent_block_items_under_key_when_nested() {
let text = " command: [\"python\", \"-m\"]\n";
let actions = code_actions(
&docs_for(text),
text,
line_range(0),
&flow_diags_for(text),
&test_uri(),
&YamlFormatOptions::default(),
);
let action = actions
.iter()
.find(|a| a.title.contains("flow sequence"))
.unwrap();
let edits = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()[&test_uri()];
let new_text = &edits[0].new_text;
assert!(
new_text.contains("python"),
"python must be present: {new_text:?}"
);
assert!(
new_text.contains("\"-m\"") || new_text.contains("'-m'") || new_text.contains("- -m"),
"second item must be present: {new_text:?}"
);
assert!(
edits[0].range.start.character > 0,
"edit must not start at col 0 (key is preserved by caller): {:?}",
edits[0].range
);
}
#[test]
fn should_indent_block_items_at_top_level_key() {
let text = "items: [one, two]\n";
let actions = code_actions(
&docs_for(text),
text,
line_range(0),
&flow_diags_for(text),
&test_uri(),
&YamlFormatOptions::default(),
);
let action = actions
.iter()
.find(|a| a.title.contains("flow sequence"))
.unwrap();
let edits = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()[&test_uri()];
let new_text = &edits[0].new_text;
assert!(
new_text.contains("- one"),
"one must be present: {new_text:?}"
);
assert!(
new_text.contains("- two"),
"two must be present: {new_text:?}"
);
assert!(
edits[0].range.start.character > 0,
"edit must not start at col 0 (key is preserved by caller): {:?}",
edits[0].range
);
}
#[test]
fn should_indent_block_items_under_key_at_indent_2() {
let text = " command: [\"a\", \"b\"]\n";
let actions = code_actions(
&docs_for(text),
text,
line_range(0),
&flow_diags_for(text),
&test_uri(),
&YamlFormatOptions::default(),
);
let action = actions
.iter()
.find(|a| a.title.contains("flow sequence"))
.unwrap();
let edits = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()[&test_uri()];
let new_text = &edits[0].new_text;
assert!(
new_text.contains("- a") || new_text.contains("- \"a\""),
"a must be present: {new_text:?}"
);
assert!(
new_text.contains("- b") || new_text.contains("- \"b\""),
"b must be present: {new_text:?}"
);
assert!(
edits[0].range.start.character > 0,
"edit must not start at col 0 (key is preserved by caller): {:?}",
edits[0].range
);
}
#[test]
fn should_not_produce_actions_for_non_overlapping_diagnostics() {
let text = "config: {a: 1}\nother: value\n";
let diag = make_diagnostic(0, 8, 14, "flowMap");
let actions = code_actions(
&docs_for(text),
text,
cursor_range(1, 0),
&[diag],
&test_uri(),
&YamlFormatOptions::default(),
);
assert!(actions.iter().all(|a| !a.title.contains("flow mapping")));
}
#[test]
fn should_return_empty_for_plain_yaml_no_diagnostics() {
let text = "key: value\n";
let actions = code_actions(
&docs_for(text),
text,
cursor_range(0, 0),
&[],
&test_uri(),
&YamlFormatOptions::default(),
);
assert!(actions.is_empty());
}
#[test]
fn should_preserve_double_quoted_item_when_converting_block_seq_to_flow() {
let text = "items:\n - \"true\"\n - \"false\"\n";
let actions = code_actions(
&docs_for(text),
text,
cursor_range(0, 0),
&[],
&test_uri(),
&YamlFormatOptions::default(),
);
let action = actions
.iter()
.find(|a| a.title.contains("block to flow"))
.unwrap();
let edit = action.edit.as_ref().unwrap();
let changes = edit.changes.as_ref().unwrap();
let edits = &changes[&test_uri()];
assert!(
edits[0].new_text.contains("[\"true\", \"false\"]"),
"pre-quoted items must not be double-quoted: {:?}",
edits[0].new_text
);
}
#[test]
fn should_preserve_single_quoted_item_when_converting_block_seq_to_flow() {
let text = "items:\n - 'hello'\n - 'world'\n";
let actions = code_actions(
&docs_for(text),
text,
cursor_range(0, 0),
&[],
&test_uri(),
&YamlFormatOptions::default(),
);
let action = actions
.iter()
.find(|a| a.title.contains("block to flow"))
.unwrap();
let edit = action.edit.as_ref().unwrap();
let changes = edit.changes.as_ref().unwrap();
let edits = &changes[&test_uri()];
assert!(
edits[0].new_text.contains("hello") && edits[0].new_text.contains("world"),
"single-quoted items must appear in flow output: {:?}",
edits[0].new_text
);
assert!(
!edits[0].new_text.contains("\"hello\"") && !edits[0].new_text.contains("\"world\""),
"safe items must not be double-quoted: {:?}",
edits[0].new_text
);
}
#[test]
fn should_quote_unsafe_item_alongside_pre_quoted_item() {
let text = "args:\n - \"true\"\n - value, with comma\n";
let actions = code_actions(
&docs_for(text),
text,
cursor_range(0, 0),
&[],
&test_uri(),
&YamlFormatOptions::default(),
);
let action = actions
.iter()
.find(|a| a.title.contains("block to flow"))
.unwrap();
let edit = action.edit.as_ref().unwrap();
let changes = edit.changes.as_ref().unwrap();
let edits = &changes[&test_uri()];
assert!(
edits[0]
.new_text
.contains("[\"true\", \"value, with comma\"]"),
"pre-quoted item preserved and unsafe item quoted: {:?}",
edits[0].new_text
);
}
#[test]
fn should_not_quote_plain_item_alongside_pre_quoted_item() {
let text = "args:\n - \"true\"\n - plain\n";
let actions = code_actions(
&docs_for(text),
text,
cursor_range(0, 0),
&[],
&test_uri(),
&YamlFormatOptions::default(),
);
let action = actions
.iter()
.find(|a| a.title.contains("block to flow"))
.unwrap();
let edit = action.edit.as_ref().unwrap();
let changes = edit.changes.as_ref().unwrap();
let edits = &changes[&test_uri()];
assert!(
edits[0].new_text.contains("[\"true\", plain]"),
"pre-quoted item preserved and plain item unquoted: {:?}",
edits[0].new_text
);
}
#[test]
fn flow_map_sequence_item_no_data_loss() {
let text = "- {target: linux, os: ubuntu}\n";
let action = flow_map_action(text).expect("action must be offered");
let new_text = new_text_for(&action);
assert!(new_text.contains("target: linux"), "new_text: {new_text:?}");
assert!(new_text.contains("os: ubuntu"), "new_text: {new_text:?}");
assert!(!new_text.contains('{'), "new_text: {new_text:?}");
assert!(!new_text.contains('}'), "new_text: {new_text:?}");
let edit = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()[&test_uri()][0];
assert!(
edit.range.start.character > 0,
"range must not start at col 0: {:?}",
edit.range
);
}
#[test]
fn flow_map_multiline_spans_all_lines() {
let text = "key:\n {a: 1,\n b: 2}\n";
let action = flow_map_action(text).expect("action must be offered");
let new_text = new_text_for(&action);
assert!(new_text.contains("a: 1"), "new_text: {new_text:?}");
assert!(new_text.contains("b: 2"), "new_text: {new_text:?}");
let edit = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()[&test_uri()][0];
assert_eq!(
edit.range.start.line, 1,
"range must start on line 1: {:?}",
edit.range
);
assert_eq!(
edit.range.end.line, 2,
"range must end on line 2: {:?}",
edit.range
);
}
#[test]
fn flow_map_as_mapping_value_edit_covers_node_only() {
let text = "key: {a: 1, b: 2}\n";
let action = flow_map_action(text).expect("action must be offered");
let new_text = new_text_for(&action);
assert!(new_text.contains("a: 1"), "new_text: {new_text:?}");
assert!(new_text.contains("b: 2"), "new_text: {new_text:?}");
let edit = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()[&test_uri()][0];
assert!(
edit.range.start.character > 0,
"edit should start past col 0: {:?}",
edit.range
);
}
#[test]
fn flow_map_nested_no_data_loss() {
let text = "outer: {inner: {x: 1}}\n";
let action = flow_map_action(text).expect("action must be offered");
let new_text = new_text_for(&action);
assert!(
new_text.contains('1'),
"scalar value 1 must survive: {new_text:?}"
);
assert!(
new_text.contains('x'),
"scalar key x must survive: {new_text:?}"
);
}
#[test]
fn flow_map_empty_no_action() {
let text = "key: {}\n";
let docs = docs_for(text);
let diags = flow_diags_for(text);
let whole = Range::new(Position::new(0, 0), Position::new(999, 0));
let actions = code_actions(
&docs,
text,
whole,
&diags,
&test_uri(),
&YamlFormatOptions::default(),
);
let has_map_action = actions.iter().any(|a| a.title.contains("flow mapping"));
if has_map_action {
let action = actions
.iter()
.find(|a| a.title.contains("flow mapping"))
.unwrap();
let new_text = new_text_for(action);
assert!(
!new_text.is_empty(),
"action for empty map must produce non-empty text"
);
}
}
#[test]
fn flow_map_no_matching_node_no_action() {
let text = "key: value\n";
let docs = docs_for(text);
let fake_diag = make_flow_diag("flowMap", 0, 5, 0, 10);
let whole = Range::new(Position::new(0, 0), Position::new(999, 0));
let actions = code_actions(
&docs,
text,
whole,
&[fake_diag],
&test_uri(),
&YamlFormatOptions::default(),
);
assert!(
actions.iter().all(|a| !a.title.contains("flow mapping")),
"no matching node should yield no action"
);
}
#[test]
fn flow_map_with_nested_flow_seq_no_data_loss() {
let text = "root: {a: [1, 2]}\n";
let action = flow_map_action(text).expect("action must be offered");
let new_text = new_text_for(&action);
assert!(new_text.contains('a'), "key a must survive: {new_text:?}");
assert!(
new_text.contains('1'),
"scalar 1 must survive: {new_text:?}"
);
assert!(
new_text.contains('2'),
"scalar 2 must survive: {new_text:?}"
);
}
#[test]
fn flow_map_to_block_inside_flow_seq_no_data_loss() {
let text = "list: [{a: 1, b: 2}]\n";
let action = flow_map_action(text).expect("flow-map action must be offered for inner map");
let new_text = new_text_for(&action);
assert!(new_text.contains('a'), "key a must survive: {new_text:?}");
assert!(
new_text.contains('1'),
"scalar 1 must survive: {new_text:?}"
);
assert!(new_text.contains('b'), "key b must survive: {new_text:?}");
assert!(
new_text.contains('2'),
"scalar 2 must survive: {new_text:?}"
);
assert!(
!new_text.contains('{'),
"block output must not contain '{{': {new_text:?}"
);
}
#[test]
fn flow_seq_all_scalars_to_block() {
let text = "list: [a, b, c]\n";
let action = flow_seq_action(text).expect("action must be offered");
let new_text = new_text_for(&action);
assert!(new_text.contains("- a"), "new_text: {new_text:?}");
assert!(new_text.contains("- b"), "new_text: {new_text:?}");
assert!(new_text.contains("- c"), "new_text: {new_text:?}");
assert!(!new_text.contains('['), "new_text: {new_text:?}");
assert!(!new_text.contains(']'), "new_text: {new_text:?}");
}
#[test]
fn flow_seq_of_flow_maps_no_data_loss() {
let text = "items: [{x: 1}, {x: 2}]\n";
let action = flow_seq_action(text).expect("action must be offered");
let new_text = new_text_for(&action);
assert!(
new_text.contains('1'),
"scalar 1 must survive: {new_text:?}"
);
assert!(
new_text.contains('2'),
"scalar 2 must survive: {new_text:?}"
);
}
#[test]
fn flow_seq_empty_no_destructive_action() {
let text = "list: []\n";
let docs = docs_for(text);
let diags = flow_diags_for(text);
let whole = Range::new(Position::new(0, 0), Position::new(999, 0));
let actions = code_actions(
&docs,
text,
whole,
&diags,
&test_uri(),
&YamlFormatOptions::default(),
);
let has_seq_action = actions.iter().any(|a| a.title.contains("flow sequence"));
if has_seq_action {
let action = actions
.iter()
.find(|a| a.title.contains("flow sequence"))
.unwrap();
let new_text = new_text_for(action);
assert!(
!new_text.is_empty(),
"action for empty seq must produce non-empty text"
);
}
}
#[test]
fn sig_accepts_empty_docs() {
let actions = code_actions(
&[],
"key: value\n",
cursor_range(0, 0),
&[],
&test_uri(),
&YamlFormatOptions::default(),
);
assert!(actions.iter().all(|a| !a.title.contains("flow")));
}
#[test]
fn sig_parsed_docs_with_flow_map_returns_action() {
let text = "- {k: v}\n";
let docs = docs_for(text);
let diags = flow_diags_for(text);
let whole = Range::new(Position::new(0, 0), Position::new(999, 0));
let actions = code_actions(
&docs,
text,
whole,
&diags,
&test_uri(),
&YamlFormatOptions::default(),
);
assert!(
actions.iter().any(|a| a.title.contains("flow mapping")),
"should return flow map action: {actions:?}"
);
}
#[test]
fn sig_tab_action_still_works_with_docs() {
let text = "\tkey: value\n";
let docs = docs_for(text);
let actions = code_actions(
&docs,
text,
cursor_range(0, 0),
&[],
&test_uri(),
&YamlFormatOptions::default(),
);
assert!(
actions.iter().any(|a| a.title.contains("tabs to spaces")),
"tab action should still be offered: {actions:?}"
);
}
#[test]
#[expect(
clippy::too_many_lines,
reason = "INT-1 exercises end-to-end scalar preservation; nested collect helpers inflate line count"
)]
fn int_sequence_item_flow_map_preserves_all_scalars() {
let text = "- {target: linux, os: ubuntu}\n";
let docs = docs_for(text);
let pre_scalars: Vec<String> = {
let mut out = Vec::new();
fn collect(node: &Node<Span>, out: &mut Vec<String>) {
match node {
Node::Scalar { value, .. } => out.push(value.clone()),
Node::Mapping { entries, .. } => {
for (k, v) in entries {
collect(k, out);
collect(v, out);
}
}
Node::Sequence { items, .. } => {
for item in items {
collect(item, out);
}
}
Node::Alias { .. } => {}
}
}
for doc in &docs {
collect(&doc.root, &mut out);
}
out
};
let diags = flow_diags_for(text);
let whole = Range::new(Position::new(0, 0), Position::new(999, 0));
let actions = code_actions(
&docs,
text,
whole,
&diags,
&test_uri(),
&YamlFormatOptions::default(),
);
for action in &actions {
if action.kind.as_ref() != Some(&CodeActionKind::REFACTOR_REWRITE) {
continue;
}
let Some(edits) = action
.edit
.as_ref()
.and_then(|e| e.changes.as_ref())
.and_then(|c| c.get(&test_uri()))
else {
continue;
};
if edits.is_empty() {
continue;
}
let edit = &edits[0];
let lines: Vec<&str> = text.lines().collect();
let start_line = edit.range.start.line as usize;
let end_line = edit.range.end.line as usize;
let start_char = edit.range.start.character as usize;
let end_char = edit.range.end.character as usize;
let mut result = String::new();
for (i, line) in lines.iter().enumerate() {
if i < start_line || i > end_line {
result.push_str(line);
result.push('\n');
} else if i == start_line && i == end_line {
result.push_str(&line[..start_char]);
result.push_str(&edit.new_text);
result.push_str(&line[end_char..]);
result.push('\n');
} else if i == start_line {
result.push_str(&line[..start_char]);
result.push_str(&edit.new_text);
} else if i == end_line {
result.push_str(&line[end_char..]);
result.push('\n');
}
}
let post_docs = docs_for(&result);
let mut post_scalars = Vec::new();
fn collect2(node: &Node<Span>, out: &mut Vec<String>) {
match node {
Node::Scalar { value, .. } => out.push(value.clone()),
Node::Mapping { entries, .. } => {
for (k, v) in entries {
collect2(k, out);
collect2(v, out);
}
}
Node::Sequence { items, .. } => {
for item in items {
collect2(item, out);
}
}
Node::Alias { .. } => {}
}
}
for doc in &post_docs {
collect2(&doc.root, &mut post_scalars);
}
for scalar in &pre_scalars {
assert!(
post_scalars.contains(scalar),
"scalar {scalar:?} was lost after applying action {:?}\npre: {pre_scalars:?}\npost: {post_scalars:?}\nresult: {result:?}",
action.title
);
}
}
}
#[test]
fn int_github_token_expression_preserved() {
let text = "env:\n - {GITHUB_TOKEN: \"${{ secrets.GITHUB_TOKEN }}\"}\n";
let docs = docs_for(text);
let diags = flow_diags_for(text);
let whole = Range::new(Position::new(0, 0), Position::new(999, 0));
let actions = code_actions(
&docs,
text,
whole,
&diags,
&test_uri(),
&YamlFormatOptions::default(),
);
for action in &actions {
if action.kind.as_ref() != Some(&CodeActionKind::REFACTOR_REWRITE) {
continue;
}
let Some(edits) = action
.edit
.as_ref()
.and_then(|e| e.changes.as_ref())
.and_then(|c| c.get(&test_uri()))
else {
continue;
};
if edits.is_empty() {
continue;
}
let new_text = &edits[0].new_text;
assert!(
new_text.contains("GITHUB_TOKEN"),
"GITHUB_TOKEN key must be preserved: {new_text:?}"
);
assert!(
new_text.contains("secrets.GITHUB_TOKEN"),
"token value must be preserved: {new_text:?}"
);
}
}
}