use tower_lsp::lsp_types::{CodeAction, CodeActionKind, Position, Range, TextEdit};
use rlsp_yaml_parser::node::Node;
use rlsp_yaml_parser::{CollectionStyle, Document, LineIndex, Span};
use crate::editing::formatter::{YamlFormatOptions, format_subtree};
use super::make_action;
pub(super) fn block_to_flow(
docs: &[Document<Span>],
line_idx: usize,
uri: &tower_lsp::lsp_types::Url,
options: &YamlFormatOptions,
) -> Option<CodeAction> {
let (node, key_loc, idx) = find_innermost_block_collection(docs, line_idx)?;
let loc = node_loc(node);
let loc_start_line = idx.line_column(loc.start).0 as usize;
let key_start_line = idx.line_column(key_loc.start).0 as usize;
if loc_start_line <= key_start_line {
return None;
}
if has_nested_collection_child(node) {
return None;
}
let mut flow_node = node.clone();
match &mut flow_node {
Node::Mapping { style, .. } | Node::Sequence { style, .. } => {
*style = CollectionStyle::Flow;
}
Node::Scalar { .. } | Node::Alias { .. } => return None,
}
let (key_start_line_1based, key_start_col) = idx.line_column(key_loc.start);
let base_indent = key_start_col as usize + 2;
let key_line = key_start_line_1based.saturating_sub(1); let formatted = format_subtree(&flow_node, options, base_indent);
let new_text = format!(" {formatted}");
if new_text.trim().is_empty() {
return None;
}
let title = "Convert block to flow style".to_string();
let (_, key_end_col) = idx.line_column(key_loc.end);
let edit_start_col = key_end_col as usize + 1;
let (loc_end_line, loc_end_col) = idx.line_column(loc.end);
#[expect(
clippy::cast_possible_truncation,
reason = "edit_start_col is a usize byte offset that always fits u32"
)]
let edit_range = Range::new(
Position::new(key_line, edit_start_col as u32),
Position::new(loc_end_line.saturating_sub(1), loc_end_col + 1),
);
Some(make_action(
title,
uri,
vec![TextEdit {
range: edit_range,
new_text,
}],
CodeActionKind::REFACTOR_REWRITE,
None,
))
}
pub(super) fn block_text_and_start_col(
node: &Node<Span>,
loc: Span,
text: &str,
idx: &LineIndex,
options: &YamlFormatOptions,
) -> (String, usize) {
let start_col = idx.line_column(loc.start).1 as usize;
let line_idx = idx.line_column(loc.start).0.saturating_sub(1) as usize;
let lines: Vec<&str> = text.lines().collect();
let is_mapping_value = lines.get(line_idx).is_some_and(|line| {
let prefix = if start_col <= line.len() {
&line[..start_col]
} else {
line
};
prefix.trim_end().ends_with(':')
});
if is_mapping_value {
let key_indent = lines
.get(line_idx)
.map_or(0, |line| line.len() - line.trim_start().len());
let base_indent = key_indent + 2;
let indent_str = " ".repeat(base_indent);
let formatted = format_subtree(node, options, base_indent);
(format!("\n{indent_str}{formatted}"), start_col)
} else {
let formatted = format_subtree(node, options, start_col);
(formatted, start_col)
}
}
fn find_innermost_block_collection<'a>(
docs: &'a [Document<Span>],
line_idx: usize,
) -> Option<(&'a Node<Span>, &'a Span, &'a LineIndex)> {
let parser_line = line_idx + 1;
let mut best: Option<(&'a Node<Span>, &'a Span, &'a LineIndex)> = None;
for doc in docs {
let idx = doc.line_index();
find_innermost_block_in_node(&doc.root, parser_line, &mut best, idx);
}
best
}
fn find_innermost_block_in_node<'a>(
node: &'a Node<Span>,
parser_line: usize,
best: &mut Option<(&'a Node<Span>, &'a Span, &'a LineIndex)>,
idx: &'a LineIndex,
) {
match node {
Node::Mapping { entries, .. } => {
for (k, v) in entries {
if idx.line_column(node_loc(k).start).0 as usize == parser_line
&& is_block_collection(v)
{
*best = Some((v, node_loc(k), idx));
}
find_innermost_block_in_node(k, parser_line, best, idx);
find_innermost_block_in_node(v, parser_line, best, idx);
}
}
Node::Sequence { items, .. } => {
for item in items {
find_innermost_block_in_node(item, parser_line, best, idx);
}
}
Node::Scalar { .. } | Node::Alias { .. } => {}
}
}
const fn is_block_collection(node: &Node<Span>) -> bool {
matches!(
node,
Node::Mapping {
style: CollectionStyle::Block,
..
} | Node::Sequence {
style: CollectionStyle::Block,
..
}
)
}
pub(super) fn has_nested_collection_child(node: &Node<Span>) -> bool {
match node {
Node::Mapping { entries, .. } => entries.iter().any(|(_, v)| is_block_collection(v)),
Node::Sequence { items, .. } => items.iter().any(is_block_collection),
Node::Scalar { .. } | Node::Alias { .. } => false,
}
}
pub(super) const fn node_loc(node: &Node<Span>) -> &Span {
match node {
Node::Mapping { loc, .. }
| Node::Sequence { loc, .. }
| Node::Scalar { loc, .. }
| Node::Alias { loc, .. } => loc,
}
}
#[cfg(test)]
mod tests {
use super::super::test_helpers::apply_block_to_flow_edit;
use crate::parser::parse_yaml;
#[test]
fn should_produce_reparseable_yaml_when_long_sequence_wraps() {
let text = "items:\n - long_item_aaa\n - long_item_bbb\n - long_item_ccc\n - long_item_ddd\n - long_item_eee\n - long_item_fff\n";
let result = apply_block_to_flow_edit(text, 0);
let parse_result = parse_yaml(&result);
assert!(
parse_result.diagnostics.is_empty(),
"edited YAML must reparse without diagnostics; got: {:?}\nresult text:\n{result}",
parse_result.diagnostics
);
assert_eq!(
parse_result.documents.len(),
1,
"edited YAML must produce exactly one document; result text:\n{result}"
);
}
#[test]
fn should_produce_reparseable_yaml_when_long_nested_mapping_wraps() {
let text = "outer:\n inner:\n key_aaa: val_aaa\n key_bbb: val_bbb\n key_ccc: val_ccc\n key_ddd: val_ddd\n key_eee: val_eee\n";
let result = apply_block_to_flow_edit(text, 1);
let parse_result = parse_yaml(&result);
assert!(
parse_result.diagnostics.is_empty(),
"edited YAML must reparse without diagnostics; got: {:?}\nresult text:\n{result}",
parse_result.diagnostics
);
assert_eq!(
parse_result.documents.len(),
1,
"edited YAML must produce exactly one document; result text:\n{result}"
);
}
}