use std::collections::HashMap;
use rlsp_yaml_parser::node::{Document, Node};
use rlsp_yaml_parser::{LineIndex, Pos, Span};
use tower_lsp::lsp_types::{Position, Range, TextEdit, Url, WorkspaceEdit};
#[must_use]
pub fn prepare_rename(docs: &[Document<Span>], position: Position) -> Option<Range> {
let cursor = lsp_to_pos(position);
let (doc, idx) = containing_document(docs, cursor)?;
let (anchors, aliases) = collect_anchor_alias_entries(doc);
anchors
.iter()
.find(|(_, loc)| span_contains(*loc, cursor, idx))
.or_else(|| {
aliases
.iter()
.find(|(_, loc)| span_contains(*loc, cursor, idx))
})
.map(|(_, loc)| span_to_range(*loc, idx))
}
#[must_use]
pub fn rename(
docs: &[Document<Span>],
uri: &Url,
position: Position,
new_name: &str,
) -> Option<WorkspaceEdit> {
if !is_valid_anchor_name(new_name) {
return None;
}
let cursor = lsp_to_pos(position);
let (doc, idx) = containing_document(docs, cursor)?;
let (anchors, aliases) = collect_anchor_alias_entries(doc);
let name = anchors
.iter()
.find(|(_, loc)| span_contains(*loc, cursor, idx))
.map(|(n, _)| n.as_str())
.or_else(|| {
aliases
.iter()
.find(|(_, loc)| span_contains(*loc, cursor, idx))
.map(|(n, _)| n.as_str())
})?;
let anchor_edits = anchors
.iter()
.filter(|(n, _)| n == name)
.map(|(_, loc)| TextEdit {
range: span_to_range(*loc, idx),
new_text: format!("&{new_name}"),
});
let alias_edits = aliases
.iter()
.filter(|(n, _)| n == name)
.map(|(_, loc)| TextEdit {
range: span_to_range(*loc, idx),
new_text: format!("*{new_name}"),
});
let edits: Vec<TextEdit> = anchor_edits.chain(alias_edits).collect();
let mut changes = HashMap::new();
changes.insert(uri.clone(), edits);
Some(WorkspaceEdit {
changes: Some(changes),
..WorkspaceEdit::default()
})
}
fn is_valid_anchor_name(name: &str) -> bool {
!name.is_empty() && name.len() <= 256 && name.chars().all(is_anchor_name_char)
}
const fn is_anchor_name_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.'
}
type NamedSpans = Vec<(String, Span)>;
fn collect_anchor_alias_entries(doc: &Document<Span>) -> (NamedSpans, NamedSpans) {
let mut anchors = Vec::new();
let mut aliases = Vec::new();
collect_node(&doc.root, &mut anchors, &mut aliases);
(anchors, aliases)
}
fn collect_node(node: &Node<Span>, anchors: &mut NamedSpans, aliases: &mut NamedSpans) {
match node {
Node::Scalar { .. } | Node::Mapping { .. } | Node::Sequence { .. } => {
if let (Some(name), Some(loc)) = (node.anchor(), node.anchor_loc()) {
anchors.push((name.to_owned(), loc));
}
}
Node::Alias { name, loc, .. } => {
aliases.push((name.clone(), *loc));
}
}
match node {
Node::Mapping { entries, .. } => {
for (k, v) in entries {
collect_node(k, anchors, aliases);
collect_node(v, anchors, aliases);
}
}
Node::Sequence { items, .. } => {
for item in items {
collect_node(item, anchors, aliases);
}
}
Node::Scalar { .. } | Node::Alias { .. } => {}
}
}
fn containing_document(
docs: &[Document<Span>],
cursor: Pos,
) -> Option<(&Document<Span>, &LineIndex)> {
docs.iter().find_map(|doc| {
let idx = doc.line_index();
if span_contains(node_loc(&doc.root), cursor, idx) {
Some((doc, idx))
} else {
None
}
})
}
const fn node_loc(node: &Node<Span>) -> Span {
match node {
Node::Scalar { loc, .. }
| Node::Mapping { loc, .. }
| Node::Sequence { loc, .. }
| Node::Alias { loc, .. } => *loc,
}
}
fn span_contains(span: Span, cursor: Pos, idx: &LineIndex) -> bool {
let start = (
idx.line_column(span.start).0 as usize,
idx.line_column(span.start).1 as usize,
);
let end = (
idx.line_column(span.end).0 as usize,
idx.line_column(span.end).1 as usize,
);
let pos = (cursor.line, cursor.column);
pos >= start && pos < end
}
const fn lsp_to_pos(position: Position) -> Pos {
Pos {
byte_offset: 0,
line: position.line as usize + 1,
column: position.character as usize,
}
}
fn span_to_range(loc: Span, idx: &LineIndex) -> Range {
Range::new(
Position::new(
idx.line_column(loc.start).0.saturating_sub(1),
idx.line_column(loc.start).1,
),
Position::new(
idx.line_column(loc.end).0.saturating_sub(1),
idx.line_column(loc.end).1,
),
)
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
use crate::test_utils::{parse_docs, test_uri};
fn parse(yaml: &str) -> Vec<Document<Span>> {
parse_docs(yaml)
}
fn pos(line: u32, character: u32) -> Position {
Position::new(line, character)
}
#[test]
fn should_return_range_when_cursor_on_anchor() {
let docs = parse("key: &myanchor value\n");
let result = prepare_rename(&docs, 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 docs = parse("defaults: &defaults\n key: val\nproduction:\n <<: *defaults\n");
let result = prepare_rename(&docs, 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 docs = parse("key: &anchor value\n");
let result = prepare_rename(&docs, 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 prepare_rename_cursor_at_first_char_of_anchor() {
let docs = parse("x: &anchor\n");
let result = prepare_rename(&docs, pos(0, 3));
assert!(result.is_some());
}
#[test]
fn prepare_rename_cursor_one_past_anchor_token_returns_none() {
let docs = parse("x: &anchor\n");
let result = prepare_rename(&docs, pos(0, 10));
assert!(result.is_none());
}
#[test]
fn prepare_rename_cursor_in_middle_of_anchor_name_returns_full_range() {
let docs = parse("key: &longname value\n");
let result = prepare_rename(&docs, pos(0, 8));
let range = result.expect("should return a range");
assert_eq!(range.start.character, 5, "&longname starts at col 5");
assert_eq!(
range.end.character, 14,
"&longname ends at col 14 (& + 8 chars)"
);
}
#[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_not_in_token("key: &anchor\n", 0, 12)]
#[case::cursor_at_document_end_not_in_token("key: &anchor", 0, 12)]
fn prepare_rename_returns_none(#[case] text: &str, #[case] line: u32, #[case] character: u32) {
let docs = parse(text);
let result = prepare_rename(&docs, pos(line, character));
assert!(result.is_none());
}
#[test]
fn should_produce_correct_edit_ranges() {
let text = "key: &old value\nref: *old\n";
let uri = test_uri();
let docs = parse(text);
let result = rename(&docs, &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);
let anchor_edit = edits
.iter()
.find(|e| e.new_text == "&new")
.expect("anchor edit");
assert_eq!(anchor_edit.range.start.line, 0);
assert_eq!(anchor_edit.range.start.character, 5);
assert_eq!(anchor_edit.range.end.line, 0);
assert_eq!(anchor_edit.range.end.character, 9);
let alias_edit = edits
.iter()
.find(|e| e.new_text == "*new")
.expect("alias edit");
assert_eq!(alias_edit.range.start.line, 1);
assert_eq!(alias_edit.range.start.character, 5);
assert_eq!(alias_edit.range.end.line, 1);
assert_eq!(alias_edit.range.end.character, 9);
}
#[test]
fn rename_anchor_on_mapping_collection_edits_token_span_not_body() {
let text = "defaults: &d\n k: v\nref: *d\n";
let uri = test_uri();
let docs = parse(text);
let result = rename(&docs, &uri, pos(0, 10), "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);
let anchor_edit = edits
.iter()
.find(|e| e.new_text == "&renamed")
.expect("anchor edit");
assert_eq!(
anchor_edit.range.start.line, 0,
"anchor edit must be on line 0 (token), not the mapping body"
);
assert_eq!(anchor_edit.range.start.character, 10, "&d starts at col 10");
assert_eq!(anchor_edit.range.end.character, 12, "&d ends at col 12");
}
#[test]
fn rename_anchor_on_sequence_edits_token_span_not_body() {
let text = "items: &seq\n - a\n - b\nref: *seq\n";
let uri = test_uri();
let docs = parse(text);
let result = rename(&docs, &uri, pos(0, 7), "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);
let anchor_edit = edits
.iter()
.find(|e| e.new_text == "&renamed")
.expect("anchor edit");
assert_eq!(
anchor_edit.range.start.line, 0,
"anchor edit must be on line 0 (token)"
);
assert_eq!(anchor_edit.range.start.character, 7, "&seq starts at col 7");
assert_eq!(anchor_edit.range.end.character, 11, "&seq ends at col 11");
}
#[test]
fn rename_utf8_anchor_name_produces_correct_column_ranges() {
let text = "x: &résumé\nref: *résumé\n";
let uri = test_uri();
let docs = parse(text);
let result = rename(&docs, &uri, pos(0, 3), "newname");
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);
let anchor_edit = edits
.iter()
.find(|e| e.new_text == "&newname")
.expect("anchor edit");
assert_eq!(
anchor_edit.range.start.character, 3,
"anchor starts at col 3"
);
assert_eq!(
anchor_edit.range.end.character, 10,
"anchor ends at col 10 (3 + 7 codepoints)"
);
let alias_edit = edits
.iter()
.find(|e| e.new_text == "*newname")
.expect("alias edit");
assert_eq!(alias_edit.range.start.character, 5, "alias starts at col 5");
assert_eq!(
alias_edit.range.end.character, 12,
"alias ends at col 12 (5 + 7 codepoints)"
);
}
#[rstest]
#[case::newline("name\n")]
#[case::tab("name\t")]
#[case::carriage_return("name\r")]
fn rename_rejects_control_char_new_name(#[case] new_name: &str) {
let text = "key: &anchor value\n";
let uri = test_uri();
let docs = parse(text);
let result = rename(&docs, &uri, pos(0, 5), new_name);
assert!(result.is_none());
}
#[test]
fn should_accept_new_name_at_exactly_max_length() {
let text = "key: &anchor value\n";
let uri = test_uri();
let docs = parse(text);
let max_name = "a".repeat(256);
let result = rename(&docs, &uri, pos(0, 5), &max_name);
assert!(
result.is_some(),
"name of exactly 256 chars must be accepted"
);
}
}