use rlsp_yaml_parser::Span;
use rlsp_yaml_parser::node::{Document, Node};
use tower_lsp::lsp_types::{Position, Range, SelectionRange};
#[must_use]
pub fn selection_ranges(docs: &[Document<Span>], positions: &[Position]) -> Vec<SelectionRange> {
if docs.is_empty() || positions.is_empty() {
return Vec::new();
}
positions
.iter()
.filter_map(|pos| selection_range_for_position(docs, *pos))
.collect()
}
fn selection_range_for_position(
docs: &[Document<Span>],
position: Position,
) -> Option<SelectionRange> {
let line = position.line as usize;
let col = position.character as usize;
let doc = docs.iter().find(|d| {
let loc = node_span(&d.root);
let start_line_0 = loc.start.line.saturating_sub(1);
let end_line_0 = loc.end.line.saturating_sub(1);
line >= start_line_0 && line <= end_line_0
})?;
let mut ancestor_spans: Vec<Span> = Vec::new();
collect_ancestor_spans(&doc.root, line, col, &mut ancestor_spans);
if ancestor_spans.is_empty() {
return None;
}
let doc_root_span = node_span(&doc.root);
let doc_root = span_to_lsp_range(&doc_root_span);
let mut current: Option<Box<SelectionRange>> = Some(Box::new(SelectionRange {
range: doc_root,
parent: None,
}));
for span in ancestor_spans.iter().rev() {
let range = span_to_lsp_range(span);
if range == doc_root {
continue;
}
let sr = SelectionRange {
range,
parent: current,
};
current = Some(Box::new(sr));
}
current.map(|b| *b)
}
const fn node_span(node: &Node<Span>) -> Span {
match node {
Node::Scalar { loc, .. }
| Node::Mapping { loc, .. }
| Node::Sequence { loc, .. }
| Node::Alias { loc, .. } => *loc,
}
}
fn collect_ancestor_spans(
node: &Node<Span>,
line: usize,
col: usize,
ancestor_spans: &mut Vec<Span>,
) {
let depth_before = ancestor_spans.len();
match node {
Node::Mapping { entries, .. } => {
for (key, value) in entries {
let key_span = node_span(key);
let key_line_0 = key_span.start.line.saturating_sub(1);
let val_end = node_span(value).end;
let entry_end_line_0 = val_end.line.saturating_sub(1);
if line < key_line_0 || line > entry_end_line_0 {
continue;
}
collect_ancestor_spans(value, line, col, ancestor_spans);
if ancestor_spans.len() > depth_before {
ancestor_spans.push(Span {
start: key_span.start,
end: val_end,
});
break;
}
if key_line_0 == line && col >= key_span.start.column && col <= key_span.end.column
{
ancestor_spans.push(key_span);
ancestor_spans.push(Span {
start: key_span.start,
end: val_end,
});
break;
}
if key_line_0 == line {
ancestor_spans.push(Span {
start: key_span.start,
end: val_end,
});
break;
}
}
}
Node::Sequence { items, .. } => {
for item in items {
let item_span = node_span(item);
let start_line_0 = item_span.start.line.saturating_sub(1);
let end_line_0 = item_span.end.line.saturating_sub(1);
if line < start_line_0 || line > end_line_0 {
continue;
}
collect_ancestor_spans(item, line, col, ancestor_spans);
if ancestor_spans.len() > depth_before {
ancestor_spans.push(item_span);
break;
}
if col >= item_span.start.column {
ancestor_spans.push(item_span);
break;
}
}
}
Node::Scalar { loc, .. } | Node::Alias { loc, .. } => {
if loc.start.line > 0 {
let start_line_0 = loc.start.line.saturating_sub(1);
let end_line_0 = loc.end.line.saturating_sub(1);
if line >= start_line_0 && line <= end_line_0 && col >= loc.start.column {
ancestor_spans.push(*loc);
}
}
}
}
}
fn span_to_lsp_range(span: &Span) -> Range {
#[expect(
clippy::cast_possible_truncation,
reason = "LSP line/col are u32; always fits"
)]
let start_line = span.start.line.saturating_sub(1) as u32;
#[expect(
clippy::cast_possible_truncation,
reason = "LSP line/col are u32; always fits"
)]
let start_col = span.start.column as u32;
#[expect(
clippy::cast_possible_truncation,
reason = "LSP line/col are u32; always fits"
)]
let end_line = span.end.line.saturating_sub(1) as u32;
#[expect(
clippy::cast_possible_truncation,
reason = "LSP line/col are u32; always fits"
)]
let end_col = span.end.column as u32;
Range::new(
Position::new(start_line, start_col),
Position::new(end_line, end_col),
)
}
#[cfg(test)]
#[expect(
clippy::indexing_slicing,
clippy::expect_used,
clippy::unwrap_used,
clippy::cast_possible_truncation,
reason = "test code"
)]
mod tests {
use std::fmt::Write as _;
use rstest::rstest;
use super::*;
use crate::test_utils::parse_docs as parse_docs_inner;
#[expect(clippy::unnecessary_wraps, reason = "callers use Option API")]
fn parse_docs(text: &str) -> Option<Vec<Document<Span>>> {
Some(parse_docs_inner(text))
}
fn pos(line: u32, character: u32) -> Position {
Position::new(line, character)
}
#[test]
fn should_return_value_range_expanding_to_key_value_then_document() {
let text = "key: value\n";
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[pos(0, 6)]);
assert_eq!(
result.len(),
1,
"should return one SelectionRange per position"
);
let sr = &result[0];
assert_eq!(sr.range.start.line, 0);
assert!(
sr.parent.is_some(),
"should have a parent range (key-value pair)"
);
let parent = sr.parent.as_ref().expect("parent");
assert_eq!(parent.range.start.line, 0);
assert!(
parent.parent.is_some(),
"should have a grandparent range (document root)"
);
}
#[test]
fn should_return_key_range_expanding_to_key_value_then_document() {
let text = "key: value\n";
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[pos(0, 1)]);
assert_eq!(result.len(), 1);
let sr = &result[0];
assert_eq!(sr.range.start.line, 0);
assert!(sr.parent.is_some(), "should have parent (key-value pair)");
let parent = sr.parent.as_ref().expect("parent");
assert_eq!(parent.range.start.line, 0);
assert!(
parent.parent.is_some(),
"should have grandparent (document root)"
);
}
#[test]
fn should_return_sequence_item_expanding_to_sequence_then_document() {
let text = "items:\n - one\n - two\n";
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[pos(1, 5)]);
assert_eq!(result.len(), 1);
let sr = &result[0];
assert_eq!(sr.range.start.line, 1);
assert!(sr.parent.is_some(), "should have parent (sequence)");
assert!(
sr.parent.as_ref().expect("parent").parent.is_some(),
"should have grandparent (document root)"
);
}
#[test]
fn should_handle_nested_mapping() {
let text = "server:\n host: localhost\n";
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[pos(1, 8)]);
assert_eq!(result.len(), 1);
let sr = &result[0];
assert_eq!(sr.range.start.line, 1);
assert!(sr.parent.is_some(), "should have parent (host: localhost)");
let parent = sr.parent.as_ref().expect("parent");
assert!(
parent.parent.is_some(),
"should have grandparent (server mapping = doc root)"
);
}
#[test]
fn should_handle_multiple_positions() {
let text = "name: Alice\nage: 30\n";
let docs = parse_docs(text);
let positions = [pos(0, 6), pos(1, 5)];
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &positions);
assert_eq!(
result.len(),
2,
"should return one SelectionRange per position"
);
assert_eq!(result[0].range.start.line, 0);
assert_eq!(result[1].range.start.line, 1);
}
#[test]
fn should_handle_sequence_of_mappings() {
let text = "users:\n - name: Alice\n age: 30\n - name: Bob\n age: 25\n";
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[pos(1, 10)]);
assert_eq!(result.len(), 1);
let sr = &result[0];
assert_eq!(sr.range.start.line, 1);
assert!(sr.parent.is_some(), "should have parent (name: Alice)");
}
#[test]
fn should_scope_selection_to_current_document_in_multi_doc_yaml() {
let text = "doc1key: value1\n---\ndoc2key: value2\n";
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[pos(2, 0)]);
assert_eq!(result.len(), 1);
let sr = &result[0];
let mut outermost = sr;
while let Some(ref p) = outermost.parent {
outermost = p;
}
assert!(
outermost.range.start.line >= 2,
"outermost range should be scoped to the second document (start >= line 2), \
got start line {}",
outermost.range.start.line
);
}
#[test]
fn should_handle_first_document_in_multi_doc_yaml() {
let text = "doc1key: value1\n---\ndoc2key: value2\n";
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[pos(0, 0)]);
assert_eq!(result.len(), 1);
let sr = &result[0];
let mut outermost = sr;
while let Some(ref p) = outermost.parent {
outermost = p;
}
assert!(
outermost.range.end.line <= 1,
"outermost range should not cross the --- separator (end line must be <= 1), \
got end line {}",
outermost.range.end.line
);
}
#[test]
fn should_return_empty_for_empty_document() {
let result = selection_ranges(&[], &[pos(0, 0)]);
assert!(
result.is_empty(),
"should return empty Vec for empty document"
);
}
#[test]
fn should_handle_empty_positions_slice() {
let text = "key: value\n";
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[]);
assert!(
result.is_empty(),
"should return empty Vec for empty positions slice"
);
}
#[test]
fn should_return_empty_for_cursor_on_dot_dot_dot_line() {
let text = "key: value\n...\nother: val\n";
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[pos(1, 0)]);
assert!(
result.is_empty(),
"cursor on '...' line should produce no selection range"
);
}
#[rstest]
#[case::position_beyond_document("key: value\n", pos(99, 0))]
#[case::position_beyond_line_length("key: value\n", pos(0, 999))]
#[case::cursor_on_document_separator("a: 1\n---\nb: 2\n", pos(1, 0))]
#[case::cursor_on_comment_only_document("# just a comment\n", pos(0, 2))]
#[case::cursor_on_comment_line_in_middle(
"key: value\n# this is a comment\nother: data\n",
pos(1, 5)
)]
#[case::sequence_value_in_mapping("items:\n - alpha\n - beta\n - gamma\n", pos(1, 4))]
#[case::deeply_nested_sequence_value("data:\n - nested:\n - deep_value\n", pos(2, 10))]
#[case::key_at_column_zero("empty:\nother: val\n", pos(0, 0))]
#[case::alias_in_sequence("base: &anchor value\ncopy:\n - *anchor\n", pos(2, 4))]
fn selection_ranges_does_not_panic(#[case] text: &str, #[case] position: Position) {
let docs = parse_docs(text);
let _ = selection_ranges(docs.as_deref().unwrap_or(&[]), &[position]);
}
#[test]
fn should_not_panic_on_deeply_nested_yaml_ast_walk() {
let mut text = String::new();
for i in 0..64usize {
let indent = " ".repeat(i);
writeln!(text, "{indent}l{i}:").unwrap();
}
let leaf_indent = " ".repeat(64);
writeln!(text, "{leaf_indent}leaf: deep").unwrap();
let docs = parse_docs(&text);
let result = selection_ranges(
docs.as_deref().unwrap_or(&[]),
&[pos(64, leaf_indent.len() as u32)],
);
let mut depth = 0usize;
if let Some(sr) = result.first() {
let mut current = sr;
while let Some(ref p) = current.parent {
depth += 1;
current = p;
assert!(
depth <= 200,
"parent chain should be bounded (not infinite)"
);
}
}
}
#[test]
fn should_scope_document_end_at_dot_dot_dot_terminator() {
let text = "key: value\n...\nafter: end\n";
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[pos(0, 5)]);
if let Some(sr) = result.first() {
let mut outermost = sr;
while let Some(ref p) = outermost.parent {
outermost = p;
}
assert!(
outermost.range.end.line <= 1,
"document root should end at or before '...', got end line {}",
outermost.range.end.line
);
}
}
#[test]
fn should_handle_single_line_document() {
let text = "key: value";
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[pos(0, 5)]);
if let Some(sr) = result.first() {
let mut outermost = sr;
while let Some(ref p) = outermost.parent {
outermost = p;
}
assert_eq!(outermost.range.start.line, outermost.range.end.line);
}
}
#[test]
fn should_correctly_find_document_for_line_after_separator() {
let text = "a: 1\n---\nb: 2\n---\nc: 3\n";
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[pos(4, 3)]);
if let Some(sr) = result.first() {
let mut outermost = sr;
while let Some(ref p) = outermost.parent {
outermost = p;
}
assert!(
outermost.range.start.line >= 4,
"outermost range should be scoped to third document, got start line {}",
outermost.range.start.line
);
}
}
#[test]
fn nested_mapping_value_selection_has_correct_line_bounds() {
let text = "server:\n host: localhost\n port: 8080\n";
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[pos(2, 8)]);
assert_eq!(result.len(), 1);
let sr = &result[0];
assert_eq!(
sr.range.start.line, 2,
"innermost should be on line 2 (port value)"
);
let parent = sr
.parent
.as_ref()
.expect("should have parent (port: 8080 entry)");
assert_eq!(
parent.range.start.line, 2,
"entry range should start on line 2"
);
let grandparent = parent
.parent
.as_ref()
.expect("should have grandparent (server mapping)");
assert!(
grandparent.range.start.line <= 1,
"server mapping should start at line 0 or 1, got {}",
grandparent.range.start.line
);
}
#[test]
fn sequence_item_selection_has_correct_line_bounds() {
let text = "items:\n - alpha\n - beta\n - gamma\n";
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[pos(3, 5)]);
assert_eq!(result.len(), 1);
let sr = &result[0];
assert_eq!(
sr.range.start.line, 3,
"innermost should be on line 3 (gamma)"
);
assert!(
sr.parent.is_some(),
"should have parent covering sequence items"
);
assert!(
sr.parent.as_ref().expect("parent").parent.is_some(),
"should have at least three levels of parent chain"
);
}
#[test]
fn cursor_on_key_of_nested_mapping_expands_correctly() {
let text = "outer:\n inner: leaf\n";
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[pos(1, 2)]);
assert_eq!(result.len(), 1);
let sr = &result[0];
assert_eq!(sr.range.start.line, 1, "key span should be on line 1");
let parent = sr
.parent
.as_ref()
.expect("should have parent (inner: leaf entry)");
assert_eq!(
parent.range.start.line, 1,
"entry range should start at line 1"
);
let grandparent = parent.parent.as_ref().expect("should have grandparent");
assert!(
grandparent.range.start.line <= 1,
"outer mapping parent should start at line 0 or 1, got {}",
grandparent.range.start.line
);
}
#[test]
fn deeply_nested_sequence_selection_chain_depth() {
let text = "list:\n - nested:\n - leaf\n";
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[pos(2, 8)]);
assert_eq!(result.len(), 1);
let sr = &result[0];
let mut depth = 1usize;
let mut current = sr;
while let Some(ref p) = current.parent {
depth += 1;
current = p;
}
assert!(
depth >= 4,
"expected at least 4 levels in selection chain, got {depth}"
);
}
#[test]
fn regression_value_range_start_line_is_zero_for_top_level_key() {
let text = "key: value\n";
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[pos(0, 6)]);
assert_eq!(result.len(), 1);
let sr = &result[0];
assert_eq!(sr.range.start.line, 0, "value range start should be line 0");
assert_eq!(sr.range.end.line, 0, "value range end should be line 0");
let parent = sr
.parent
.as_ref()
.expect("should have parent (key-value entry)");
assert_eq!(
parent.range.start.line, 0,
"entry range start should be line 0"
);
assert_eq!(parent.range.end.line, 0, "entry range end should be line 0");
}
#[test]
fn regression_nested_mapping_host_line_is_one() {
let text = "server:\n host: localhost\n";
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[pos(1, 8)]);
assert_eq!(result.len(), 1);
let sr = &result[0];
assert_eq!(
sr.range.start.line, 1,
"value (localhost) should be on line 1"
);
assert_eq!(sr.range.end.line, 1, "value end should also be line 1");
let parent = sr
.parent
.as_ref()
.expect("should have parent (host: localhost)");
assert_eq!(parent.range.start.line, 1, "entry should start on line 1");
let grandparent = parent
.parent
.as_ref()
.expect("should have grandparent (server mapping = doc root)");
assert_eq!(
grandparent.range.start.line, 0,
"server mapping should start at line 0 (has real span now)"
);
}
#[rstest]
#[case::key_expands_to_entry_to_mapping_to_root(
"server:\n host: localhost\n port: 8080\n",
pos(1, 2)
)]
#[case::nested_leaf_scalar_produces_full_chain(
"outer:\n inner:\n leaf: deep\n",
pos(2, 10)
)]
#[case::multi_doc_cursor_in_doc2_excludes_doc1_ranges(
"doc1key: value1\n---\ndoc2key: value2\n",
pos(2, 0)
)]
#[case::comment_line_returns_no_selection_range(
"key: value\n# a comment\nother: data\n",
pos(1, 5)
)]
fn selection_ranges_regression(#[case] text: &str, #[case] position: Position) {
let docs = parse_docs(text);
let result = selection_ranges(docs.as_deref().unwrap_or(&[]), &[position]);
match position {
p if p == pos(1, 2) && text.starts_with("server:") => {
assert_eq!(result.len(), 1, "case (a): should return one range");
let sr = &result[0];
assert_eq!(sr.range.start.line, 1, "case (a): innermost on line 1");
let mut outermost = sr;
let mut depth = 1usize;
while let Some(ref p) = outermost.parent {
depth += 1;
outermost = p;
}
assert!(depth >= 3, "case (a): expected >= 3 levels, got {depth}");
assert_eq!(
outermost.range.start.line, 0,
"case (a): outermost (doc root) starts at line 0"
);
}
p if p == pos(2, 10) => {
assert_eq!(result.len(), 1, "case (b): should return one range");
let sr = &result[0];
assert_eq!(sr.range.start.line, 2, "case (b): innermost on line 2");
let mut depth = 1usize;
let mut current = sr;
while let Some(ref p) = current.parent {
depth += 1;
current = p;
}
assert!(depth >= 4, "case (b): expected >= 4 levels, got {depth}");
}
p if p == pos(2, 0) && text.starts_with("doc1key") => {
assert_eq!(result.len(), 1, "case (c): should return one range");
let sr = &result[0];
let mut outermost = sr;
while let Some(ref p) = outermost.parent {
outermost = p;
}
assert!(
outermost.range.start.line >= 2,
"case (c): outermost start must be >= 2 (doc 2), got {}",
outermost.range.start.line
);
assert!(
outermost.range.end.line >= 2,
"case (c): outermost end must be >= 2 (doc 2), got {}",
outermost.range.end.line
);
}
p if p == pos(1, 5) => {
assert!(
result.is_empty(),
"case (d): comment line should return empty"
);
}
_ => {}
}
}
}