use std::collections::HashMap;
use tower_lsp::lsp_types::{Position, Range, TextEdit, Url, WorkspaceEdit};
#[derive(Debug, Clone)]
struct Token {
name: String,
line: u32,
start_col: u32,
end_col: u32,
is_anchor: bool,
}
#[must_use]
pub fn prepare_rename(text: &str, position: Position) -> Option<Range> {
let lines: Vec<&str> = text.lines().collect();
let line_idx = position.line as usize;
let col_idx = position.character as usize;
let line = lines.get(line_idx)?;
if col_idx > line.len() {
return None;
}
let doc_range = document_range_for_line(&lines, line_idx);
let tokens = scan_tokens(&lines, doc_range.0, doc_range.1);
let token = tokens.iter().find(|t| {
t.line == position.line && col_idx >= t.start_col as usize && col_idx < t.end_col as usize
})?;
Some(Range::new(
Position::new(token.line, token.start_col),
Position::new(token.line, token.end_col),
))
}
#[must_use]
pub fn rename(text: &str, uri: &Url, position: Position, new_name: &str) -> Option<WorkspaceEdit> {
if !is_valid_anchor_name(new_name) {
return None;
}
let lines: Vec<&str> = text.lines().collect();
let line_idx = position.line as usize;
let col_idx = position.character as usize;
let line = lines.get(line_idx)?;
if col_idx > line.len() {
return None;
}
let doc_range = document_range_for_line(&lines, line_idx);
let tokens = scan_tokens(&lines, doc_range.0, doc_range.1);
let cursor_token = tokens.iter().find(|t| {
t.line == position.line && col_idx >= t.start_col as usize && col_idx < t.end_col as usize
})?;
let name = &cursor_token.name;
let edits: Vec<TextEdit> = tokens
.iter()
.filter(|t| t.name == *name)
.map(|t| {
let prefix = if t.is_anchor { "&" } else { "*" };
TextEdit {
range: Range::new(
Position::new(t.line, t.start_col),
Position::new(t.line, t.end_col),
),
new_text: format!("{prefix}{new_name}"),
}
})
.collect();
let mut changes = HashMap::new();
changes.insert(uri.clone(), edits);
Some(WorkspaceEdit {
changes: Some(changes),
..WorkspaceEdit::default()
})
}
fn document_range_for_line(lines: &[&str], line_idx: usize) -> (usize, usize) {
let mut start = 0;
let end = lines.len();
for i in (0..=line_idx).rev() {
let trimmed = lines.get(i).map_or("", |l| l.trim());
if trimmed == "---" && i < line_idx {
start = i + 1;
break;
}
}
for i in (line_idx + 1)..end {
let trimmed = lines.get(i).map_or("", |l| l.trim());
if trimmed == "---" {
return (start, i);
}
}
(start, end)
}
fn scan_tokens(lines: &[&str], start_line: usize, end_line: usize) -> Vec<Token> {
let mut tokens = Vec::new();
for line_idx in start_line..end_line {
let Some(line) = lines.get(line_idx) else {
continue;
};
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
#[expect(
clippy::cast_possible_truncation,
reason = "LSP line/col are u32; always fits"
)]
let line_num = line_idx as u32;
let mut chars = line.char_indices().peekable();
while let Some((i, ch)) = chars.next() {
if ch == '&' || ch == '*' {
let is_anchor = ch == '&';
let name_start = i + 1;
let mut name_end = name_start;
while let Some(&(j, next_ch)) = chars.peek() {
if is_anchor_name_char(next_ch) {
name_end = j + next_ch.len_utf8();
chars.next();
} else {
break;
}
}
if name_end > name_start {
#[expect(
clippy::cast_possible_truncation,
reason = "LSP line/col are u32; always fits"
)]
tokens.push(Token {
name: line[name_start..name_end].to_string(),
line: line_num,
start_col: i as u32,
end_col: name_end as u32,
is_anchor,
});
}
}
}
}
tokens
}
const fn is_anchor_name_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.'
}
fn is_valid_anchor_name(name: &str) -> bool {
!name.is_empty() && name.len() <= 256 && name.chars().all(is_anchor_name_char)
}
#[cfg(test)]
#[expect(clippy::indexing_slicing, clippy::expect_used, reason = "test code")]
mod tests {
use rstest::rstest;
use super::*;
fn pos(line: u32, character: u32) -> Position {
Position::new(line, character)
}
fn test_uri() -> Url {
Url::parse("file:///test/doc.yaml").expect("valid test URI")
}
#[test]
fn should_return_range_when_cursor_on_anchor() {
let text = "key: &myanchor value\n";
let result = prepare_rename(text, pos(0, 6));
let range = result.expect("should return a range");
assert_eq!(range.start.line, 0);
assert_eq!(range.start.character, 5, "&myanchor starts at column 5");
assert_eq!(range.end.character, 14, "&myanchor ends at column 14");
}
#[test]
fn should_return_range_when_cursor_on_alias() {
let text = "defaults: &defaults\n key: val\nproduction:\n <<: *defaults\n";
let result = prepare_rename(text, pos(3, 7));
let range = result.expect("should return a range");
assert_eq!(range.start.line, 3);
assert!(range.start.character <= 7);
assert!(range.end.character > 7);
}
#[test]
fn should_return_range_when_cursor_at_end_of_anchor_name() {
let text = "key: &anchor value\n";
let result = prepare_rename(text, pos(0, 11));
let range = result.expect("should return a range");
assert_eq!(range.start.line, 0);
assert_eq!(range.start.character, 5);
}
#[rstest]
#[case::not_on_anchor_or_alias("key: value\n", 0, 0)]
#[case::empty_document("", 0, 0)]
#[case::beyond_document_lines("key: &anchor value\n", 10, 0)]
#[case::beyond_line_length("key: &anchor value\n", 0, 100)]
#[case::anchor_in_comment("# &fake\nkey: value\n", 0, 2)]
#[case::cursor_at_exact_end_of_line("key: &anchor\n", 0, 12)]
#[case::cursor_at_document_end("key: &anchor", 0, 12)]
fn prepare_rename_returns_none(#[case] text: &str, #[case] line: u32, #[case] character: u32) {
let result = prepare_rename(text, pos(line, character));
assert!(result.is_none());
}
#[rstest]
#[case::anchor_and_single_alias(
"defaults: &old\n key: val\nproduction:\n <<: *old\n",
0,
10,
"new",
2
)]
#[case::anchor_and_multiple_aliases(
"defaults: &shared\n key: val\ndev:\n <<: *shared\nprod:\n <<: *shared\n",
0,
10,
"common",
3
)]
#[case::cursor_on_alias(
"defaults: &old\n key: val\nproduction:\n <<: *old\n",
3,
7,
"new",
2
)]
#[case::anchor_with_no_aliases("key: &lonely value\n", 0, 5, "orphan", 1)]
#[case::not_across_document_boundaries(
"doc1: &name\n ref: *name\n---\ndoc2: &name\n ref: *name\n",
0,
6,
"renamed",
2
)]
#[case::within_second_document(
"doc1: &name\n---\ndoc2: &name\n ref: *name\n",
2,
6,
"other",
2
)]
fn rename_returns_edits_len(
#[case] text: &str,
#[case] line: u32,
#[case] character: u32,
#[case] new_name: &str,
#[case] expected_len: usize,
) {
let uri = test_uri();
let result = rename(text, &uri, pos(line, character), new_name);
let edit = result.expect("should return WorkspaceEdit");
let changes = edit.changes.expect("should have changes");
let edits = changes.get(&uri).expect("should have edits for uri");
assert_eq!(edits.len(), expected_len);
}
#[test]
fn should_produce_correct_edit_ranges() {
let text = "key: &old value\nref: *old\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "new");
let edit = result.expect("should return WorkspaceEdit");
let changes = edit.changes.expect("should have changes");
let edits = changes.get(&uri).expect("should have edits for uri");
assert_eq!(edits.len(), 2);
assert_eq!(edits[0].range.start.line, 0);
assert_eq!(edits[0].range.start.character, 5);
assert_eq!(edits[0].range.end.line, 0);
assert_eq!(edits[0].range.end.character, 9);
assert_eq!(edits[0].new_text, "&new");
assert_eq!(edits[1].range.start.line, 1);
assert_eq!(edits[1].range.start.character, 5);
assert_eq!(edits[1].range.end.line, 1);
assert_eq!(edits[1].range.end.character, 9);
assert_eq!(edits[1].new_text, "*new");
}
#[rstest]
#[case::cursor_not_on_anchor_or_alias("key: value\n", 0, 0, "anything")]
#[case::empty_document("", 0, 0, "anything")]
#[case::beyond_document_lines("key: &anchor value\n", 10, 0, "anything")]
#[case::beyond_line_length("key: &anchor value\n", 0, 100, "anything")]
fn rename_returns_none_invalid_position(
#[case] text: &str,
#[case] line: u32,
#[case] character: u32,
#[case] new_name: &str,
) {
let uri = test_uri();
let result = rename(text, &uri, pos(line, character), new_name);
assert!(result.is_none());
}
#[rstest]
#[case::empty_name("")]
#[case::spaces("has space")]
#[case::open_bracket("bad[name")]
#[case::close_bracket("bad]name")]
#[case::open_brace("bad{name")]
#[case::close_brace("bad}name")]
#[case::colon("bad:name")]
#[case::comma("bad,name")]
#[case::whitespace_only(" ")]
#[case::hash("name#comment")]
#[case::newline("name\n")]
#[case::tab("name\t")]
#[case::carriage_return("name\r")]
#[case::ampersand("name&other")]
#[case::asterisk("name*other")]
#[case::exclamation("name!tag")]
fn rename_rejects_invalid_new_name(#[case] new_name: &str) {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), new_name);
assert!(result.is_none());
}
#[rstest]
#[case::hyphen("valid-name")]
#[case::underscore("valid_name")]
#[case::dot("valid.name")]
#[case::starts_with_digit("123abc")]
fn rename_accepts_valid_new_name(#[case] new_name: &str) {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), new_name);
assert!(result.is_some());
}
#[test]
fn should_reject_new_name_exceeding_max_length() {
let text = "key: &anchor value\n";
let uri = test_uri();
let long_name = "a".repeat(257);
let result = rename(text, &uri, pos(0, 5), &long_name);
assert!(
result.is_none(),
"name longer than 256 chars must be rejected"
);
}
#[test]
fn should_accept_new_name_at_exactly_max_length() {
let text = "key: &anchor value\n";
let uri = test_uri();
let max_name = "a".repeat(256);
let result = rename(text, &uri, pos(0, 5), &max_name);
assert!(
result.is_some(),
"name of exactly 256 chars must be accepted"
);
}
}