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;
}
#[allow(clippy::cast_possible_truncation)]
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 {
#[allow(clippy::cast_possible_truncation)]
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)]
mod tests {
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);
}
#[test]
fn should_return_none_when_cursor_not_on_anchor_or_alias() {
let text = "key: value\n";
let result = prepare_rename(text, pos(0, 0));
assert!(
result.is_none(),
"should return None when cursor is not on an anchor or alias"
);
}
#[test]
fn should_return_none_for_empty_document() {
let text = "";
let result = prepare_rename(text, pos(0, 0));
assert!(result.is_none(), "should return None for empty document");
}
#[test]
fn should_return_none_for_position_beyond_document_lines() {
let text = "key: &anchor value\n";
let result = prepare_rename(text, pos(10, 0));
assert!(
result.is_none(),
"should return None for position beyond document lines"
);
}
#[test]
fn should_return_none_for_position_beyond_line_length() {
let text = "key: &anchor value\n";
let result = prepare_rename(text, pos(0, 100));
assert!(
result.is_none(),
"should return None for position beyond line length"
);
}
#[test]
fn should_return_none_for_anchor_in_comment() {
let text = "# &fake\nkey: value\n";
let result = prepare_rename(text, pos(0, 2));
assert!(result.is_none(), "should return None for anchor in comment");
}
#[test]
fn should_rename_anchor_and_single_alias() {
let text = "defaults: &old\n key: val\nproduction:\n <<: *old\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 10), "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, "should have 2 edits (anchor + alias)");
}
#[test]
fn should_rename_anchor_and_multiple_aliases() {
let text = "defaults: &shared\n key: val\ndev:\n <<: *shared\nprod:\n <<: *shared\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 10), "common");
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(), 3, "should have 3 edits (1 anchor + 2 aliases)");
}
#[test]
fn should_rename_when_cursor_on_alias() {
let text = "defaults: &old\n key: val\nproduction:\n <<: *old\n";
let uri = test_uri();
let result = rename(text, &uri, pos(3, 7), "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, "should have 2 edits (anchor + alias)");
}
#[test]
fn should_rename_anchor_with_no_aliases() {
let text = "key: &lonely value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "orphan");
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(), 1, "should have 1 edit (just the anchor)");
}
#[test]
fn should_not_rename_across_document_boundaries() {
let text = "doc1: &name\n ref: *name\n---\ndoc2: &name\n ref: *name\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 6), "renamed");
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,
"should have only 2 edits (anchor and alias in doc1 only)"
);
}
#[test]
fn should_rename_within_second_document() {
let text = "doc1: &name\n---\ndoc2: &name\n ref: *name\n";
let uri = test_uri();
let result = rename(text, &uri, pos(2, 6), "other");
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,
"should have 2 edits (only doc2's anchor and alias)"
);
}
#[test]
fn rename_should_return_none_when_cursor_not_on_anchor_or_alias() {
let text = "key: value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 0), "anything");
assert!(
result.is_none(),
"should return None when cursor is not on an anchor or alias"
);
}
#[test]
fn rename_should_return_none_for_empty_document() {
let text = "";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 0), "anything");
assert!(result.is_none(), "should return None for empty document");
}
#[test]
fn rename_should_return_none_for_position_beyond_document_lines() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(10, 0), "anything");
assert!(
result.is_none(),
"should return None for position beyond document lines"
);
}
#[test]
fn rename_should_return_none_for_position_beyond_line_length() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 100), "anything");
assert!(
result.is_none(),
"should return None for position beyond line length"
);
}
#[test]
fn should_reject_empty_new_name() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "");
assert!(result.is_none(), "should return None for empty new_name");
}
#[test]
fn should_reject_new_name_with_spaces() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "has space");
assert!(
result.is_none(),
"should return None for new_name with spaces"
);
}
#[test]
fn should_reject_new_name_with_open_bracket() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "bad[name");
assert!(result.is_none(), "should return None for new_name with [");
}
#[test]
fn should_reject_new_name_with_close_bracket() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "bad]name");
assert!(result.is_none(), "should return None for new_name with ]");
}
#[test]
fn should_reject_new_name_with_open_brace() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "bad{name");
assert!(result.is_none(), "should return None for new_name with {{");
}
#[test]
fn should_reject_new_name_with_close_brace() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "bad}name");
assert!(result.is_none(), "should return None for new_name with }}");
}
#[test]
fn should_reject_new_name_with_colon() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "bad:name");
assert!(result.is_none(), "should return None for new_name with :");
}
#[test]
fn should_reject_new_name_with_comma() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "bad,name");
assert!(result.is_none(), "should return None for new_name with ,");
}
#[test]
fn should_accept_new_name_with_hyphen() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "valid-name");
assert!(result.is_some(), "should accept new_name with hyphen");
}
#[test]
fn should_accept_new_name_with_underscore() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "valid_name");
assert!(result.is_some(), "should accept new_name with underscore");
}
#[test]
fn should_accept_new_name_with_dot() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "valid.name");
assert!(result.is_some(), "should accept new_name with dot");
}
#[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");
}
#[test]
fn should_reject_new_name_with_whitespace_only() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), " ");
assert!(
result.is_none(),
"should return None for whitespace-only new_name"
);
}
#[test]
fn should_reject_new_name_with_hash() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "name#comment");
assert!(result.is_none(), "should return None for new_name with #");
}
#[test]
fn should_reject_new_name_with_newline() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "name\n");
assert!(
result.is_none(),
"should return None for new_name with newline"
);
}
#[test]
fn should_reject_new_name_with_tab() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "name\t");
assert!(result.is_none(), "should return None for new_name with tab");
}
#[test]
fn should_reject_new_name_with_carriage_return() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "name\r");
assert!(
result.is_none(),
"should return None for new_name with carriage return"
);
}
#[test]
fn should_reject_new_name_with_ampersand() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "name&other");
assert!(result.is_none(), "should return None for new_name with &");
}
#[test]
fn should_reject_new_name_with_asterisk() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "name*other");
assert!(result.is_none(), "should return None for new_name with *");
}
#[test]
fn should_reject_new_name_with_exclamation() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "name!tag");
assert!(result.is_none(), "should return None for new_name with !");
}
#[test]
fn should_accept_new_name_starting_with_digit() {
let text = "key: &anchor value\n";
let uri = test_uri();
let result = rename(text, &uri, pos(0, 5), "123abc");
assert!(
result.is_some(),
"should accept new_name starting with digit"
);
}
#[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"
);
}
#[test]
fn should_return_none_for_cursor_at_exact_end_of_line() {
let text = "key: &anchor\n";
let result = prepare_rename(text, pos(0, 12));
assert!(
result.is_none(),
"should return None for cursor one past last char"
);
}
#[test]
fn should_handle_cursor_at_document_end() {
let text = "key: &anchor";
let result = prepare_rename(text, pos(0, 12));
assert!(
result.is_none(),
"should return None for cursor at/past end"
);
}
}