Add experimental LSP-based context retrieval system for edit prediction (#44036)

To do

* [x] Default to no context retrieval. Allow opting in to LSP-based
retrieval via a setting (for users in `zeta2` feature flag)
* [x] Feed this context to models when enabled
* [x] Make the zeta2 context view work well with LSP retrieval
* [x] Add a UI for the setting (for feature-flagged users)
* [x] Ensure Zeta CLI `context` command is usable

---

* [ ] Filter out LSP definitions that are too large / entire files (e.g.
modules)
* [ ] Introduce timeouts
* [ ] Test with other LSPs
* [ ] Figure out hangs

Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>
Co-authored-by: Agus Zubiaga <agus@zed.dev>
This commit is contained in:
Max Brunsfeld
2025-12-04 12:48:39 -08:00
committed by GitHub
co-authored by Ben Kunkle Agus Zubiaga
parent cd8679e81a
commit 76167109db
31 changed files with 2478 additions and 1337 deletions
@@ -0,0 +1,42 @@
[package]
name = "edit_prediction_context2"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
license = "GPL-3.0-or-later"
[lints]
workspace = true
[lib]
path = "src/edit_prediction_context2.rs"
[dependencies]
parking_lot.workspace = true
anyhow.workspace = true
collections.workspace = true
futures.workspace = true
gpui.workspace = true
language.workspace = true
lsp.workspace = true
project.workspace = true
log.workspace = true
serde.workspace = true
smallvec.workspace = true
tree-sitter.workspace = true
util.workspace = true
[dev-dependencies]
env_logger.workspace = true
indoc.workspace = true
futures.workspace = true
gpui = { workspace = true, features = ["test-support"] }
language = { workspace = true, features = ["test-support"] }
lsp = { workspace = true, features = ["test-support"] }
pretty_assertions.workspace = true
project = {workspace= true, features = ["test-support"]}
serde_json.workspace = true
settings = {workspace= true, features = ["test-support"]}
text = { workspace = true, features = ["test-support"] }
util = { workspace = true, features = ["test-support"] }
zlog.workspace = true
+1
View File
@@ -0,0 +1 @@
../../LICENSE-GPL
@@ -0,0 +1,324 @@
use crate::RelatedExcerpt;
use language::{BufferSnapshot, OffsetRangeExt as _, Point};
use std::ops::Range;
#[cfg(not(test))]
const MAX_OUTLINE_ITEM_BODY_SIZE: usize = 512;
#[cfg(test)]
const MAX_OUTLINE_ITEM_BODY_SIZE: usize = 24;
pub fn assemble_excerpts(
buffer: &BufferSnapshot,
mut input_ranges: Vec<Range<Point>>,
) -> Vec<RelatedExcerpt> {
merge_ranges(&mut input_ranges);
let mut outline_ranges = Vec::new();
let outline_items = buffer.outline_items_as_points_containing(0..buffer.len(), false, None);
let mut outline_ix = 0;
for input_range in &mut input_ranges {
*input_range = clip_range_to_lines(input_range, false, buffer);
while let Some(outline_item) = outline_items.get(outline_ix) {
let item_range = clip_range_to_lines(&outline_item.range, false, buffer);
if item_range.start > input_range.start {
break;
}
if item_range.end > input_range.start {
let body_range = outline_item
.body_range(buffer)
.map(|body| clip_range_to_lines(&body, true, buffer))
.filter(|body_range| {
body_range.to_offset(buffer).len() > MAX_OUTLINE_ITEM_BODY_SIZE
});
add_outline_item(
item_range.clone(),
body_range.clone(),
buffer,
&mut outline_ranges,
);
if let Some(body_range) = body_range
&& input_range.start < body_range.start
{
let mut child_outline_ix = outline_ix + 1;
while let Some(next_outline_item) = outline_items.get(child_outline_ix) {
if next_outline_item.range.end > body_range.end {
break;
}
if next_outline_item.depth == outline_item.depth + 1 {
let next_item_range =
clip_range_to_lines(&next_outline_item.range, false, buffer);
add_outline_item(
next_item_range,
next_outline_item
.body_range(buffer)
.map(|body| clip_range_to_lines(&body, true, buffer)),
buffer,
&mut outline_ranges,
);
child_outline_ix += 1;
}
}
}
}
outline_ix += 1;
}
}
input_ranges.extend_from_slice(&outline_ranges);
merge_ranges(&mut input_ranges);
input_ranges
.into_iter()
.map(|range| {
let offset_range = range.to_offset(buffer);
RelatedExcerpt {
point_range: range,
anchor_range: buffer.anchor_before(offset_range.start)
..buffer.anchor_after(offset_range.end),
text: buffer.as_rope().slice(offset_range),
}
})
.collect()
}
fn clip_range_to_lines(
range: &Range<Point>,
inward: bool,
buffer: &BufferSnapshot,
) -> Range<Point> {
let mut range = range.clone();
if inward {
if range.start.column > 0 {
range.start.column = buffer.line_len(range.start.row);
}
range.end.column = 0;
} else {
range.start.column = 0;
if range.end.column > 0 {
range.end.column = buffer.line_len(range.end.row);
}
}
range
}
fn add_outline_item(
mut item_range: Range<Point>,
body_range: Option<Range<Point>>,
buffer: &BufferSnapshot,
outline_ranges: &mut Vec<Range<Point>>,
) {
if let Some(mut body_range) = body_range {
if body_range.start.column > 0 {
body_range.start.column = buffer.line_len(body_range.start.row);
}
body_range.end.column = 0;
let head_range = item_range.start..body_range.start;
if head_range.start < head_range.end {
outline_ranges.push(head_range);
}
let tail_range = body_range.end..item_range.end;
if tail_range.start < tail_range.end {
outline_ranges.push(tail_range);
}
} else {
item_range.start.column = 0;
item_range.end.column = buffer.line_len(item_range.end.row);
outline_ranges.push(item_range);
}
}
pub fn merge_ranges(ranges: &mut Vec<Range<Point>>) {
ranges.sort_unstable_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end)));
let mut index = 1;
while index < ranges.len() {
let mut prev_range_end = ranges[index - 1].end;
if prev_range_end.column > 0 {
prev_range_end += Point::new(1, 0);
}
if (prev_range_end + Point::new(1, 0))
.cmp(&ranges[index].start)
.is_ge()
{
let removed = ranges.remove(index);
if removed.end.cmp(&ranges[index - 1].end).is_gt() {
ranges[index - 1].end = removed.end;
}
} else {
index += 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use gpui::{TestAppContext, prelude::*};
use indoc::indoc;
use language::{Buffer, Language, LanguageConfig, LanguageMatcher, OffsetRangeExt};
use pretty_assertions::assert_eq;
use std::{fmt::Write as _, sync::Arc};
use util::test::marked_text_ranges;
#[gpui::test]
fn test_rust(cx: &mut TestAppContext) {
let table = [
(
indoc! {r#"
struct User {
first_name: String,
«last_name»: String,
age: u32,
email: String,
create_at: Instant,
}
impl User {
pub fn first_name(&self) -> String {
self.first_name.clone()
}
pub fn full_name(&self) -> String {
« format!("{} {}", self.first_name, self.last_name)
» }
}
"#},
indoc! {r#"
struct User {
first_name: String,
last_name: String,
}
impl User {
pub fn full_name(&self) -> String {
format!("{} {}", self.first_name, self.last_name)
}
}
"#},
),
(
indoc! {r#"
struct «User» {
first_name: String,
last_name: String,
age: u32,
}
impl User {
// methods
}
"#
},
indoc! {r#"
struct User {
first_name: String,
last_name: String,
age: u32,
}
"#},
),
(
indoc! {r#"
trait «FooProvider» {
const NAME: &'static str;
fn provide_foo(&self, id: usize) -> Foo;
fn provide_foo_batched(&self, ids: &[usize]) -> Vec<Foo> {
ids.iter()
.map(|id| self.provide_foo(*id))
.collect()
}
fn sync(&self);
}
"#
},
indoc! {r#"
trait FooProvider {
const NAME: &'static str;
fn provide_foo(&self, id: usize) -> Foo;
fn provide_foo_batched(&self, ids: &[usize]) -> Vec<Foo> {
}
fn sync(&self);
}
"#},
),
];
for (input, expected_output) in table {
let (input, ranges) = marked_text_ranges(&input, false);
let buffer =
cx.new(|cx| Buffer::local(input, cx).with_language(Arc::new(rust_lang()), cx));
buffer.read_with(cx, |buffer, _cx| {
let ranges: Vec<Range<Point>> = ranges
.into_iter()
.map(|range| range.to_point(&buffer))
.collect();
let excerpts = assemble_excerpts(&buffer.snapshot(), ranges);
let output = format_excerpts(buffer, &excerpts);
assert_eq!(output, expected_output);
});
}
}
fn format_excerpts(buffer: &Buffer, excerpts: &[RelatedExcerpt]) -> String {
let mut output = String::new();
let file_line_count = buffer.max_point().row;
let mut current_row = 0;
for excerpt in excerpts {
if excerpt.text.is_empty() {
continue;
}
if current_row < excerpt.point_range.start.row {
writeln!(&mut output, "").unwrap();
}
current_row = excerpt.point_range.start.row;
for line in excerpt.text.to_string().lines() {
output.push_str(line);
output.push('\n');
current_row += 1;
}
}
if current_row < file_line_count {
writeln!(&mut output, "").unwrap();
}
output
}
fn rust_lang() -> Language {
Language::new(
LanguageConfig {
name: "Rust".into(),
matcher: LanguageMatcher {
path_suffixes: vec!["rs".to_string()],
..Default::default()
},
..Default::default()
},
Some(language::tree_sitter_rust::LANGUAGE.into()),
)
.with_outline_query(include_str!("../../languages/src/rust/outline.scm"))
.unwrap()
}
}
@@ -0,0 +1,465 @@
use crate::assemble_excerpts::assemble_excerpts;
use anyhow::Result;
use collections::HashMap;
use futures::{FutureExt, StreamExt as _, channel::mpsc, future};
use gpui::{App, AppContext, AsyncApp, Context, Entity, EventEmitter, Task, WeakEntity};
use language::{Anchor, Buffer, BufferSnapshot, OffsetRangeExt as _, Point, Rope, ToOffset as _};
use project::{LocationLink, Project, ProjectPath};
use serde::{Serialize, Serializer};
use smallvec::SmallVec;
use std::{
collections::hash_map,
ops::Range,
sync::Arc,
time::{Duration, Instant},
};
use util::{RangeExt as _, ResultExt};
mod assemble_excerpts;
#[cfg(test)]
mod edit_prediction_context_tests;
#[cfg(test)]
mod fake_definition_lsp;
pub struct RelatedExcerptStore {
project: WeakEntity<Project>,
related_files: Vec<RelatedFile>,
cache: HashMap<Identifier, Arc<CacheEntry>>,
update_tx: mpsc::UnboundedSender<(Entity<Buffer>, Anchor)>,
}
pub enum RelatedExcerptStoreEvent {
StartedRefresh,
FinishedRefresh {
cache_hit_count: usize,
cache_miss_count: usize,
mean_definition_latency: Duration,
max_definition_latency: Duration,
},
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct Identifier {
pub name: String,
pub range: Range<Anchor>,
}
enum DefinitionTask {
CacheHit(Arc<CacheEntry>),
CacheMiss(Task<Result<Option<Vec<LocationLink>>>>),
}
#[derive(Debug)]
struct CacheEntry {
definitions: SmallVec<[CachedDefinition; 1]>,
}
#[derive(Clone, Debug)]
struct CachedDefinition {
path: ProjectPath,
buffer: Entity<Buffer>,
anchor_range: Range<Anchor>,
}
#[derive(Clone, Debug, Serialize)]
pub struct RelatedFile {
#[serde(serialize_with = "serialize_project_path")]
pub path: ProjectPath,
#[serde(skip)]
pub buffer: WeakEntity<Buffer>,
pub excerpts: Vec<RelatedExcerpt>,
pub max_row: u32,
}
impl RelatedFile {
pub fn merge_excerpts(&mut self) {
self.excerpts.sort_unstable_by(|a, b| {
a.point_range
.start
.cmp(&b.point_range.start)
.then(b.point_range.end.cmp(&a.point_range.end))
});
let mut index = 1;
while index < self.excerpts.len() {
if self.excerpts[index - 1]
.point_range
.end
.cmp(&self.excerpts[index].point_range.start)
.is_ge()
{
let removed = self.excerpts.remove(index);
if removed
.point_range
.end
.cmp(&self.excerpts[index - 1].point_range.end)
.is_gt()
{
self.excerpts[index - 1].point_range.end = removed.point_range.end;
self.excerpts[index - 1].anchor_range.end = removed.anchor_range.end;
}
} else {
index += 1;
}
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct RelatedExcerpt {
#[serde(skip)]
pub anchor_range: Range<Anchor>,
#[serde(serialize_with = "serialize_point_range")]
pub point_range: Range<Point>,
#[serde(serialize_with = "serialize_rope")]
pub text: Rope,
}
fn serialize_project_path<S: Serializer>(
project_path: &ProjectPath,
serializer: S,
) -> Result<S::Ok, S::Error> {
project_path.path.serialize(serializer)
}
fn serialize_rope<S: Serializer>(rope: &Rope, serializer: S) -> Result<S::Ok, S::Error> {
rope.to_string().serialize(serializer)
}
fn serialize_point_range<S: Serializer>(
range: &Range<Point>,
serializer: S,
) -> Result<S::Ok, S::Error> {
[
[range.start.row, range.start.column],
[range.end.row, range.end.column],
]
.serialize(serializer)
}
const DEBOUNCE_DURATION: Duration = Duration::from_millis(100);
impl EventEmitter<RelatedExcerptStoreEvent> for RelatedExcerptStore {}
impl RelatedExcerptStore {
pub fn new(project: &Entity<Project>, cx: &mut Context<Self>) -> Self {
let (update_tx, mut update_rx) = mpsc::unbounded::<(Entity<Buffer>, Anchor)>();
cx.spawn(async move |this, cx| {
let executor = cx.background_executor().clone();
while let Some((mut buffer, mut position)) = update_rx.next().await {
let mut timer = executor.timer(DEBOUNCE_DURATION).fuse();
loop {
futures::select_biased! {
next = update_rx.next() => {
if let Some((new_buffer, new_position)) = next {
buffer = new_buffer;
position = new_position;
timer = executor.timer(DEBOUNCE_DURATION).fuse();
} else {
return anyhow::Ok(());
}
}
_ = timer => break,
}
}
Self::fetch_excerpts(this.clone(), buffer, position, cx).await?;
}
anyhow::Ok(())
})
.detach_and_log_err(cx);
RelatedExcerptStore {
project: project.downgrade(),
update_tx,
related_files: Vec::new(),
cache: Default::default(),
}
}
pub fn refresh(&mut self, buffer: Entity<Buffer>, position: Anchor, _: &mut Context<Self>) {
self.update_tx.unbounded_send((buffer, position)).ok();
}
pub fn related_files(&self) -> &[RelatedFile] {
&self.related_files
}
async fn fetch_excerpts(
this: WeakEntity<Self>,
buffer: Entity<Buffer>,
position: Anchor,
cx: &mut AsyncApp,
) -> Result<()> {
let (project, snapshot) = this.read_with(cx, |this, cx| {
(this.project.upgrade(), buffer.read(cx).snapshot())
})?;
let Some(project) = project else {
return Ok(());
};
let file = snapshot.file().cloned();
if let Some(file) = &file {
log::debug!("retrieving_context buffer:{}", file.path().as_unix_str());
}
this.update(cx, |_, cx| {
cx.emit(RelatedExcerptStoreEvent::StartedRefresh);
})?;
let identifiers = cx
.background_spawn(async move { identifiers_for_position(&snapshot, position) })
.await;
let async_cx = cx.clone();
let start_time = Instant::now();
let futures = this.update(cx, |this, cx| {
identifiers
.into_iter()
.filter_map(|identifier| {
let task = if let Some(entry) = this.cache.get(&identifier) {
DefinitionTask::CacheHit(entry.clone())
} else {
DefinitionTask::CacheMiss(
this.project
.update(cx, |project, cx| {
project.definitions(&buffer, identifier.range.start, cx)
})
.ok()?,
)
};
let cx = async_cx.clone();
let project = project.clone();
Some(async move {
match task {
DefinitionTask::CacheHit(cache_entry) => {
Some((identifier, cache_entry, None))
}
DefinitionTask::CacheMiss(task) => {
let locations = task.await.log_err()??;
let duration = start_time.elapsed();
cx.update(|cx| {
(
identifier,
Arc::new(CacheEntry {
definitions: locations
.into_iter()
.filter_map(|location| {
process_definition(location, &project, cx)
})
.collect(),
}),
Some(duration),
)
})
.ok()
}
}
})
})
.collect::<Vec<_>>()
})?;
let mut cache_hit_count = 0;
let mut cache_miss_count = 0;
let mut mean_definition_latency = Duration::ZERO;
let mut max_definition_latency = Duration::ZERO;
let mut new_cache = HashMap::default();
new_cache.reserve(futures.len());
for (identifier, entry, duration) in future::join_all(futures).await.into_iter().flatten() {
new_cache.insert(identifier, entry);
if let Some(duration) = duration {
cache_miss_count += 1;
mean_definition_latency += duration;
max_definition_latency = max_definition_latency.max(duration);
} else {
cache_hit_count += 1;
}
}
mean_definition_latency /= cache_miss_count.max(1) as u32;
let (new_cache, related_files) = rebuild_related_files(new_cache, cx).await?;
if let Some(file) = &file {
log::debug!(
"finished retrieving context buffer:{}, latency:{:?}",
file.path().as_unix_str(),
start_time.elapsed()
);
}
this.update(cx, |this, cx| {
this.cache = new_cache;
this.related_files = related_files;
cx.emit(RelatedExcerptStoreEvent::FinishedRefresh {
cache_hit_count,
cache_miss_count,
mean_definition_latency,
max_definition_latency,
});
})?;
anyhow::Ok(())
}
}
async fn rebuild_related_files(
new_entries: HashMap<Identifier, Arc<CacheEntry>>,
cx: &mut AsyncApp,
) -> Result<(HashMap<Identifier, Arc<CacheEntry>>, Vec<RelatedFile>)> {
let mut snapshots = HashMap::default();
for entry in new_entries.values() {
for definition in &entry.definitions {
if let hash_map::Entry::Vacant(e) = snapshots.entry(definition.buffer.entity_id()) {
definition
.buffer
.read_with(cx, |buffer, _| buffer.parsing_idle())?
.await;
e.insert(
definition
.buffer
.read_with(cx, |buffer, _| buffer.snapshot())?,
);
}
}
}
Ok(cx
.background_spawn(async move {
let mut files = Vec::<RelatedFile>::new();
let mut ranges_by_buffer = HashMap::<_, Vec<Range<Point>>>::default();
let mut paths_by_buffer = HashMap::default();
for entry in new_entries.values() {
for definition in &entry.definitions {
let Some(snapshot) = snapshots.get(&definition.buffer.entity_id()) else {
continue;
};
paths_by_buffer.insert(definition.buffer.entity_id(), definition.path.clone());
ranges_by_buffer
.entry(definition.buffer.clone())
.or_default()
.push(definition.anchor_range.to_point(snapshot));
}
}
for (buffer, ranges) in ranges_by_buffer {
let Some(snapshot) = snapshots.get(&buffer.entity_id()) else {
continue;
};
let Some(project_path) = paths_by_buffer.get(&buffer.entity_id()) else {
continue;
};
let excerpts = assemble_excerpts(snapshot, ranges);
files.push(RelatedFile {
path: project_path.clone(),
buffer: buffer.downgrade(),
excerpts,
max_row: snapshot.max_point().row,
});
}
files.sort_by_key(|file| file.path.clone());
(new_entries, files)
})
.await)
}
fn process_definition(
location: LocationLink,
project: &Entity<Project>,
cx: &mut App,
) -> Option<CachedDefinition> {
let buffer = location.target.buffer.read(cx);
let anchor_range = location.target.range;
let file = buffer.file()?;
let worktree = project.read(cx).worktree_for_id(file.worktree_id(cx), cx)?;
if worktree.read(cx).is_single_file() {
return None;
}
Some(CachedDefinition {
path: ProjectPath {
worktree_id: file.worktree_id(cx),
path: file.path().clone(),
},
buffer: location.target.buffer,
anchor_range,
})
}
/// Gets all of the identifiers that are present in the given line, and its containing
/// outline items.
fn identifiers_for_position(buffer: &BufferSnapshot, position: Anchor) -> Vec<Identifier> {
let offset = position.to_offset(buffer);
let point = buffer.offset_to_point(offset);
let line_range = Point::new(point.row, 0)..Point::new(point.row + 1, 0).min(buffer.max_point());
let mut ranges = vec![line_range.to_offset(&buffer)];
// Include the range of the outline item itself, but not its body.
let outline_items = buffer.outline_items_as_offsets_containing(offset..offset, false, None);
for item in outline_items {
if let Some(body_range) = item.body_range(&buffer) {
ranges.push(item.range.start..body_range.start.to_offset(&buffer));
} else {
ranges.push(item.range.clone());
}
}
ranges.sort_by(|a, b| a.start.cmp(&b.start).then(b.end.cmp(&a.end)));
ranges.dedup_by(|a, b| {
if a.start <= b.end {
b.start = b.start.min(a.start);
b.end = b.end.max(a.end);
true
} else {
false
}
});
let mut identifiers = Vec::new();
let outer_range =
ranges.first().map_or(0, |r| r.start)..ranges.last().map_or(buffer.len(), |r| r.end);
let mut captures = buffer
.syntax
.captures(outer_range.clone(), &buffer.text, |grammar| {
grammar
.highlights_config
.as_ref()
.map(|config| &config.query)
});
for range in ranges {
captures.set_byte_range(range.start..outer_range.end);
let mut last_range = None;
while let Some(capture) = captures.peek() {
let node_range = capture.node.byte_range();
if node_range.start > range.end {
break;
}
let config = captures.grammars()[capture.grammar_index]
.highlights_config
.as_ref();
if let Some(config) = config
&& config.identifier_capture_indices.contains(&capture.index)
&& range.contains_inclusive(&node_range)
&& Some(&node_range) != last_range.as_ref()
{
let name = buffer.text_for_range(node_range.clone()).collect();
identifiers.push(Identifier {
range: buffer.anchor_after(node_range.start)
..buffer.anchor_before(node_range.end),
name,
});
last_range = Some(node_range);
}
captures.advance();
}
}
identifiers
}
@@ -0,0 +1,360 @@
use super::*;
use futures::channel::mpsc::UnboundedReceiver;
use gpui::TestAppContext;
use indoc::indoc;
use language::{Language, LanguageConfig, LanguageMatcher, Point, ToPoint as _, tree_sitter_rust};
use lsp::FakeLanguageServer;
use project::{FakeFs, LocationLink, Project};
use serde_json::json;
use settings::SettingsStore;
use std::sync::Arc;
use util::path;
#[gpui::test]
async fn test_edit_prediction_context(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/root"), test_project_1()).await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let mut servers = setup_fake_lsp(&project, cx);
let (buffer, _handle) = project
.update(cx, |project, cx| {
project.open_local_buffer_with_lsp(path!("/root/src/main.rs"), cx)
})
.await
.unwrap();
let _server = servers.next().await.unwrap();
cx.run_until_parked();
let related_excerpt_store = cx.new(|cx| RelatedExcerptStore::new(&project, cx));
related_excerpt_store.update(cx, |store, cx| {
let position = {
let buffer = buffer.read(cx);
let offset = buffer.text().find("todo").unwrap();
buffer.anchor_before(offset)
};
store.refresh(buffer.clone(), position, cx);
});
cx.executor().advance_clock(DEBOUNCE_DURATION);
related_excerpt_store.update(cx, |store, _| {
let excerpts = store.related_files();
assert_related_files(
&excerpts,
&[
(
"src/company.rs",
&[indoc! {"
pub struct Company {
owner: Arc<Person>,
address: Address,
}"}],
),
(
"src/main.rs",
&[
indoc! {"
pub struct Session {
company: Arc<Company>,
}
impl Session {
pub fn set_company(&mut self, company: Arc<Company>) {"},
indoc! {"
}
}"},
],
),
(
"src/person.rs",
&[
indoc! {"
impl Person {
pub fn get_first_name(&self) -> &str {
&self.first_name
}"},
"}",
],
),
],
);
});
}
#[gpui::test]
async fn test_fake_definition_lsp(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/root"), test_project_1()).await;
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
let mut servers = setup_fake_lsp(&project, cx);
let (buffer, _handle) = project
.update(cx, |project, cx| {
project.open_local_buffer_with_lsp(path!("/root/src/main.rs"), cx)
})
.await
.unwrap();
let _server = servers.next().await.unwrap();
cx.run_until_parked();
let buffer_text = buffer.read_with(cx, |buffer, _| buffer.text());
let definitions = project
.update(cx, |project, cx| {
let offset = buffer_text.find("Address {").unwrap();
project.definitions(&buffer, offset, cx)
})
.await
.unwrap()
.unwrap();
assert_definitions(&definitions, &["pub struct Address {"], cx);
let definitions = project
.update(cx, |project, cx| {
let offset = buffer_text.find("State::CA").unwrap();
project.definitions(&buffer, offset, cx)
})
.await
.unwrap()
.unwrap();
assert_definitions(&definitions, &["pub enum State {"], cx);
let definitions = project
.update(cx, |project, cx| {
let offset = buffer_text.find("to_string()").unwrap();
project.definitions(&buffer, offset, cx)
})
.await
.unwrap()
.unwrap();
assert_definitions(&definitions, &["pub fn to_string(&self) -> String {"], cx);
}
fn init_test(cx: &mut TestAppContext) {
let settings_store = cx.update(|cx| SettingsStore::test(cx));
cx.set_global(settings_store);
env_logger::try_init().ok();
}
fn setup_fake_lsp(
project: &Entity<Project>,
cx: &mut TestAppContext,
) -> UnboundedReceiver<FakeLanguageServer> {
let (language_registry, fs) = project.read_with(cx, |project, _| {
(project.languages().clone(), project.fs().clone())
});
let language = rust_lang();
language_registry.add(language.clone());
fake_definition_lsp::register_fake_definition_server(&language_registry, language, fs)
}
fn test_project_1() -> serde_json::Value {
let person_rs = indoc! {r#"
pub struct Person {
first_name: String,
last_name: String,
email: String,
age: u32,
}
impl Person {
pub fn get_first_name(&self) -> &str {
&self.first_name
}
pub fn get_last_name(&self) -> &str {
&self.last_name
}
pub fn get_email(&self) -> &str {
&self.email
}
pub fn get_age(&self) -> u32 {
self.age
}
}
"#};
let address_rs = indoc! {r#"
pub struct Address {
street: String,
city: String,
state: State,
zip: u32,
}
pub enum State {
CA,
OR,
WA,
TX,
// ...
}
impl Address {
pub fn get_street(&self) -> &str {
&self.street
}
pub fn get_city(&self) -> &str {
&self.city
}
pub fn get_state(&self) -> State {
self.state
}
pub fn get_zip(&self) -> u32 {
self.zip
}
}
"#};
let company_rs = indoc! {r#"
use super::person::Person;
use super::address::Address;
pub struct Company {
owner: Arc<Person>,
address: Address,
}
impl Company {
pub fn get_owner(&self) -> &Person {
&self.owner
}
pub fn get_address(&self) -> &Address {
&self.address
}
pub fn to_string(&self) -> String {
format!("{} ({})", self.owner.first_name, self.address.city)
}
}
"#};
let main_rs = indoc! {r#"
use std::sync::Arc;
use super::person::Person;
use super::address::Address;
use super::company::Company;
pub struct Session {
company: Arc<Company>,
}
impl Session {
pub fn set_company(&mut self, company: Arc<Company>) {
self.company = company;
if company.owner != self.company.owner {
log("new owner", company.owner.get_first_name()); todo();
}
}
}
fn main() {
let company = Company {
owner: Arc::new(Person {
first_name: "John".to_string(),
last_name: "Doe".to_string(),
email: "john@example.com".to_string(),
age: 30,
}),
address: Address {
street: "123 Main St".to_string(),
city: "Anytown".to_string(),
state: State::CA,
zip: 12345,
},
};
println!("Company: {}", company.to_string());
}
"#};
json!({
"src": {
"person.rs": person_rs,
"address.rs": address_rs,
"company.rs": company_rs,
"main.rs": main_rs,
},
})
}
fn assert_related_files(actual_files: &[RelatedFile], expected_files: &[(&str, &[&str])]) {
let actual_files = actual_files
.iter()
.map(|file| {
let excerpts = file
.excerpts
.iter()
.map(|excerpt| excerpt.text.to_string())
.collect::<Vec<_>>();
(file.path.path.as_unix_str(), excerpts)
})
.collect::<Vec<_>>();
let expected_excerpts = expected_files
.iter()
.map(|(path, texts)| {
(
*path,
texts
.iter()
.map(|line| line.to_string())
.collect::<Vec<_>>(),
)
})
.collect::<Vec<_>>();
pretty_assertions::assert_eq!(actual_files, expected_excerpts)
}
fn assert_definitions(definitions: &[LocationLink], first_lines: &[&str], cx: &mut TestAppContext) {
let actual_first_lines = definitions
.iter()
.map(|definition| {
definition.target.buffer.read_with(cx, |buffer, _| {
let mut start = definition.target.range.start.to_point(&buffer);
start.column = 0;
let end = Point::new(start.row, buffer.line_len(start.row));
buffer
.text_for_range(start..end)
.collect::<String>()
.trim()
.to_string()
})
})
.collect::<Vec<String>>();
assert_eq!(actual_first_lines, first_lines);
}
pub(crate) fn rust_lang() -> Arc<Language> {
Arc::new(
Language::new(
LanguageConfig {
name: "Rust".into(),
matcher: LanguageMatcher {
path_suffixes: vec!["rs".to_string()],
first_line_pattern: None,
},
..Default::default()
},
Some(tree_sitter_rust::LANGUAGE.into()),
)
.with_highlights_query(include_str!("../../languages/src/rust/highlights.scm"))
.unwrap()
.with_outline_query(include_str!("../../languages/src/rust/outline.scm"))
.unwrap(),
)
}
@@ -0,0 +1,329 @@
use collections::HashMap;
use futures::channel::mpsc::UnboundedReceiver;
use language::{Language, LanguageRegistry};
use lsp::{
FakeLanguageServer, LanguageServerBinary, TextDocumentSyncCapability, TextDocumentSyncKind, Uri,
};
use parking_lot::Mutex;
use project::Fs;
use std::{ops::Range, path::PathBuf, sync::Arc};
use tree_sitter::{Parser, QueryCursor, StreamingIterator, Tree};
/// Registers a fake language server that implements go-to-definition using tree-sitter,
/// making the assumption that all names are unique, and all variables' types are
/// explicitly declared.
pub fn register_fake_definition_server(
language_registry: &Arc<LanguageRegistry>,
language: Arc<Language>,
fs: Arc<dyn Fs>,
) -> UnboundedReceiver<FakeLanguageServer> {
let index = Arc::new(Mutex::new(DefinitionIndex::new(language.clone())));
language_registry.register_fake_lsp(
language.name(),
language::FakeLspAdapter {
name: "fake-definition-lsp",
initialization_options: None,
prettier_plugins: Vec::new(),
disk_based_diagnostics_progress_token: None,
disk_based_diagnostics_sources: Vec::new(),
language_server_binary: LanguageServerBinary {
path: PathBuf::from("fake-definition-lsp"),
arguments: Vec::new(),
env: None,
},
capabilities: lsp::ServerCapabilities {
definition_provider: Some(lsp::OneOf::Left(true)),
text_document_sync: Some(TextDocumentSyncCapability::Kind(
TextDocumentSyncKind::FULL,
)),
..Default::default()
},
label_for_completion: None,
initializer: Some(Box::new({
move |server| {
server.handle_notification::<lsp::notification::DidOpenTextDocument, _>({
let index = index.clone();
move |params, _cx| {
index
.lock()
.open_buffer(params.text_document.uri, &params.text_document.text);
}
});
server.handle_notification::<lsp::notification::DidCloseTextDocument, _>({
let index = index.clone();
let fs = fs.clone();
move |params, cx| {
let uri = params.text_document.uri;
let path = uri.to_file_path().ok();
index.lock().mark_buffer_closed(&uri);
if let Some(path) = path {
let index = index.clone();
let fs = fs.clone();
cx.spawn(async move |_cx| {
if let Ok(content) = fs.load(&path).await {
index.lock().index_file(uri, &content);
}
})
.detach();
}
}
});
server.handle_notification::<lsp::notification::DidChangeWatchedFiles, _>({
let index = index.clone();
let fs = fs.clone();
move |params, cx| {
let index = index.clone();
let fs = fs.clone();
cx.spawn(async move |_cx| {
for event in params.changes {
if index.lock().is_buffer_open(&event.uri) {
continue;
}
match event.typ {
lsp::FileChangeType::DELETED => {
index.lock().remove_definitions_for_file(&event.uri);
}
lsp::FileChangeType::CREATED
| lsp::FileChangeType::CHANGED => {
if let Some(path) = event.uri.to_file_path().ok() {
if let Ok(content) = fs.load(&path).await {
index.lock().index_file(event.uri, &content);
}
}
}
_ => {}
}
}
})
.detach();
}
});
server.handle_notification::<lsp::notification::DidChangeTextDocument, _>({
let index = index.clone();
move |params, _cx| {
if let Some(change) = params.content_changes.into_iter().last() {
index
.lock()
.index_file(params.text_document.uri, &change.text);
}
}
});
server.handle_notification::<lsp::notification::DidChangeWorkspaceFolders, _>(
{
let index = index.clone();
let fs = fs.clone();
move |params, cx| {
let index = index.clone();
let fs = fs.clone();
let files = fs.as_fake().files();
cx.spawn(async move |_cx| {
for folder in params.event.added {
let Ok(path) = folder.uri.to_file_path() else {
continue;
};
for file in &files {
if let Some(uri) = Uri::from_file_path(&file).ok()
&& file.starts_with(&path)
&& let Ok(content) = fs.load(&file).await
{
index.lock().index_file(uri, &content);
}
}
}
})
.detach();
}
},
);
server.set_request_handler::<lsp::request::GotoDefinition, _, _>({
let index = index.clone();
move |params, _cx| {
let result = index.lock().get_definitions(
params.text_document_position_params.text_document.uri,
params.text_document_position_params.position,
);
async move { Ok(result) }
}
});
}
})),
},
)
}
struct DefinitionIndex {
language: Arc<Language>,
definitions: HashMap<String, Vec<lsp::Location>>,
files: HashMap<Uri, FileEntry>,
}
#[derive(Debug)]
struct FileEntry {
contents: String,
is_open_in_buffer: bool,
}
impl DefinitionIndex {
fn new(language: Arc<Language>) -> Self {
Self {
language,
definitions: HashMap::default(),
files: HashMap::default(),
}
}
fn remove_definitions_for_file(&mut self, uri: &Uri) {
self.definitions.retain(|_, locations| {
locations.retain(|loc| &loc.uri != uri);
!locations.is_empty()
});
self.files.remove(uri);
}
fn open_buffer(&mut self, uri: Uri, content: &str) {
self.index_file_inner(uri, content, true);
}
fn mark_buffer_closed(&mut self, uri: &Uri) {
if let Some(entry) = self.files.get_mut(uri) {
entry.is_open_in_buffer = false;
}
}
fn is_buffer_open(&self, uri: &Uri) -> bool {
self.files
.get(uri)
.map(|entry| entry.is_open_in_buffer)
.unwrap_or(false)
}
fn index_file(&mut self, uri: Uri, content: &str) {
self.index_file_inner(uri, content, false);
}
fn index_file_inner(&mut self, uri: Uri, content: &str, is_open_in_buffer: bool) -> Option<()> {
self.remove_definitions_for_file(&uri);
let grammar = self.language.grammar()?;
let outline_config = grammar.outline_config.as_ref()?;
let mut parser = Parser::new();
parser.set_language(&grammar.ts_language).ok()?;
let tree = parser.parse(content, None)?;
let declarations = extract_declarations_from_tree(&tree, content, outline_config);
for (name, byte_range) in declarations {
let range = byte_range_to_lsp_range(content, byte_range);
let location = lsp::Location {
uri: uri.clone(),
range,
};
self.definitions
.entry(name)
.or_insert_with(Vec::new)
.push(location);
}
self.files.insert(
uri,
FileEntry {
contents: content.to_string(),
is_open_in_buffer,
},
);
Some(())
}
fn get_definitions(
&mut self,
uri: Uri,
position: lsp::Position,
) -> Option<lsp::GotoDefinitionResponse> {
let entry = self.files.get(&uri)?;
let name = word_at_position(&entry.contents, position)?;
let locations = self.definitions.get(name).cloned()?;
Some(lsp::GotoDefinitionResponse::Array(locations))
}
}
fn extract_declarations_from_tree(
tree: &Tree,
content: &str,
outline_config: &language::OutlineConfig,
) -> Vec<(String, Range<usize>)> {
let mut cursor = QueryCursor::new();
let mut declarations = Vec::new();
let mut matches = cursor.matches(&outline_config.query, tree.root_node(), content.as_bytes());
while let Some(query_match) = matches.next() {
let mut name_range: Option<Range<usize>> = None;
let mut has_item_range = false;
for capture in query_match.captures {
let range = capture.node.byte_range();
if capture.index == outline_config.name_capture_ix {
name_range = Some(range);
} else if capture.index == outline_config.item_capture_ix {
has_item_range = true;
}
}
if let Some(name_range) = name_range
&& has_item_range
{
let name = content[name_range.clone()].to_string();
if declarations.iter().any(|(n, _)| n == &name) {
continue;
}
declarations.push((name, name_range));
}
}
declarations
}
fn byte_range_to_lsp_range(content: &str, byte_range: Range<usize>) -> lsp::Range {
let start = byte_offset_to_position(content, byte_range.start);
let end = byte_offset_to_position(content, byte_range.end);
lsp::Range { start, end }
}
fn byte_offset_to_position(content: &str, offset: usize) -> lsp::Position {
let mut line = 0;
let mut character = 0;
let mut current_offset = 0;
for ch in content.chars() {
if current_offset >= offset {
break;
}
if ch == '\n' {
line += 1;
character = 0;
} else {
character += 1;
}
current_offset += ch.len_utf8();
}
lsp::Position { line, character }
}
fn word_at_position(content: &str, position: lsp::Position) -> Option<&str> {
let mut lines = content.lines();
let line = lines.nth(position.line as usize)?;
let column = position.character as usize;
if column > line.len() {
return None;
}
let start = line[..column]
.rfind(|c: char| !c.is_alphanumeric() && c != '_')
.map(|i| i + 1)
.unwrap_or(0);
let end = line[column..]
.find(|c: char| !c.is_alphanumeric() && c != '_')
.map(|i| i + column)
.unwrap_or(line.len());
Some(&line[start..end]).filter(|word| !word.is_empty())
}