ZIm/crates/collab/src/tests/random_project_collaboration_tests.rs
Techatrix d5f2bca382
Filter LSP code actions based on the requested kinds (#20847)
I've observed that Zed's implementation of [Code Actions On
Format](https://zed.dev/docs/configuring-zed#code-actions-on-format)
uses the
[CodeActionContext.only](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#codeActionContext)
parameter to request specific code action kinds from the server. The
issue is that it does not filter out code actions from the response,
believing that the server will do it.

The [LSP
specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#codeActionContext)
says that the client is responsible for filtering out unwanted code
actions:

```js
/**
* Requested kind of actions to return.
*
* Actions not of this kind are filtered out by the client before being
* shown. So servers can omit computing them.
*/
only?: CodeActionKind[];
```

This PR will filter out unwanted code action on the client side.

I have initially encountered this issue because the [ZLS language
server](https://github.com/zigtools/zls) (until
https://github.com/zigtools/zls/pull/2087) does not filter code action
based on `CodeActionContext.only` so Zed runs all received code actions
even if they are explicitly disabled in the `code_actions_on_format`
setting.

Release Notes:

- Fix the `code_actions_on_format` setting when used with a language
server like ZLS

---------

Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
2024-11-22 13:01:00 +01:00

1602 lines
66 KiB
Rust

use super::{RandomizedTest, TestClient, TestError, TestServer, UserTestPlan};
use crate::{db::UserId, tests::run_randomized_test};
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use call::ActiveCall;
use collections::{BTreeMap, HashMap};
use editor::Bias;
use fs::{FakeFs, Fs as _};
use futures::StreamExt;
use git::repository::GitFileStatus;
use gpui::{BackgroundExecutor, Model, TestAppContext};
use language::{
range_to_lsp, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, PointUtf16,
};
use lsp::FakeLanguageServer;
use pretty_assertions::assert_eq;
use project::{
search::SearchQuery, search::SearchResult, Project, ProjectPath, DEFAULT_COMPLETION_CONTEXT,
};
use rand::{
distributions::{Alphanumeric, DistString},
prelude::*,
};
use serde::{Deserialize, Serialize};
use std::{
ops::{Deref, Range},
path::{Path, PathBuf},
rc::Rc,
sync::Arc,
};
use util::ResultExt;
#[gpui::test(
iterations = 100,
on_failure = "crate::tests::save_randomized_test_plan"
)]
async fn test_random_project_collaboration(
cx: &mut TestAppContext,
executor: BackgroundExecutor,
rng: StdRng,
) {
run_randomized_test::<ProjectCollaborationTest>(cx, executor, rng).await;
}
#[derive(Clone, Debug, Serialize, Deserialize)]
enum ClientOperation {
AcceptIncomingCall,
RejectIncomingCall,
LeaveCall,
InviteContactToCall {
user_id: UserId,
},
OpenLocalProject {
first_root_name: String,
},
OpenRemoteProject {
host_id: UserId,
first_root_name: String,
},
AddWorktreeToProject {
project_root_name: String,
new_root_path: PathBuf,
},
CloseRemoteProject {
project_root_name: String,
},
OpenBuffer {
project_root_name: String,
is_local: bool,
full_path: PathBuf,
},
SearchProject {
project_root_name: String,
is_local: bool,
query: String,
detach: bool,
},
EditBuffer {
project_root_name: String,
is_local: bool,
full_path: PathBuf,
edits: Vec<(Range<usize>, Arc<str>)>,
},
CloseBuffer {
project_root_name: String,
is_local: bool,
full_path: PathBuf,
},
SaveBuffer {
project_root_name: String,
is_local: bool,
full_path: PathBuf,
detach: bool,
},
RequestLspDataInBuffer {
project_root_name: String,
is_local: bool,
full_path: PathBuf,
offset: usize,
kind: LspRequestKind,
detach: bool,
},
CreateWorktreeEntry {
project_root_name: String,
is_local: bool,
full_path: PathBuf,
is_dir: bool,
},
WriteFsEntry {
path: PathBuf,
is_dir: bool,
content: String,
},
GitOperation {
operation: GitOperation,
},
}
#[derive(Clone, Debug, Serialize, Deserialize)]
enum GitOperation {
WriteGitIndex {
repo_path: PathBuf,
contents: Vec<(PathBuf, String)>,
},
WriteGitBranch {
repo_path: PathBuf,
new_branch: Option<String>,
},
WriteGitStatuses {
repo_path: PathBuf,
statuses: Vec<(PathBuf, GitFileStatus)>,
git_operation: bool,
},
}
#[derive(Clone, Debug, Serialize, Deserialize)]
enum LspRequestKind {
Rename,
Completion,
CodeAction,
Definition,
Highlights,
}
struct ProjectCollaborationTest;
#[async_trait(?Send)]
impl RandomizedTest for ProjectCollaborationTest {
type Operation = ClientOperation;
async fn initialize(server: &mut TestServer, users: &[UserTestPlan]) {
let db = &server.app_state.db;
for (ix, user_a) in users.iter().enumerate() {
for user_b in &users[ix + 1..] {
db.send_contact_request(user_a.user_id, user_b.user_id)
.await
.unwrap();
db.respond_to_contact_request(user_b.user_id, user_a.user_id, true)
.await
.unwrap();
}
}
}
fn generate_operation(
client: &TestClient,
rng: &mut StdRng,
plan: &mut UserTestPlan,
cx: &TestAppContext,
) -> ClientOperation {
let call = cx.read(ActiveCall::global);
loop {
match rng.gen_range(0..100_u32) {
// Mutate the call
0..=29 => {
// Respond to an incoming call
if call.read_with(cx, |call, _| call.incoming().borrow().is_some()) {
break if rng.gen_bool(0.7) {
ClientOperation::AcceptIncomingCall
} else {
ClientOperation::RejectIncomingCall
};
}
match rng.gen_range(0..100_u32) {
// Invite a contact to the current call
0..=70 => {
let available_contacts =
client.user_store().read_with(cx, |user_store, _| {
user_store
.contacts()
.iter()
.filter(|contact| contact.online && !contact.busy)
.cloned()
.collect::<Vec<_>>()
});
if !available_contacts.is_empty() {
let contact = available_contacts.choose(rng).unwrap();
break ClientOperation::InviteContactToCall {
user_id: UserId(contact.user.id as i32),
};
}
}
// Leave the current call
71.. => {
if plan.allow_client_disconnection
&& call.read_with(cx, |call, _| call.room().is_some())
{
break ClientOperation::LeaveCall;
}
}
}
}
// Mutate projects
30..=59 => match rng.gen_range(0..100_u32) {
// Open a new project
0..=70 => {
// Open a remote project
if let Some(room) = call.read_with(cx, |call, _| call.room().cloned()) {
let existing_dev_server_project_ids = cx.read(|cx| {
client
.dev_server_projects()
.iter()
.map(|p| p.read(cx).remote_id().unwrap())
.collect::<Vec<_>>()
});
let new_dev_server_projects = room.read_with(cx, |room, _| {
room.remote_participants()
.values()
.flat_map(|participant| {
participant.projects.iter().filter_map(|project| {
if existing_dev_server_project_ids.contains(&project.id)
{
None
} else {
Some((
UserId::from_proto(participant.user.id),
project.worktree_root_names[0].clone(),
))
}
})
})
.collect::<Vec<_>>()
});
if !new_dev_server_projects.is_empty() {
let (host_id, first_root_name) =
new_dev_server_projects.choose(rng).unwrap().clone();
break ClientOperation::OpenRemoteProject {
host_id,
first_root_name,
};
}
}
// Open a local project
else {
let first_root_name = plan.next_root_dir_name();
break ClientOperation::OpenLocalProject { first_root_name };
}
}
// Close a remote project
71..=80 => {
if !client.dev_server_projects().is_empty() {
let project = client.dev_server_projects().choose(rng).unwrap().clone();
let first_root_name = root_name_for_project(&project, cx);
break ClientOperation::CloseRemoteProject {
project_root_name: first_root_name,
};
}
}
// Mutate project worktrees
81.. => match rng.gen_range(0..100_u32) {
// Add a worktree to a local project
0..=50 => {
let Some(project) = client.local_projects().choose(rng).cloned() else {
continue;
};
let project_root_name = root_name_for_project(&project, cx);
let mut paths = client.fs().paths(false);
paths.remove(0);
let new_root_path = if paths.is_empty() || rng.gen() {
Path::new("/").join(plan.next_root_dir_name())
} else {
paths.choose(rng).unwrap().clone()
};
break ClientOperation::AddWorktreeToProject {
project_root_name,
new_root_path,
};
}
// Add an entry to a worktree
_ => {
let Some(project) = choose_random_project(client, rng) else {
continue;
};
let project_root_name = root_name_for_project(&project, cx);
let is_local = project.read_with(cx, |project, _| project.is_local());
let worktree = project.read_with(cx, |project, cx| {
project
.worktrees(cx)
.filter(|worktree| {
let worktree = worktree.read(cx);
worktree.is_visible()
&& worktree.entries(false, 0).any(|e| e.is_file())
&& worktree.root_entry().map_or(false, |e| e.is_dir())
})
.choose(rng)
});
let Some(worktree) = worktree else { continue };
let is_dir = rng.gen::<bool>();
let mut full_path =
worktree.read_with(cx, |w, _| PathBuf::from(w.root_name()));
full_path.push(gen_file_name(rng));
if !is_dir {
full_path.set_extension("rs");
}
break ClientOperation::CreateWorktreeEntry {
project_root_name,
is_local,
full_path,
is_dir,
};
}
},
},
// Query and mutate buffers
60..=90 => {
let Some(project) = choose_random_project(client, rng) else {
continue;
};
let project_root_name = root_name_for_project(&project, cx);
let is_local = project.read_with(cx, |project, _| project.is_local());
match rng.gen_range(0..100_u32) {
// Manipulate an existing buffer
0..=70 => {
let Some(buffer) = client
.buffers_for_project(&project)
.iter()
.choose(rng)
.cloned()
else {
continue;
};
let full_path = buffer
.read_with(cx, |buffer, cx| buffer.file().unwrap().full_path(cx));
match rng.gen_range(0..100_u32) {
// Close the buffer
0..=15 => {
break ClientOperation::CloseBuffer {
project_root_name,
is_local,
full_path,
};
}
// Save the buffer
16..=29 if buffer.read_with(cx, |b, _| b.is_dirty()) => {
let detach = rng.gen_bool(0.3);
break ClientOperation::SaveBuffer {
project_root_name,
is_local,
full_path,
detach,
};
}
// Edit the buffer
30..=69 => {
let edits = buffer
.read_with(cx, |buffer, _| buffer.get_random_edits(rng, 3));
break ClientOperation::EditBuffer {
project_root_name,
is_local,
full_path,
edits,
};
}
// Make an LSP request
_ => {
let offset = buffer.read_with(cx, |buffer, _| {
buffer.clip_offset(
rng.gen_range(0..=buffer.len()),
language::Bias::Left,
)
});
let detach = rng.gen();
break ClientOperation::RequestLspDataInBuffer {
project_root_name,
full_path,
offset,
is_local,
kind: match rng.gen_range(0..5_u32) {
0 => LspRequestKind::Rename,
1 => LspRequestKind::Highlights,
2 => LspRequestKind::Definition,
3 => LspRequestKind::CodeAction,
4.. => LspRequestKind::Completion,
},
detach,
};
}
}
}
71..=80 => {
let query = rng.gen_range('a'..='z').to_string();
let detach = rng.gen_bool(0.3);
break ClientOperation::SearchProject {
project_root_name,
is_local,
query,
detach,
};
}
// Open a buffer
81.. => {
let worktree = project.read_with(cx, |project, cx| {
project
.worktrees(cx)
.filter(|worktree| {
let worktree = worktree.read(cx);
worktree.is_visible()
&& worktree.entries(false, 0).any(|e| e.is_file())
})
.choose(rng)
});
let Some(worktree) = worktree else { continue };
let full_path = worktree.read_with(cx, |worktree, _| {
let entry = worktree
.entries(false, 0)
.filter(|e| e.is_file())
.choose(rng)
.unwrap();
if entry.path.as_ref() == Path::new("") {
Path::new(worktree.root_name()).into()
} else {
Path::new(worktree.root_name()).join(&entry.path)
}
});
break ClientOperation::OpenBuffer {
project_root_name,
is_local,
full_path,
};
}
}
}
// Update a git related action
91..=95 => {
break ClientOperation::GitOperation {
operation: generate_git_operation(rng, client),
};
}
// Create or update a file or directory
96.. => {
let is_dir = rng.gen::<bool>();
let content;
let mut path;
let dir_paths = client.fs().directories(false);
if is_dir {
content = String::new();
path = dir_paths.choose(rng).unwrap().clone();
path.push(gen_file_name(rng));
} else {
content = Alphanumeric.sample_string(rng, 16);
// Create a new file or overwrite an existing file
let file_paths = client.fs().files();
if file_paths.is_empty() || rng.gen_bool(0.5) {
path = dir_paths.choose(rng).unwrap().clone();
path.push(gen_file_name(rng));
path.set_extension("rs");
} else {
path = file_paths.choose(rng).unwrap().clone()
};
}
break ClientOperation::WriteFsEntry {
path,
is_dir,
content,
};
}
}
}
}
async fn apply_operation(
client: &TestClient,
operation: ClientOperation,
cx: &mut TestAppContext,
) -> Result<(), TestError> {
match operation {
ClientOperation::AcceptIncomingCall => {
let active_call = cx.read(ActiveCall::global);
if active_call.read_with(cx, |call, _| call.incoming().borrow().is_none()) {
Err(TestError::Inapplicable)?;
}
log::info!("{}: accepting incoming call", client.username);
active_call
.update(cx, |call, cx| call.accept_incoming(cx))
.await?;
}
ClientOperation::RejectIncomingCall => {
let active_call = cx.read(ActiveCall::global);
if active_call.read_with(cx, |call, _| call.incoming().borrow().is_none()) {
Err(TestError::Inapplicable)?;
}
log::info!("{}: declining incoming call", client.username);
active_call.update(cx, |call, cx| call.decline_incoming(cx))?;
}
ClientOperation::LeaveCall => {
let active_call = cx.read(ActiveCall::global);
if active_call.read_with(cx, |call, _| call.room().is_none()) {
Err(TestError::Inapplicable)?;
}
log::info!("{}: hanging up", client.username);
active_call.update(cx, |call, cx| call.hang_up(cx)).await?;
}
ClientOperation::InviteContactToCall { user_id } => {
let active_call = cx.read(ActiveCall::global);
log::info!("{}: inviting {}", client.username, user_id,);
active_call
.update(cx, |call, cx| call.invite(user_id.to_proto(), None, cx))
.await
.log_err();
}
ClientOperation::OpenLocalProject { first_root_name } => {
log::info!(
"{}: opening local project at {:?}",
client.username,
first_root_name
);
let root_path = Path::new("/").join(&first_root_name);
client.fs().create_dir(&root_path).await.unwrap();
client
.fs()
.create_file(&root_path.join("main.rs"), Default::default())
.await
.unwrap();
let project = client.build_local_project(root_path, cx).await.0;
ensure_project_shared(&project, client, cx).await;
client.local_projects_mut().push(project.clone());
}
ClientOperation::AddWorktreeToProject {
project_root_name,
new_root_path,
} => {
let project = project_for_root_name(client, &project_root_name, cx)
.ok_or(TestError::Inapplicable)?;
log::info!(
"{}: finding/creating local worktree at {:?} to project with root path {}",
client.username,
new_root_path,
project_root_name
);
ensure_project_shared(&project, client, cx).await;
if !client.fs().paths(false).contains(&new_root_path) {
client.fs().create_dir(&new_root_path).await.unwrap();
}
project
.update(cx, |project, cx| {
project.find_or_create_worktree(&new_root_path, true, cx)
})
.await
.unwrap();
}
ClientOperation::CloseRemoteProject { project_root_name } => {
let project = project_for_root_name(client, &project_root_name, cx)
.ok_or(TestError::Inapplicable)?;
log::info!(
"{}: closing remote project with root path {}",
client.username,
project_root_name,
);
let ix = client
.dev_server_projects()
.iter()
.position(|p| p == &project)
.unwrap();
cx.update(|_| {
client.dev_server_projects_mut().remove(ix);
client.buffers().retain(|p, _| *p != project);
drop(project);
});
}
ClientOperation::OpenRemoteProject {
host_id,
first_root_name,
} => {
let active_call = cx.read(ActiveCall::global);
let project = active_call
.update(cx, |call, cx| {
let room = call.room().cloned()?;
let participant = room
.read(cx)
.remote_participants()
.get(&host_id.to_proto())?;
let project_id = participant
.projects
.iter()
.find(|project| project.worktree_root_names[0] == first_root_name)?
.id;
Some(room.update(cx, |room, cx| {
room.join_project(
project_id,
client.language_registry().clone(),
FakeFs::new(cx.background_executor().clone()),
cx,
)
}))
})
.ok_or(TestError::Inapplicable)?;
log::info!(
"{}: joining remote project of user {}, root name {}",
client.username,
host_id,
first_root_name,
);
let project = project.await?;
client.dev_server_projects_mut().push(project.clone());
}
ClientOperation::CreateWorktreeEntry {
project_root_name,
is_local,
full_path,
is_dir,
} => {
let project = project_for_root_name(client, &project_root_name, cx)
.ok_or(TestError::Inapplicable)?;
let project_path = project_path_for_full_path(&project, &full_path, cx)
.ok_or(TestError::Inapplicable)?;
log::info!(
"{}: creating {} at path {:?} in {} project {}",
client.username,
if is_dir { "dir" } else { "file" },
full_path,
if is_local { "local" } else { "remote" },
project_root_name,
);
ensure_project_shared(&project, client, cx).await;
project
.update(cx, |p, cx| p.create_entry(project_path, is_dir, cx))
.await?;
}
ClientOperation::OpenBuffer {
project_root_name,
is_local,
full_path,
} => {
let project = project_for_root_name(client, &project_root_name, cx)
.ok_or(TestError::Inapplicable)?;
let project_path = project_path_for_full_path(&project, &full_path, cx)
.ok_or(TestError::Inapplicable)?;
log::info!(
"{}: opening buffer {:?} in {} project {}",
client.username,
full_path,
if is_local { "local" } else { "remote" },
project_root_name,
);
ensure_project_shared(&project, client, cx).await;
let buffer = project
.update(cx, |project, cx| project.open_buffer(project_path, cx))
.await?;
client.buffers_for_project(&project).insert(buffer);
}
ClientOperation::EditBuffer {
project_root_name,
is_local,
full_path,
edits,
} => {
let project = project_for_root_name(client, &project_root_name, cx)
.ok_or(TestError::Inapplicable)?;
let buffer = buffer_for_full_path(client, &project, &full_path, cx)
.ok_or(TestError::Inapplicable)?;
log::info!(
"{}: editing buffer {:?} in {} project {} with {:?}",
client.username,
full_path,
if is_local { "local" } else { "remote" },
project_root_name,
edits
);
ensure_project_shared(&project, client, cx).await;
buffer.update(cx, |buffer, cx| {
let snapshot = buffer.snapshot();
buffer.edit(
edits.into_iter().map(|(range, text)| {
let start = snapshot.clip_offset(range.start, Bias::Left);
let end = snapshot.clip_offset(range.end, Bias::Right);
(start..end, text)
}),
None,
cx,
);
});
}
ClientOperation::CloseBuffer {
project_root_name,
is_local,
full_path,
} => {
let project = project_for_root_name(client, &project_root_name, cx)
.ok_or(TestError::Inapplicable)?;
let buffer = buffer_for_full_path(client, &project, &full_path, cx)
.ok_or(TestError::Inapplicable)?;
log::info!(
"{}: closing buffer {:?} in {} project {}",
client.username,
full_path,
if is_local { "local" } else { "remote" },
project_root_name
);
ensure_project_shared(&project, client, cx).await;
cx.update(|_| {
client.buffers_for_project(&project).remove(&buffer);
drop(buffer);
});
}
ClientOperation::SaveBuffer {
project_root_name,
is_local,
full_path,
detach,
} => {
let project = project_for_root_name(client, &project_root_name, cx)
.ok_or(TestError::Inapplicable)?;
let buffer = buffer_for_full_path(client, &project, &full_path, cx)
.ok_or(TestError::Inapplicable)?;
log::info!(
"{}: saving buffer {:?} in {} project {}, {}",
client.username,
full_path,
if is_local { "local" } else { "remote" },
project_root_name,
if detach { "detaching" } else { "awaiting" }
);
ensure_project_shared(&project, client, cx).await;
let requested_version = buffer.read_with(cx, |buffer, _| buffer.version());
let save =
project.update(cx, |project, cx| project.save_buffer(buffer.clone(), cx));
let save = cx.spawn(|cx| async move {
save.await
.map_err(|err| anyhow!("save request failed: {:?}", err))?;
assert!(buffer
.read_with(&cx, |buffer, _| { buffer.saved_version().to_owned() })
.expect("App should not be dropped")
.observed_all(&requested_version));
anyhow::Ok(())
});
if detach {
cx.update(|cx| save.detach_and_log_err(cx));
} else {
save.await?;
}
}
ClientOperation::RequestLspDataInBuffer {
project_root_name,
is_local,
full_path,
offset,
kind,
detach,
} => {
let project = project_for_root_name(client, &project_root_name, cx)
.ok_or(TestError::Inapplicable)?;
let buffer = buffer_for_full_path(client, &project, &full_path, cx)
.ok_or(TestError::Inapplicable)?;
log::info!(
"{}: request LSP {:?} for buffer {:?} in {} project {}, {}",
client.username,
kind,
full_path,
if is_local { "local" } else { "remote" },
project_root_name,
if detach { "detaching" } else { "awaiting" }
);
use futures::{FutureExt as _, TryFutureExt as _};
let offset = buffer.read_with(cx, |b, _| b.clip_offset(offset, Bias::Left));
let process_lsp_request = project.update(cx, |project, cx| match kind {
LspRequestKind::Rename => project
.prepare_rename(buffer, offset, cx)
.map_ok(|_| ())
.boxed(),
LspRequestKind::Completion => project
.completions(&buffer, offset, DEFAULT_COMPLETION_CONTEXT, cx)
.map_ok(|_| ())
.boxed(),
LspRequestKind::CodeAction => project
.code_actions(&buffer, offset..offset, None, cx)
.map(|_| Ok(()))
.boxed(),
LspRequestKind::Definition => project
.definition(&buffer, offset, cx)
.map_ok(|_| ())
.boxed(),
LspRequestKind::Highlights => project
.document_highlights(&buffer, offset, cx)
.map_ok(|_| ())
.boxed(),
});
let request = cx.foreground_executor().spawn(process_lsp_request);
if detach {
request.detach();
} else {
request.await?;
}
}
ClientOperation::SearchProject {
project_root_name,
is_local,
query,
detach,
} => {
let project = project_for_root_name(client, &project_root_name, cx)
.ok_or(TestError::Inapplicable)?;
log::info!(
"{}: search {} project {} for {:?}, {}",
client.username,
if is_local { "local" } else { "remote" },
project_root_name,
query,
if detach { "detaching" } else { "awaiting" }
);
let mut search = project.update(cx, |project, cx| {
project.search(
SearchQuery::text(
query,
false,
false,
false,
Default::default(),
Default::default(),
None,
)
.unwrap(),
cx,
)
});
drop(project);
let search = cx.executor().spawn(async move {
let mut results = HashMap::default();
while let Some(result) = search.next().await {
if let SearchResult::Buffer { buffer, ranges } = result {
results.entry(buffer).or_insert(ranges);
}
}
results
});
search.await;
}
ClientOperation::WriteFsEntry {
path,
is_dir,
content,
} => {
if !client
.fs()
.directories(false)
.contains(&path.parent().unwrap().to_owned())
{
return Err(TestError::Inapplicable);
}
if is_dir {
log::info!("{}: creating dir at {:?}", client.username, path);
client.fs().create_dir(&path).await.unwrap();
} else {
let exists = client.fs().metadata(&path).await?.is_some();
let verb = if exists { "updating" } else { "creating" };
log::info!("{}: {} file at {:?}", verb, client.username, path);
client
.fs()
.save(&path, &content.as_str().into(), text::LineEnding::Unix)
.await
.unwrap();
}
}
ClientOperation::GitOperation { operation } => match operation {
GitOperation::WriteGitIndex {
repo_path,
contents,
} => {
if !client.fs().directories(false).contains(&repo_path) {
return Err(TestError::Inapplicable);
}
for (path, _) in contents.iter() {
if !client.fs().files().contains(&repo_path.join(path)) {
return Err(TestError::Inapplicable);
}
}
log::info!(
"{}: writing git index for repo {:?}: {:?}",
client.username,
repo_path,
contents
);
let dot_git_dir = repo_path.join(".git");
let contents = contents
.iter()
.map(|(path, contents)| (path.as_path(), contents.clone()))
.collect::<Vec<_>>();
if client.fs().metadata(&dot_git_dir).await?.is_none() {
client.fs().create_dir(&dot_git_dir).await?;
}
client.fs().set_index_for_repo(&dot_git_dir, &contents);
}
GitOperation::WriteGitBranch {
repo_path,
new_branch,
} => {
if !client.fs().directories(false).contains(&repo_path) {
return Err(TestError::Inapplicable);
}
log::info!(
"{}: writing git branch for repo {:?}: {:?}",
client.username,
repo_path,
new_branch
);
let dot_git_dir = repo_path.join(".git");
if client.fs().metadata(&dot_git_dir).await?.is_none() {
client.fs().create_dir(&dot_git_dir).await?;
}
client
.fs()
.set_branch_name(&dot_git_dir, new_branch.clone());
}
GitOperation::WriteGitStatuses {
repo_path,
statuses,
git_operation,
} => {
if !client.fs().directories(false).contains(&repo_path) {
return Err(TestError::Inapplicable);
}
for (path, _) in statuses.iter() {
if !client.fs().files().contains(&repo_path.join(path)) {
return Err(TestError::Inapplicable);
}
}
log::info!(
"{}: writing git statuses for repo {:?}: {:?}",
client.username,
repo_path,
statuses
);
let dot_git_dir = repo_path.join(".git");
let statuses = statuses
.iter()
.map(|(path, val)| (path.as_path(), *val))
.collect::<Vec<_>>();
if client.fs().metadata(&dot_git_dir).await?.is_none() {
client.fs().create_dir(&dot_git_dir).await?;
}
if git_operation {
client.fs().set_status_for_repo_via_git_operation(
&dot_git_dir,
statuses.as_slice(),
);
} else {
client.fs().set_status_for_repo_via_working_copy_change(
&dot_git_dir,
statuses.as_slice(),
);
}
}
},
}
Ok(())
}
async fn on_client_added(client: &Rc<TestClient>, _: &mut TestAppContext) {
client.language_registry().add(Arc::new(Language::new(
LanguageConfig {
name: "Rust".into(),
matcher: LanguageMatcher {
path_suffixes: vec!["rs".to_string()],
..Default::default()
},
..Default::default()
},
None,
)));
client.language_registry().register_fake_lsp(
"Rust",
FakeLspAdapter {
name: "the-fake-language-server",
capabilities: lsp::LanguageServer::full_capabilities(),
initializer: Some(Box::new({
let fs = client.app_state.fs.clone();
move |fake_server: &mut FakeLanguageServer| {
fake_server.handle_request::<lsp::request::Completion, _, _>(
|_, _| async move {
Ok(Some(lsp::CompletionResponse::Array(vec![
lsp::CompletionItem {
text_edit: Some(lsp::CompletionTextEdit::Edit(
lsp::TextEdit {
range: lsp::Range::new(
lsp::Position::new(0, 0),
lsp::Position::new(0, 0),
),
new_text: "the-new-text".to_string(),
},
)),
..Default::default()
},
])))
},
);
fake_server.handle_request::<lsp::request::CodeActionRequest, _, _>(
|_, _| async move {
Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction(
lsp::CodeAction {
title: "the-code-action".to_string(),
..Default::default()
},
)]))
},
);
fake_server.handle_request::<lsp::request::PrepareRenameRequest, _, _>(
|params, _| async move {
Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range::new(
params.position,
params.position,
))))
},
);
fake_server.handle_request::<lsp::request::GotoDefinition, _, _>({
let fs = fs.clone();
move |_, cx| {
let background = cx.background_executor();
let mut rng = background.rng();
let count = rng.gen_range::<usize, _>(1..3);
let files = fs.as_fake().files();
let files = (0..count)
.map(|_| files.choose(&mut rng).unwrap().clone())
.collect::<Vec<_>>();
async move {
log::info!("LSP: Returning definitions in files {:?}", &files);
Ok(Some(lsp::GotoDefinitionResponse::Array(
files
.into_iter()
.map(|file| lsp::Location {
uri: lsp::Url::from_file_path(file).unwrap(),
range: Default::default(),
})
.collect(),
)))
}
}
});
fake_server.handle_request::<lsp::request::DocumentHighlightRequest, _, _>(
move |_, cx| {
let mut highlights = Vec::new();
let background = cx.background_executor();
let mut rng = background.rng();
let highlight_count = rng.gen_range(1..=5);
for _ in 0..highlight_count {
let start_row = rng.gen_range(0..100);
let start_column = rng.gen_range(0..100);
let end_row = rng.gen_range(0..100);
let end_column = rng.gen_range(0..100);
let start = PointUtf16::new(start_row, start_column);
let end = PointUtf16::new(end_row, end_column);
let range = if start > end { end..start } else { start..end };
highlights.push(lsp::DocumentHighlight {
range: range_to_lsp(range.clone()),
kind: Some(lsp::DocumentHighlightKind::READ),
});
}
highlights.sort_unstable_by_key(|highlight| {
(highlight.range.start, highlight.range.end)
});
async move { Ok(Some(highlights)) }
},
);
}
})),
..Default::default()
},
);
}
async fn on_quiesce(_: &mut TestServer, clients: &mut [(Rc<TestClient>, TestAppContext)]) {
for (client, client_cx) in clients.iter() {
for guest_project in client.dev_server_projects().iter() {
guest_project.read_with(client_cx, |guest_project, cx| {
let host_project = clients.iter().find_map(|(client, cx)| {
let project = client
.local_projects()
.iter()
.find(|host_project| {
host_project.read_with(cx, |host_project, _| {
host_project.remote_id() == guest_project.remote_id()
})
})?
.clone();
Some((project, cx))
});
if !guest_project.is_disconnected(cx) {
if let Some((host_project, host_cx)) = host_project {
let host_worktree_snapshots =
host_project.read_with(host_cx, |host_project, cx| {
host_project
.worktrees(cx)
.map(|worktree| {
let worktree = worktree.read(cx);
(worktree.id(), worktree.snapshot())
})
.collect::<BTreeMap<_, _>>()
});
let guest_worktree_snapshots = guest_project
.worktrees(cx)
.map(|worktree| {
let worktree = worktree.read(cx);
(worktree.id(), worktree.snapshot())
})
.collect::<BTreeMap<_, _>>();
assert_eq!(
guest_worktree_snapshots.values().map(|w| w.abs_path()).collect::<Vec<_>>(),
host_worktree_snapshots.values().map(|w| w.abs_path()).collect::<Vec<_>>(),
"{} has different worktrees than the host for project {:?}",
client.username, guest_project.remote_id(),
);
for (id, host_snapshot) in &host_worktree_snapshots {
let guest_snapshot = &guest_worktree_snapshots[id];
assert_eq!(
guest_snapshot.root_name(),
host_snapshot.root_name(),
"{} has different root name than the host for worktree {}, project {:?}",
client.username,
id,
guest_project.remote_id(),
);
assert_eq!(
guest_snapshot.abs_path(),
host_snapshot.abs_path(),
"{} has different abs path than the host for worktree {}, project: {:?}",
client.username,
id,
guest_project.remote_id(),
);
assert_eq!(
guest_snapshot.entries(false, 0).collect::<Vec<_>>(),
host_snapshot.entries(false, 0).collect::<Vec<_>>(),
"{} has different snapshot than the host for worktree {:?} ({:?}) and project {:?}",
client.username,
host_snapshot.abs_path(),
id,
guest_project.remote_id(),
);
assert_eq!(guest_snapshot.repositories().collect::<Vec<_>>(), host_snapshot.repositories().collect::<Vec<_>>(),
"{} has different repositories than the host for worktree {:?} and project {:?}",
client.username,
host_snapshot.abs_path(),
guest_project.remote_id(),
);
assert_eq!(guest_snapshot.scan_id(), host_snapshot.scan_id(),
"{} has different scan id than the host for worktree {:?} and project {:?}",
client.username,
host_snapshot.abs_path(),
guest_project.remote_id(),
);
}
}
}
for buffer in guest_project.opened_buffers(cx) {
let buffer = buffer.read(cx);
assert_eq!(
buffer.deferred_ops_len(),
0,
"{} has deferred operations for buffer {:?} in project {:?}",
client.username,
buffer.file().unwrap().full_path(cx),
guest_project.remote_id(),
);
}
});
}
let buffers = client.buffers().clone();
for (guest_project, guest_buffers) in &buffers {
let project_id = if guest_project.read_with(client_cx, |project, cx| {
project.is_local() || project.is_disconnected(cx)
}) {
continue;
} else {
guest_project
.read_with(client_cx, |project, _| project.remote_id())
.unwrap()
};
let guest_user_id = client.user_id().unwrap();
let host_project = clients.iter().find_map(|(client, cx)| {
let project = client
.local_projects()
.iter()
.find(|host_project| {
host_project.read_with(cx, |host_project, _| {
host_project.remote_id() == Some(project_id)
})
})?
.clone();
Some((client.user_id().unwrap(), project, cx))
});
let (host_user_id, host_project, host_cx) =
if let Some((host_user_id, host_project, host_cx)) = host_project {
(host_user_id, host_project, host_cx)
} else {
continue;
};
for guest_buffer in guest_buffers {
let buffer_id =
guest_buffer.read_with(client_cx, |buffer, _| buffer.remote_id());
let host_buffer = host_project.read_with(host_cx, |project, cx| {
project.buffer_for_id(buffer_id, cx).unwrap_or_else(|| {
panic!(
"host does not have buffer for guest:{}, peer:{:?}, id:{}",
client.username,
client.peer_id(),
buffer_id
)
})
});
let path = host_buffer
.read_with(host_cx, |buffer, cx| buffer.file().unwrap().full_path(cx));
assert_eq!(
guest_buffer.read_with(client_cx, |buffer, _| buffer.deferred_ops_len()),
0,
"{}, buffer {}, path {:?} has deferred operations",
client.username,
buffer_id,
path,
);
assert_eq!(
guest_buffer.read_with(client_cx, |buffer, _| buffer.text()),
host_buffer.read_with(host_cx, |buffer, _| buffer.text()),
"{}, buffer {}, path {:?}, differs from the host's buffer",
client.username,
buffer_id,
path
);
let host_file = host_buffer.read_with(host_cx, |b, _| b.file().cloned());
let guest_file = guest_buffer.read_with(client_cx, |b, _| b.file().cloned());
match (host_file, guest_file) {
(Some(host_file), Some(guest_file)) => {
assert_eq!(guest_file.path(), host_file.path());
assert_eq!(guest_file.disk_state(), host_file.disk_state(),
"guest {} disk_state does not match host {} for path {:?} in project {}",
guest_user_id,
host_user_id,
guest_file.path(),
project_id,
);
}
(None, None) => {}
(None, _) => panic!("host's file is None, guest's isn't"),
(_, None) => panic!("guest's file is None, hosts's isn't"),
}
let host_diff_base = host_buffer
.read_with(host_cx, |b, _| b.diff_base().map(ToString::to_string));
let guest_diff_base = guest_buffer
.read_with(client_cx, |b, _| b.diff_base().map(ToString::to_string));
assert_eq!(
guest_diff_base, host_diff_base,
"guest {} diff base does not match host's for path {path:?} in project {project_id}",
client.username
);
let host_saved_version =
host_buffer.read_with(host_cx, |b, _| b.saved_version().clone());
let guest_saved_version =
guest_buffer.read_with(client_cx, |b, _| b.saved_version().clone());
assert_eq!(
guest_saved_version, host_saved_version,
"guest {} saved version does not match host's for path {path:?} in project {project_id}",
client.username
);
let host_is_dirty = host_buffer.read_with(host_cx, |b, _| b.is_dirty());
let guest_is_dirty = guest_buffer.read_with(client_cx, |b, _| b.is_dirty());
assert_eq!(
guest_is_dirty, host_is_dirty,
"guest {} dirty state does not match host's for path {path:?} in project {project_id}",
client.username
);
let host_saved_mtime = host_buffer.read_with(host_cx, |b, _| b.saved_mtime());
let guest_saved_mtime =
guest_buffer.read_with(client_cx, |b, _| b.saved_mtime());
assert_eq!(
guest_saved_mtime, host_saved_mtime,
"guest {} saved mtime does not match host's for path {path:?} in project {project_id}",
client.username
);
let host_is_dirty = host_buffer.read_with(host_cx, |b, _| b.is_dirty());
let guest_is_dirty = guest_buffer.read_with(client_cx, |b, _| b.is_dirty());
assert_eq!(guest_is_dirty, host_is_dirty,
"guest {} dirty status does not match host's for path {path:?} in project {project_id}",
client.username
);
let host_has_conflict = host_buffer.read_with(host_cx, |b, _| b.has_conflict());
let guest_has_conflict =
guest_buffer.read_with(client_cx, |b, _| b.has_conflict());
assert_eq!(guest_has_conflict, host_has_conflict,
"guest {} conflict status does not match host's for path {path:?} in project {project_id}",
client.username
);
}
}
}
}
}
fn generate_git_operation(rng: &mut StdRng, client: &TestClient) -> GitOperation {
fn generate_file_paths(
repo_path: &Path,
rng: &mut StdRng,
client: &TestClient,
) -> Vec<PathBuf> {
let mut paths = client
.fs()
.files()
.into_iter()
.filter(|path| path.starts_with(repo_path))
.collect::<Vec<_>>();
let count = rng.gen_range(0..=paths.len());
paths.shuffle(rng);
paths.truncate(count);
paths
.iter()
.map(|path| path.strip_prefix(repo_path).unwrap().to_path_buf())
.collect::<Vec<_>>()
}
let repo_path = client.fs().directories(false).choose(rng).unwrap().clone();
match rng.gen_range(0..100_u32) {
0..=25 => {
let file_paths = generate_file_paths(&repo_path, rng, client);
let contents = file_paths
.into_iter()
.map(|path| (path, Alphanumeric.sample_string(rng, 16)))
.collect();
GitOperation::WriteGitIndex {
repo_path,
contents,
}
}
26..=63 => {
let new_branch = (rng.gen_range(0..10) > 3).then(|| Alphanumeric.sample_string(rng, 8));
GitOperation::WriteGitBranch {
repo_path,
new_branch,
}
}
64..=100 => {
let file_paths = generate_file_paths(&repo_path, rng, client);
let statuses = file_paths
.into_iter()
.map(|paths| {
(
paths,
match rng.gen_range(0..3_u32) {
0 => GitFileStatus::Added,
1 => GitFileStatus::Modified,
2 => GitFileStatus::Conflict,
_ => unreachable!(),
},
)
})
.collect::<Vec<_>>();
let git_operation = rng.gen::<bool>();
GitOperation::WriteGitStatuses {
repo_path,
statuses,
git_operation,
}
}
_ => unreachable!(),
}
}
fn buffer_for_full_path(
client: &TestClient,
project: &Model<Project>,
full_path: &PathBuf,
cx: &TestAppContext,
) -> Option<Model<language::Buffer>> {
client
.buffers_for_project(project)
.iter()
.find(|buffer| {
buffer.read_with(cx, |buffer, cx| {
buffer.file().unwrap().full_path(cx) == *full_path
})
})
.cloned()
}
fn project_for_root_name(
client: &TestClient,
root_name: &str,
cx: &TestAppContext,
) -> Option<Model<Project>> {
if let Some(ix) = project_ix_for_root_name(client.local_projects().deref(), root_name, cx) {
return Some(client.local_projects()[ix].clone());
}
if let Some(ix) = project_ix_for_root_name(client.dev_server_projects().deref(), root_name, cx)
{
return Some(client.dev_server_projects()[ix].clone());
}
None
}
fn project_ix_for_root_name(
projects: &[Model<Project>],
root_name: &str,
cx: &TestAppContext,
) -> Option<usize> {
projects.iter().position(|project| {
project.read_with(cx, |project, cx| {
let worktree = project.visible_worktrees(cx).next().unwrap();
worktree.read(cx).root_name() == root_name
})
})
}
fn root_name_for_project(project: &Model<Project>, cx: &TestAppContext) -> String {
project.read_with(cx, |project, cx| {
project
.visible_worktrees(cx)
.next()
.unwrap()
.read(cx)
.root_name()
.to_string()
})
}
fn project_path_for_full_path(
project: &Model<Project>,
full_path: &Path,
cx: &TestAppContext,
) -> Option<ProjectPath> {
let mut components = full_path.components();
let root_name = components.next().unwrap().as_os_str().to_str().unwrap();
let path = components.as_path().into();
let worktree_id = project.read_with(cx, |project, cx| {
project.worktrees(cx).find_map(|worktree| {
let worktree = worktree.read(cx);
if worktree.root_name() == root_name {
Some(worktree.id())
} else {
None
}
})
})?;
Some(ProjectPath { worktree_id, path })
}
async fn ensure_project_shared(
project: &Model<Project>,
client: &TestClient,
cx: &mut TestAppContext,
) {
let first_root_name = root_name_for_project(project, cx);
let active_call = cx.read(ActiveCall::global);
if active_call.read_with(cx, |call, _| call.room().is_some())
&& project.read_with(cx, |project, _| project.is_local() && !project.is_shared())
{
match active_call
.update(cx, |call, cx| call.share_project(project.clone(), cx))
.await
{
Ok(project_id) => {
log::info!(
"{}: shared project {} with id {}",
client.username,
first_root_name,
project_id
);
}
Err(error) => {
log::error!(
"{}: error sharing project {}: {:?}",
client.username,
first_root_name,
error
);
}
}
}
}
fn choose_random_project(client: &TestClient, rng: &mut StdRng) -> Option<Model<Project>> {
client
.local_projects()
.deref()
.iter()
.chain(client.dev_server_projects().iter())
.choose(rng)
.cloned()
}
fn gen_file_name(rng: &mut StdRng) -> String {
let mut name = String::new();
for _ in 0..10 {
let letter = rng.gen_range('a'..='z');
name.push(letter);
}
name
}