feat: show diff hunk scope labels

Show per-side Tree-sitter scope paths inline and as sticky continuation labels.
Keep presentation rows out of source coordinates, selections, and edits.

Verified: cargo test (949 passed), cargo build, and real PTY smoke covering
split/unified, wrapping, both split axes, sticky scrolling, live rename and undo.

Size exception: 549 implementation lines added plus deleted, excluding tests
and docs. Keep syntax extraction and coordinate-safe rendering together as one
working behavior.
This commit is contained in:
Valdemar Lorenzen
2026-10-15 14:15:05 +02:00
parent 75dc31b66f
commit a43094dda3
5 changed files with 958 additions and 68 deletions
+463
View File
@@ -1,3 +1,4 @@
use std::cell::OnceCell;
use std::cmp::Reverse;
use std::collections::{BTreeSet, HashMap, VecDeque};
use std::path::{Path, PathBuf};
@@ -40,6 +41,25 @@ pub struct SyntaxSpan {
pub kind: SyntaxKind,
}
/// Innermost named declaration, with its full enclosing path and end-exclusive source rows.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct ScopeContext<'a> {
pub start_row: usize,
pub end_row: usize,
pub label: &'a str,
}
struct CachedScope {
start_row: usize,
end_row: usize,
label: String,
}
struct ScopeIndex {
declarations: Vec<CachedScope>,
rows: Vec<Option<usize>>,
}
/// Frontend-owned, per-buffer parser state. Revision and path identify unchanged input.
#[derive(Default)]
pub struct SyntaxCache {
@@ -58,6 +78,7 @@ pub struct SyntaxCache {
tree: Option<Tree>,
injection_parser: Parser,
injections: Vec<Layer>,
scopes: OnceCell<ScopeIndex>,
cursor: QueryCursor,
captures: Vec<Capture>,
highlights: Vec<Highlight>,
@@ -131,6 +152,7 @@ impl SyntaxCache {
visible.unwrap_or(std::slice::from_ref(&all_rows)),
final_newline && !lines.is_empty(),
);
let _ = self.scope_at(0);
}
fn update_rows(
@@ -196,6 +218,7 @@ impl SyntaxCache {
}
self.highlighted.fill(false);
self.context = None;
self.scopes.take();
}
if language_changed {
self.tree = None;
@@ -251,6 +274,7 @@ impl SyntaxCache {
self.tree = None;
self.injections.clear();
self.context = None;
self.scopes.take();
self.highlighted.fill(false);
for spans in self.lines.iter_mut().chain(&mut self.string_spans) {
spans.clear();
@@ -268,6 +292,52 @@ impl SyntaxCache {
self.lines.get(row).map_or(&[], Vec::as_slice)
}
/// Lazily index existing trees; subsequent lookups neither allocate nor reparse.
pub(crate) fn scope_at(&self, row: usize) -> Option<ScopeContext<'_>> {
let syntax = self.syntax?;
let tree = self.tree.as_ref()?;
if row >= self.lines.len()
|| (!has_scope_language(syntax.id)
&& !self
.injections
.iter()
.any(|layer| has_scope_language(layer.syntax.id)))
{
return None;
}
let index = self.scopes.get_or_init(|| {
let mut index = ScopeIndex {
declarations: Vec::new(),
rows: vec![None; self.lines.len()],
};
collect_scopes(
tree.root_node(),
syntax.id,
&self.source,
&[],
&mut index.declarations,
&mut index.rows,
);
for layer in &self.injections {
collect_scopes(
layer.tree.root_node(),
layer.syntax.id,
&self.source,
&layer.ranges,
&mut index.declarations,
&mut index.rows,
);
}
index
});
let scope = &index.declarations[index.rows[row]?];
Some(ScopeContext {
start_row: scope.start_row,
end_row: scope.end_row,
label: &scope.label,
})
}
/// String coverage before foreground overrides, including embedded-language highlighting.
/// An included line break has end_byte == line.len() + 1; foreground spans exclude it.
pub(crate) fn string_spans(&self, row: usize) -> &[SyntaxSpan] {
@@ -473,6 +543,244 @@ impl SyntaxCache {
}
}
fn has_scope_language(language: &str) -> bool {
matches!(
language,
"rust"
| "python"
| "ruby"
| "javascript"
| "typescript"
| "typescriptreact"
| "go"
| "c"
| "cpp"
| "shellscript"
| "zig"
)
}
fn collect_scopes(
root: Node<'_>,
language: &str,
source: &str,
ranges: &[Range],
scopes: &mut Vec<CachedScope>,
rows: &mut [Option<usize>],
) {
if !has_scope_language(language) {
return;
}
let mut cursor = root.walk();
let mut ancestors: Vec<(usize, usize)> = Vec::new();
loop {
let node = cursor.node();
while ancestors
.last()
.is_some_and(|(_, end)| *end <= node.start_byte())
{
ancestors.pop();
}
if let Some((extent, name)) =
scope_declaration(node, language, source).filter(|(extent, _)| {
ranges.is_empty()
|| ranges.iter().any(|range| {
range.start_byte <= extent.start_byte()
&& extent.end_byte() <= range.end_byte
})
})
{
let parent = ancestors
.last()
.map_or("", |(index, _)| scopes[*index].label.as_str());
let label = if parent.is_empty() {
name
} else {
format!("{parent} › {name}")
};
let start_row = extent.start_position().row;
let end = extent.end_position();
let end_row = end.row + usize::from(end.column > 0);
let index = scopes.len();
scopes.push(CachedScope {
start_row,
end_row,
label,
});
let end_row = end_row.min(rows.len());
rows[start_row.min(end_row)..end_row].fill(Some(index));
ancestors.push((index, node.end_byte()));
}
if cursor.goto_first_child() {
continue;
}
while !cursor.goto_next_sibling() {
if !cursor.goto_parent() {
return;
}
}
}
}
fn scope_text<'a>(node: Node<'_>, source: &'a str) -> Option<&'a str> {
(!node.has_error() && !node.is_missing() && node.kind() != "computed_property_name")
.then(|| source.get(node.byte_range()))
.flatten()
.filter(|name| !name.trim().is_empty())
}
fn scope_declaration<'a>(
node: Node<'a>,
language: &str,
source: &str,
) -> Option<(Node<'a>, String)> {
if !node.is_named() || node.is_missing() {
return None;
}
let field = |name| node.child_by_field_name(name);
let mut extent = node;
let name = match (language, node.kind()) {
("rust", "impl_item") => {
let target = scope_text(field("type")?, source)?;
let label = match field("trait").and_then(|node| scope_text(node, source)) {
Some(trait_name) => scope_label(&["impl", trait_name, "for", target]),
None => scope_label(&["impl", target]),
};
return Some((node, label));
}
(
"rust",
"function_item"
| "function_signature_item"
| "trait_item"
| "mod_item"
| "struct_item"
| "enum_item",
)
| ("python", "function_definition" | "class_definition")
| ("ruby", "class" | "module" | "method" | "singleton_method")
| (
"javascript" | "typescript" | "typescriptreact",
"function_declaration"
| "generator_function_declaration"
| "method_definition"
| "class_declaration"
| "class"
| "abstract_class_declaration"
| "interface_declaration"
| "internal_module",
)
| ("go", "function_declaration")
| (
"c" | "cpp",
"class_specifier"
| "struct_specifier"
| "union_specifier"
| "enum_specifier"
| "namespace_definition",
)
| ("shellscript", "function_definition")
| ("zig", "function_declaration") => field("name")?,
("go", "method_declaration") => {
let receiver = field("receiver")?
.named_child(0)?
.child_by_field_name("type")?;
let receiver = scope_text(receiver, source)?;
let name = scope_text(field("name")?, source)?;
return Some((node, scope_label(&[receiver, "›", name])));
}
("go", "type_spec")
if field("type")
.is_some_and(|node| matches!(node.kind(), "struct_type" | "interface_type")) =>
{
field("name")?
}
("c" | "cpp", "function_definition") => {
let mut declarator = field("declarator")?;
loop {
if matches!(
declarator.kind(),
"identifier"
| "field_identifier"
| "qualified_identifier"
| "operator_name"
| "destructor_name"
) {
break declarator;
}
declarator = declarator.child_by_field_name("declarator").or_else(|| {
matches!(
declarator.kind(),
"reference_declarator" | "parenthesized_declarator"
)
.then(|| declarator.named_child(0))
.flatten()
})?;
}
}
(
"javascript" | "typescript" | "typescriptreact",
"arrow_function" | "function_expression" | "generator_function",
) => {
if let Some(name) = field("name") {
name
} else {
extent = node.parent()?;
match extent.kind() {
"variable_declarator" => {
let name = extent.child_by_field_name("name")?;
(name.kind() == "identifier").then_some(name)?
}
"pair" => extent.child_by_field_name("key")?,
"field_definition" => extent.child_by_field_name("property")?,
"public_field_definition" => extent.child_by_field_name("name")?,
"assignment_expression" => {
let name = extent.child_by_field_name("left")?;
matches!(name.kind(), "identifier" | "member_expression").then_some(name)?
}
_ => return None,
}
}
}
(
"zig",
"struct_declaration" | "union_declaration" | "enum_declaration" | "opaque_declaration",
) => {
extent = node.parent()?;
if extent.kind() != "variable_declaration"
|| node.prev_sibling().is_none_or(|node| node.kind() != "=")
{
return None;
}
let mut cursor = extent.walk();
extent
.named_children(&mut cursor)
.find(|node| node.kind() == "identifier")?
}
_ => return None,
};
if let Some(parent) = node.parent()
&& parent.kind() == "decorated_definition"
{
extent = parent;
}
Some((extent, scope_label(&[scope_text(name, source)?])))
}
fn scope_label(parts: &[&str]) -> String {
let mut label = String::new();
for word in parts.iter().flat_map(|part| part.split_whitespace()) {
if !label.is_empty() {
label.push(' ');
}
label.extend(
word.chars()
.map(|ch| if ch.is_control() { '�' } else { ch }),
);
}
label
}
fn same_language(left: Option<&LanguageSpec>, right: Option<&LanguageSpec>) -> bool {
match (left, right) {
(Some(left), Some(right)) => std::ptr::eq(left, right),
@@ -1249,6 +1557,161 @@ mod tests {
source.split('\n').map(str::to_owned).collect()
}
#[test]
fn scopes_follow_real_declarations_and_each_sources_own_tree() {
let original = "impl State {\n fn old() {\n call();\n }\n}\n\nfn next() {\n call();\n}\n";
let before = cache(original, "old.rs");
let mut live = cache(&original.replace("old()", "renamed()"), "new.rs");
assert_eq!(
before.scope_at(2),
Some(ScopeContext {
start_row: 1,
end_row: 4,
label: "impl State › old",
})
);
assert_eq!(before.scope_at(3).unwrap().label, "impl State › old");
assert_eq!(before.scope_at(4).unwrap().label, "impl State");
assert_eq!(before.scope_at(5), None);
assert_eq!(
before.scope_at(6),
Some(ScopeContext {
start_row: 6,
end_row: 9,
label: "next",
})
);
assert_eq!(before.scope_at(9), None);
assert_eq!(before.scope_at(usize::MAX), None);
let changed = lines(&original.replace("old()", "updated()"));
live.update_visible(&changed, Some(Path::new("new.rs")), 1, &[2..3]);
assert_eq!(live.scope_at(2).unwrap().label, "impl State › updated");
assert_eq!(before.scope_at(2).unwrap().label, "impl State › old");
let counts = live.parse_counts();
live.update_visible(&changed, Some(Path::new("new.rs")), 2, &[6..9]);
assert_eq!(live.parse_counts(), counts);
live.update_visible(&changed, Some(Path::new("renamed.txt")), 2, &[0..1]);
assert_eq!(live.scope_at(2), None);
assert_eq!(before.scope_at(2).unwrap().label, "impl State › old");
let incomplete = cache("impl State {\n fn run() {\n let = ;\n }\n}", "a.rs");
assert_eq!(incomplete.scope_at(2).unwrap().label, "impl State › run");
let python = cache(
"class State:\n @decorator\n def run(self):\n work()\n\n def next(self):\n work()\n",
"a.py",
);
assert_eq!(
python.scope_at(1),
Some(ScopeContext {
start_row: 1,
end_row: 4,
label: "State › run",
})
);
assert_eq!(python.scope_at(4).unwrap().label, "State");
assert_eq!(python.scope_at(5).unwrap().label, "State › next");
assert_eq!(python.scope_at(7), None);
for (path, source, expected) in [
(
"a.rs",
"trait State {\n fn run(&self) {\n work();\n }\n}",
Some("State › run"),
),
(
"a.rb",
"class State\n def run\n work\n end\nend",
Some("State › run"),
),
(
"a.js",
"function outer() {\n function inner() {\n work();\n }\n}",
Some("outer › inner"),
),
(
"a.ts",
"class State {\n run = () => {\n work();\n };\n}",
Some("State › run"),
),
(
"a.tsx",
"const View = () => {\n work();\n return <main />;\n};",
Some("View"),
),
(
"a.js",
"const handlers = { run: () => {\n work();\n} };",
Some("run"),
),
(
"a.js",
"State.run = () => {\n work();\n};",
Some("State.run"),
),
(
"a.go",
"package main\nfunc (s *State) run() {\n work()\n}",
Some("*State › run"),
),
(
"a.go",
"package main\nfunc run() {\n work()\n}",
Some("run"),
),
(
"a.c",
"int run(void) {\n work();\n return 7;\n}",
Some("run"),
),
(
"a.cpp",
"class State {\n void run() {\n work();\n }\n};",
Some("State › run"),
),
(
"a.cpp",
"void State::run() {\n work();\n}",
Some("State::run"),
),
("a.sh", "run() {\n work\n}", Some("run")),
(
"a.zig",
"const State = struct {\n pub fn run() void {\n work();\n }\n};",
Some("State › run"),
),
("a.md", "```rust\nfn run() {\n work();\n}\n```", Some("run")),
(
"a.html",
"<script>\nfunction run() {\n work();\n}\n</script>",
Some("run"),
),
("a.js", "use(() => {\n work();\n});", None),
(
"a.js",
"const handlers = { [key]: () => {\n work();\n} };",
None,
),
("a.txt", "fn run() {\n work();\n}", None),
("a.unknown", "fn run() {\n work();\n}", None),
("a.json", "{\"work\": \"function run() {}\"}", None),
("a.yaml", "work: function run() {}", None),
] {
let syntax = cache(source, path);
let row = source[..source.find("work").unwrap()]
.bytes()
.filter(|byte| *byte == b'\n')
.count();
assert_eq!(
syntax.scope_at(row).map(|scope| scope.label),
expected,
"{path}: {source}"
);
if let Some(scope) = syntax.scope_at(row) {
assert!(scope.start_row <= row && row < scope.end_row, "{path}");
}
}
}
fn source_range(source: &str) -> Range {
Range {
start_byte: 0,