use std::panic::AssertUnwindSafe;
use std::path::Path;
use lsp_types::{Position, Range, SelectionRange};
use rowan::{TextRange, TextSize, TokenAtOffset};
use crate::incremental::Analysis;
use crate::parser::parse;
use crate::syntax::{SyntaxKind, SyntaxNode, SyntaxToken};
use crate::text::{LineIndex, PositionEncoding};
pub fn compute_selection_ranges(
text: &str,
positions: &[Position],
encoding: PositionEncoding,
) -> Vec<SelectionRange> {
let root = parse(text).cst;
selections_for_tree(&root, text, positions, encoding)
}
pub(crate) fn selection_ranges_via_db(
snapshot: &Analysis,
path: &Path,
text: &str,
positions: &[Position],
encoding: PositionEncoding,
) -> Vec<SelectionRange> {
let cached = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let file = snapshot.lookup_file(path)?;
if snapshot.file_text(file) != text {
return None;
}
let root = snapshot.parsed_tree(file);
Some(selections_for_tree(&root, text, positions, encoding))
}));
match cached {
Ok(Some(ranges)) => ranges,
Ok(None) | Err(_) => compute_selection_ranges(text, positions, encoding),
}
}
fn selections_for_tree(
root: &SyntaxNode,
text: &str,
positions: &[Position],
encoding: PositionEncoding,
) -> Vec<SelectionRange> {
let line_index = LineIndex::new(text);
positions
.iter()
.map(|&position| {
let offset = line_index.position_to_byte(position, encoding);
link_chain(&range_chain(root, offset), &line_index, encoding)
})
.collect()
}
fn range_chain(root: &SyntaxNode, offset: usize) -> Vec<TextRange> {
let token = match root.token_at_offset(TextSize::new(offset as u32)) {
TokenAtOffset::None => None,
TokenAtOffset::Single(token) => Some(token),
TokenAtOffset::Between(left, right) => Some(pick_boundary_token(left, right)),
};
let Some(token) = token else {
return vec![root.text_range()];
};
let mut chain = Vec::new();
if !matches!(token.kind(), SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE) {
chain.push(token.text_range());
}
for node in token.parent_ancestors() {
let range = node.text_range();
if chain.last() != Some(&range) {
chain.push(range);
}
}
chain
}
fn pick_boundary_token(left: SyntaxToken, right: SyntaxToken) -> SyntaxToken {
fn priority(kind: SyntaxKind) -> u8 {
match kind {
SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE => 0,
SyntaxKind::COMMENT | SyntaxKind::BLOCK_COMMENT => 1,
SyntaxKind::IDENT => 3,
_ => 2,
}
}
if priority(right.kind()) >= priority(left.kind()) {
right
} else {
left
}
}
fn link_chain(
chain: &[TextRange],
line_index: &LineIndex<'_>,
encoding: PositionEncoding,
) -> SelectionRange {
let mut linked: Option<SelectionRange> = None;
for &range in chain.iter().rev() {
linked = Some(SelectionRange {
range: Range::new(
line_index.byte_to_position(range.start().into(), encoding),
line_index.byte_to_position(range.end().into(), encoding),
),
parent: linked.map(Box::new),
});
}
linked.expect("range_chain is never empty")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::incremental::IncrementalDatabase;
#[test]
fn selections_via_db_match_compute_and_fall_back() {
let path = Path::new("/work/a.jl");
let buffer = "function f(x)\n x + 1\nend\n";
let positions = [Position::new(1, 4)];
let expected = compute_selection_ranges(buffer, &positions, PositionEncoding::Utf8);
assert_eq!(expected.len(), 1, "fixture must yield a chain");
let mut db = IncrementalDatabase::default();
db.upsert_file(path, buffer.to_string());
assert_eq!(
selection_ranges_via_db(
&db.snapshot(),
path,
buffer,
&positions,
PositionEncoding::Utf8
),
expected,
"cached-tree chains must match the re-parse path"
);
let mut stale = IncrementalDatabase::default();
stale.upsert_file(path, "y = 1\n".to_string());
assert_eq!(
selection_ranges_via_db(
&stale.snapshot(),
path,
buffer,
&positions,
PositionEncoding::Utf8
),
expected,
"version skew must fall back to the buffer text"
);
let empty = IncrementalDatabase::default();
assert_eq!(
selection_ranges_via_db(
&empty.snapshot(),
path,
buffer,
&positions,
PositionEncoding::Utf8
),
expected,
"untracked path must fall back to the buffer text"
);
}
}