use tower_lsp::lsp_types::{CodeAction, CodeActionKind, Diagnostic, Position, Range, TextEdit};
use rlsp_yaml_parser::node::Node;
use rlsp_yaml_parser::{Document, Span};
use crate::editing::formatter::{YamlFormatOptions, format_subtree};
use super::{block_to_flow::node_loc, make_action};
pub(super) fn delete_unused_anchor(
docs: &[Document<Span>],
text: &str,
diag: &Diagnostic,
uri: &tower_lsp::lsp_types::Url,
) -> Option<CodeAction> {
let diag_line = diag.range.start.line as usize;
let anchor_start_col = diag.range.start.character as usize;
let anchor_end_col = diag.range.end.character as usize;
let line = text.lines().nth(diag_line)?;
if anchor_start_col >= line.len() || anchor_end_col > line.len() {
return None;
}
let anchor_name = &line[anchor_start_col + 1..anchor_end_col];
let (node, idx) = find_anchored_node(docs, diag_line, anchor_name)?;
let loc = node_loc(node);
if matches!(node, Node::Alias { .. }) {
return None;
}
let mut deanchored = node.clone();
deanchored.clear_anchor();
let base_indent = idx.line_column(loc.start).1 as usize;
let new_text = format_subtree(&deanchored, &YamlFormatOptions::default(), base_indent);
#[expect(
clippy::cast_possible_truncation,
reason = "LSP line/col are u32; always fits"
)]
let edit_range = Range::new(
Position::new(diag_line as u32, anchor_start_col as u32),
Position::new(
idx.line_column(loc.end).0.saturating_sub(1),
idx.line_column(loc.end).1,
),
);
Some(make_action(
"Delete unused anchor".to_string(),
uri,
vec![TextEdit {
range: edit_range,
new_text,
}],
CodeActionKind::QUICKFIX,
Some(vec![diag.clone()]),
))
}
fn find_anchored_node<'a>(
docs: &'a [Document<Span>],
diag_line: usize,
anchor_name: &str,
) -> Option<(&'a Node<Span>, &'a rlsp_yaml_parser::LineIndex)> {
let parser_line = diag_line + 1;
for doc in docs {
let idx = doc.line_index();
if let Some(node) = find_anchored_node_in(&doc.root, parser_line, anchor_name, idx) {
return Some((node, idx));
}
}
None
}
fn find_anchored_node_in<'a>(
node: &'a Node<Span>,
parser_line: usize,
anchor_name: &str,
idx: &rlsp_yaml_parser::LineIndex,
) -> Option<&'a Node<Span>> {
if node.anchor() == Some(anchor_name) {
let loc_line = idx.line_column(node_loc(node).start).0 as usize;
if loc_line == parser_line || loc_line == parser_line + 1 {
return Some(node);
}
}
match node {
Node::Mapping { entries, .. } => {
for (k, v) in entries {
if let Some(found) = find_anchored_node_in(k, parser_line, anchor_name, idx) {
return Some(found);
}
if let Some(found) = find_anchored_node_in(v, parser_line, anchor_name, idx) {
return Some(found);
}
}
None
}
Node::Sequence { items, .. } => {
for item in items {
if let Some(found) = find_anchored_node_in(item, parser_line, anchor_name, idx) {
return Some(found);
}
}
None
}
Node::Scalar { .. } | Node::Alias { .. } => None,
}
}
#[cfg(test)]
#[expect(clippy::indexing_slicing, clippy::unwrap_used, reason = "test code")]
mod tests {
use super::super::code_actions;
use super::super::test_helpers::{docs_for, line_range, make_diagnostic};
use crate::test_utils::test_uri;
#[test]
fn delete_anchor_plain_scalar_value() {
let text = "defaults: &unused value\n";
let diag = make_diagnostic(0, 10, 17, "unusedAnchor");
let actions = code_actions(&docs_for(text), text, line_range(0), &[diag], &test_uri());
let action = actions
.iter()
.find(|a| a.title.contains("unused anchor"))
.unwrap();
let edits = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()[&test_uri()];
assert_eq!(edits[0].new_text, "value");
assert!(!edits[0].new_text.contains("&unused"));
assert_eq!(edits[0].range.start.character, 10);
}
#[test]
fn delete_anchor_sole_value_empty_scalar() {
let text = "data: &unused\n";
let diag = make_diagnostic(0, 6, 13, "unusedAnchor");
let actions = code_actions(&docs_for(text), text, line_range(0), &[diag], &test_uri());
let action = actions
.iter()
.find(|a| a.title.contains("unused anchor"))
.unwrap();
let edits = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()[&test_uri()];
assert!(!edits[0].new_text.contains("&unused"));
}
#[test]
fn delete_anchor_quoted_scalar() {
let text = "key: &a \"hello\"\n";
let diag = make_diagnostic(0, 5, 7, "unusedAnchor");
let actions = code_actions(&docs_for(text), text, line_range(0), &[diag], &test_uri());
let action = actions
.iter()
.find(|a| a.title.contains("unused anchor"))
.unwrap();
let edits = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()[&test_uri()];
assert!(!edits[0].new_text.contains("&a"));
assert!(edits[0].new_text.contains("hello"));
}
#[test]
fn delete_anchor_user_tag_preserved() {
let text = "key: &a !custom \"hello\"\n";
let diag = make_diagnostic(0, 5, 7, "unusedAnchor");
let actions = code_actions(&docs_for(text), text, line_range(0), &[diag], &test_uri());
let action = actions
.iter()
.find(|a| a.title.contains("unused anchor"))
.unwrap();
let edits = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()[&test_uri()];
assert!(!edits[0].new_text.contains("&a"));
assert!(
edits[0].new_text.contains("!custom"),
"user tag must be preserved: {:?}",
edits[0].new_text
);
assert!(edits[0].new_text.contains("hello"));
}
#[test]
fn delete_anchor_flow_sequence() {
let text = "list: &nums [1, 2, 3]\n";
let diag = make_diagnostic(0, 6, 11, "unusedAnchor");
let actions = code_actions(&docs_for(text), text, line_range(0), &[diag], &test_uri());
let action = actions
.iter()
.find(|a| a.title.contains("unused anchor"))
.unwrap();
let edits = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()[&test_uri()];
assert!(!edits[0].new_text.contains("&nums"));
assert!(
edits[0].new_text.contains('['),
"flow sequence bracket must be preserved: {:?}",
edits[0].new_text
);
assert!(edits[0].new_text.contains('1'));
}
#[test]
fn delete_anchor_block_mapping_value() {
let text = "base: &defaults\n x: 1\n y: 2\n";
let diag = make_diagnostic(0, 6, 15, "unusedAnchor");
let actions = code_actions(&docs_for(text), text, line_range(0), &[diag], &test_uri());
let action = actions
.iter()
.find(|a| a.title.contains("unused anchor"))
.unwrap();
let edits = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()[&test_uri()];
assert!(
!edits[0].new_text.contains("&defaults"),
"anchor must be removed: {:?}",
edits[0].new_text
);
assert!(
edits[0].new_text.contains("x: 1"),
"x entry must be preserved: {:?}",
edits[0].new_text
);
assert!(
edits[0].new_text.contains("y: 2"),
"y entry must be preserved: {:?}",
edits[0].new_text
);
}
#[test]
fn delete_anchor_trailing_comment_preserved() {
let text = "key: &a value # keep me\n";
let diag = make_diagnostic(0, 5, 7, "unusedAnchor");
let actions = code_actions(&docs_for(text), text, line_range(0), &[diag], &test_uri());
let action = actions
.iter()
.find(|a| a.title.contains("unused anchor"))
.unwrap();
let edits = &action.edit.as_ref().unwrap().changes.as_ref().unwrap()[&test_uri()];
assert!(
edits[0].range.end.character as usize <= "key: &a value".len(),
"edit end must not reach into trailing comment: {:?}",
edits[0].range
);
assert!(
!edits[0].new_text.contains('#'),
"new_text must not contain the trailing comment: {:?}",
edits[0].new_text
);
let mut result = text.to_string();
let start = "key: ".len();
let end = "key: &a value".len();
result.replace_range(start..end, &edits[0].new_text);
assert!(
result.contains("# keep me"),
"trailing comment must survive: {result:?}"
);
}
#[test]
fn delete_anchor_stale_diagnostic_returns_no_action() {
let text = "data: value\n";
let diag = make_diagnostic(0, 6, 13, "unusedAnchor");
let actions = code_actions(&docs_for(text), text, line_range(0), &[diag], &test_uri());
assert!(
actions.iter().all(|a| !a.title.contains("unused anchor")),
"stale diagnostic must not produce an action"
);
}
}