#![allow(clippy::format_collect)] mod agent_registry_store; mod bookmark_store; mod color_extractor; mod context_server_store; mod debugger; mod ext_agent_tests; mod extension_agent_tests; mod git_store; mod image_store; mod lsp_command; mod lsp_store; mod manifest_tree; mod project_search; mod search; mod search_history; mod signature_help; mod task_inventory; mod trusted_worktrees; mod yarn; use anyhow::Result; use async_trait::async_trait; use buffer_diff::{ BufferDiffEvent, DiffChanged, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkStatusKind, assert_hunks, }; use collections::{BTreeSet, HashMap, HashSet}; use encoding_rs; use fs::{FakeFs, PathEventKind}; use futures::{StreamExt, future}; use git::{ GitHostingProviderRegistry, repository::{RepoPath, repo_path}, status::{DiffStat, FileStatus, StatusCode, TrackedStatus}, }; use git2::RepositoryInitOptions; use gpui::{ App, AppContext, BackgroundExecutor, BorrowAppContext, Entity, FutureExt, SharedString, Task, TestAppContext, UpdateGlobal, }; use itertools::Itertools; use language::{ Buffer, BufferEvent, Diagnostic, DiagnosticEntry, DiagnosticEntryRef, DiagnosticSet, DiagnosticSourceKind, DiskState, FakeLspAdapter, Language, LanguageAwareStyling, LanguageConfig, LanguageMatcher, LanguageName, LineEnding, ManifestName, ManifestProvider, ManifestQuery, OffsetRangeExt, Point, ToPoint, Toolchain, ToolchainList, ToolchainLister, ToolchainMetadata, language_settings::{ Formatter, FormatterList, LanguageSettings, LanguageSettingsContent, LineEndingSetting, }, markdown_lang, rust_lang, tree_sitter_typescript, }; use lsp::{ CodeActionKind, DEFAULT_LSP_REQUEST_TIMEOUT, DiagnosticSeverity, DocumentChanges, FileOperationFilter, LanguageServerId, LanguageServerName, NumberOrString, TextDocumentEdit, Uri, WillRenameFiles, notification::DidRenameFiles, }; use parking_lot::Mutex; use paths::{config_dir, global_gitignore_path, tasks_file}; use postage::stream::Stream as _; use pretty_assertions::{assert_eq, assert_matches}; use project::{ Event, TaskContexts, git_store::{GitStoreEvent, Repository, RepositoryEvent, StatusEntry, pending_op}, search::{SearchQuery, SearchResult}, task_store::{TaskSettingsLocation, TaskStore}, *, }; use rand::{Rng as _, rngs::StdRng}; use serde_json::json; use settings::SettingsStore; #[cfg(not(windows))] use std::os; use std::{ cell::RefCell, env, mem, num::NonZeroU32, ops::Range, path::{Path, PathBuf}, rc::Rc, str::FromStr, sync::{Arc, OnceLock, atomic}, task::Poll, time::Duration, }; use sum_tree::SumTree; use task::{ResolvedTask, ShellKind, TaskContext}; use text::{Anchor, PointUtf16, ReplicaId, ToOffset, Unclipped}; use unindent::Unindent as _; use util::{ TryFutureExt as _, assert_set_eq, maybe, path, paths::{PathMatcher, PathStyle}, rel_path::{RelPath, rel_path}, test::{TempTree, marked_text_offsets}, uri, }; use worktree::WorktreeModelHandle as _; #[gpui::test] async fn test_block_via_channel(cx: &mut gpui::TestAppContext) { cx.executor().allow_parking(); let (tx, mut rx) = futures::channel::mpsc::unbounded(); let _thread = std::thread::spawn(move || { #[cfg(not(target_os = "windows"))] std::fs::metadata("/tmp").unwrap(); #[cfg(target_os = "windows")] std::fs::metadata("C:/Windows").unwrap(); std::thread::sleep(Duration::from_millis(1000)); tx.unbounded_send(1).unwrap(); }); rx.next().await.unwrap(); } #[gpui::test] async fn test_block_via_smol(cx: &mut gpui::TestAppContext) { cx.executor().allow_parking(); let io_task = smol::unblock(move || { println!("sleeping on thread {:?}", std::thread::current().id()); std::thread::sleep(Duration::from_millis(10)); 1 }); let task = cx.foreground_executor().spawn(async move { io_task.await; }); task.await; } #[gpui::test] async fn test_default_session_work_dirs_prefers_directory_worktrees_over_single_file_parents( cx: &mut gpui::TestAppContext, ) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), json!({ "dir-project": { "src": { "main.rs": "fn main() {}" } }, "single-file.rs": "fn helper() {}" }), ) .await; let project = Project::test( fs, [ Path::new(path!("/root/single-file.rs")), Path::new(path!("/root/dir-project")), ], cx, ) .await; let work_dirs = project.read_with(cx, |project, cx| project.default_path_list(cx)); let ordered_paths = work_dirs.ordered_paths().cloned().collect::>(); assert_eq!( ordered_paths, vec![ PathBuf::from(path!("/root/dir-project")), PathBuf::from(path!("/root")), ] ); } #[gpui::test] async fn test_default_session_work_dirs_falls_back_to_home_for_empty_project( cx: &mut gpui::TestAppContext, ) { init_test(cx); let fs = FakeFs::new(cx.executor()); let project = Project::test(fs, [], cx).await; let work_dirs = project.read_with(cx, |project, cx| project.default_path_list(cx)); let ordered_paths = work_dirs.ordered_paths().cloned().collect::>(); assert_eq!(ordered_paths, vec![paths::home_dir().to_path_buf()]); } // NOTE: // While POSIX symbolic links are somewhat supported on Windows, they are an opt in by the user, and thus // we assume that they are not supported out of the box. #[cfg(not(windows))] #[gpui::test] async fn test_symlinks(cx: &mut gpui::TestAppContext) { init_test(cx); cx.executor().allow_parking(); let dir = TempTree::new(json!({ "root": { "apple": "", "banana": { "carrot": { "date": "", "endive": "", } }, "fennel": { "grape": "", } } })); let root_link_path = dir.path().join("root_link"); os::unix::fs::symlink(dir.path().join("root"), &root_link_path).unwrap(); os::unix::fs::symlink( dir.path().join("root/fennel"), dir.path().join("root/finnochio"), ) .unwrap(); let project = Project::test( Arc::new(RealFs::new(None, cx.executor())), [root_link_path.as_ref()], cx, ) .await; project.update(cx, |project, cx| { let tree = project.worktrees(cx).next().unwrap().read(cx); assert_eq!(tree.file_count(), 5); assert_eq!( tree.entry_for_path(rel_path("fennel/grape")).unwrap().inode, tree.entry_for_path(rel_path("finnochio/grape")) .unwrap() .inode ); }); } #[gpui::test] async fn test_editorconfig_support(cx: &mut gpui::TestAppContext) { init_test(cx); let dir = TempTree::new(json!({ ".editorconfig": r#" root = true [*.rs] indent_style = tab indent_size = 3 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true max_line_length = 120 [*.js] tab_width = 10 max_line_length = off "#, ".zed": { "settings.json": r#"{ "tab_size": 8, "hard_tabs": false, "ensure_final_newline_on_save": false, "remove_trailing_whitespace_on_save": false, "preferred_line_length": 64, "soft_wrap": "editor_width", }"#, }, "a.rs": "fn a() {\n A\n}", "b": { ".editorconfig": r#" [*.rs] indent_size = 2 max_line_length = off, "#, "b.rs": "fn b() {\n B\n}", }, "c.js": "def c\n C\nend", "d": { ".editorconfig": r#" [*.rs] indent_size = 1 "#, "d.rs": "fn d() {\n D\n}", }, "README.json": "tabs are better\n", })); let path = dir.path(); let fs = FakeFs::new(cx.executor()); fs.insert_tree_from_real_fs(path, path).await; let project = Project::test(fs, [path], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(js_lang()); language_registry.add(json_lang()); language_registry.add(rust_lang()); let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); cx.executor().run_until_parked(); let settings_for = async |path: &str, cx: &mut TestAppContext| -> LanguageSettings { let buffer = project .update(cx, |project, cx| { project.open_buffer((worktree.read(cx).id(), rel_path(path)), cx) }) .await .unwrap(); cx.update(|cx| LanguageSettings::for_buffer(&buffer.read(cx), cx).into_owned()) }; let settings_a = settings_for("a.rs", cx).await; let settings_b = settings_for("b/b.rs", cx).await; let settings_c = settings_for("c.js", cx).await; let settings_d = settings_for("d/d.rs", cx).await; let settings_readme = settings_for("README.json", cx).await; // .editorconfig overrides .zed/settings assert_eq!(Some(settings_a.tab_size), NonZeroU32::new(3)); assert_eq!(settings_a.hard_tabs, true); assert_eq!(settings_a.ensure_final_newline_on_save, true); assert_eq!(settings_a.remove_trailing_whitespace_on_save, true); assert_eq!(settings_a.line_ending, LineEndingSetting::EnforceLf); assert_eq!(settings_a.preferred_line_length, 120); // .editorconfig in b/ overrides .editorconfig in root assert_eq!(Some(settings_b.tab_size), NonZeroU32::new(2)); // .editorconfig in subdirectory overrides .editorconfig in root assert_eq!(Some(settings_d.tab_size), NonZeroU32::new(1)); // "indent_size" is not set, so "tab_width" is used assert_eq!(Some(settings_c.tab_size), NonZeroU32::new(10)); // When max_line_length is "off", default to .zed/settings.json assert_eq!(settings_b.preferred_line_length, 64); assert_eq!(settings_c.preferred_line_length, 64); // README.md should not be affected by .editorconfig's globe "*.rs" assert_eq!(Some(settings_readme.tab_size), NonZeroU32::new(8)); } #[gpui::test] async fn test_external_editorconfig_support(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/grandparent"), json!({ ".editorconfig": "[*]\nindent_size = 4\n", "parent": { ".editorconfig": "[*.rs]\nindent_size = 2\n", "worktree": { ".editorconfig": "[*.md]\nindent_size = 3\n", "main.rs": "fn main() {}", "README.md": "# README", "other.txt": "other content", } } }), ) .await; let project = Project::test(fs, [path!("/grandparent/parent/worktree").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); language_registry.add(markdown_lang()); let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); cx.executor().run_until_parked(); let settings_for = async |path: &str, cx: &mut TestAppContext| -> LanguageSettings { let buffer = project .update(cx, |project, cx| { project.open_buffer((worktree.read(cx).id(), rel_path(path)), cx) }) .await .unwrap(); cx.update(|cx| LanguageSettings::for_buffer(&buffer.read(cx), cx).into_owned()) }; let settings_rs = settings_for("main.rs", cx).await; let settings_md = settings_for("README.md", cx).await; let settings_txt = settings_for("other.txt", cx).await; // main.rs gets indent_size = 2 from parent's external .editorconfig assert_eq!(Some(settings_rs.tab_size), NonZeroU32::new(2)); // README.md gets indent_size = 3 from internal worktree .editorconfig assert_eq!(Some(settings_md.tab_size), NonZeroU32::new(3)); // other.txt gets indent_size = 4 from grandparent's external .editorconfig assert_eq!(Some(settings_txt.tab_size), NonZeroU32::new(4)); } #[gpui::test] async fn test_internal_editorconfig_root_stops_traversal(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/worktree"), json!({ ".editorconfig": "[*]\nindent_size = 99\n", "src": { ".editorconfig": "root = true\n[*]\nindent_size = 2\n", "file.rs": "fn main() {}", } }), ) .await; let project = Project::test(fs, [path!("/worktree").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); cx.executor().run_until_parked(); let buffer = project .update(cx, |project, cx| { project.open_buffer((worktree.read(cx).id(), rel_path("src/file.rs")), cx) }) .await .unwrap(); cx.update(|cx| { let settings = LanguageSettings::for_buffer(buffer.read(cx), cx).into_owned(); assert_eq!(Some(settings.tab_size), NonZeroU32::new(2)); }); } #[gpui::test] async fn test_external_editorconfig_root_stops_traversal(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/parent"), json!({ ".editorconfig": "[*]\nindent_size = 99\n", "worktree": { ".editorconfig": "root = true\n[*]\nindent_size = 2\n", "file.rs": "fn main() {}", } }), ) .await; let project = Project::test(fs, [path!("/parent/worktree").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); cx.executor().run_until_parked(); let buffer = project .update(cx, |project, cx| { project.open_buffer((worktree.read(cx).id(), rel_path("file.rs")), cx) }) .await .unwrap(); cx.update(|cx| { let settings = LanguageSettings::for_buffer(&buffer.read(cx), cx); // file.rs gets indent_size = 2 from worktree's root config, NOT 99 from parent assert_eq!(Some(settings.tab_size), NonZeroU32::new(2)); }); } #[gpui::test] async fn test_external_editorconfig_root_in_parent_stops_traversal(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/grandparent"), json!({ ".editorconfig": "[*]\nindent_size = 99\n", "parent": { ".editorconfig": "root = true\n[*]\nindent_size = 4\n", "worktree": { "file.rs": "fn main() {}", } } }), ) .await; let project = Project::test(fs, [path!("/grandparent/parent/worktree").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); cx.executor().run_until_parked(); let buffer = project .update(cx, |project, cx| { project.open_buffer((worktree.read(cx).id(), rel_path("file.rs")), cx) }) .await .unwrap(); cx.update(|cx| { let settings = LanguageSettings::for_buffer(&buffer.read(cx), cx); // file.rs gets indent_size = 4 from parent's root config, NOT 99 from grandparent assert_eq!(Some(settings.tab_size), NonZeroU32::new(4)); }); } #[gpui::test] async fn test_external_editorconfig_shared_across_worktrees(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/parent"), json!({ ".editorconfig": "root = true\n[*]\nindent_size = 5\n", "worktree_a": { "file.rs": "fn a() {}", ".editorconfig": "[*]\ninsert_final_newline = true\n", }, "worktree_b": { "file.rs": "fn b() {}", ".editorconfig": "[*]\ninsert_final_newline = false\n", } }), ) .await; let project = Project::test( fs, [ path!("/parent/worktree_a").as_ref(), path!("/parent/worktree_b").as_ref(), ], cx, ) .await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); cx.executor().run_until_parked(); let worktrees: Vec<_> = cx.update(|cx| project.read(cx).worktrees(cx).collect()); assert_eq!(worktrees.len(), 2); for worktree in worktrees { let buffer = project .update(cx, |project, cx| { project.open_buffer((worktree.read(cx).id(), rel_path("file.rs")), cx) }) .await .unwrap(); cx.update(|cx| { let settings = LanguageSettings::for_buffer(&buffer.read(cx), cx); // Both worktrees should get indent_size = 5 from shared parent .editorconfig assert_eq!(Some(settings.tab_size), NonZeroU32::new(5)); }); } } #[gpui::test] async fn test_external_editorconfig_not_loaded_without_internal_config( cx: &mut gpui::TestAppContext, ) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/parent"), json!({ ".editorconfig": "[*]\nindent_size = 99\n", "worktree": { "file.rs": "fn main() {}", } }), ) .await; let project = Project::test(fs, [path!("/parent/worktree").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); cx.executor().run_until_parked(); let buffer = project .update(cx, |project, cx| { project.open_buffer((worktree.read(cx).id(), rel_path("file.rs")), cx) }) .await .unwrap(); cx.update(|cx| { let settings = LanguageSettings::for_buffer(&buffer.read(cx), cx); // file.rs should have default tab_size = 4, NOT 99 from parent's external .editorconfig // because without an internal .editorconfig, external configs are not loaded assert_eq!(Some(settings.tab_size), NonZeroU32::new(4)); }); } #[gpui::test] async fn test_external_editorconfig_modification_triggers_refresh(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/parent"), json!({ ".editorconfig": "[*]\nindent_size = 4\n", "worktree": { ".editorconfig": "[*]\n", "file.rs": "fn main() {}", } }), ) .await; let project = Project::test(fs.clone(), [path!("/parent/worktree").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); cx.executor().run_until_parked(); let buffer = project .update(cx, |project, cx| { project.open_buffer((worktree.read(cx).id(), rel_path("file.rs")), cx) }) .await .unwrap(); cx.update(|cx| { let settings = LanguageSettings::for_buffer(&buffer.read(cx), cx); // Test initial settings: tab_size = 4 from parent's external .editorconfig assert_eq!(Some(settings.tab_size), NonZeroU32::new(4)); }); fs.atomic_write( PathBuf::from(path!("/parent/.editorconfig")), "[*]\nindent_size = 8\n".to_owned(), ) .await .unwrap(); cx.executor().run_until_parked(); let buffer = project .update(cx, |project, cx| { project.open_buffer((worktree.read(cx).id(), rel_path("file.rs")), cx) }) .await .unwrap(); cx.update(|cx| { let settings = LanguageSettings::for_buffer(&buffer.read(cx), cx); // Test settings updated: tab_size = 8 assert_eq!(Some(settings.tab_size), NonZeroU32::new(8)); }); } #[gpui::test] async fn test_adding_worktree_discovers_external_editorconfigs(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/parent"), json!({ ".editorconfig": "root = true\n[*]\nindent_size = 7\n", "existing_worktree": { ".editorconfig": "[*]\n", "file.rs": "fn a() {}", }, "new_worktree": { ".editorconfig": "[*]\n", "file.rs": "fn b() {}", } }), ) .await; let project = Project::test(fs, [path!("/parent/existing_worktree").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); cx.executor().run_until_parked(); let buffer = project .update(cx, |project, cx| { let id = project.worktrees(cx).next().unwrap().read(cx).id(); project.open_buffer((id, rel_path("file.rs")), cx) }) .await .unwrap(); cx.update(|cx| { let settings = LanguageSettings::for_buffer(&buffer.read(cx), cx).into_owned(); // Test existing worktree has tab_size = 7 assert_eq!(Some(settings.tab_size), NonZeroU32::new(7)); }); let (new_worktree, _) = project .update(cx, |project, cx| { project.find_or_create_worktree(path!("/parent/new_worktree"), true, cx) }) .await .unwrap(); cx.executor().run_until_parked(); let buffer = project .update(cx, |project, cx| { project.open_buffer((new_worktree.read(cx).id(), rel_path("file.rs")), cx) }) .await .unwrap(); cx.update(|cx| { let settings = LanguageSettings::for_buffer(&buffer.read(cx), cx); // Verify new worktree also has tab_size = 7 from shared parent editorconfig assert_eq!(Some(settings.tab_size), NonZeroU32::new(7)); }); } #[gpui::test] async fn test_removing_worktree_cleans_up_external_editorconfig(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/parent"), json!({ ".editorconfig": "[*]\nindent_size = 6\n", "worktree": { ".editorconfig": "[*]\n", "file.rs": "fn main() {}", } }), ) .await; let project = Project::test(fs, [path!("/parent/worktree").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); let worktree_id = worktree.read_with(cx, |tree, _| tree.id()); cx.executor().run_until_parked(); cx.update(|cx| { let store = cx.global::(); let (worktree_ids, external_paths, watcher_paths) = store.editorconfig_store.read(cx).test_state(); // Test external config is loaded assert!(worktree_ids.contains(&worktree_id)); assert!(!external_paths.is_empty()); assert!(!watcher_paths.is_empty()); }); project.update(cx, |project, cx| { project.remove_worktree(worktree_id, cx); }); cx.executor().run_until_parked(); cx.update(|cx| { let store = cx.global::(); let (worktree_ids, external_paths, watcher_paths) = store.editorconfig_store.read(cx).test_state(); // Test worktree state, external configs, and watchers all removed assert!(!worktree_ids.contains(&worktree_id)); assert!(external_paths.is_empty()); assert!(watcher_paths.is_empty()); }); } #[gpui::test] async fn test_shared_external_editorconfig_cleanup_with_multiple_worktrees( cx: &mut gpui::TestAppContext, ) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/parent"), json!({ ".editorconfig": "root = true\n[*]\nindent_size = 5\n", "worktree_a": { ".editorconfig": "[*]\n", "file.rs": "fn a() {}", }, "worktree_b": { ".editorconfig": "[*]\n", "file.rs": "fn b() {}", } }), ) .await; let project = Project::test( fs, [ path!("/parent/worktree_a").as_ref(), path!("/parent/worktree_b").as_ref(), ], cx, ) .await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); cx.executor().run_until_parked(); let (worktree_a_id, worktree_b, worktree_b_id) = cx.update(|cx| { let worktrees: Vec<_> = project.read(cx).worktrees(cx).collect(); assert_eq!(worktrees.len(), 2); let worktree_a = &worktrees[0]; let worktree_b = &worktrees[1]; let worktree_a_id = worktree_a.read(cx).id(); let worktree_b_id = worktree_b.read(cx).id(); (worktree_a_id, worktree_b.clone(), worktree_b_id) }); cx.update(|cx| { let store = cx.global::(); let (worktree_ids, external_paths, _) = store.editorconfig_store.read(cx).test_state(); // Test both worktrees have settings and share external config assert!(worktree_ids.contains(&worktree_a_id)); assert!(worktree_ids.contains(&worktree_b_id)); assert_eq!(external_paths.len(), 1); // single shared external config }); project.update(cx, |project, cx| { project.remove_worktree(worktree_a_id, cx); }); cx.executor().run_until_parked(); cx.update(|cx| { let store = cx.global::(); let (worktree_ids, external_paths, watcher_paths) = store.editorconfig_store.read(cx).test_state(); // Test worktree_a is gone but external config remains for worktree_b assert!(!worktree_ids.contains(&worktree_a_id)); assert!(worktree_ids.contains(&worktree_b_id)); // External config should still exist because worktree_b uses it assert_eq!(external_paths.len(), 1); assert_eq!(watcher_paths.len(), 1); }); let buffer = project .update(cx, |project, cx| { project.open_buffer((worktree_b.read(cx).id(), rel_path("file.rs")), cx) }) .await .unwrap(); cx.update(|cx| { let settings = LanguageSettings::for_buffer(&buffer.read(cx), cx); // Test worktree_b still has correct settings assert_eq!(Some(settings.tab_size), NonZeroU32::new(5)); }); } #[gpui::test] async fn test_git_provider_project_setting(cx: &mut gpui::TestAppContext) { init_test(cx); cx.update(|cx| { GitHostingProviderRegistry::default_global(cx); git_hosting_providers::init(cx); }); let fs = FakeFs::new(cx.executor()); let str_path = path!("/dir"); let path = Path::new(str_path); fs.insert_tree( path!("/dir"), json!({ ".zed": { "settings.json": r#"{ "git_hosting_providers": [ { "provider": "gitlab", "base_url": "https://google.com", "name": "foo" } ] }"# }, }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let (_worktree, _) = project.read_with(cx, |project, cx| project.find_worktree(path, cx).unwrap()); cx.executor().run_until_parked(); cx.update(|cx| { let provider = GitHostingProviderRegistry::global(cx); assert!( provider .list_hosting_providers() .into_iter() .any(|provider| provider.name() == "foo") ); }); fs.atomic_write( Path::new(path!("/dir/.zed/settings.json")).to_owned(), "{}".into(), ) .await .unwrap(); cx.run_until_parked(); cx.update(|cx| { let provider = GitHostingProviderRegistry::global(cx); assert!( !provider .list_hosting_providers() .into_iter() .any(|provider| provider.name() == "foo") ); }); } #[gpui::test] async fn test_managing_project_specific_settings(cx: &mut gpui::TestAppContext) { init_test(cx); TaskStore::init(None); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ ".zed": { "settings.json": r#"{ "tab_size": 8 }"#, "tasks.json": r#"[{ "label": "cargo check all", "command": "cargo", "args": ["check", "--all"] },]"#, }, "a": { "a.rs": "fn a() {\n A\n}" }, "b": { ".zed": { "settings.json": r#"{ "tab_size": 2 }"#, "tasks.json": r#"[{ "label": "cargo check", "command": "cargo", "args": ["check"] },]"#, }, "b.rs": "fn b() {\n B\n}" } }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); cx.executor().run_until_parked(); let worktree_id = cx.update(|cx| { project.update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }) }); let mut task_contexts = TaskContexts::default(); task_contexts.active_worktree_context = Some((worktree_id, TaskContext::default())); let task_contexts = Arc::new(task_contexts); let topmost_local_task_source_kind = TaskSourceKind::Worktree { id: worktree_id, directory_in_worktree: rel_path(".zed").into(), id_base: "local worktree tasks from directory \".zed\"".into(), }; let buffer_a = project .update(cx, |project, cx| { project.open_buffer((worktree.read(cx).id(), rel_path("a/a.rs")), cx) }) .await .unwrap(); let buffer_b = project .update(cx, |project, cx| { project.open_buffer((worktree.read(cx).id(), rel_path("b/b.rs")), cx) }) .await .unwrap(); cx.update(|cx| { let settings_a = LanguageSettings::for_buffer(&buffer_a.read(cx), cx); let settings_b = LanguageSettings::for_buffer(&buffer_b.read(cx), cx); assert_eq!(settings_a.tab_size.get(), 8); assert_eq!(settings_b.tab_size.get(), 2); }); let all_tasks = cx .update(|cx| get_all_tasks(&project, task_contexts.clone(), cx)) .await .into_iter() .map(|(source_kind, task)| { let resolved = task.resolved; ( source_kind, task.resolved_label, resolved.args, resolved.env, ) }) .collect::>(); assert_eq!( all_tasks, vec![ ( TaskSourceKind::Worktree { id: worktree_id, directory_in_worktree: rel_path("b/.zed").into(), id_base: "local worktree tasks from directory \"b/.zed\"".into() }, "cargo check".to_string(), vec!["check".to_string()], HashMap::default(), ), ( topmost_local_task_source_kind.clone(), "cargo check all".to_string(), vec!["check".to_string(), "--all".to_string()], HashMap::default(), ), ] ); let (_, resolved_task) = cx .update(|cx| get_all_tasks(&project, task_contexts.clone(), cx)) .await .into_iter() .find(|(source_kind, _)| source_kind == &topmost_local_task_source_kind) .expect("should have one global task"); project.update(cx, |project, cx| { let task_inventory = project .task_store() .read(cx) .task_inventory() .cloned() .unwrap(); task_inventory.update(cx, |inventory, _| { inventory.task_scheduled(topmost_local_task_source_kind.clone(), resolved_task); inventory .update_file_based_tasks( TaskSettingsLocation::Global(tasks_file()), Some( &json!([{ "label": "cargo check unstable", "command": "cargo", "args": [ "check", "--all", "--all-targets" ], "env": { "RUSTFLAGS": "-Zunstable-options" } }]) .to_string(), ), ) .unwrap(); }); }); cx.run_until_parked(); let all_tasks = cx .update(|cx| get_all_tasks(&project, task_contexts.clone(), cx)) .await .into_iter() .map(|(source_kind, task)| { let resolved = task.resolved; ( source_kind, task.resolved_label, resolved.args, resolved.env, ) }) .collect::>(); assert_eq!( all_tasks, vec![ ( topmost_local_task_source_kind.clone(), "cargo check all".to_string(), vec!["check".to_string(), "--all".to_string()], HashMap::default(), ), ( TaskSourceKind::Worktree { id: worktree_id, directory_in_worktree: rel_path("b/.zed").into(), id_base: "local worktree tasks from directory \"b/.zed\"".into() }, "cargo check".to_string(), vec!["check".to_string()], HashMap::default(), ), ( TaskSourceKind::AbsPath { abs_path: paths::tasks_file().clone(), id_base: "global tasks.json".into(), }, "cargo check unstable".to_string(), vec![ "check".to_string(), "--all".to_string(), "--all-targets".to_string(), ], HashMap::from_iter(Some(( "RUSTFLAGS".to_string(), "-Zunstable-options".to_string() ))), ), ] ); } #[gpui::test] async fn test_invalid_local_tasks_shows_toast_with_doc_link(cx: &mut gpui::TestAppContext) { init_test(cx); TaskStore::init(None); // We need to start with a valid `.zed/tasks.json` file as otherwise the // event is emitted before we havd a chance to setup the event subscription. let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ ".zed": { "tasks.json": r#"[{ "label": "valid task", "command": "echo" }]"#, }, "file.rs": "" }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let saw_toast = Rc::new(RefCell::new(false)); // Update the `.zed/tasks.json` file with an invalid variable, so we can // later assert that the `Event::Toast` even is emitted. fs.save( path!("/dir/.zed/tasks.json").as_ref(), &r#"[{ "label": "test $ZED_FOO", "command": "echo" }]"#.into(), Default::default(), ) .await .unwrap(); project.update(cx, |_, cx| { let saw_toast = saw_toast.clone(); cx.subscribe(&project, move |_, _, event: &Event, _| match event { Event::Toast { notification_id, message, link: Some(ToastLink { url, .. }), } => { assert!(notification_id.starts_with("local-tasks-")); assert!(message.contains("ZED_FOO")); assert_eq!(*url, "https://zed.dev/docs/tasks"); *saw_toast.borrow_mut() = true; } _ => {} }) .detach(); }); cx.run_until_parked(); assert!( *saw_toast.borrow(), "Expected `Event::Toast` was never emitted" ); } #[gpui::test] async fn test_fallback_to_single_worktree_tasks(cx: &mut gpui::TestAppContext) { init_test(cx); TaskStore::init(None); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ ".zed": { "tasks.json": r#"[{ "label": "test worktree root", "command": "echo $ZED_WORKTREE_ROOT" }]"#, }, "a": { "a.rs": "fn a() {\n A\n}" }, }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let _worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); cx.executor().run_until_parked(); let worktree_id = cx.update(|cx| { project.update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }) }); let active_non_worktree_item_tasks = cx .update(|cx| { get_all_tasks( &project, Arc::new(TaskContexts { active_item_context: Some((Some(worktree_id), None, TaskContext::default())), active_worktree_context: None, other_worktree_contexts: Vec::new(), lsp_task_sources: HashMap::default(), latest_selection: None, }), cx, ) }) .await; assert!( active_non_worktree_item_tasks.is_empty(), "A task can not be resolved with context with no ZED_WORKTREE_ROOT data" ); let active_worktree_tasks = cx .update(|cx| { get_all_tasks( &project, Arc::new(TaskContexts { active_item_context: Some((Some(worktree_id), None, TaskContext::default())), active_worktree_context: Some((worktree_id, { let mut worktree_context = TaskContext::default(); worktree_context .task_variables .insert(task::VariableName::WorktreeRoot, "/dir".to_string()); worktree_context })), other_worktree_contexts: Vec::new(), lsp_task_sources: HashMap::default(), latest_selection: None, }), cx, ) }) .await; assert_eq!( active_worktree_tasks .into_iter() .map(|(source_kind, task)| { let resolved = task.resolved; (source_kind, resolved.command.unwrap()) }) .collect::>(), vec![( TaskSourceKind::Worktree { id: worktree_id, directory_in_worktree: rel_path(".zed").into(), id_base: "local worktree tasks from directory \".zed\"".into(), }, "echo /dir".to_string(), )] ); } #[gpui::test] async fn test_running_multiple_instances_of_a_single_server_in_one_worktree( cx: &mut gpui::TestAppContext, ) { pub(crate) struct PyprojectTomlManifestProvider; impl ManifestProvider for PyprojectTomlManifestProvider { fn name(&self) -> ManifestName { SharedString::new_static("pyproject.toml").into() } fn search( &self, ManifestQuery { path, depth, delegate, }: ManifestQuery, ) -> Option> { const WORKSPACE_LOCKFILES: &[&str] = &["uv.lock", "poetry.lock", "pdm.lock", "Pipfile.lock"]; let mut innermost_pyproject = None; let mut outermost_workspace_root = None; for path in path.ancestors().take(depth) { let pyproject_path = path.join(rel_path("pyproject.toml")); if delegate.exists(&pyproject_path, Some(false)) { if innermost_pyproject.is_none() { innermost_pyproject = Some(Arc::from(path)); } let has_lockfile = WORKSPACE_LOCKFILES.iter().any(|lockfile| { let lockfile_path = path.join(rel_path(lockfile)); delegate.exists(&lockfile_path, Some(false)) }); if has_lockfile { outermost_workspace_root = Some(Arc::from(path)); } } } outermost_workspace_root.or(innermost_pyproject) } } init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/the-root"), json!({ ".zed": { "settings.json": r#" { "languages": { "Python": { "language_servers": ["ty"] } } }"# }, "project-a": { ".venv": {}, "file.py": "", "pyproject.toml": "" }, "project-b": { ".venv": {}, "source_file.py":"", "another_file.py": "", "pyproject.toml": "" } }), ) .await; cx.update(|cx| { ManifestProvidersStore::global(cx).register(Arc::new(PyprojectTomlManifestProvider)) }); let project = Project::test(fs.clone(), [path!("/the-root").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); let _fake_python_server = language_registry.register_fake_lsp( "Python", FakeLspAdapter { name: "ty", capabilities: lsp::ServerCapabilities { ..Default::default() }, ..Default::default() }, ); language_registry.add(python_lang(fs.clone())); let (first_buffer, _handle) = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/the-root/project-a/file.py"), cx) }) .await .unwrap(); cx.executor().run_until_parked(); let servers = project.update(cx, |project, cx| { project.lsp_store().update(cx, |this, cx| { first_buffer.update(cx, |buffer, cx| { this.running_language_servers_for_local_buffer(buffer, cx) .map(|(adapter, server)| (adapter.clone(), server.clone())) .collect::>() }) }) }); cx.executor().run_until_parked(); assert_eq!(servers.len(), 1); let (adapter, server) = servers.into_iter().next().unwrap(); assert_eq!(adapter.name(), LanguageServerName::new_static("ty")); assert_eq!(server.server_id(), LanguageServerId(0)); // `workspace_folders` are set to the rooting point. assert_eq!( server.workspace_folders(), BTreeSet::from_iter( [Uri::from_file_path(path!("/the-root/project-a")).unwrap()].into_iter() ) ); let (second_project_buffer, _other_handle) = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/the-root/project-b/source_file.py"), cx) }) .await .unwrap(); cx.executor().run_until_parked(); let servers = project.update(cx, |project, cx| { project.lsp_store().update(cx, |this, cx| { second_project_buffer.update(cx, |buffer, cx| { this.running_language_servers_for_local_buffer(buffer, cx) .map(|(adapter, server)| (adapter.clone(), server.clone())) .collect::>() }) }) }); cx.executor().run_until_parked(); assert_eq!(servers.len(), 1); let (adapter, server) = servers.into_iter().next().unwrap(); assert_eq!(adapter.name(), LanguageServerName::new_static("ty")); // We're not using venvs at all here, so both folders should fall under the same root. assert_eq!(server.server_id(), LanguageServerId(0)); // Now, let's select a different toolchain for one of subprojects. let Toolchains { toolchains: available_toolchains_for_b, root_path, .. } = project .update(cx, |this, cx| { let worktree_id = this.worktrees(cx).next().unwrap().read(cx).id(); this.available_toolchains( ProjectPath { worktree_id, path: rel_path("project-b/source_file.py").into(), }, LanguageName::new_static("Python"), cx, ) }) .await .expect("A toolchain to be discovered"); assert_eq!(root_path.as_ref(), rel_path("project-b")); assert_eq!(available_toolchains_for_b.toolchains().len(), 1); let currently_active_toolchain = project .update(cx, |this, cx| { let worktree_id = this.worktrees(cx).next().unwrap().read(cx).id(); this.active_toolchain( ProjectPath { worktree_id, path: rel_path("project-b/source_file.py").into(), }, LanguageName::new_static("Python"), cx, ) }) .await; assert!(currently_active_toolchain.is_none()); let _ = project .update(cx, |this, cx| { let worktree_id = this.worktrees(cx).next().unwrap().read(cx).id(); this.activate_toolchain( ProjectPath { worktree_id, path: root_path, }, available_toolchains_for_b .toolchains .into_iter() .next() .unwrap(), cx, ) }) .await .unwrap(); cx.run_until_parked(); let servers = project.update(cx, |project, cx| { project.lsp_store().update(cx, |this, cx| { second_project_buffer.update(cx, |buffer, cx| { this.running_language_servers_for_local_buffer(buffer, cx) .map(|(adapter, server)| (adapter.clone(), server.clone())) .collect::>() }) }) }); cx.executor().run_until_parked(); assert_eq!(servers.len(), 1); let (adapter, server) = servers.into_iter().next().unwrap(); assert_eq!(adapter.name(), LanguageServerName::new_static("ty")); // There's a new language server in town. assert_eq!(server.server_id(), LanguageServerId(1)); } #[gpui::test] async fn test_managing_language_servers(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "test.rs": "const A: i32 = 1;", "test2.rs": "", "Cargo.toml": "a = 1", "package.json": "{\"a\": 1}", }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); let mut fake_rust_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { name: "the-rust-language-server", capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string(), "::".to_string()]), ..Default::default() }), text_document_sync: Some(lsp::TextDocumentSyncCapability::Options( lsp::TextDocumentSyncOptions { save: Some(lsp::TextDocumentSyncSaveOptions::Supported(true)), ..Default::default() }, )), ..Default::default() }, ..Default::default() }, ); let mut fake_json_servers = language_registry.register_fake_lsp( "JSON", FakeLspAdapter { name: "the-json-language-server", capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![":".to_string()]), ..Default::default() }), text_document_sync: Some(lsp::TextDocumentSyncCapability::Options( lsp::TextDocumentSyncOptions { save: Some(lsp::TextDocumentSyncSaveOptions::Supported(true)), ..Default::default() }, )), ..Default::default() }, ..Default::default() }, ); // Open a buffer without an associated language server. let (toml_buffer, _handle) = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/Cargo.toml"), cx) }) .await .unwrap(); // Open a buffer with an associated language server before the language for it has been loaded. let (rust_buffer, _handle2) = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/test.rs"), cx) }) .await .unwrap(); rust_buffer.update(cx, |buffer, _| { assert_eq!(buffer.language().map(|l| l.name()), None); }); // Now we add the languages to the project, and ensure they get assigned to all // the relevant open buffers. language_registry.add(json_lang()); language_registry.add(rust_lang()); cx.executor().run_until_parked(); rust_buffer.update(cx, |buffer, _| { assert_eq!(buffer.language().map(|l| l.name()), Some("Rust".into())); }); // A server is started up, and it is notified about Rust files. let mut fake_rust_server = fake_rust_servers.next().await.unwrap(); assert_eq!( fake_rust_server .receive_notification::() .await .text_document, lsp::TextDocumentItem { uri: lsp::Uri::from_file_path(path!("/dir/test.rs")).unwrap(), version: 0, text: "const A: i32 = 1;".to_string(), language_id: "rust".to_string(), } ); // The buffer is configured based on the language server's capabilities. rust_buffer.update(cx, |buffer, _| { assert_eq!( buffer .completion_triggers() .iter() .cloned() .collect::>(), &[".".to_string(), "::".to_string()] ); }); toml_buffer.update(cx, |buffer, _| { assert!(buffer.completion_triggers().is_empty()); }); // Edit a buffer. The changes are reported to the language server. rust_buffer.update(cx, |buffer, cx| buffer.edit([(16..16, "2")], None, cx)); assert_eq!( fake_rust_server .receive_notification::() .await .text_document, lsp::VersionedTextDocumentIdentifier::new( lsp::Uri::from_file_path(path!("/dir/test.rs")).unwrap(), 1 ) ); // Open a third buffer with a different associated language server. let (json_buffer, _json_handle) = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/package.json"), cx) }) .await .unwrap(); // A json language server is started up and is only notified about the json buffer. let mut fake_json_server = fake_json_servers.next().await.unwrap(); assert_eq!( fake_json_server .receive_notification::() .await .text_document, lsp::TextDocumentItem { uri: lsp::Uri::from_file_path(path!("/dir/package.json")).unwrap(), version: 0, text: "{\"a\": 1}".to_string(), language_id: "json".to_string(), } ); // This buffer is configured based on the second language server's // capabilities. json_buffer.update(cx, |buffer, _| { assert_eq!( buffer .completion_triggers() .iter() .cloned() .collect::>(), &[":".to_string()] ); }); // When opening another buffer whose language server is already running, // it is also configured based on the existing language server's capabilities. let (rust_buffer2, _handle4) = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/test2.rs"), cx) }) .await .unwrap(); rust_buffer2.update(cx, |buffer, _| { assert_eq!( buffer .completion_triggers() .iter() .cloned() .collect::>(), &[".".to_string(), "::".to_string()] ); }); // Changes are reported only to servers matching the buffer's language. toml_buffer.update(cx, |buffer, cx| buffer.edit([(5..5, "23")], None, cx)); rust_buffer2.update(cx, |buffer, cx| { buffer.edit([(0..0, "let x = 1;")], None, cx) }); assert_eq!( fake_rust_server .receive_notification::() .await .text_document, lsp::VersionedTextDocumentIdentifier::new( lsp::Uri::from_file_path(path!("/dir/test2.rs")).unwrap(), 1 ) ); // Save notifications are reported to all servers. project .update(cx, |project, cx| project.save_buffer(toml_buffer, cx)) .await .unwrap(); assert_eq!( fake_rust_server .receive_notification::() .await .text_document, lsp::TextDocumentIdentifier::new( lsp::Uri::from_file_path(path!("/dir/Cargo.toml")).unwrap() ) ); assert_eq!( fake_json_server .receive_notification::() .await .text_document, lsp::TextDocumentIdentifier::new( lsp::Uri::from_file_path(path!("/dir/Cargo.toml")).unwrap() ) ); // Renames are reported only to servers matching the buffer's language. fs.rename( Path::new(path!("/dir/test2.rs")), Path::new(path!("/dir/test3.rs")), Default::default(), ) .await .unwrap(); assert_eq!( fake_rust_server .receive_notification::() .await .text_document, lsp::TextDocumentIdentifier::new(lsp::Uri::from_file_path(path!("/dir/test2.rs")).unwrap()), ); assert_eq!( fake_rust_server .receive_notification::() .await .text_document, lsp::TextDocumentItem { uri: lsp::Uri::from_file_path(path!("/dir/test3.rs")).unwrap(), version: 0, text: rust_buffer2.update(cx, |buffer, _| buffer.text()), language_id: "rust".to_string(), }, ); rust_buffer2.update(cx, |buffer, cx| { buffer.update_diagnostics( LanguageServerId(0), DiagnosticSet::from_sorted_entries( vec![DiagnosticEntry { diagnostic: Default::default(), range: Anchor::min_max_range_for_buffer(buffer.remote_id()), }], &buffer.snapshot(), ), cx, ); assert_eq!( buffer .snapshot() .diagnostics_in_range::<_, usize>(0..buffer.len(), false) .count(), 1 ); }); // When the rename changes the extension of the file, the buffer gets closed on the old // language server and gets opened on the new one. fs.rename( Path::new(path!("/dir/test3.rs")), Path::new(path!("/dir/test3.json")), Default::default(), ) .await .unwrap(); assert_eq!( fake_rust_server .receive_notification::() .await .text_document, lsp::TextDocumentIdentifier::new(lsp::Uri::from_file_path(path!("/dir/test3.rs")).unwrap()), ); assert_eq!( fake_json_server .receive_notification::() .await .text_document, lsp::TextDocumentItem { uri: lsp::Uri::from_file_path(path!("/dir/test3.json")).unwrap(), version: 0, text: rust_buffer2.update(cx, |buffer, _| buffer.text()), language_id: "json".to_string(), }, ); // We clear the diagnostics, since the language has changed. rust_buffer2.update(cx, |buffer, _| { assert_eq!( buffer .snapshot() .diagnostics_in_range::<_, usize>(0..buffer.len(), false) .count(), 0 ); }); // The renamed file's version resets after changing language server. rust_buffer2.update(cx, |buffer, cx| buffer.edit([(0..0, "// ")], None, cx)); assert_eq!( fake_json_server .receive_notification::() .await .text_document, lsp::VersionedTextDocumentIdentifier::new( lsp::Uri::from_file_path(path!("/dir/test3.json")).unwrap(), 1 ) ); // Restart language servers project.update(cx, |project, cx| { project.restart_language_servers_for_buffers( vec![rust_buffer.clone(), json_buffer.clone()], HashSet::default(), cx, ); }); let mut rust_shutdown_requests = fake_rust_server .set_request_handler::(|_, _| future::ready(Ok(()))); let mut json_shutdown_requests = fake_json_server .set_request_handler::(|_, _| future::ready(Ok(()))); futures::join!(rust_shutdown_requests.next(), json_shutdown_requests.next()); let mut fake_rust_server = fake_rust_servers.next().await.unwrap(); let mut fake_json_server = fake_json_servers.next().await.unwrap(); // Ensure rust document is reopened in new rust language server assert_eq!( fake_rust_server .receive_notification::() .await .text_document, lsp::TextDocumentItem { uri: lsp::Uri::from_file_path(path!("/dir/test.rs")).unwrap(), version: 0, text: rust_buffer.update(cx, |buffer, _| buffer.text()), language_id: "rust".to_string(), } ); // Ensure json documents are reopened in new json language server assert_set_eq!( [ fake_json_server .receive_notification::() .await .text_document, fake_json_server .receive_notification::() .await .text_document, ], [ lsp::TextDocumentItem { uri: lsp::Uri::from_file_path(path!("/dir/package.json")).unwrap(), version: 0, text: json_buffer.update(cx, |buffer, _| buffer.text()), language_id: "json".to_string(), }, lsp::TextDocumentItem { uri: lsp::Uri::from_file_path(path!("/dir/test3.json")).unwrap(), version: 0, text: rust_buffer2.update(cx, |buffer, _| buffer.text()), language_id: "json".to_string(), } ] ); // Close notifications are reported only to servers matching the buffer's language. cx.update(|_| drop(_json_handle)); let close_message = lsp::DidCloseTextDocumentParams { text_document: lsp::TextDocumentIdentifier::new( lsp::Uri::from_file_path(path!("/dir/package.json")).unwrap(), ), }; assert_eq!( fake_json_server .receive_notification::() .await, close_message, ); } #[gpui::test] async fn test_late_lsp_adapter_registration(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "test.rs": "const A: i32 = 1;", }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); // Add the language first so the buffer gets assigned a language. language_registry.add(rust_lang()); cx.executor().run_until_parked(); // Open a buffer — it gets assigned the Rust language but there is no LSP adapter yet. let (rust_buffer, _handle) = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/test.rs"), cx) }) .await .unwrap(); rust_buffer.update(cx, |buffer, _| { assert_eq!(buffer.language().map(|l| l.name()), Some("Rust".into())); }); // Now register the LSP adapter late (simulating an extension loading after startup). let mut fake_rust_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { name: "the-rust-language-server", capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string(), "::".to_string()]), ..Default::default() }), ..Default::default() }, ..Default::default() }, ); cx.executor().run_until_parked(); // The language server should start and receive a DidOpenTextDocument notification // for the already-open buffer. let mut fake_rust_server = fake_rust_servers.next().await.unwrap(); assert_eq!( fake_rust_server .receive_notification::() .await .text_document, lsp::TextDocumentItem { uri: lsp::Uri::from_file_path(path!("/dir/test.rs")).unwrap(), version: 0, text: "const A: i32 = 1;".to_string(), language_id: "rust".to_string(), } ); // The buffer should be configured with the language server's capabilities. rust_buffer.update(cx, |buffer, _| { assert_eq!( buffer .completion_triggers() .iter() .cloned() .collect::>(), &[".".to_string(), "::".to_string()] ); }); } #[gpui::test] async fn test_language_server_relative_path(cx: &mut gpui::TestAppContext) { init_test(cx); let settings_json_contents = json!({ "languages": { "Rust": { "language_servers": ["my_fake_lsp", "lsp_on_path"] } }, "lsp": { "my_fake_lsp": { "binary": { // file exists, so this is treated as a relative path "path": path!(".relative_path/to/my_fake_lsp_binary.exe").to_string(), } }, "lsp_on_path": { "binary": { // file doesn't exist, so it will fall back on PATH env var "path": path!("lsp_on_path.exe").to_string(), } } }, }); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/the-root"), json!({ ".zed": { "settings.json": settings_json_contents.to_string(), }, ".relative_path": { "to": { "my_fake_lsp.exe": "", }, }, "src": { "main.rs": "", } }), ) .await; let project = Project::test(fs.clone(), [path!("/the-root").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut my_fake_lsp = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { name: "my_fake_lsp", ..Default::default() }, ); let mut lsp_on_path = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { name: "lsp_on_path", ..Default::default() }, ); cx.run_until_parked(); // Start the language server by opening a buffer with a compatible file extension. project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/the-root/src/main.rs"), cx) }) .await .unwrap(); let lsp_path = my_fake_lsp.next().await.unwrap().binary.path; assert_eq!( lsp_path.to_string_lossy(), path!("/the-root/.relative_path/to/my_fake_lsp_binary.exe"), ); let lsp_path = lsp_on_path.next().await.unwrap().binary.path; assert_eq!(lsp_path.to_string_lossy(), path!("lsp_on_path.exe")); } #[gpui::test] async fn test_language_server_tilde_path(cx: &mut gpui::TestAppContext) { init_test(cx); let settings_json_contents = json!({ "languages": { "Rust": { "language_servers": ["tilde_lsp"] } }, "lsp": { "tilde_lsp": { "binary": { "path": "~/.local/bin/rust-analyzer", } } }, }); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), json!({ ".zed": { "settings.json": settings_json_contents.to_string(), }, "src": { "main.rs": "fn main() {}", } }), ) .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut tilde_lsp = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { name: "tilde_lsp", ..Default::default() }, ); cx.run_until_parked(); project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/root/src/main.rs"), cx) }) .await .unwrap(); let lsp_path = tilde_lsp.next().await.unwrap().binary.path; let expected_path = paths::home_dir().join(".local/bin/rust-analyzer"); assert_eq!( lsp_path, expected_path, "Tilde path should expand to home directory" ); } #[gpui::test] async fn test_rescan_fs_change_is_reported_to_language_servers_as_changed( cx: &mut gpui::TestAppContext, ) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/the-root"), json!({ "Cargo.lock": "", "src": { "a.rs": "", } }), ) .await; let project = Project::test(fs.clone(), [path!("/the-root").as_ref()], cx).await; let (language_registry, _lsp_store) = project.read_with(cx, |project, _| { (project.languages().clone(), project.lsp_store()) }); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { name: "the-language-server", ..Default::default() }, ); cx.executor().run_until_parked(); project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/the-root/src/a.rs"), cx) }) .await .unwrap(); let fake_server = fake_servers.next().await.unwrap(); cx.executor().run_until_parked(); let file_changes = Arc::new(Mutex::new(Vec::new())); fake_server .request::( lsp::RegistrationParams { registrations: vec![lsp::Registration { id: Default::default(), method: "workspace/didChangeWatchedFiles".to_string(), register_options: serde_json::to_value( lsp::DidChangeWatchedFilesRegistrationOptions { watchers: vec![lsp::FileSystemWatcher { glob_pattern: lsp::GlobPattern::String( path!("/the-root/Cargo.lock").to_string(), ), kind: None, }], }, ) .ok(), }], }, DEFAULT_LSP_REQUEST_TIMEOUT, ) .await .into_response() .unwrap(); fake_server.handle_notification::({ let file_changes = file_changes.clone(); move |params, _| { let mut file_changes = file_changes.lock(); file_changes.extend(params.changes); } }); cx.executor().run_until_parked(); assert_eq!(mem::take(&mut *file_changes.lock()), &[]); fs.emit_fs_event(path!("/the-root/Cargo.lock"), Some(PathEventKind::Rescan)); cx.executor().run_until_parked(); assert_eq!( &*file_changes.lock(), &[lsp::FileEvent { uri: lsp::Uri::from_file_path(path!("/the-root/Cargo.lock")).unwrap(), typ: lsp::FileChangeType::CHANGED, }] ); } #[gpui::test] async fn test_reporting_fs_changes_to_language_servers(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/the-root"), json!({ ".gitignore": "target\n", "Cargo.lock": "", "src": { "a.rs": "", "b.rs": "", }, "target": { "x": { "out": { "x.rs": "" } }, "y": { "out": { "y.rs": "", } }, "z": { "out": { "z.rs": "" } } } }), ) .await; fs.insert_tree( path!("/the-registry"), json!({ "dep1": { "src": { "dep1.rs": "", } }, "dep2": { "src": { "dep2.rs": "", } }, }), ) .await; fs.insert_tree( path!("/the/stdlib"), json!({ "LICENSE": "", "src": { "string.rs": "", } }), ) .await; let project = Project::test(fs.clone(), [path!("/the-root").as_ref()], cx).await; let (language_registry, lsp_store) = project.read_with(cx, |project, _| { (project.languages().clone(), project.lsp_store()) }); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { name: "the-language-server", ..Default::default() }, ); cx.executor().run_until_parked(); // Start the language server by opening a buffer with a compatible file extension. project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/the-root/src/a.rs"), cx) }) .await .unwrap(); // Initially, we don't load ignored files because the language server has not explicitly asked us to watch them. project.update(cx, |project, cx| { let worktree = project.worktrees(cx).next().unwrap(); assert_eq!( worktree .read(cx) .snapshot() .entries(true, 0) .map(|entry| (entry.path.as_unix_str(), entry.is_ignored)) .collect::>(), &[ ("", false), (".gitignore", false), ("Cargo.lock", false), ("src", false), ("src/a.rs", false), ("src/b.rs", false), ("target", true), ] ); }); let prev_read_dir_count = fs.read_dir_call_count(); let fake_server = fake_servers.next().await.unwrap(); cx.executor().run_until_parked(); let server_id = lsp_store.read_with(cx, |lsp_store, _| { let (id, _) = lsp_store.language_server_statuses().next().unwrap(); id }); // Simulate jumping to a definition in a dependency outside of the worktree. let _out_of_worktree_buffer = project .update(cx, |project, cx| { project.open_local_buffer_via_lsp( lsp::Uri::from_file_path(path!("/the-registry/dep1/src/dep1.rs")).unwrap(), server_id, cx, ) }) .await .unwrap(); // Keep track of the FS events reported to the language server. let file_changes = Arc::new(Mutex::new(Vec::new())); fake_server .request::( lsp::RegistrationParams { registrations: vec![lsp::Registration { id: Default::default(), method: "workspace/didChangeWatchedFiles".to_string(), register_options: serde_json::to_value( lsp::DidChangeWatchedFilesRegistrationOptions { watchers: vec![ lsp::FileSystemWatcher { glob_pattern: lsp::GlobPattern::String( path!("/the-root/Cargo.toml").to_string(), ), kind: None, }, lsp::FileSystemWatcher { glob_pattern: lsp::GlobPattern::String( path!("/the-root/src/*.{rs,c}").to_string(), ), kind: None, }, lsp::FileSystemWatcher { glob_pattern: lsp::GlobPattern::String( path!("/the-root/target/y/**/*.rs").to_string(), ), kind: None, }, lsp::FileSystemWatcher { glob_pattern: lsp::GlobPattern::String( path!("/the/stdlib/src/**/*.rs").to_string(), ), kind: None, }, lsp::FileSystemWatcher { glob_pattern: lsp::GlobPattern::String( path!("**/Cargo.lock").to_string(), ), kind: None, }, ], }, ) .ok(), }], }, DEFAULT_LSP_REQUEST_TIMEOUT, ) .await .into_response() .unwrap(); fake_server.handle_notification::({ let file_changes = file_changes.clone(); move |params, _| { let mut file_changes = file_changes.lock(); file_changes.extend(params.changes); file_changes.sort_by(|a, b| a.uri.cmp(&b.uri)); } }); cx.executor().run_until_parked(); assert_eq!(mem::take(&mut *file_changes.lock()), &[]); assert_eq!(fs.read_dir_call_count() - prev_read_dir_count, 4); let mut new_watched_paths = fs.watched_paths(); new_watched_paths.retain(|path| { !path.starts_with(config_dir()) && !path.starts_with(global_gitignore_path().unwrap()) }); assert_eq!( &new_watched_paths, &[ Path::new(path!("/the-root")), Path::new(path!("/the-registry/dep1/src/dep1.rs")), Path::new(path!("/the/stdlib/src")) ] ); // Now the language server has asked us to watch an ignored directory path, // so we recursively load it. project.update(cx, |project, cx| { let worktree = project.visible_worktrees(cx).next().unwrap(); assert_eq!( worktree .read(cx) .snapshot() .entries(true, 0) .map(|entry| (entry.path.as_unix_str(), entry.is_ignored)) .collect::>(), &[ ("", false), (".gitignore", false), ("Cargo.lock", false), ("src", false), ("src/a.rs", false), ("src/b.rs", false), ("target", true), ("target/x", true), ("target/y", true), ("target/y/out", true), ("target/y/out/y.rs", true), ("target/z", true), ] ); }); // Perform some file system mutations, two of which match the watched patterns, // and one of which does not. fs.create_file(path!("/the-root/src/c.rs").as_ref(), Default::default()) .await .unwrap(); fs.create_file(path!("/the-root/src/d.txt").as_ref(), Default::default()) .await .unwrap(); fs.remove_file(path!("/the-root/src/b.rs").as_ref(), Default::default()) .await .unwrap(); fs.create_file( path!("/the-root/target/x/out/x2.rs").as_ref(), Default::default(), ) .await .unwrap(); fs.create_file( path!("/the-root/target/y/out/y2.rs").as_ref(), Default::default(), ) .await .unwrap(); fs.save( path!("/the-root/Cargo.lock").as_ref(), &"".into(), Default::default(), ) .await .unwrap(); fs.save( path!("/the-stdlib/LICENSE").as_ref(), &"".into(), Default::default(), ) .await .unwrap(); fs.save( path!("/the/stdlib/src/string.rs").as_ref(), &"".into(), Default::default(), ) .await .unwrap(); // The language server receives events for the FS mutations that match its watch patterns. cx.executor().run_until_parked(); assert_eq!( &*file_changes.lock(), &[ lsp::FileEvent { uri: lsp::Uri::from_file_path(path!("/the-root/Cargo.lock")).unwrap(), typ: lsp::FileChangeType::CHANGED, }, lsp::FileEvent { uri: lsp::Uri::from_file_path(path!("/the-root/src/b.rs")).unwrap(), typ: lsp::FileChangeType::DELETED, }, lsp::FileEvent { uri: lsp::Uri::from_file_path(path!("/the-root/src/c.rs")).unwrap(), typ: lsp::FileChangeType::CREATED, }, lsp::FileEvent { uri: lsp::Uri::from_file_path(path!("/the-root/target/y/out/y2.rs")).unwrap(), typ: lsp::FileChangeType::CREATED, }, lsp::FileEvent { uri: lsp::Uri::from_file_path(path!("/the/stdlib/src/string.rs")).unwrap(), typ: lsp::FileChangeType::CHANGED, }, ] ); } #[gpui::test] async fn test_single_file_worktrees_diagnostics(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.rs": "let a = 1;", "b.rs": "let b = 2;" }), ) .await; let project = Project::test( fs, [path!("/dir/a.rs").as_ref(), path!("/dir/b.rs").as_ref()], cx, ) .await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); let buffer_a = project .update(cx, |project, cx| { project.open_local_buffer(path!("/dir/a.rs"), cx) }) .await .unwrap(); let buffer_b = project .update(cx, |project, cx| { project.open_local_buffer(path!("/dir/b.rs"), cx) }) .await .unwrap(); lsp_store.update(cx, |lsp_store, cx| { lsp_store .update_diagnostics( LanguageServerId(0), lsp::PublishDiagnosticsParams { uri: Uri::from_file_path(path!("/dir/a.rs")).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 5)), severity: Some(lsp::DiagnosticSeverity::ERROR), message: "error 1".to_string(), ..Default::default() }], }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap(); lsp_store .update_diagnostics( LanguageServerId(0), lsp::PublishDiagnosticsParams { uri: Uri::from_file_path(path!("/dir/b.rs")).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 5)), severity: Some(DiagnosticSeverity::WARNING), message: "error 2".to_string(), ..Default::default() }], }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap(); }); buffer_a.update(cx, |buffer, _| { let chunks = chunks_with_diagnostics(buffer, 0..buffer.len()); assert_eq!( chunks .iter() .map(|(s, d)| (s.as_str(), *d)) .collect::>(), &[ ("let ", None), ("a", Some(DiagnosticSeverity::ERROR)), (" = 1;", None), ] ); }); buffer_b.update(cx, |buffer, _| { let chunks = chunks_with_diagnostics(buffer, 0..buffer.len()); assert_eq!( chunks .iter() .map(|(s, d)| (s.as_str(), *d)) .collect::>(), &[ ("let ", None), ("b", Some(DiagnosticSeverity::WARNING)), (" = 2;", None), ] ); }); } #[gpui::test] async fn test_omitted_diagnostics(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), json!({ "dir": { ".git": { "HEAD": "ref: refs/heads/main", }, ".gitignore": "b.rs", "a.rs": "let a = 1;", "b.rs": "let b = 2;", }, "other.rs": "let b = c;" }), ) .await; let project = Project::test(fs, [path!("/root/dir").as_ref()], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); let (worktree, _) = project .update(cx, |project, cx| { project.find_or_create_worktree(path!("/root/dir"), true, cx) }) .await .unwrap(); let main_worktree_id = worktree.read_with(cx, |tree, _| tree.id()); let (worktree, _) = project .update(cx, |project, cx| { project.find_or_create_worktree(path!("/root/other.rs"), false, cx) }) .await .unwrap(); let other_worktree_id = worktree.update(cx, |tree, _| tree.id()); let server_id = LanguageServerId(0); lsp_store.update(cx, |lsp_store, cx| { lsp_store .update_diagnostics( server_id, lsp::PublishDiagnosticsParams { uri: Uri::from_file_path(path!("/root/dir/b.rs")).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 5)), severity: Some(lsp::DiagnosticSeverity::ERROR), message: "unused variable 'b'".to_string(), ..Default::default() }], }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap(); lsp_store .update_diagnostics( server_id, lsp::PublishDiagnosticsParams { uri: Uri::from_file_path(path!("/root/other.rs")).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(0, 8), lsp::Position::new(0, 9)), severity: Some(lsp::DiagnosticSeverity::ERROR), message: "unknown variable 'c'".to_string(), ..Default::default() }], }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap(); }); let main_ignored_buffer = project .update(cx, |project, cx| { project.open_buffer((main_worktree_id, rel_path("b.rs")), cx) }) .await .unwrap(); main_ignored_buffer.update(cx, |buffer, _| { let chunks = chunks_with_diagnostics(buffer, 0..buffer.len()); assert_eq!( chunks .iter() .map(|(s, d)| (s.as_str(), *d)) .collect::>(), &[ ("let ", None), ("b", Some(DiagnosticSeverity::ERROR)), (" = 2;", None), ], "Gigitnored buffers should still get in-buffer diagnostics", ); }); let other_buffer = project .update(cx, |project, cx| { project.open_buffer((other_worktree_id, rel_path("")), cx) }) .await .unwrap(); other_buffer.update(cx, |buffer, _| { let chunks = chunks_with_diagnostics(buffer, 0..buffer.len()); assert_eq!( chunks .iter() .map(|(s, d)| (s.as_str(), *d)) .collect::>(), &[ ("let b = ", None), ("c", Some(DiagnosticSeverity::ERROR)), (";", None), ], "Buffers from hidden projects should still get in-buffer diagnostics" ); }); project.update(cx, |project, cx| { assert_eq!(project.diagnostic_summaries(false, cx).next(), None); assert_eq!( project.diagnostic_summaries(true, cx).collect::>(), vec![( ProjectPath { worktree_id: main_worktree_id, path: rel_path("b.rs").into(), }, server_id, DiagnosticSummary { error_count: 1, warning_count: 0, } )] ); assert_eq!(project.diagnostic_summary(false, cx).error_count, 0); assert_eq!(project.diagnostic_summary(true, cx).error_count, 1); }); } #[gpui::test] async fn test_disk_based_diagnostics_progress(cx: &mut gpui::TestAppContext) { init_test(cx); let progress_token = "the-progress-token"; let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.rs": "fn a() { A }", "b.rs": "const y: i32 = 1", }), ) .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { disk_based_diagnostics_progress_token: Some(progress_token.into()), disk_based_diagnostics_sources: vec!["disk".into()], ..Default::default() }, ); let worktree_id = project.update(cx, |p, cx| p.worktrees(cx).next().unwrap().read(cx).id()); // Cause worktree to start the fake language server let _ = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/b.rs"), cx) }) .await .unwrap(); let mut events = cx.events(&project); let fake_server = fake_servers.next().await.unwrap(); assert_eq!( events.next().await.unwrap(), Event::LanguageServerAdded( LanguageServerId(0), fake_server.server.name(), Some(worktree_id) ), ); fake_server .start_progress(format!("{}/0", progress_token)) .await; assert_eq!( events.next().await.unwrap(), Event::DiskBasedDiagnosticsStarted { language_server_id: LanguageServerId(0), } ); fake_server.notify::(lsp::PublishDiagnosticsParams { uri: Uri::from_file_path(path!("/dir/a.rs")).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(0, 9), lsp::Position::new(0, 10)), severity: Some(lsp::DiagnosticSeverity::ERROR), message: "undefined variable 'A'".to_string(), ..Default::default() }], }); assert_eq!( events.next().await.unwrap(), Event::DiagnosticsUpdated { language_server_id: LanguageServerId(0), paths: vec![(worktree_id, rel_path("a.rs")).into()], } ); fake_server.end_progress(format!("{}/0", progress_token)); assert_eq!( events.next().await.unwrap(), Event::DiskBasedDiagnosticsFinished { language_server_id: LanguageServerId(0) } ); let buffer = project .update(cx, |p, cx| p.open_local_buffer(path!("/dir/a.rs"), cx)) .await .unwrap(); buffer.update(cx, |buffer, _| { let snapshot = buffer.snapshot(); let diagnostics = snapshot .diagnostics_in_range::<_, Point>(0..buffer.len(), false) .collect::>(); assert_eq!( diagnostics, &[DiagnosticEntryRef { range: Point::new(0, 9)..Point::new(0, 10), diagnostic: &Diagnostic { severity: lsp::DiagnosticSeverity::ERROR, message: "undefined variable 'A'".to_string(), group_id: 0, is_primary: true, source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() } }] ) }); // Ensure publishing empty diagnostics twice only results in one update event. fake_server.notify::(lsp::PublishDiagnosticsParams { uri: Uri::from_file_path(path!("/dir/a.rs")).unwrap(), version: None, diagnostics: Default::default(), }); assert_eq!( events.next().await.unwrap(), Event::DiagnosticsUpdated { language_server_id: LanguageServerId(0), paths: vec![(worktree_id, rel_path("a.rs")).into()], } ); fake_server.notify::(lsp::PublishDiagnosticsParams { uri: Uri::from_file_path(path!("/dir/a.rs")).unwrap(), version: None, diagnostics: Default::default(), }); cx.executor().run_until_parked(); assert_eq!(futures::poll!(events.next()), Poll::Pending); } #[gpui::test] async fn test_restarting_server_with_diagnostics_running(cx: &mut gpui::TestAppContext) { init_test(cx); let progress_token = "the-progress-token"; let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/dir"), json!({ "a.rs": "" })).await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { name: "the-language-server", disk_based_diagnostics_sources: vec!["disk".into()], disk_based_diagnostics_progress_token: Some(progress_token.into()), ..FakeLspAdapter::default() }, ); let worktree_id = project.update(cx, |p, cx| p.worktrees(cx).next().unwrap().read(cx).id()); let (buffer, _handle) = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) }) .await .unwrap(); let buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id()); // Simulate diagnostics starting to update. let fake_server = fake_servers.next().await.unwrap(); cx.executor().run_until_parked(); fake_server.start_progress(progress_token).await; // Restart the server before the diagnostics finish updating. project.update(cx, |project, cx| { project.restart_language_servers_for_buffers(vec![buffer], HashSet::default(), cx); }); let mut events = cx.events(&project); // Simulate the newly started server sending more diagnostics. let fake_server = fake_servers.next().await.unwrap(); cx.executor().run_until_parked(); assert_eq!( events.next().await.unwrap(), Event::LanguageServerRemoved(LanguageServerId(0)) ); assert_eq!( events.next().await.unwrap(), Event::LanguageServerAdded( LanguageServerId(1), fake_server.server.name(), Some(worktree_id) ) ); fake_server.start_progress(progress_token).await; assert_eq!( events.next().await.unwrap(), Event::LanguageServerBufferRegistered { server_id: LanguageServerId(1), buffer_id, buffer_abs_path: PathBuf::from(path!("/dir/a.rs")), name: Some(fake_server.server.name()) } ); assert_eq!( events.next().await.unwrap(), Event::DiskBasedDiagnosticsStarted { language_server_id: LanguageServerId(1) } ); project.update(cx, |project, cx| { assert_eq!( project .language_servers_running_disk_based_diagnostics(cx) .collect::>(), [LanguageServerId(1)] ); }); // All diagnostics are considered done, despite the old server's diagnostic // task never completing. fake_server.end_progress(progress_token); assert_eq!( events.next().await.unwrap(), Event::DiskBasedDiagnosticsFinished { language_server_id: LanguageServerId(1) } ); project.update(cx, |project, cx| { assert_eq!( project .language_servers_running_disk_based_diagnostics(cx) .collect::>(), [] as [language::LanguageServerId; 0] ); }); } #[gpui::test] async fn test_restarting_server_with_diagnostics_published(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/dir"), json!({ "a.rs": "x" })).await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp("Rust", FakeLspAdapter::default()); let (buffer, _) = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) }) .await .unwrap(); // Publish diagnostics let fake_server = fake_servers.next().await.unwrap(); fake_server.notify::(lsp::PublishDiagnosticsParams { uri: Uri::from_file_path(path!("/dir/a.rs")).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)), severity: Some(lsp::DiagnosticSeverity::ERROR), message: "the message".to_string(), ..Default::default() }], }); cx.executor().run_until_parked(); buffer.update(cx, |buffer, _| { assert_eq!( buffer .snapshot() .diagnostics_in_range::<_, usize>(0..1, false) .map(|entry| entry.diagnostic.message.clone()) .collect::>(), ["the message".to_string()] ); }); project.update(cx, |project, cx| { assert_eq!( project.diagnostic_summary(false, cx), DiagnosticSummary { error_count: 1, warning_count: 0, } ); }); project.update(cx, |project, cx| { project.restart_language_servers_for_buffers(vec![buffer.clone()], HashSet::default(), cx); }); // The diagnostics are cleared. cx.executor().run_until_parked(); buffer.update(cx, |buffer, _| { assert_eq!( buffer .snapshot() .diagnostics_in_range::<_, usize>(0..1, false) .map(|entry| entry.diagnostic.message.clone()) .collect::>(), Vec::::new(), ); }); project.update(cx, |project, cx| { assert_eq!( project.diagnostic_summary(false, cx), DiagnosticSummary { error_count: 0, warning_count: 0, } ); }); } #[gpui::test] async fn test_restarted_server_reporting_invalid_buffer_version(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/dir"), json!({ "a.rs": "" })).await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp("Rust", FakeLspAdapter::default()); let (buffer, _handle) = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) }) .await .unwrap(); // Before restarting the server, report diagnostics with an unknown buffer version. let fake_server = fake_servers.next().await.unwrap(); fake_server.notify::(lsp::PublishDiagnosticsParams { uri: lsp::Uri::from_file_path(path!("/dir/a.rs")).unwrap(), version: Some(10000), diagnostics: Vec::new(), }); cx.executor().run_until_parked(); project.update(cx, |project, cx| { project.restart_language_servers_for_buffers(vec![buffer.clone()], HashSet::default(), cx); }); let mut fake_server = fake_servers.next().await.unwrap(); let notification = fake_server .receive_notification::() .await .text_document; assert_eq!(notification.version, 0); } #[gpui::test] async fn test_cancel_language_server_work(cx: &mut gpui::TestAppContext) { init_test(cx); let progress_token = "the-progress-token"; let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/dir"), json!({ "a.rs": "" })).await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { name: "the-language-server", disk_based_diagnostics_sources: vec!["disk".into()], disk_based_diagnostics_progress_token: Some(progress_token.into()), ..Default::default() }, ); let (buffer, _handle) = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) }) .await .unwrap(); // Simulate diagnostics starting to update. let mut fake_server = fake_servers.next().await.unwrap(); fake_server .start_progress_with( "another-token", lsp::WorkDoneProgressBegin { cancellable: Some(false), ..Default::default() }, DEFAULT_LSP_REQUEST_TIMEOUT, ) .await; // Ensure progress notification is fully processed before starting the next one cx.executor().run_until_parked(); fake_server .start_progress_with( progress_token, lsp::WorkDoneProgressBegin { cancellable: Some(true), ..Default::default() }, DEFAULT_LSP_REQUEST_TIMEOUT, ) .await; // Ensure progress notification is fully processed before cancelling cx.executor().run_until_parked(); project.update(cx, |project, cx| { project.cancel_language_server_work_for_buffers([buffer.clone()], cx) }); cx.executor().run_until_parked(); let cancel_notification = fake_server .receive_notification::() .await; assert_eq!( cancel_notification.token, NumberOrString::String(progress_token.into()) ); } #[gpui::test] async fn test_toggling_enable_language_server(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/dir"), json!({ "a.rs": "", "b.js": "" })) .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); let mut fake_rust_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { name: "rust-lsp", ..Default::default() }, ); let mut fake_js_servers = language_registry.register_fake_lsp( "JavaScript", FakeLspAdapter { name: "js-lsp", ..Default::default() }, ); language_registry.add(rust_lang()); language_registry.add(js_lang()); let _rs_buffer = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) }) .await .unwrap(); let _js_buffer = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/b.js"), cx) }) .await .unwrap(); let mut fake_rust_server_1 = fake_rust_servers.next().await.unwrap(); assert_eq!( fake_rust_server_1 .receive_notification::() .await .text_document .uri .as_str(), uri!("file:///dir/a.rs") ); let mut fake_js_server = fake_js_servers.next().await.unwrap(); assert_eq!( fake_js_server .receive_notification::() .await .text_document .uri .as_str(), uri!("file:///dir/b.js") ); // Disable Rust language server, ensuring only that server gets stopped. cx.update(|cx| { SettingsStore::update_global(cx, |settings, cx| { settings.update_user_settings(cx, |settings| { settings.languages_mut().insert( "Rust".into(), LanguageSettingsContent { enable_language_server: Some(false), ..Default::default() }, ); }); }) }); fake_rust_server_1 .receive_notification::() .await; // Enable Rust and disable JavaScript language servers, ensuring that the // former gets started again and that the latter stops. cx.update(|cx| { SettingsStore::update_global(cx, |settings, cx| { settings.update_user_settings(cx, |settings| { settings.languages_mut().insert( "Rust".into(), LanguageSettingsContent { enable_language_server: Some(true), ..Default::default() }, ); settings.languages_mut().insert( "JavaScript".into(), LanguageSettingsContent { enable_language_server: Some(false), ..Default::default() }, ); }); }) }); let mut fake_rust_server_2 = fake_rust_servers.next().await.unwrap(); assert_eq!( fake_rust_server_2 .receive_notification::() .await .text_document .uri .as_str(), uri!("file:///dir/a.rs") ); fake_js_server .receive_notification::() .await; } #[gpui::test(iterations = 3)] async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) { init_test(cx); let text = " fn a() { A } fn b() { BB } fn c() { CCC } " .unindent(); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/dir"), json!({ "a.rs": text })).await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { disk_based_diagnostics_sources: vec!["disk".into()], ..Default::default() }, ); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/dir/a.rs"), cx) }) .await .unwrap(); let _handle = project.update(cx, |project, cx| { project.register_buffer_with_language_servers(&buffer, cx) }); let mut fake_server = fake_servers.next().await.unwrap(); let open_notification = fake_server .receive_notification::() .await; // Edit the buffer, moving the content down buffer.update(cx, |buffer, cx| buffer.edit([(0..0, "\n\n")], None, cx)); let change_notification_1 = fake_server .receive_notification::() .await; assert!(change_notification_1.text_document.version > open_notification.text_document.version); // Report some diagnostics for the initial version of the buffer fake_server.notify::(lsp::PublishDiagnosticsParams { uri: lsp::Uri::from_file_path(path!("/dir/a.rs")).unwrap(), version: Some(open_notification.text_document.version), diagnostics: vec![ lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(0, 9), lsp::Position::new(0, 10)), severity: Some(DiagnosticSeverity::ERROR), message: "undefined variable 'A'".to_string(), source: Some("disk".to_string()), ..Default::default() }, lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(1, 9), lsp::Position::new(1, 11)), severity: Some(DiagnosticSeverity::ERROR), message: "undefined variable 'BB'".to_string(), source: Some("disk".to_string()), ..Default::default() }, lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(2, 9), lsp::Position::new(2, 12)), severity: Some(DiagnosticSeverity::ERROR), source: Some("disk".to_string()), message: "undefined variable 'CCC'".to_string(), ..Default::default() }, ], }); // The diagnostics have moved down since they were created. cx.executor().run_until_parked(); buffer.update(cx, |buffer, _| { assert_eq!( buffer .snapshot() .diagnostics_in_range::<_, Point>(Point::new(3, 0)..Point::new(5, 0), false) .collect::>(), &[ DiagnosticEntry { range: Point::new(3, 9)..Point::new(3, 11), diagnostic: Diagnostic { source: Some("disk".into()), severity: DiagnosticSeverity::ERROR, message: "undefined variable 'BB'".to_string(), is_disk_based: true, group_id: 1, is_primary: true, source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() }, }, DiagnosticEntry { range: Point::new(4, 9)..Point::new(4, 12), diagnostic: Diagnostic { source: Some("disk".into()), severity: DiagnosticSeverity::ERROR, message: "undefined variable 'CCC'".to_string(), is_disk_based: true, group_id: 2, is_primary: true, source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() } } ] ); assert_eq!( chunks_with_diagnostics(buffer, 0..buffer.len()), [ ("\n\nfn a() { ".to_string(), None), ("A".to_string(), Some(DiagnosticSeverity::ERROR)), (" }\nfn b() { ".to_string(), None), ("BB".to_string(), Some(DiagnosticSeverity::ERROR)), (" }\nfn c() { ".to_string(), None), ("CCC".to_string(), Some(DiagnosticSeverity::ERROR)), (" }\n".to_string(), None), ] ); assert_eq!( chunks_with_diagnostics(buffer, Point::new(3, 10)..Point::new(4, 11)), [ ("B".to_string(), Some(DiagnosticSeverity::ERROR)), (" }\nfn c() { ".to_string(), None), ("CC".to_string(), Some(DiagnosticSeverity::ERROR)), ] ); }); // Ensure overlapping diagnostics are highlighted correctly. fake_server.notify::(lsp::PublishDiagnosticsParams { uri: lsp::Uri::from_file_path(path!("/dir/a.rs")).unwrap(), version: Some(open_notification.text_document.version), diagnostics: vec![ lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(0, 9), lsp::Position::new(0, 10)), severity: Some(DiagnosticSeverity::ERROR), message: "undefined variable 'A'".to_string(), source: Some("disk".to_string()), ..Default::default() }, lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(0, 9), lsp::Position::new(0, 12)), severity: Some(DiagnosticSeverity::WARNING), message: "unreachable statement".to_string(), source: Some("disk".to_string()), ..Default::default() }, ], }); cx.executor().run_until_parked(); buffer.update(cx, |buffer, _| { assert_eq!( buffer .snapshot() .diagnostics_in_range::<_, Point>(Point::new(2, 0)..Point::new(3, 0), false) .collect::>(), &[ DiagnosticEntry { range: Point::new(2, 9)..Point::new(2, 12), diagnostic: Diagnostic { source: Some("disk".into()), severity: DiagnosticSeverity::WARNING, message: "unreachable statement".to_string(), is_disk_based: true, group_id: 4, is_primary: true, source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() } }, DiagnosticEntry { range: Point::new(2, 9)..Point::new(2, 10), diagnostic: Diagnostic { source: Some("disk".into()), severity: DiagnosticSeverity::ERROR, message: "undefined variable 'A'".to_string(), is_disk_based: true, group_id: 3, is_primary: true, source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() }, } ] ); assert_eq!( chunks_with_diagnostics(buffer, Point::new(2, 0)..Point::new(3, 0)), [ ("fn a() { ".to_string(), None), ("A".to_string(), Some(DiagnosticSeverity::ERROR)), (" }".to_string(), Some(DiagnosticSeverity::WARNING)), ("\n".to_string(), None), ] ); assert_eq!( chunks_with_diagnostics(buffer, Point::new(2, 10)..Point::new(3, 0)), [ (" }".to_string(), Some(DiagnosticSeverity::WARNING)), ("\n".to_string(), None), ] ); }); // Keep editing the buffer and ensure disk-based diagnostics get translated according to the // changes since the last save. buffer.update(cx, |buffer, cx| { buffer.edit([(Point::new(2, 0)..Point::new(2, 0), " ")], None, cx); buffer.edit( [(Point::new(2, 8)..Point::new(2, 10), "(x: usize)")], None, cx, ); buffer.edit([(Point::new(3, 10)..Point::new(3, 10), "xxx")], None, cx); }); let change_notification_2 = fake_server .receive_notification::() .await; assert!( change_notification_2.text_document.version > change_notification_1.text_document.version ); // Handle out-of-order diagnostics fake_server.notify::(lsp::PublishDiagnosticsParams { uri: lsp::Uri::from_file_path(path!("/dir/a.rs")).unwrap(), version: Some(change_notification_2.text_document.version), diagnostics: vec![ lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(1, 9), lsp::Position::new(1, 11)), severity: Some(DiagnosticSeverity::ERROR), message: "undefined variable 'BB'".to_string(), source: Some("disk".to_string()), ..Default::default() }, lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(0, 9), lsp::Position::new(0, 10)), severity: Some(DiagnosticSeverity::WARNING), message: "undefined variable 'A'".to_string(), source: Some("disk".to_string()), ..Default::default() }, ], }); cx.executor().run_until_parked(); buffer.update(cx, |buffer, _| { assert_eq!( buffer .snapshot() .diagnostics_in_range::<_, Point>(0..buffer.len(), false) .collect::>(), &[ DiagnosticEntry { range: Point::new(2, 21)..Point::new(2, 22), diagnostic: Diagnostic { source: Some("disk".into()), severity: DiagnosticSeverity::WARNING, message: "undefined variable 'A'".to_string(), is_disk_based: true, group_id: 6, is_primary: true, source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() } }, DiagnosticEntry { range: Point::new(3, 9)..Point::new(3, 14), diagnostic: Diagnostic { source: Some("disk".into()), severity: DiagnosticSeverity::ERROR, message: "undefined variable 'BB'".to_string(), is_disk_based: true, group_id: 5, is_primary: true, source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() }, } ] ); }); } #[gpui::test] async fn test_empty_diagnostic_ranges(cx: &mut gpui::TestAppContext) { init_test(cx); let text = concat!( "let one = ;\n", // "let two = \n", "let three = 3;\n", ); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/dir"), json!({ "a.rs": text })).await; let project = Project::test(fs, [Path::new(path!("/dir"))], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/dir/a.rs"), cx) }) .await .unwrap(); project.update(cx, |project, cx| { project.lsp_store().update(cx, |lsp_store, cx| { lsp_store .update_diagnostic_entries( LanguageServerId(0), PathBuf::from(path!("/dir/a.rs")), None, None, vec![ DiagnosticEntry { range: Unclipped(PointUtf16::new(0, 10)) ..Unclipped(PointUtf16::new(0, 10)), diagnostic: Diagnostic { severity: DiagnosticSeverity::ERROR, message: "syntax error 1".to_string(), source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() }, }, DiagnosticEntry { range: Unclipped(PointUtf16::new(1, 10)) ..Unclipped(PointUtf16::new(1, 10)), diagnostic: Diagnostic { severity: DiagnosticSeverity::ERROR, message: "syntax error 2".to_string(), source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() }, }, ], cx, ) .unwrap(); }) }); // An empty range is extended forward to include the following character. // At the end of a line, an empty range is extended backward to include // the preceding character. buffer.update(cx, |buffer, _| { let chunks = chunks_with_diagnostics(buffer, 0..buffer.len()); assert_eq!( chunks .iter() .map(|(s, d)| (s.as_str(), *d)) .collect::>(), &[ ("let one = ", None), (";", Some(DiagnosticSeverity::ERROR)), ("\nlet two =", None), (" ", Some(DiagnosticSeverity::ERROR)), ("\nlet three = 3;\n", None) ] ); }); } #[gpui::test] async fn test_diagnostics_from_multiple_language_servers(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/dir"), json!({ "a.rs": "one two three" })) .await; let project = Project::test(fs, [Path::new(path!("/dir"))], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); lsp_store.update(cx, |lsp_store, cx| { lsp_store .update_diagnostic_entries( LanguageServerId(0), Path::new(path!("/dir/a.rs")).to_owned(), None, None, vec![DiagnosticEntry { range: Unclipped(PointUtf16::new(0, 0))..Unclipped(PointUtf16::new(0, 3)), diagnostic: Diagnostic { severity: DiagnosticSeverity::ERROR, is_primary: true, message: "syntax error a1".to_string(), source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() }, }], cx, ) .unwrap(); lsp_store .update_diagnostic_entries( LanguageServerId(1), Path::new(path!("/dir/a.rs")).to_owned(), None, None, vec![DiagnosticEntry { range: Unclipped(PointUtf16::new(0, 0))..Unclipped(PointUtf16::new(0, 3)), diagnostic: Diagnostic { severity: DiagnosticSeverity::ERROR, is_primary: true, message: "syntax error b1".to_string(), source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() }, }], cx, ) .unwrap(); assert_eq!( lsp_store.diagnostic_summary(false, cx), DiagnosticSummary { error_count: 2, warning_count: 0, } ); }); } #[gpui::test] async fn test_diagnostic_summaries_cleared_on_worktree_entry_removal( cx: &mut gpui::TestAppContext, ) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/dir"), json!({ "a.rs": "one", "b.rs": "two" })) .await; let project = Project::test(fs.clone(), [Path::new(path!("/dir"))], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); lsp_store.update(cx, |lsp_store, cx| { lsp_store .update_diagnostic_entries( LanguageServerId(0), Path::new(path!("/dir/a.rs")).to_owned(), None, None, vec![DiagnosticEntry { range: Unclipped(PointUtf16::new(0, 0))..Unclipped(PointUtf16::new(0, 3)), diagnostic: Diagnostic { severity: DiagnosticSeverity::ERROR, is_primary: true, message: "error in a".to_string(), source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() }, }], cx, ) .unwrap(); lsp_store .update_diagnostic_entries( LanguageServerId(0), Path::new(path!("/dir/b.rs")).to_owned(), None, None, vec![DiagnosticEntry { range: Unclipped(PointUtf16::new(0, 0))..Unclipped(PointUtf16::new(0, 3)), diagnostic: Diagnostic { severity: DiagnosticSeverity::WARNING, is_primary: true, message: "warning in b".to_string(), source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() }, }], cx, ) .unwrap(); assert_eq!( lsp_store.diagnostic_summary(false, cx), DiagnosticSummary { error_count: 1, warning_count: 1, } ); }); fs.remove_file(path!("/dir/a.rs").as_ref(), Default::default()) .await .unwrap(); cx.executor().run_until_parked(); lsp_store.update(cx, |lsp_store, cx| { assert_eq!( lsp_store.diagnostic_summary(false, cx), DiagnosticSummary { error_count: 0, warning_count: 1, }, ); }); } #[gpui::test] async fn test_diagnostic_summaries_cleared_on_server_restart(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/dir"), json!({ "a.rs": "x" })).await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp("Rust", FakeLspAdapter::default()); let (buffer, _handle) = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) }) .await .unwrap(); let fake_server = fake_servers.next().await.unwrap(); fake_server.notify::(lsp::PublishDiagnosticsParams { uri: Uri::from_file_path(path!("/dir/a.rs")).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 1)), severity: Some(lsp::DiagnosticSeverity::ERROR), message: "error before restart".to_string(), ..Default::default() }], }); cx.executor().run_until_parked(); project.update(cx, |project, cx| { assert_eq!( project.diagnostic_summary(false, cx), DiagnosticSummary { error_count: 1, warning_count: 0, } ); }); let mut events = cx.events(&project); project.update(cx, |project, cx| { project.restart_language_servers_for_buffers(vec![buffer.clone()], HashSet::default(), cx); }); cx.executor().run_until_parked(); let mut received_diagnostics_updated = false; while let Some(Some(event)) = futures::FutureExt::now_or_never(futures::StreamExt::next(&mut events)) { if matches!(event, Event::DiagnosticsUpdated { .. }) { received_diagnostics_updated = true; } } assert!( received_diagnostics_updated, "DiagnosticsUpdated event should be emitted when a language server is stopped" ); project.update(cx, |project, cx| { assert_eq!( project.diagnostic_summary(false, cx), DiagnosticSummary { error_count: 0, warning_count: 0, } ); }); } #[gpui::test] async fn test_diagnostic_summaries_cleared_on_buffer_reload(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/dir"), json!({ "a.rs": "one two three" })) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let pull_count = Arc::new(atomic::AtomicUsize::new(0)); let closure_pull_count = pull_count.clone(); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( lsp::DiagnosticOptions { identifier: Some("test-reload".to_string()), inter_file_dependencies: true, workspace_diagnostics: false, work_done_progress_options: Default::default(), }, )), ..lsp::ServerCapabilities::default() }, initializer: Some(Box::new(move |fake_server| { let pull_count = closure_pull_count.clone(); fake_server.set_request_handler::( move |_, _| { let pull_count = pull_count.clone(); async move { pull_count.fetch_add(1, atomic::Ordering::SeqCst); Ok(lsp::DocumentDiagnosticReportResult::Report( lsp::DocumentDiagnosticReport::Full( lsp::RelatedFullDocumentDiagnosticReport { related_documents: None, full_document_diagnostic_report: lsp::FullDocumentDiagnosticReport { result_id: None, items: Vec::new(), }, }, ), )) } }, ); })), ..FakeLspAdapter::default() }, ); let (_buffer, _handle) = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) }) .await .unwrap(); let fake_server = fake_servers.next().await.unwrap(); cx.executor().run_until_parked(); // Publish initial diagnostics via the fake server. fake_server.notify::(lsp::PublishDiagnosticsParams { uri: Uri::from_file_path(path!("/dir/a.rs")).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 3)), severity: Some(lsp::DiagnosticSeverity::ERROR), message: "error in a".to_string(), ..Default::default() }], }); cx.executor().run_until_parked(); project.update(cx, |project, cx| { assert_eq!( project.diagnostic_summary(false, cx), DiagnosticSummary { error_count: 1, warning_count: 0, } ); }); let pulls_before = pull_count.load(atomic::Ordering::SeqCst); // Change the file on disk. The FS event triggers buffer reload, // which in turn triggers pull_diagnostics_for_buffer. fs.save( path!("/dir/a.rs").as_ref(), &"fixed content".into(), LineEnding::Unix, ) .await .unwrap(); cx.executor().run_until_parked(); let pulls_after = pull_count.load(atomic::Ordering::SeqCst); assert!( pulls_after > pulls_before, "Expected document diagnostic pull after buffer reload (before={pulls_before}, after={pulls_after})" ); } #[gpui::test] async fn test_edits_from_lsp2_with_past_version(cx: &mut gpui::TestAppContext) { init_test(cx); let text = " fn a() { f1(); } fn b() { f2(); } fn c() { f3(); } " .unindent(); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.rs": text.clone(), }), ) .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp("Rust", FakeLspAdapter::default()); let (buffer, _handle) = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) }) .await .unwrap(); let mut fake_server = fake_servers.next().await.unwrap(); let lsp_document_version = fake_server .receive_notification::() .await .text_document .version; // Simulate editing the buffer after the language server computes some edits. buffer.update(cx, |buffer, cx| { buffer.edit( [( Point::new(0, 0)..Point::new(0, 0), "// above first function\n", )], None, cx, ); buffer.edit( [( Point::new(2, 0)..Point::new(2, 0), " // inside first function\n", )], None, cx, ); buffer.edit( [( Point::new(6, 4)..Point::new(6, 4), "// inside second function ", )], None, cx, ); assert_eq!( buffer.text(), " // above first function fn a() { // inside first function f1(); } fn b() { // inside second function f2(); } fn c() { f3(); } " .unindent() ); }); let edits = lsp_store .update(cx, |lsp_store, cx| { lsp_store.as_local_mut().unwrap().edits_from_lsp( &buffer, vec![ // replace body of first function lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(3, 0)), new_text: " fn a() { f10(); } " .unindent(), }, // edit inside second function lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(4, 6), lsp::Position::new(4, 6)), new_text: "00".into(), }, // edit inside third function via two distinct edits lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(7, 5), lsp::Position::new(7, 5)), new_text: "4000".into(), }, lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(7, 5), lsp::Position::new(7, 6)), new_text: "".into(), }, ], LanguageServerId(0), Some(lsp_document_version), cx, ) }) .await .unwrap(); buffer.update(cx, |buffer, cx| { for (range, new_text) in edits { buffer.edit([(range, new_text)], None, cx); } assert_eq!( buffer.text(), " // above first function fn a() { // inside first function f10(); } fn b() { // inside second function f200(); } fn c() { f4000(); } " .unindent() ); }); } #[gpui::test] async fn test_edits_from_lsp2_with_edits_on_adjacent_lines(cx: &mut gpui::TestAppContext) { init_test(cx); let text = " use a::b; use a::c; fn f() { b(); c(); } " .unindent(); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.rs": text.clone(), }), ) .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/dir/a.rs"), cx) }) .await .unwrap(); // Simulate the language server sending us a small edit in the form of a very large diff. // Rust-analyzer does this when performing a merge-imports code action. let edits = lsp_store .update(cx, |lsp_store, cx| { lsp_store.as_local_mut().unwrap().edits_from_lsp( &buffer, [ // Replace the first use statement without editing the semicolon. lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 4), lsp::Position::new(0, 8)), new_text: "a::{b, c}".into(), }, // Reinsert the remainder of the file between the semicolon and the final // newline of the file. lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 9), lsp::Position::new(0, 9)), new_text: "\n\n".into(), }, lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 9), lsp::Position::new(0, 9)), new_text: " fn f() { b(); c(); }" .unindent(), }, // Delete everything after the first newline of the file. lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(7, 0)), new_text: "".into(), }, ], LanguageServerId(0), None, cx, ) }) .await .unwrap(); buffer.update(cx, |buffer, cx| { let edits = edits .into_iter() .map(|(range, text)| { ( range.start.to_point(buffer)..range.end.to_point(buffer), text, ) }) .collect::>(); assert_eq!( edits, [ (Point::new(0, 4)..Point::new(0, 8), "a::{b, c}".into()), (Point::new(1, 0)..Point::new(2, 0), "".into()) ] ); for (range, new_text) in edits { buffer.edit([(range, new_text)], None, cx); } assert_eq!( buffer.text(), " use a::{b, c}; fn f() { b(); c(); } " .unindent() ); }); } #[gpui::test] async fn test_edits_from_lsp_with_replacement_followed_by_adjacent_insertion( cx: &mut gpui::TestAppContext, ) { init_test(cx); let text = "Path()"; let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.rs": text }), ) .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/dir/a.rs"), cx) }) .await .unwrap(); // Simulate the language server sending us a pair of edits at the same location, // with an insertion following a replacement (which violates the LSP spec). let edits = lsp_store .update(cx, |lsp_store, cx| { lsp_store.as_local_mut().unwrap().edits_from_lsp( &buffer, [ lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), new_text: "Path".into(), }, lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)), new_text: "from path import Path\n\n\n".into(), }, ], LanguageServerId(0), None, cx, ) }) .await .unwrap(); buffer.update(cx, |buffer, cx| { buffer.edit(edits, None, cx); assert_eq!(buffer.text(), "from path import Path\n\n\nPath()") }); } #[gpui::test] async fn test_invalid_edits_from_lsp2(cx: &mut gpui::TestAppContext) { init_test(cx); let text = " use a::b; use a::c; fn f() { b(); c(); } " .unindent(); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.rs": text.clone(), }), ) .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/dir/a.rs"), cx) }) .await .unwrap(); // Simulate the language server sending us edits in a non-ordered fashion, // with ranges sometimes being inverted or pointing to invalid locations. let edits = lsp_store .update(cx, |lsp_store, cx| { lsp_store.as_local_mut().unwrap().edits_from_lsp( &buffer, [ lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 9), lsp::Position::new(0, 9)), new_text: "\n\n".into(), }, lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 8), lsp::Position::new(0, 4)), new_text: "a::{b, c}".into(), }, lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(99, 0)), new_text: "".into(), }, lsp::TextEdit { range: lsp::Range::new(lsp::Position::new(0, 9), lsp::Position::new(0, 9)), new_text: " fn f() { b(); c(); }" .unindent(), }, ], LanguageServerId(0), None, cx, ) }) .await .unwrap(); buffer.update(cx, |buffer, cx| { let edits = edits .into_iter() .map(|(range, text)| { ( range.start.to_point(buffer)..range.end.to_point(buffer), text, ) }) .collect::>(); assert_eq!( edits, [ (Point::new(0, 4)..Point::new(0, 8), "a::{b, c}".into()), (Point::new(1, 0)..Point::new(2, 0), "".into()) ] ); for (range, new_text) in edits { buffer.edit([(range, new_text)], None, cx); } assert_eq!( buffer.text(), " use a::{b, c}; fn f() { b(); c(); } " .unindent() ); }); } fn chunks_with_diagnostics( buffer: &Buffer, range: Range, ) -> Vec<(String, Option)> { let mut chunks: Vec<(String, Option)> = Vec::new(); for chunk in buffer.snapshot().chunks( range, LanguageAwareStyling { tree_sitter: true, diagnostics: true, }, ) { if chunks .last() .is_some_and(|prev_chunk| prev_chunk.1 == chunk.diagnostic_severity) { chunks.last_mut().unwrap().0.push_str(chunk.text); } else { chunks.push((chunk.text.to_string(), chunk.diagnostic_severity)); } } chunks } #[gpui::test(iterations = 10)] async fn test_definition(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.rs": "const fn a() { A }", "b.rs": "const y: i32 = crate::a()", }), ) .await; let project = Project::test(fs, [path!("/dir/b.rs").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp("Rust", FakeLspAdapter::default()); let (buffer, _handle) = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/b.rs"), cx) }) .await .unwrap(); let fake_server = fake_servers.next().await.unwrap(); cx.executor().run_until_parked(); fake_server.set_request_handler::(|params, _| async move { let params = params.text_document_position_params; assert_eq!( params.text_document.uri.to_file_path().unwrap(), Path::new(path!("/dir/b.rs")), ); assert_eq!(params.position, lsp::Position::new(0, 22)); Ok(Some(lsp::GotoDefinitionResponse::Scalar( lsp::Location::new( lsp::Uri::from_file_path(path!("/dir/a.rs")).unwrap(), lsp::Range::new(lsp::Position::new(0, 9), lsp::Position::new(0, 10)), ), ))) }); let mut definitions = project .update(cx, |project, cx| project.definitions(&buffer, 22, cx)) .await .unwrap() .unwrap(); // Assert no new language server started cx.executor().run_until_parked(); assert!(fake_servers.try_recv().is_err()); assert_eq!(definitions.len(), 1); let definition = definitions.pop().unwrap(); cx.update(|cx| { let target_buffer = definition.target.buffer.read(cx); assert_eq!( target_buffer .file() .unwrap() .as_local() .unwrap() .abs_path(cx), Path::new(path!("/dir/a.rs")), ); assert_eq!(definition.target.range.to_offset(target_buffer), 9..10); assert_eq!( list_worktrees(&project, cx), [ (path!("/dir/b.rs").as_ref(), true), (path!("/dir/a.rs").as_ref(), false), ], ); drop(definition); }); cx.update(|cx| { assert_eq!( list_worktrees(&project, cx), [(path!("/dir/b.rs").as_ref(), true)] ); }); fn list_worktrees<'a>(project: &'a Entity, cx: &'a App) -> Vec<(&'a Path, bool)> { project .read(cx) .worktrees(cx) .map(|worktree| { let worktree = worktree.read(cx); ( worktree.as_local().unwrap().abs_path().as_ref(), worktree.is_visible(), ) }) .collect::>() } } #[gpui::test] async fn test_completions_with_text_edit(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.ts": "", }), ) .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(typescript_lang()); let mut fake_language_servers = language_registry.register_fake_lsp( "TypeScript", FakeLspAdapter { capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string()]), ..Default::default() }), ..Default::default() }, ..Default::default() }, ); let (buffer, _handle) = project .update(cx, |p, cx| { p.open_local_buffer_with_lsp(path!("/dir/a.ts"), cx) }) .await .unwrap(); let fake_server = fake_language_servers.next().await.unwrap(); cx.executor().run_until_parked(); // When text_edit exists, it takes precedence over insert_text and label let text = "let a = obj.fqn"; buffer.update(cx, |buffer, cx| buffer.set_text(text, cx)); let completions = project.update(cx, |project, cx| { project.completions(&buffer, text.len(), DEFAULT_COMPLETION_CONTEXT, cx) }); fake_server .set_request_handler::(|_, _| async { Ok(Some(lsp::CompletionResponse::Array(vec![ lsp::CompletionItem { label: "labelText".into(), insert_text: Some("insertText".into()), text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { range: lsp::Range::new( lsp::Position::new(0, text.len() as u32 - 3), lsp::Position::new(0, text.len() as u32), ), new_text: "textEditText".into(), })), ..Default::default() }, ]))) }) .next() .await; let completions = completions .await .unwrap() .into_iter() .flat_map(|response| response.completions) .collect::>(); let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); assert_eq!(completions.len(), 1); assert_eq!(completions[0].new_text, "textEditText"); assert_eq!( completions[0].replace_range.to_offset(&snapshot), text.len() - 3..text.len() ); } #[gpui::test] async fn test_completions_with_edit_ranges(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.ts": "", }), ) .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(typescript_lang()); let mut fake_language_servers = language_registry.register_fake_lsp( "TypeScript", FakeLspAdapter { capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string()]), ..Default::default() }), ..Default::default() }, ..Default::default() }, ); let (buffer, _handle) = project .update(cx, |p, cx| { p.open_local_buffer_with_lsp(path!("/dir/a.ts"), cx) }) .await .unwrap(); let fake_server = fake_language_servers.next().await.unwrap(); cx.executor().run_until_parked(); let text = "let a = obj.fqn"; // Test 1: When text_edit is None but text_edit_text exists with default edit_range { buffer.update(cx, |buffer, cx| buffer.set_text(text, cx)); let completions = project.update(cx, |project, cx| { project.completions(&buffer, text.len(), DEFAULT_COMPLETION_CONTEXT, cx) }); fake_server .set_request_handler::(|_, _| async { Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { is_incomplete: false, item_defaults: Some(lsp::CompletionListItemDefaults { edit_range: Some(lsp::CompletionListItemDefaultsEditRange::Range( lsp::Range::new( lsp::Position::new(0, text.len() as u32 - 3), lsp::Position::new(0, text.len() as u32), ), )), ..Default::default() }), items: vec![lsp::CompletionItem { label: "labelText".into(), text_edit_text: Some("textEditText".into()), text_edit: None, ..Default::default() }], }))) }) .next() .await; let completions = completions .await .unwrap() .into_iter() .flat_map(|response| response.completions) .collect::>(); let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); assert_eq!(completions.len(), 1); assert_eq!(completions[0].new_text, "textEditText"); assert_eq!( completions[0].replace_range.to_offset(&snapshot), text.len() - 3..text.len() ); } // Test 2: When both text_edit and text_edit_text are None with default edit_range { buffer.update(cx, |buffer, cx| buffer.set_text(text, cx)); let completions = project.update(cx, |project, cx| { project.completions(&buffer, text.len(), DEFAULT_COMPLETION_CONTEXT, cx) }); fake_server .set_request_handler::(|_, _| async { Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { is_incomplete: false, item_defaults: Some(lsp::CompletionListItemDefaults { edit_range: Some(lsp::CompletionListItemDefaultsEditRange::Range( lsp::Range::new( lsp::Position::new(0, text.len() as u32 - 3), lsp::Position::new(0, text.len() as u32), ), )), ..Default::default() }), items: vec![lsp::CompletionItem { label: "labelText".into(), text_edit_text: None, insert_text: Some("irrelevant".into()), text_edit: None, ..Default::default() }], }))) }) .next() .await; let completions = completions .await .unwrap() .into_iter() .flat_map(|response| response.completions) .collect::>(); let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); assert_eq!(completions.len(), 1); assert_eq!(completions[0].new_text, "labelText"); assert_eq!( completions[0].replace_range.to_offset(&snapshot), text.len() - 3..text.len() ); } } #[gpui::test] async fn test_completions_without_edit_ranges(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.ts": "", }), ) .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(typescript_lang()); let mut fake_language_servers = language_registry.register_fake_lsp( "TypeScript", FakeLspAdapter { capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![":".to_string()]), ..Default::default() }), ..Default::default() }, ..Default::default() }, ); let (buffer, _handle) = project .update(cx, |p, cx| { p.open_local_buffer_with_lsp(path!("/dir/a.ts"), cx) }) .await .unwrap(); let fake_server = fake_language_servers.next().await.unwrap(); cx.executor().run_until_parked(); // Test 1: When text_edit is None but insert_text exists (no edit_range in defaults) let text = "let a = b.fqn"; buffer.update(cx, |buffer, cx| buffer.set_text(text, cx)); let completions = project.update(cx, |project, cx| { project.completions(&buffer, text.len(), DEFAULT_COMPLETION_CONTEXT, cx) }); fake_server .set_request_handler::(|_, _| async move { Ok(Some(lsp::CompletionResponse::Array(vec![ lsp::CompletionItem { label: "fullyQualifiedName?".into(), insert_text: Some("fullyQualifiedName".into()), ..Default::default() }, ]))) }) .next() .await; let completions = completions .await .unwrap() .into_iter() .flat_map(|response| response.completions) .collect::>(); let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); assert_eq!(completions.len(), 1); assert_eq!(completions[0].new_text, "fullyQualifiedName"); assert_eq!( completions[0].replace_range.to_offset(&snapshot), text.len() - 3..text.len() ); // Test 2: When both text_edit and insert_text are None (no edit_range in defaults) let text = "let a = \"atoms/cmp\""; buffer.update(cx, |buffer, cx| buffer.set_text(text, cx)); let completions = project.update(cx, |project, cx| { project.completions(&buffer, text.len() - 1, DEFAULT_COMPLETION_CONTEXT, cx) }); fake_server .set_request_handler::(|_, _| async move { Ok(Some(lsp::CompletionResponse::Array(vec![ lsp::CompletionItem { label: "component".into(), ..Default::default() }, ]))) }) .next() .await; let completions = completions .await .unwrap() .into_iter() .flat_map(|response| response.completions) .collect::>(); let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); assert_eq!(completions.len(), 1); assert_eq!(completions[0].new_text, "component"); assert_eq!( completions[0].replace_range.to_offset(&snapshot), text.len() - 4..text.len() - 1 ); } #[gpui::test] async fn test_completions_with_carriage_returns(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.ts": "", }), ) .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(typescript_lang()); let mut fake_language_servers = language_registry.register_fake_lsp( "TypeScript", FakeLspAdapter { capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![":".to_string()]), ..Default::default() }), ..Default::default() }, ..Default::default() }, ); let (buffer, _handle) = project .update(cx, |p, cx| { p.open_local_buffer_with_lsp(path!("/dir/a.ts"), cx) }) .await .unwrap(); let fake_server = fake_language_servers.next().await.unwrap(); cx.executor().run_until_parked(); let text = "let a = b.fqn"; buffer.update(cx, |buffer, cx| buffer.set_text(text, cx)); let completions = project.update(cx, |project, cx| { project.completions(&buffer, text.len(), DEFAULT_COMPLETION_CONTEXT, cx) }); fake_server .set_request_handler::(|_, _| async move { Ok(Some(lsp::CompletionResponse::Array(vec![ lsp::CompletionItem { label: "fullyQualifiedName?".into(), insert_text: Some("fully\rQualified\r\nName".into()), ..Default::default() }, ]))) }) .next() .await; let completions = completions .await .unwrap() .into_iter() .flat_map(|response| response.completions) .collect::>(); assert_eq!(completions.len(), 1); assert_eq!(completions[0].new_text, "fully\nQualified\nName"); } #[gpui::test] async fn test_supports_range_formatting_ignores_unrelated_language_servers( cx: &mut gpui::TestAppContext, ) { init_test(cx); cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { settings.project.all_languages.defaults.formatter = Some(FormatterList::Single( Formatter::LanguageServer(settings::LanguageServerFormatterSpecifier::Current), )); }); }); }); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.ts": "", "b.rs": "", }), ) .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(typescript_lang()); language_registry.add(rust_lang()); let mut typescript_language_servers = language_registry.register_fake_lsp( "TypeScript", FakeLspAdapter { name: "typescript-fake-language-server", capabilities: lsp::ServerCapabilities { document_range_formatting_provider: Some(lsp::OneOf::Left(true)), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ); let mut rust_language_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { name: "rust-fake-language-server", capabilities: lsp::ServerCapabilities { document_formatting_provider: Some(lsp::OneOf::Left(true)), document_range_formatting_provider: Some(lsp::OneOf::Left(false)), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ); let (typescript_buffer, _typescript_handle) = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/a.ts"), cx) }) .await .unwrap(); let (rust_buffer, _rust_handle) = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/b.rs"), cx) }) .await .unwrap(); let _typescript_language_server = typescript_language_servers.next().await.unwrap(); let _rust_language_server = rust_language_servers.next().await.unwrap(); cx.executor().run_until_parked(); assert!(project.read_with(cx, |project, cx| { project.supports_range_formatting(&typescript_buffer, cx) })); assert!(!project.read_with(cx, |project, cx| { project.supports_range_formatting(&rust_buffer, cx) })); } #[gpui::test(iterations = 10)] async fn test_apply_code_actions_with_commands(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.ts": "a", }), ) .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(typescript_lang()); let mut fake_language_servers = language_registry.register_fake_lsp( "TypeScript", FakeLspAdapter { capabilities: lsp::ServerCapabilities { code_action_provider: Some(lsp::CodeActionProviderCapability::Options( lsp::CodeActionOptions { resolve_provider: Some(true), ..lsp::CodeActionOptions::default() }, )), execute_command_provider: Some(lsp::ExecuteCommandOptions { commands: vec!["_the/command".to_string()], ..lsp::ExecuteCommandOptions::default() }), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ); let (buffer, _handle) = project .update(cx, |p, cx| { p.open_local_buffer_with_lsp(path!("/dir/a.ts"), cx) }) .await .unwrap(); let fake_server = fake_language_servers.next().await.unwrap(); cx.executor().run_until_parked(); // Language server returns code actions that contain commands, and not edits. let actions = project.update(cx, |project, cx| { project.code_actions(&buffer, 0..0, None, cx) }); fake_server .set_request_handler::(|_, _| async move { Ok(Some(vec![ lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction { title: "The code action".into(), data: Some(serde_json::json!({ "command": "_the/command", })), ..lsp::CodeAction::default() }), lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction { title: "two".into(), ..lsp::CodeAction::default() }), ])) }) .next() .await; let action = actions.await.unwrap().unwrap()[0].clone(); let apply = project.update(cx, |project, cx| { project.apply_code_action(buffer.clone(), action, true, cx) }); // Resolving the code action does not populate its edits. In absence of // edits, we must execute the given command. fake_server.set_request_handler::( |mut action, _| async move { if action.data.is_some() { action.command = Some(lsp::Command { title: "The command".into(), command: "_the/command".into(), arguments: Some(vec![json!("the-argument")]), }); } Ok(action) }, ); // While executing the command, the language server sends the editor // a `workspaceEdit` request. fake_server .set_request_handler::({ let fake = fake_server.clone(); move |params, _| { assert_eq!(params.command, "_the/command"); let fake = fake.clone(); async move { fake.server .request::( lsp::ApplyWorkspaceEditParams { label: None, edit: lsp::WorkspaceEdit { changes: Some( [( lsp::Uri::from_file_path(path!("/dir/a.ts")).unwrap(), vec![lsp::TextEdit { range: lsp::Range::new( lsp::Position::new(0, 0), lsp::Position::new(0, 0), ), new_text: "X".into(), }], )] .into_iter() .collect(), ), ..Default::default() }, }, DEFAULT_LSP_REQUEST_TIMEOUT, ) .await .into_response() .unwrap(); Ok(Some(json!(null))) } } }) .next() .await; // Applying the code action returns a project transaction containing the edits // sent by the language server in its `workspaceEdit` request. let transaction = apply.await.unwrap(); assert!(transaction.0.contains_key(&buffer)); buffer.update(cx, |buffer, cx| { assert_eq!(buffer.text(), "Xa"); buffer.undo(cx); assert_eq!(buffer.text(), "a"); }); } #[gpui::test] async fn test_rename_file_to_new_directory(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); let expected_contents = "content"; fs.as_fake() .insert_tree( "/root", json!({ "test.txt": expected_contents }), ) .await; let project = Project::test(fs, [path!("/root").as_ref()], cx).await; let (worktree, entry_id) = project.read_with(cx, |project, cx| { let worktree = project.worktrees(cx).next().unwrap(); let entry_id = worktree .read(cx) .entry_for_path(rel_path("test.txt")) .unwrap() .id; (worktree, entry_id) }); let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id()); let _result = project .update(cx, |project, cx| { project.rename_entry( entry_id, (worktree_id, rel_path("dir1/dir2/dir3/test.txt")).into(), cx, ) }) .await .unwrap(); worktree.read_with(cx, |worktree, _| { assert!( worktree.entry_for_path(rel_path("test.txt")).is_none(), "Old file should have been removed" ); assert!( worktree .entry_for_path(rel_path("dir1/dir2/dir3/test.txt")) .is_some(), "Whole directory hierarchy and the new file should have been created" ); }); assert_eq!( worktree .update(cx, |worktree, cx| { worktree.load_file(rel_path("dir1/dir2/dir3/test.txt"), cx) }) .await .unwrap() .text, expected_contents, "Moved file's contents should be preserved" ); let entry_id = worktree.read_with(cx, |worktree, _| { worktree .entry_for_path(rel_path("dir1/dir2/dir3/test.txt")) .unwrap() .id }); let _result = project .update(cx, |project, cx| { project.rename_entry( entry_id, (worktree_id, rel_path("dir1/dir2/test.txt")).into(), cx, ) }) .await .unwrap(); worktree.read_with(cx, |worktree, _| { assert!( worktree.entry_for_path(rel_path("test.txt")).is_none(), "First file should not reappear" ); assert!( worktree .entry_for_path(rel_path("dir1/dir2/dir3/test.txt")) .is_none(), "Old file should have been removed" ); assert!( worktree .entry_for_path(rel_path("dir1/dir2/test.txt")) .is_some(), "No error should have occurred after moving into existing directory" ); }); assert_eq!( worktree .update(cx, |worktree, cx| { worktree.load_file(rel_path("dir1/dir2/test.txt"), cx) }) .await .unwrap() .text, expected_contents, "Moved file's contents should be preserved" ); } #[gpui::test(iterations = 10)] async fn test_save_file(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "file1": "the old contents", }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let buffer = project .update(cx, |p, cx| p.open_local_buffer(path!("/dir/file1"), cx)) .await .unwrap(); buffer.update(cx, |buffer, cx| { assert_eq!(buffer.text(), "the old contents"); buffer.edit([(0..0, "a line of text.\n".repeat(10 * 1024))], None, cx); }); project .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) .await .unwrap(); let new_text = fs .load(Path::new(path!("/dir/file1"))) .await .unwrap() .replace("\r\n", "\n"); assert_eq!(new_text, buffer.update(cx, |buffer, _| buffer.text())); } #[gpui::test(iterations = 10)] async fn test_save_file_spawns_language_server(cx: &mut gpui::TestAppContext) { // Issue: #24349 init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/dir"), json!({})).await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_rust_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { name: "the-rust-language-server", capabilities: lsp::ServerCapabilities { completion_provider: Some(lsp::CompletionOptions { trigger_characters: Some(vec![".".to_string(), "::".to_string()]), ..Default::default() }), text_document_sync: Some(lsp::TextDocumentSyncCapability::Options( lsp::TextDocumentSyncOptions { save: Some(lsp::TextDocumentSyncSaveOptions::Supported(true)), ..Default::default() }, )), ..Default::default() }, ..Default::default() }, ); let buffer = project .update(cx, |this, cx| this.create_buffer(None, false, cx)) .unwrap() .await; project.update(cx, |this, cx| { this.register_buffer_with_language_servers(&buffer, cx); buffer.update(cx, |buffer, cx| { assert!(!this.has_language_servers_for(buffer, cx)); }) }); project .update(cx, |this, cx| { let worktree_id = this.worktrees(cx).next().unwrap().read(cx).id(); this.save_buffer_as( buffer.clone(), ProjectPath { worktree_id, path: rel_path("file.rs").into(), }, cx, ) }) .await .unwrap(); // A server is started up, and it is notified about Rust files. let mut fake_rust_server = fake_rust_servers.next().await.unwrap(); assert_eq!( fake_rust_server .receive_notification::() .await .text_document, lsp::TextDocumentItem { uri: lsp::Uri::from_file_path(path!("/dir/file.rs")).unwrap(), version: 0, text: "".to_string(), language_id: "rust".to_string(), } ); project.update(cx, |this, cx| { buffer.update(cx, |buffer, cx| { assert!(this.has_language_servers_for(buffer, cx)); }) }); } #[gpui::test(iterations = 30)] async fn test_file_changes_multiple_times_on_disk(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "file1": "the original contents", }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let worktree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); let buffer = project .update(cx, |p, cx| p.open_local_buffer(path!("/dir/file1"), cx)) .await .unwrap(); // Change the buffer's file on disk, and then wait for the file change // to be detected by the worktree, so that the buffer starts reloading. fs.save( path!("/dir/file1").as_ref(), &"the first contents".into(), Default::default(), ) .await .unwrap(); worktree.next_event(cx).await; // Change the buffer's file again. Depending on the random seed, the // previous file change may still be in progress. fs.save( path!("/dir/file1").as_ref(), &"the second contents".into(), Default::default(), ) .await .unwrap(); worktree.next_event(cx).await; cx.executor().run_until_parked(); let on_disk_text = fs.load(Path::new(path!("/dir/file1"))).await.unwrap(); buffer.read_with(cx, |buffer, _| { assert_eq!(buffer.text(), on_disk_text); assert!(!buffer.is_dirty(), "buffer should not be dirty"); assert!(!buffer.has_conflict(), "buffer should not be dirty"); }); } #[gpui::test(iterations = 30)] async fn test_edit_buffer_while_it_reloads(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "file1": "the original contents", }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let worktree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); let buffer = project .update(cx, |p, cx| p.open_local_buffer(path!("/dir/file1"), cx)) .await .unwrap(); // Change the buffer's file on disk, and then wait for the file change // to be detected by the worktree, so that the buffer starts reloading. fs.save( path!("/dir/file1").as_ref(), &"the first contents".into(), Default::default(), ) .await .unwrap(); worktree.next_event(cx).await; cx.executor() .spawn(cx.executor().simulate_random_delay()) .await; // Perform a noop edit, causing the buffer's version to increase. buffer.update(cx, |buffer, cx| { buffer.edit([(0..0, " ")], None, cx); buffer.undo(cx); }); cx.executor().run_until_parked(); let on_disk_text = fs.load(Path::new(path!("/dir/file1"))).await.unwrap(); buffer.read_with(cx, |buffer, _| { let buffer_text = buffer.text(); if buffer_text == on_disk_text { assert!( !buffer.is_dirty() && !buffer.has_conflict(), "buffer shouldn't be dirty. text: {buffer_text:?}, disk text: {on_disk_text:?}", ); } // If the file change occurred while the buffer was processing the first // change, the buffer will be in a conflicting state. else { assert!(buffer.is_dirty(), "buffer should report that it is dirty. text: {buffer_text:?}, disk text: {on_disk_text:?}"); assert!(buffer.has_conflict(), "buffer should report that it is dirty. text: {buffer_text:?}, disk text: {on_disk_text:?}"); } }); } #[gpui::test] async fn test_save_in_single_file_worktree(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "file1": "the old contents", }), ) .await; let project = Project::test(fs.clone(), [path!("/dir/file1").as_ref()], cx).await; let buffer = project .update(cx, |p, cx| p.open_local_buffer(path!("/dir/file1"), cx)) .await .unwrap(); buffer.update(cx, |buffer, cx| { buffer.edit([(0..0, "a line of text.\n".repeat(10 * 1024))], None, cx); }); project .update(cx, |project, cx| project.save_buffer(buffer.clone(), cx)) .await .unwrap(); let new_text = fs .load(Path::new(path!("/dir/file1"))) .await .unwrap() .replace("\r\n", "\n"); assert_eq!(new_text, buffer.update(cx, |buffer, _| buffer.text())); } #[gpui::test] async fn test_save_as(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree("/dir", json!({})).await; let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; let languages = project.update(cx, |project, _| project.languages().clone()); languages.add(rust_lang()); let buffer = project.update(cx, |project, cx| { project.create_local_buffer("", None, false, cx) }); buffer.update(cx, |buffer, cx| { buffer.edit([(0..0, "abc")], None, cx); assert!(buffer.is_dirty()); assert!(!buffer.has_conflict()); assert_eq!(buffer.language().unwrap().name(), "Plain Text"); }); project .update(cx, |project, cx| { let worktree_id = project.worktrees(cx).next().unwrap().read(cx).id(); let path = ProjectPath { worktree_id, path: rel_path("file1.rs").into(), }; project.save_buffer_as(buffer.clone(), path, cx) }) .await .unwrap(); assert_eq!(fs.load(Path::new("/dir/file1.rs")).await.unwrap(), "abc"); cx.executor().run_until_parked(); buffer.update(cx, |buffer, cx| { assert_eq!( buffer.file().unwrap().full_path(cx), Path::new("dir/file1.rs") ); assert!(!buffer.is_dirty()); assert!(!buffer.has_conflict()); assert_eq!(buffer.language().unwrap().name(), "Rust"); }); let opened_buffer = project .update(cx, |project, cx| { project.open_local_buffer("/dir/file1.rs", cx) }) .await .unwrap(); assert_eq!(opened_buffer, buffer); } #[gpui::test] async fn test_save_as_existing_file(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; fs.insert_tree( path!("/dir"), json!({ "data_a.txt": "data about a" }), ) .await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/dir/data_a.txt"), cx) }) .await .unwrap(); buffer.update(cx, |buffer, cx| { buffer.edit([(11..12, "b")], None, cx); }); // Save buffer's contents as a new file and confirm that the buffer's now // associated with `data_b.txt` instead of `data_a.txt`, confirming that the // file associated with the buffer has now been updated to `data_b.txt` project .update(cx, |project, cx| { let worktree_id = project.worktrees(cx).next().unwrap().read(cx).id(); let new_path = ProjectPath { worktree_id, path: rel_path("data_b.txt").into(), }; project.save_buffer_as(buffer.clone(), new_path, cx) }) .await .unwrap(); buffer.update(cx, |buffer, cx| { assert_eq!( buffer.file().unwrap().full_path(cx), Path::new("dir/data_b.txt") ) }); // Open the original `data_a.txt` file, confirming that its contents are // unchanged and the resulting buffer's associated file is `data_a.txt`. let original_buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/dir/data_a.txt"), cx) }) .await .unwrap(); original_buffer.update(cx, |buffer, cx| { assert_eq!(buffer.text(), "data about a"); assert_eq!( buffer.file().unwrap().full_path(cx), Path::new("dir/data_a.txt") ) }); } #[gpui::test(retries = 5)] async fn test_rescan_and_remote_updates(cx: &mut gpui::TestAppContext) { use worktree::WorktreeModelHandle as _; init_test(cx); cx.executor().allow_parking(); let dir = TempTree::new(json!({ "a": { "file1": "", "file2": "", "file3": "", }, "b": { "c": { "file4": "", "file5": "", } } })); let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [dir.path()], cx).await; let buffer_for_path = |path: &'static str, cx: &mut gpui::TestAppContext| { let buffer = project.update(cx, |p, cx| p.open_local_buffer(dir.path().join(path), cx)); async move { buffer.await.unwrap() } }; let id_for_path = |path: &'static str, cx: &mut gpui::TestAppContext| { project.update(cx, |project, cx| { let tree = project.worktrees(cx).next().unwrap(); tree.read(cx) .entry_for_path(rel_path(path)) .unwrap_or_else(|| panic!("no entry for path {}", path)) .id }) }; let buffer2 = buffer_for_path("a/file2", cx).await; let buffer3 = buffer_for_path("a/file3", cx).await; let buffer4 = buffer_for_path("b/c/file4", cx).await; let buffer5 = buffer_for_path("b/c/file5", cx).await; let file2_id = id_for_path("a/file2", cx); let file3_id = id_for_path("a/file3", cx); let file4_id = id_for_path("b/c/file4", cx); // Create a remote copy of this worktree. let tree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); let metadata = tree.update(cx, |tree, _| tree.metadata_proto()); let updates = Arc::new(Mutex::new(Vec::new())); tree.update(cx, |tree, cx| { let updates = updates.clone(); tree.observe_updates(0, cx, move |update| { updates.lock().push(update); async { true } }); }); let remote = cx.update(|cx| { Worktree::remote( 0, ReplicaId::REMOTE_SERVER, metadata, project.read(cx).client().into(), project.read(cx).path_style(cx), cx, ) }); cx.executor().run_until_parked(); cx.update(|cx| { assert!(!buffer2.read(cx).is_dirty()); assert!(!buffer3.read(cx).is_dirty()); assert!(!buffer4.read(cx).is_dirty()); assert!(!buffer5.read(cx).is_dirty()); }); // Rename and delete files and directories. tree.flush_fs_events(cx).await; std::fs::rename(dir.path().join("a/file3"), dir.path().join("b/c/file3")).unwrap(); std::fs::remove_file(dir.path().join("b/c/file5")).unwrap(); std::fs::rename(dir.path().join("b/c"), dir.path().join("d")).unwrap(); std::fs::rename(dir.path().join("a/file2"), dir.path().join("a/file2.new")).unwrap(); tree.flush_fs_events(cx).await; cx.update(|app| { assert_eq!( tree.read(app).paths().collect::>(), vec![ rel_path("a"), rel_path("a/file1"), rel_path("a/file2.new"), rel_path("b"), rel_path("d"), rel_path("d/file3"), rel_path("d/file4"), ] ); }); assert_eq!(id_for_path("a/file2.new", cx), file2_id); assert_eq!(id_for_path("d/file3", cx), file3_id); assert_eq!(id_for_path("d/file4", cx), file4_id); cx.update(|cx| { assert_eq!( buffer2.read(cx).file().unwrap().path().as_ref(), rel_path("a/file2.new") ); assert_eq!( buffer3.read(cx).file().unwrap().path().as_ref(), rel_path("d/file3") ); assert_eq!( buffer4.read(cx).file().unwrap().path().as_ref(), rel_path("d/file4") ); assert_eq!( buffer5.read(cx).file().unwrap().path().as_ref(), rel_path("b/c/file5") ); assert_matches!( buffer2.read(cx).file().unwrap().disk_state(), DiskState::Present { .. } ); assert_matches!( buffer3.read(cx).file().unwrap().disk_state(), DiskState::Present { .. } ); assert_matches!( buffer4.read(cx).file().unwrap().disk_state(), DiskState::Present { .. } ); assert_eq!( buffer5.read(cx).file().unwrap().disk_state(), DiskState::Deleted ); }); // Update the remote worktree. Check that it becomes consistent with the // local worktree. cx.executor().run_until_parked(); remote.update(cx, |remote, _| { for update in updates.lock().drain(..) { remote.as_remote_mut().unwrap().update_from_remote(update); } }); cx.executor().run_until_parked(); remote.update(cx, |remote, _| { assert_eq!( remote.paths().collect::>(), vec![ rel_path("a"), rel_path("a/file1"), rel_path("a/file2.new"), rel_path("b"), rel_path("d"), rel_path("d/file3"), rel_path("d/file4"), ] ); }); } #[cfg(target_os = "linux")] #[gpui::test(retries = 5)] async fn test_recreated_directory_receives_child_events(cx: &mut gpui::TestAppContext) { init_test(cx); cx.executor().allow_parking(); let dir = TempTree::new(json!({})); let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [dir.path()], cx).await; let tree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; let repro_dir = dir.path().join("repro"); std::fs::create_dir(&repro_dir).unwrap(); tree.flush_fs_events(cx).await; cx.update(|cx| { assert!(tree.read(cx).entry_for_path(rel_path("repro")).is_some()); }); std::fs::remove_dir_all(&repro_dir).unwrap(); tree.flush_fs_events(cx).await; cx.update(|cx| { assert!(tree.read(cx).entry_for_path(rel_path("repro")).is_none()); }); std::fs::create_dir(&repro_dir).unwrap(); tree.flush_fs_events(cx).await; cx.update(|cx| { assert!(tree.read(cx).entry_for_path(rel_path("repro")).is_some()); }); std::fs::write(repro_dir.join("repro-marker"), "").unwrap(); tree.flush_fs_events(cx).await; cx.update(|cx| { assert!( tree.read(cx) .entry_for_path(rel_path("repro/repro-marker")) .is_some() ); }); } #[gpui::test(iterations = 10)] async fn test_buffer_identity_across_renames(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a": { "file1": "", } }), ) .await; let project = Project::test(fs, [Path::new(path!("/dir"))], cx).await; let tree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap()); let tree_id = tree.update(cx, |tree, _| tree.id()); let id_for_path = |path: &'static str, cx: &mut gpui::TestAppContext| { project.update(cx, |project, cx| { let tree = project.worktrees(cx).next().unwrap(); tree.read(cx) .entry_for_path(rel_path(path)) .unwrap_or_else(|| panic!("no entry for path {}", path)) .id }) }; let dir_id = id_for_path("a", cx); let file_id = id_for_path("a/file1", cx); let buffer = project .update(cx, |p, cx| { p.open_buffer((tree_id, rel_path("a/file1")), cx) }) .await .unwrap(); buffer.update(cx, |buffer, _| assert!(!buffer.is_dirty())); project .update(cx, |project, cx| { project.rename_entry(dir_id, (tree_id, rel_path("b")).into(), cx) }) .unwrap() .await .into_included() .unwrap(); cx.executor().run_until_parked(); assert_eq!(id_for_path("b", cx), dir_id); assert_eq!(id_for_path("b/file1", cx), file_id); buffer.update(cx, |buffer, _| assert!(!buffer.is_dirty())); } #[gpui::test] async fn test_buffer_deduping(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( "/dir", json!({ "a.txt": "a-contents", "b.txt": "b-contents", }), ) .await; let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; // Spawn multiple tasks to open paths, repeating some paths. let (buffer_a_1, buffer_b, buffer_a_2) = project.update(cx, |p, cx| { ( p.open_local_buffer("/dir/a.txt", cx), p.open_local_buffer("/dir/b.txt", cx), p.open_local_buffer("/dir/a.txt", cx), ) }); let buffer_a_1 = buffer_a_1.await.unwrap(); let buffer_a_2 = buffer_a_2.await.unwrap(); let buffer_b = buffer_b.await.unwrap(); assert_eq!(buffer_a_1.update(cx, |b, _| b.text()), "a-contents"); assert_eq!(buffer_b.update(cx, |b, _| b.text()), "b-contents"); // There is only one buffer per path. let buffer_a_id = buffer_a_1.entity_id(); assert_eq!(buffer_a_2.entity_id(), buffer_a_id); // Open the same path again while it is still open. drop(buffer_a_1); let buffer_a_3 = project .update(cx, |p, cx| p.open_local_buffer("/dir/a.txt", cx)) .await .unwrap(); // There's still only one buffer per path. assert_eq!(buffer_a_3.entity_id(), buffer_a_id); } #[gpui::test] async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "file1": "abc", "file2": "def", "file3": "ghi", }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let buffer1 = project .update(cx, |p, cx| p.open_local_buffer(path!("/dir/file1"), cx)) .await .unwrap(); let events = Arc::new(Mutex::new(Vec::new())); // initially, the buffer isn't dirty. buffer1.update(cx, |buffer, cx| { cx.subscribe(&buffer1, { let events = events.clone(); move |_, _, event, _| match event { BufferEvent::Operation { .. } => {} _ => events.lock().push(event.clone()), } }) .detach(); assert!(!buffer.is_dirty()); assert!(events.lock().is_empty()); buffer.edit([(1..2, "")], None, cx); }); // after the first edit, the buffer is dirty, and emits a dirtied event. buffer1.update(cx, |buffer, cx| { assert!(buffer.text() == "ac"); assert!(buffer.is_dirty()); assert_eq!( *events.lock(), &[ language::BufferEvent::Edited { is_local: true }, language::BufferEvent::DirtyChanged ] ); events.lock().clear(); buffer.did_save( buffer.version(), buffer.file().unwrap().disk_state().mtime(), cx, ); }); // after saving, the buffer is not dirty, and emits a saved event. buffer1.update(cx, |buffer, cx| { assert!(!buffer.is_dirty()); assert_eq!(*events.lock(), &[language::BufferEvent::Saved]); events.lock().clear(); buffer.edit([(1..1, "B")], None, cx); buffer.edit([(2..2, "D")], None, cx); }); // after editing again, the buffer is dirty, and emits another dirty event. buffer1.update(cx, |buffer, cx| { assert!(buffer.text() == "aBDc"); assert!(buffer.is_dirty()); assert_eq!( *events.lock(), &[ language::BufferEvent::Edited { is_local: true }, language::BufferEvent::DirtyChanged, language::BufferEvent::Edited { is_local: true }, ], ); events.lock().clear(); // After restoring the buffer to its previously-saved state, // the buffer is not considered dirty anymore. buffer.edit([(1..3, "")], None, cx); assert!(buffer.text() == "ac"); assert!(!buffer.is_dirty()); }); assert_eq!( *events.lock(), &[ language::BufferEvent::Edited { is_local: true }, language::BufferEvent::DirtyChanged ] ); // When a file is deleted, it is not considered dirty. let events = Arc::new(Mutex::new(Vec::new())); let buffer2 = project .update(cx, |p, cx| p.open_local_buffer(path!("/dir/file2"), cx)) .await .unwrap(); buffer2.update(cx, |_, cx| { cx.subscribe(&buffer2, { let events = events.clone(); move |_, _, event, _| match event { BufferEvent::Operation { .. } => {} _ => events.lock().push(event.clone()), } }) .detach(); }); fs.remove_file(path!("/dir/file2").as_ref(), Default::default()) .await .unwrap(); cx.executor().run_until_parked(); buffer2.update(cx, |buffer, _| assert!(!buffer.is_dirty())); assert_eq!( mem::take(&mut *events.lock()), &[language::BufferEvent::FileHandleChanged] ); // Buffer becomes dirty when edited. buffer2.update(cx, |buffer, cx| { buffer.edit([(2..3, "")], None, cx); assert_eq!(buffer.is_dirty(), true); }); assert_eq!( mem::take(&mut *events.lock()), &[ language::BufferEvent::Edited { is_local: true }, language::BufferEvent::DirtyChanged ] ); // Buffer becomes clean again when all of its content is removed, because // the file was deleted. buffer2.update(cx, |buffer, cx| { buffer.edit([(0..2, "")], None, cx); assert_eq!(buffer.is_empty(), true); assert_eq!(buffer.is_dirty(), false); }); assert_eq!( *events.lock(), &[ language::BufferEvent::Edited { is_local: true }, language::BufferEvent::DirtyChanged ] ); // When a file is already dirty when deleted, we don't emit a Dirtied event. let events = Arc::new(Mutex::new(Vec::new())); let buffer3 = project .update(cx, |p, cx| p.open_local_buffer(path!("/dir/file3"), cx)) .await .unwrap(); buffer3.update(cx, |_, cx| { cx.subscribe(&buffer3, { let events = events.clone(); move |_, _, event, _| match event { BufferEvent::Operation { .. } => {} _ => events.lock().push(event.clone()), } }) .detach(); }); buffer3.update(cx, |buffer, cx| { buffer.edit([(0..0, "x")], None, cx); }); events.lock().clear(); fs.remove_file(path!("/dir/file3").as_ref(), Default::default()) .await .unwrap(); cx.executor().run_until_parked(); assert_eq!(*events.lock(), &[language::BufferEvent::FileHandleChanged]); cx.update(|cx| assert!(buffer3.read(cx).is_dirty())); } #[gpui::test] async fn test_dirty_buffer_reloads_after_undo(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "file.txt": "version 1", }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let buffer = project .update(cx, |p, cx| p.open_local_buffer(path!("/dir/file.txt"), cx)) .await .unwrap(); buffer.read_with(cx, |buffer, _| { assert_eq!(buffer.text(), "version 1"); assert!(!buffer.is_dirty()); }); // User makes an edit, making the buffer dirty. buffer.update(cx, |buffer, cx| { buffer.edit([(0..0, "user edit: ")], None, cx); }); buffer.read_with(cx, |buffer, _| { assert!(buffer.is_dirty()); assert_eq!(buffer.text(), "user edit: version 1"); }); // External tool writes new content while buffer is dirty. // file_updated() updates the File but suppresses ReloadNeeded. fs.save( path!("/dir/file.txt").as_ref(), &"version 2 from external tool".into(), Default::default(), ) .await .unwrap(); cx.executor().run_until_parked(); buffer.read_with(cx, |buffer, _| { assert!(buffer.has_conflict()); assert_eq!(buffer.text(), "user edit: version 1"); }); // User undoes their edit. Buffer becomes clean, but disk has different // content. did_edit() detects the dirty->clean transition and checks if // disk changed while dirty. Since mtime differs from saved_mtime, it // emits ReloadNeeded. buffer.update(cx, |buffer, cx| { buffer.undo(cx); }); cx.executor().run_until_parked(); buffer.read_with(cx, |buffer, _| { assert_eq!( buffer.text(), "version 2 from external tool", "buffer should reload from disk after undo makes it clean" ); assert!(!buffer.is_dirty()); }); } #[gpui::test] async fn test_buffer_file_change_to_binary_fails(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "file.txt": "", }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let buffer = project .update(cx, |p, cx| p.open_local_buffer(path!("/dir/file.txt"), cx)) .await .unwrap(); fs.write( path!("/dir/file.txt").as_ref(), b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01", ) .await .unwrap(); cx.executor().run_until_parked(); // Test that existing buffer is left untouched buffer.read_with(cx, |buffer, _| { assert_eq!(buffer.text(), ""); }); } #[gpui::test] async fn test_buffer_file_changes_on_disk(cx: &mut gpui::TestAppContext) { init_test(cx); let (initial_contents, initial_offsets) = marked_text_offsets("one twoˇ\nthree ˇfourˇ five\nsixˇ seven\n"); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "the-file": initial_contents, }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let buffer = project .update(cx, |p, cx| p.open_local_buffer(path!("/dir/the-file"), cx)) .await .unwrap(); let anchors = initial_offsets .iter() .map(|offset| buffer.update(cx, |b, _| b.anchor_before(offset))) .collect::>(); // Change the file on disk, adding two new lines of text, and removing // one line. buffer.update(cx, |buffer, _| { assert!(!buffer.is_dirty()); assert!(!buffer.has_conflict()); }); let (new_contents, new_offsets) = marked_text_offsets("oneˇ\nthree ˇFOURˇ five\nsixtyˇ seven\n"); fs.save( path!("/dir/the-file").as_ref(), &new_contents.as_str().into(), LineEnding::Unix, ) .await .unwrap(); // Because the buffer was not modified, it is reloaded from disk. Its // contents are edited according to the diff between the old and new // file contents. cx.executor().run_until_parked(); buffer.update(cx, |buffer, _| { assert_eq!(buffer.text(), new_contents); assert!(!buffer.is_dirty()); assert!(!buffer.has_conflict()); let anchor_offsets = anchors .iter() .map(|anchor| anchor.to_offset(&*buffer)) .collect::>(); assert_eq!(anchor_offsets, new_offsets); }); // Modify the buffer buffer.update(cx, |buffer, cx| { buffer.edit([(0..0, " ")], None, cx); assert!(buffer.is_dirty()); assert!(!buffer.has_conflict()); }); // Change the file on disk again, adding blank lines to the beginning. fs.save( path!("/dir/the-file").as_ref(), &"\n\n\nAAAA\naaa\nBB\nbbbbb\n".into(), LineEnding::Unix, ) .await .unwrap(); // Because the buffer is modified, it doesn't reload from disk, but is // marked as having a conflict. cx.executor().run_until_parked(); buffer.update(cx, |buffer, _| { assert_eq!(buffer.text(), " ".to_string() + &new_contents); assert!(buffer.has_conflict()); }); } #[gpui::test] async fn test_buffer_line_endings(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "file1": "a\nb\nc\n", "file2": "one\r\ntwo\r\nthree\r\n", }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let buffer1 = project .update(cx, |p, cx| p.open_local_buffer(path!("/dir/file1"), cx)) .await .unwrap(); let buffer2 = project .update(cx, |p, cx| p.open_local_buffer(path!("/dir/file2"), cx)) .await .unwrap(); buffer1.update(cx, |buffer, _| { assert_eq!(buffer.text(), "a\nb\nc\n"); assert_eq!(buffer.line_ending(), LineEnding::Unix); }); buffer2.update(cx, |buffer, _| { assert_eq!(buffer.text(), "one\ntwo\nthree\n"); assert_eq!(buffer.line_ending(), LineEnding::Windows); }); // Change a file's line endings on disk from unix to windows. The buffer's // state updates correctly. fs.save( path!("/dir/file1").as_ref(), &"aaa\nb\nc\n".into(), LineEnding::Windows, ) .await .unwrap(); cx.executor().run_until_parked(); buffer1.update(cx, |buffer, _| { assert_eq!(buffer.text(), "aaa\nb\nc\n"); assert_eq!(buffer.line_ending(), LineEnding::Windows); }); // Save a file with windows line endings. The file is written correctly. buffer2.update(cx, |buffer, cx| { buffer.set_text("one\ntwo\nthree\nfour\n", cx); }); project .update(cx, |project, cx| project.save_buffer(buffer2, cx)) .await .unwrap(); assert_eq!( fs.load(path!("/dir/file2").as_ref()).await.unwrap(), "one\r\ntwo\r\nthree\r\nfour\r\n", ); } #[gpui::test] async fn test_line_ending_user_settings_on_format(cx: &mut gpui::TestAppContext) { init_test(cx); let cases = [ ( "default", None, [ ("crlf_file.rs", LineEnding::Windows), ("lf_file.rs", LineEnding::Unix), ("no_newline.rs", LineEnding::default()), ], ), ( "detect", Some(LineEndingSetting::Detect), [ ("crlf_file.rs", LineEnding::Windows), ("lf_file.rs", LineEnding::Unix), ("no_newline.rs", LineEnding::default()), ], ), ( "prefer_lf", Some(LineEndingSetting::PreferLf), [ ("crlf_file.rs", LineEnding::Windows), ("lf_file.rs", LineEnding::Unix), ("no_newline.rs", LineEnding::Unix), ], ), ( "prefer_crlf", Some(LineEndingSetting::PreferCrlf), [ ("crlf_file.rs", LineEnding::Windows), ("lf_file.rs", LineEnding::Unix), ("no_newline.rs", LineEnding::Windows), ], ), ( "enforce_lf", Some(LineEndingSetting::EnforceLf), [ ("crlf_file.rs", LineEnding::Unix), ("lf_file.rs", LineEnding::Unix), ("no_newline.rs", LineEnding::Unix), ], ), ( "enforce_crlf", Some(LineEndingSetting::EnforceCrlf), [ ("crlf_file.rs", LineEnding::Windows), ("lf_file.rs", LineEnding::Windows), ("no_newline.rs", LineEnding::Windows), ], ), ]; for (case_name, line_ending_setting, expected_line_endings) in cases { let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "crlf_file.rs": "one\r\ntwo\r\nthree\r\n", "lf_file.rs": "one\ntwo\nthree\n", "no_newline.rs": "single line", }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let worktree_id = project.update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }); cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { settings.project.all_languages.defaults.line_ending = line_ending_setting; }); }); }); cx.executor().run_until_parked(); assert_line_endings_after_format( cx, &project, worktree_id, case_name, &expected_line_endings, ) .await; } } #[gpui::test] async fn test_line_ending_editorconfig_on_format_and_save(cx: &mut gpui::TestAppContext) { init_test(cx); let cases = [ ( "editorconfig lf", "lf", "crlf_file.rs", LineEnding::Windows, [ ("crlf_file.rs", LineEnding::Unix), ("lf_file.rs", LineEnding::Unix), ("no_newline.rs", LineEnding::Unix), ], "one\ntwo\nthree\n", ), ( "editorconfig crlf", "crlf", "lf_file.rs", LineEnding::Unix, [ ("crlf_file.rs", LineEnding::Windows), ("lf_file.rs", LineEnding::Windows), ("no_newline.rs", LineEnding::Windows), ], "one\r\ntwo\r\nthree\r\n", ), ]; for ( case_name, editorconfig_end_of_line, buffer_path, initial_line_ending, expected_line_endings, expected_saved_contents, ) in cases { let file_system = FakeFs::new(cx.executor()); file_system .insert_tree( path!("/dir"), json!({ ".editorconfig": format!("root = true\n[*.rs]\nend_of_line = {editorconfig_end_of_line}\n"), "crlf_file.rs": "one\r\ntwo\r\nthree\r\n", "lf_file.rs": "one\ntwo\nthree\n", "no_newline.rs": "single line", }), ) .await; let project = Project::test(file_system.clone(), [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); cx.executor().run_until_parked(); let worktree_id = project.update(cx, |project, cx| { project.worktrees(cx).next().unwrap().read(cx).id() }); let buffer = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path(buffer_path)), cx) }) .await .unwrap(); buffer.update(cx, |buffer, _| { assert_eq!(buffer.line_ending(), initial_line_ending); }); assert_line_endings_after_format( cx, &project, worktree_id, case_name, &expected_line_endings, ) .await; project .update(cx, |project, cx| project.save_buffer(buffer, cx)) .await .unwrap(); let saved_path = PathBuf::from(path!("/dir")).join(buffer_path); assert_eq!( file_system.load(&saved_path).await.unwrap(), expected_saved_contents, ); } } #[gpui::test] async fn test_line_ending_initialization_for_new_buffers(cx: &mut gpui::TestAppContext) { init_test(cx); let cases = [ (Some(LineEndingSetting::Detect), LineEnding::default()), (Some(LineEndingSetting::PreferLf), LineEnding::Unix), (Some(LineEndingSetting::PreferCrlf), LineEnding::Windows), (Some(LineEndingSetting::EnforceLf), LineEnding::Unix), (Some(LineEndingSetting::EnforceCrlf), LineEnding::Windows), ]; for (line_ending_setting, expected_line_ending) in cases { let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/dir"), json!({})).await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { settings.project.all_languages.defaults.line_ending = line_ending_setting; }); }); }); cx.executor().run_until_parked(); let created_buffer = project .update(cx, |project, cx| project.create_buffer(None, false, cx)) .unwrap() .await; created_buffer.update(cx, |buffer, _| { assert_eq!(buffer.line_ending(), expected_line_ending); }); let local_buffer = project.update(cx, |project, cx| { project.create_local_buffer("single line", None, false, cx) }); local_buffer.update(cx, |buffer, _| { assert_eq!(buffer.line_ending(), expected_line_ending); }); let opened_missing_buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/dir/new_file.rs"), cx) }) .await .unwrap(); opened_missing_buffer.update(cx, |buffer, _| { assert_eq!(buffer.line_ending(), expected_line_ending); }); } } async fn assert_line_endings_after_format( cx: &mut gpui::TestAppContext, project: &Entity, worktree_id: WorktreeId, case_name: &str, expected_line_endings: &[(&str, LineEnding)], ) { for (path, expected_line_ending) in expected_line_endings { let buffer = project .update(cx, |project, cx| { project.open_buffer((worktree_id, rel_path(path)), cx) }) .await .unwrap(); let mut buffers = HashSet::default(); buffers.insert(buffer.clone()); project .update(cx, |project, cx| { project.format( buffers, project::lsp_store::LspFormatTarget::Buffers, false, project::lsp_store::FormatTrigger::Save, cx, ) }) .await .unwrap(); buffer.update(cx, |buffer, _| { assert_eq!( buffer.line_ending(), *expected_line_ending, "unexpected line ending for {path} in {case_name}" ); }); } } #[gpui::test] async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.rs": " fn foo(mut v: Vec) { for x in &v { v.push(1); } } " .unindent(), }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); let buffer = project .update(cx, |p, cx| p.open_local_buffer(path!("/dir/a.rs"), cx)) .await .unwrap(); let buffer_uri = Uri::from_file_path(path!("/dir/a.rs")).unwrap(); let message = lsp::PublishDiagnosticsParams { uri: buffer_uri.clone(), diagnostics: vec![ lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 9)), severity: Some(DiagnosticSeverity::WARNING), message: "error 1".to_string(), related_information: Some(vec![lsp::DiagnosticRelatedInformation { location: lsp::Location { uri: buffer_uri.clone(), range: lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 9)), }, message: "error 1 hint 1".to_string(), }]), ..Default::default() }, lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 9)), severity: Some(DiagnosticSeverity::HINT), message: "error 1 hint 1".to_string(), related_information: Some(vec![lsp::DiagnosticRelatedInformation { location: lsp::Location { uri: buffer_uri.clone(), range: lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 9)), }, message: "original diagnostic".to_string(), }]), ..Default::default() }, lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(2, 8), lsp::Position::new(2, 17)), severity: Some(DiagnosticSeverity::ERROR), message: "error 2".to_string(), related_information: Some(vec![ lsp::DiagnosticRelatedInformation { location: lsp::Location { uri: buffer_uri.clone(), range: lsp::Range::new( lsp::Position::new(1, 13), lsp::Position::new(1, 15), ), }, message: "error 2 hint 1".to_string(), }, lsp::DiagnosticRelatedInformation { location: lsp::Location { uri: buffer_uri.clone(), range: lsp::Range::new( lsp::Position::new(1, 13), lsp::Position::new(1, 15), ), }, message: "error 2 hint 2".to_string(), }, ]), ..Default::default() }, lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(1, 13), lsp::Position::new(1, 15)), severity: Some(DiagnosticSeverity::HINT), message: "error 2 hint 1".to_string(), related_information: Some(vec![lsp::DiagnosticRelatedInformation { location: lsp::Location { uri: buffer_uri.clone(), range: lsp::Range::new(lsp::Position::new(2, 8), lsp::Position::new(2, 17)), }, message: "original diagnostic".to_string(), }]), ..Default::default() }, lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(1, 13), lsp::Position::new(1, 15)), severity: Some(DiagnosticSeverity::HINT), message: "error 2 hint 2".to_string(), related_information: Some(vec![lsp::DiagnosticRelatedInformation { location: lsp::Location { uri: buffer_uri, range: lsp::Range::new(lsp::Position::new(2, 8), lsp::Position::new(2, 17)), }, message: "original diagnostic".to_string(), }]), ..Default::default() }, ], version: None, }; lsp_store .update(cx, |lsp_store, cx| { lsp_store.update_diagnostics( LanguageServerId(0), message, None, DiagnosticSourceKind::Pushed, &[], cx, ) }) .unwrap(); let buffer = buffer.update(cx, |buffer, _| buffer.snapshot()); assert_eq!( buffer .diagnostics_in_range::<_, Point>(0..buffer.len(), false) .collect::>(), &[ DiagnosticEntry { range: Point::new(1, 8)..Point::new(1, 9), diagnostic: Diagnostic { severity: DiagnosticSeverity::WARNING, message: "error 1".to_string(), group_id: 1, is_primary: true, source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() } }, DiagnosticEntry { range: Point::new(1, 8)..Point::new(1, 9), diagnostic: Diagnostic { severity: DiagnosticSeverity::HINT, message: "error 1 hint 1".to_string(), group_id: 1, is_primary: false, source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() } }, DiagnosticEntry { range: Point::new(1, 13)..Point::new(1, 15), diagnostic: Diagnostic { severity: DiagnosticSeverity::HINT, message: "error 2 hint 1".to_string(), group_id: 0, is_primary: false, source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() } }, DiagnosticEntry { range: Point::new(1, 13)..Point::new(1, 15), diagnostic: Diagnostic { severity: DiagnosticSeverity::HINT, message: "error 2 hint 2".to_string(), group_id: 0, is_primary: false, source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() } }, DiagnosticEntry { range: Point::new(2, 8)..Point::new(2, 17), diagnostic: Diagnostic { severity: DiagnosticSeverity::ERROR, message: "error 2".to_string(), group_id: 0, is_primary: true, source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() } } ] ); assert_eq!( buffer.diagnostic_group::(0).collect::>(), &[ DiagnosticEntry { range: Point::new(1, 13)..Point::new(1, 15), diagnostic: Diagnostic { severity: DiagnosticSeverity::HINT, message: "error 2 hint 1".to_string(), group_id: 0, is_primary: false, source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() } }, DiagnosticEntry { range: Point::new(1, 13)..Point::new(1, 15), diagnostic: Diagnostic { severity: DiagnosticSeverity::HINT, message: "error 2 hint 2".to_string(), group_id: 0, is_primary: false, source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() } }, DiagnosticEntry { range: Point::new(2, 8)..Point::new(2, 17), diagnostic: Diagnostic { severity: DiagnosticSeverity::ERROR, message: "error 2".to_string(), group_id: 0, is_primary: true, source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() } } ] ); assert_eq!( buffer.diagnostic_group::(1).collect::>(), &[ DiagnosticEntry { range: Point::new(1, 8)..Point::new(1, 9), diagnostic: Diagnostic { severity: DiagnosticSeverity::WARNING, message: "error 1".to_string(), group_id: 1, is_primary: true, source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() } }, DiagnosticEntry { range: Point::new(1, 8)..Point::new(1, 9), diagnostic: Diagnostic { severity: DiagnosticSeverity::HINT, message: "error 1 hint 1".to_string(), group_id: 1, is_primary: false, source_kind: DiagnosticSourceKind::Pushed, ..Diagnostic::default() } }, ] ); } #[gpui::test] async fn test_lsp_rename_notifications(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "one.rs": "const ONE: usize = 1;", "two": { "two.rs": "const TWO: usize = one::ONE + one::ONE;" } }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let watched_paths = lsp::FileOperationRegistrationOptions { filters: vec![ FileOperationFilter { scheme: Some("file".to_owned()), pattern: lsp::FileOperationPattern { glob: "**/*.rs".to_owned(), matches: Some(lsp::FileOperationPatternKind::File), options: None, }, }, FileOperationFilter { scheme: Some("file".to_owned()), pattern: lsp::FileOperationPattern { glob: "**/**".to_owned(), matches: Some(lsp::FileOperationPatternKind::Folder), options: None, }, }, ], }; let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { workspace: Some(lsp::WorkspaceServerCapabilities { workspace_folders: None, file_operations: Some(lsp::WorkspaceFileOperationsServerCapabilities { did_rename: Some(watched_paths.clone()), will_rename: Some(watched_paths), ..Default::default() }), }), ..Default::default() }, ..Default::default() }, ); let _ = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/one.rs"), cx) }) .await .unwrap(); let fake_server = fake_servers.next().await.unwrap(); cx.executor().run_until_parked(); let response = project.update(cx, |project, cx| { let worktree = project.worktrees(cx).next().unwrap(); let entry = worktree .read(cx) .entry_for_path(rel_path("one.rs")) .unwrap(); project.rename_entry( entry.id, (worktree.read(cx).id(), rel_path("three.rs")).into(), cx, ) }); let expected_edit = lsp::WorkspaceEdit { changes: None, document_changes: Some(DocumentChanges::Edits({ vec![TextDocumentEdit { edits: vec![lsp::Edit::Plain(lsp::TextEdit { range: lsp::Range { start: lsp::Position { line: 0, character: 1, }, end: lsp::Position { line: 0, character: 3, }, }, new_text: "This is not a drill".to_owned(), })], text_document: lsp::OptionalVersionedTextDocumentIdentifier { uri: Uri::from_str(uri!("file:///dir/two/two.rs")).unwrap(), version: Some(1337), }, }] })), change_annotations: None, }; let resolved_workspace_edit = Arc::new(OnceLock::new()); fake_server .set_request_handler::({ let resolved_workspace_edit = resolved_workspace_edit.clone(); let expected_edit = expected_edit.clone(); move |params, _| { let resolved_workspace_edit = resolved_workspace_edit.clone(); let expected_edit = expected_edit.clone(); async move { assert_eq!(params.files.len(), 1); assert_eq!(params.files[0].old_uri, uri!("file:///dir/one.rs")); assert_eq!(params.files[0].new_uri, uri!("file:///dir/three.rs")); resolved_workspace_edit.set(expected_edit.clone()).unwrap(); Ok(Some(expected_edit)) } } }) .next() .await .unwrap(); let _ = response.await.unwrap(); fake_server .handle_notification::(|params, _| { assert_eq!(params.files.len(), 1); assert_eq!(params.files[0].old_uri, uri!("file:///dir/one.rs")); assert_eq!(params.files[0].new_uri, uri!("file:///dir/three.rs")); }) .next() .await .unwrap(); assert_eq!(resolved_workspace_edit.get(), Some(&expected_edit)); } #[gpui::test] async fn test_rename(cx: &mut gpui::TestAppContext) { // hi init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "one.rs": "const ONE: usize = 1;", "two.rs": "const TWO: usize = one::ONE + one::ONE;" }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { capabilities: lsp::ServerCapabilities { rename_provider: Some(lsp::OneOf::Right(lsp::RenameOptions { prepare_provider: Some(true), work_done_progress_options: Default::default(), })), ..Default::default() }, ..Default::default() }, ); let (buffer, _handle) = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/one.rs"), cx) }) .await .unwrap(); let fake_server = fake_servers.next().await.unwrap(); cx.executor().run_until_parked(); let response = project.update(cx, |project, cx| { project.prepare_rename(buffer.clone(), 7, cx) }); fake_server .set_request_handler::(|params, _| async move { assert_eq!( params.text_document.uri.as_str(), uri!("file:///dir/one.rs") ); assert_eq!(params.position, lsp::Position::new(0, 7)); Ok(Some(lsp::PrepareRenameResponse::Range(lsp::Range::new( lsp::Position::new(0, 6), lsp::Position::new(0, 9), )))) }) .next() .await .unwrap(); let response = response.await.unwrap(); let PrepareRenameResponse::Success(range) = response else { panic!("{:?}", response); }; let range = buffer.update(cx, |buffer, _| range.to_offset(buffer)); assert_eq!(range, 6..9); let response = project.update(cx, |project, cx| { project.perform_rename(buffer.clone(), 7, "THREE".to_string(), cx) }); fake_server .set_request_handler::(|params, _| async move { assert_eq!( params.text_document_position.text_document.uri.as_str(), uri!("file:///dir/one.rs") ); assert_eq!( params.text_document_position.position, lsp::Position::new(0, 7) ); assert_eq!(params.new_name, "THREE"); Ok(Some(lsp::WorkspaceEdit { changes: Some( [ ( lsp::Uri::from_file_path(path!("/dir/one.rs")).unwrap(), vec![lsp::TextEdit::new( lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 9)), "THREE".to_string(), )], ), ( lsp::Uri::from_file_path(path!("/dir/two.rs")).unwrap(), vec![ lsp::TextEdit::new( lsp::Range::new( lsp::Position::new(0, 24), lsp::Position::new(0, 27), ), "THREE".to_string(), ), lsp::TextEdit::new( lsp::Range::new( lsp::Position::new(0, 35), lsp::Position::new(0, 38), ), "THREE".to_string(), ), ], ), ] .into_iter() .collect(), ), ..Default::default() })) }) .next() .await .unwrap(); let mut transaction = response.await.unwrap().0; assert_eq!(transaction.len(), 2); assert_eq!( transaction .remove_entry(&buffer) .unwrap() .0 .update(cx, |buffer, _| buffer.text()), "const THREE: usize = 1;" ); assert_eq!( transaction .into_keys() .next() .unwrap() .update(cx, |buffer, _| buffer.text()), "const TWO: usize = one::THREE + one::THREE;" ); } #[gpui::test] async fn test_search(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "one.rs": "const ONE: usize = 1;", "two.rs": "const TWO: usize = one::ONE + one::ONE;", "three.rs": "const THREE: usize = one::ONE + two::TWO;", "four.rs": "const FOUR: usize = one::ONE + three::THREE;", }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; assert_eq!( search( &project, SearchQuery::text( "TWO", false, true, false, Default::default(), Default::default(), false, None ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([ (path!("dir/two.rs").to_string(), vec![6..9]), (path!("dir/three.rs").to_string(), vec![37..40]) ]) ); let buffer_4 = project .update(cx, |project, cx| { project.open_local_buffer(path!("/dir/four.rs"), cx) }) .await .unwrap(); buffer_4.update(cx, |buffer, cx| { let text = "two::TWO"; buffer.edit([(20..28, text), (31..43, text)], None, cx); }); assert_eq!( search( &project, SearchQuery::text( "TWO", false, true, false, Default::default(), Default::default(), false, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([ (path!("dir/two.rs").to_string(), vec![6..9]), (path!("dir/three.rs").to_string(), vec![37..40]), (path!("dir/four.rs").to_string(), vec![25..28, 36..39]) ]) ); } #[gpui::test] async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) { init_test(cx); let search_query = "file"; let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "one.rs": r#"// Rust file one"#, "one.ts": r#"// TypeScript file one"#, "two.rs": r#"// Rust file two"#, "two.ts": r#"// TypeScript file two"#, }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; assert!( search( &project, SearchQuery::text( search_query, false, true, false, PathMatcher::new(&["*.odd".to_owned()], PathStyle::local()).unwrap(), Default::default(), false, None ) .unwrap(), cx ) .await .unwrap() .is_empty(), "If no inclusions match, no files should be returned" ); assert_eq!( search( &project, SearchQuery::text( search_query, false, true, false, PathMatcher::new(&["*.rs".to_owned()], PathStyle::local()).unwrap(), Default::default(), false, None ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([ (path!("dir/one.rs").to_string(), vec![8..12]), (path!("dir/two.rs").to_string(), vec![8..12]), ]), "Rust only search should give only Rust files" ); assert_eq!( search( &project, SearchQuery::text( search_query, false, true, false, PathMatcher::new(&["*.ts".to_owned(), "*.odd".to_owned()], PathStyle::local()) .unwrap(), Default::default(), false, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([ (path!("dir/one.ts").to_string(), vec![14..18]), (path!("dir/two.ts").to_string(), vec![14..18]), ]), "TypeScript only search should give only TypeScript files, even if other inclusions don't match anything" ); assert_eq!( search( &project, SearchQuery::text( search_query, false, true, false, PathMatcher::new( &["*.rs".to_owned(), "*.ts".to_owned(), "*.odd".to_owned()], PathStyle::local() ) .unwrap(), Default::default(), false, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([ (path!("dir/two.ts").to_string(), vec![14..18]), (path!("dir/one.rs").to_string(), vec![8..12]), (path!("dir/one.ts").to_string(), vec![14..18]), (path!("dir/two.rs").to_string(), vec![8..12]), ]), "Rust and typescript search should give both Rust and TypeScript files, even if other inclusions don't match anything" ); } #[gpui::test] async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) { init_test(cx); let search_query = "file"; let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "one.rs": r#"// Rust file one"#, "one.ts": r#"// TypeScript file one"#, "two.rs": r#"// Rust file two"#, "two.ts": r#"// TypeScript file two"#, }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; assert_eq!( search( &project, SearchQuery::text( search_query, false, true, false, Default::default(), PathMatcher::new(&["*.odd".to_owned()], PathStyle::local()).unwrap(), false, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([ (path!("dir/one.rs").to_string(), vec![8..12]), (path!("dir/one.ts").to_string(), vec![14..18]), (path!("dir/two.rs").to_string(), vec![8..12]), (path!("dir/two.ts").to_string(), vec![14..18]), ]), "If no exclusions match, all files should be returned" ); assert_eq!( search( &project, SearchQuery::text( search_query, false, true, false, Default::default(), PathMatcher::new(&["*.rs".to_owned()], PathStyle::local()).unwrap(), false, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([ (path!("dir/one.ts").to_string(), vec![14..18]), (path!("dir/two.ts").to_string(), vec![14..18]), ]), "Rust exclusion search should give only TypeScript files" ); assert_eq!( search( &project, SearchQuery::text( search_query, false, true, false, Default::default(), PathMatcher::new(&["*.ts".to_owned(), "*.odd".to_owned()], PathStyle::local()) .unwrap(), false, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([ (path!("dir/one.rs").to_string(), vec![8..12]), (path!("dir/two.rs").to_string(), vec![8..12]), ]), "TypeScript exclusion search should give only Rust files, even if other exclusions don't match anything" ); assert!( search( &project, SearchQuery::text( search_query, false, true, false, Default::default(), PathMatcher::new( &["*.rs".to_owned(), "*.ts".to_owned(), "*.odd".to_owned()], PathStyle::local(), ) .unwrap(), false, None, ) .unwrap(), cx ) .await .unwrap() .is_empty(), "Rust and typescript exclusion should give no files, even if other exclusions don't match anything" ); } #[gpui::test] async fn test_search_with_buffer_exclusions(cx: &mut gpui::TestAppContext) { init_test(cx); let search_query = "file"; let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "one.rs": r#"// Rust file one"#, "one.ts": r#"// TypeScript file one"#, "two.rs": r#"// Rust file two"#, "two.ts": r#"// TypeScript file two"#, }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let path_style = PathStyle::local(); let _buffer = project.update(cx, |project, cx| { project.create_local_buffer("file", None, false, cx) }); assert_eq!( search( &project, SearchQuery::text( search_query, false, true, false, Default::default(), PathMatcher::new(&["*.odd".to_owned()], path_style).unwrap(), false, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([ (path!("dir/one.rs").to_string(), vec![8..12]), (path!("dir/one.ts").to_string(), vec![14..18]), (path!("dir/two.rs").to_string(), vec![8..12]), (path!("dir/two.ts").to_string(), vec![14..18]), ]), "If no exclusions match, all files should be returned" ); assert_eq!( search( &project, SearchQuery::text( search_query, false, true, false, Default::default(), PathMatcher::new(&["*.rs".to_owned()], path_style).unwrap(), false, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([ (path!("dir/one.ts").to_string(), vec![14..18]), (path!("dir/two.ts").to_string(), vec![14..18]), ]), "Rust exclusion search should give only TypeScript files" ); assert_eq!( search( &project, SearchQuery::text( search_query, false, true, false, Default::default(), PathMatcher::new(&["*.ts".to_owned(), "*.odd".to_owned()], path_style).unwrap(), false, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([ (path!("dir/one.rs").to_string(), vec![8..12]), (path!("dir/two.rs").to_string(), vec![8..12]), ]), "TypeScript exclusion search should give only Rust files, even if other exclusions don't match anything" ); assert!( search( &project, SearchQuery::text( search_query, false, true, false, Default::default(), PathMatcher::new( &["*.rs".to_owned(), "*.ts".to_owned(), "*.odd".to_owned()], PathStyle::local(), ) .unwrap(), false, None, ) .unwrap(), cx ) .await .unwrap() .is_empty(), "Rust and typescript exclusion should give no files, even if other exclusions don't match anything" ); } #[gpui::test] async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContext) { init_test(cx); let search_query = "file"; let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "one.rs": r#"// Rust file one"#, "one.ts": r#"// TypeScript file one"#, "two.rs": r#"// Rust file two"#, "two.ts": r#"// TypeScript file two"#, }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; assert!( search( &project, SearchQuery::text( search_query, false, true, false, PathMatcher::new(&["*.odd".to_owned()], PathStyle::local()).unwrap(), PathMatcher::new(&["*.odd".to_owned()], PathStyle::local()).unwrap(), false, None, ) .unwrap(), cx ) .await .unwrap() .is_empty(), "If both no exclusions and inclusions match, exclusions should win and return nothing" ); assert!( search( &project, SearchQuery::text( search_query, false, true, false, PathMatcher::new(&["*.ts".to_owned()], PathStyle::local()).unwrap(), PathMatcher::new(&["*.ts".to_owned()], PathStyle::local()).unwrap(), false, None, ) .unwrap(), cx ) .await .unwrap() .is_empty(), "If both TypeScript exclusions and inclusions match, exclusions should win and return nothing files." ); assert!( search( &project, SearchQuery::text( search_query, false, true, false, PathMatcher::new(&["*.ts".to_owned(), "*.odd".to_owned()], PathStyle::local()) .unwrap(), PathMatcher::new(&["*.ts".to_owned(), "*.odd".to_owned()], PathStyle::local()) .unwrap(), false, None, ) .unwrap(), cx ) .await .unwrap() .is_empty(), "Non-matching inclusions and exclusions should not change that." ); assert_eq!( search( &project, SearchQuery::text( search_query, false, true, false, PathMatcher::new(&["*.ts".to_owned(), "*.odd".to_owned()], PathStyle::local()) .unwrap(), PathMatcher::new(&["*.rs".to_owned(), "*.odd".to_owned()], PathStyle::local()) .unwrap(), false, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([ (path!("dir/one.ts").to_string(), vec![14..18]), (path!("dir/two.ts").to_string(), vec![14..18]), ]), "Non-intersecting TypeScript inclusions and Rust exclusions should return TypeScript files" ); } #[gpui::test] async fn test_search_multiple_worktrees_with_inclusions(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/worktree-a"), json!({ "haystack.rs": r#"// NEEDLE"#, "haystack.ts": r#"// NEEDLE"#, }), ) .await; fs.insert_tree( path!("/worktree-b"), json!({ "haystack.rs": r#"// NEEDLE"#, "haystack.ts": r#"// NEEDLE"#, }), ) .await; let path_style = PathStyle::local(); let project = Project::test( fs.clone(), [path!("/worktree-a").as_ref(), path!("/worktree-b").as_ref()], cx, ) .await; assert_eq!( search( &project, SearchQuery::text( "NEEDLE", false, true, false, PathMatcher::new(&["worktree-a/*.rs".to_owned()], path_style).unwrap(), Default::default(), true, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([(path!("worktree-a/haystack.rs").to_string(), vec![3..9])]), "should only return results from included worktree" ); assert_eq!( search( &project, SearchQuery::text( "NEEDLE", false, true, false, PathMatcher::new(&["worktree-b/*.rs".to_owned()], path_style).unwrap(), Default::default(), true, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([(path!("worktree-b/haystack.rs").to_string(), vec![3..9])]), "should only return results from included worktree" ); assert_eq!( search( &project, SearchQuery::text( "NEEDLE", false, true, false, PathMatcher::new(&["*.ts".to_owned()], path_style).unwrap(), Default::default(), false, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([ (path!("worktree-a/haystack.ts").to_string(), vec![3..9]), (path!("worktree-b/haystack.ts").to_string(), vec![3..9]) ]), "should return results from both worktrees" ); } #[gpui::test] async fn test_search_in_gitignored_dirs(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/dir"), json!({ ".git": {}, ".gitignore": "**/target\n/node_modules\n", "target": { "index.txt": "index_key:index_value" }, "node_modules": { "eslint": { "index.ts": "const eslint_key = 'eslint value'", "package.json": r#"{ "some_key": "some value" }"#, }, "prettier": { "index.ts": "const prettier_key = 'prettier value'", "package.json": r#"{ "other_key": "other value" }"#, }, }, "package.json": r#"{ "main_key": "main value" }"#, }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let query = "key"; assert_eq!( search( &project, SearchQuery::text( query, false, false, false, Default::default(), Default::default(), false, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([(path!("dir/package.json").to_string(), vec![8..11])]), "Only one non-ignored file should have the query" ); let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let path_style = PathStyle::local(); assert_eq!( search( &project, SearchQuery::text( query, false, false, true, Default::default(), Default::default(), false, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([ (path!("dir/package.json").to_string(), vec![8..11]), (path!("dir/target/index.txt").to_string(), vec![6..9]), ( path!("dir/node_modules/prettier/package.json").to_string(), vec![9..12] ), ( path!("dir/node_modules/prettier/index.ts").to_string(), vec![15..18] ), ( path!("dir/node_modules/eslint/index.ts").to_string(), vec![13..16] ), ( path!("dir/node_modules/eslint/package.json").to_string(), vec![8..11] ), ]), "Unrestricted search with ignored directories should find every file with the query" ); let files_to_include = PathMatcher::new(&["node_modules/prettier/**".to_owned()], path_style).unwrap(); let files_to_exclude = PathMatcher::new(&["*.ts".to_owned()], path_style).unwrap(); let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; assert_eq!( search( &project, SearchQuery::text( query, false, false, true, files_to_include, files_to_exclude, false, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([( path!("dir/node_modules/prettier/package.json").to_string(), vec![9..12] )]), "With search including ignored prettier directory and excluding TS files, only one file should be found" ); } #[gpui::test] async fn test_search_with_unicode(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "one.rs": "// ПРИВЕТ? привет!", "two.rs": "// ПРИВЕТ.", "three.rs": "// привет", }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let unicode_case_sensitive_query = SearchQuery::text( "привет", false, true, false, Default::default(), Default::default(), false, None, ); assert_matches!(unicode_case_sensitive_query, Ok(SearchQuery::Text { .. })); assert_eq!( search(&project, unicode_case_sensitive_query.unwrap(), cx) .await .unwrap(), HashMap::from_iter([ (path!("dir/one.rs").to_string(), vec![17..29]), (path!("dir/three.rs").to_string(), vec![3..15]), ]) ); let unicode_case_insensitive_query = SearchQuery::text( "привет", false, false, false, Default::default(), Default::default(), false, None, ); assert_matches!( unicode_case_insensitive_query, Ok(SearchQuery::Regex { .. }) ); assert_eq!( search(&project, unicode_case_insensitive_query.unwrap(), cx) .await .unwrap(), HashMap::from_iter([ (path!("dir/one.rs").to_string(), vec![3..15, 17..29]), (path!("dir/two.rs").to_string(), vec![3..15]), (path!("dir/three.rs").to_string(), vec![3..15]), ]) ); assert_eq!( search( &project, SearchQuery::text( "привет.", false, false, false, Default::default(), Default::default(), false, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([(path!("dir/two.rs").to_string(), vec![3..16]),]) ); } #[gpui::test] async fn test_create_entry(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( "/one/two", json!({ "three": { "a.txt": "", "four": {} }, "c.rs": "" }), ) .await; let project = Project::test(fs.clone(), ["/one/two/three".as_ref()], cx).await; project .update(cx, |project, cx| { let id = project.worktrees(cx).next().unwrap().read(cx).id(); project.create_entry((id, rel_path("b..")), true, cx) }) .await .unwrap() .into_included() .unwrap(); assert_eq!( fs.paths(true), vec![ PathBuf::from(path!("/")), PathBuf::from(path!("/one")), PathBuf::from(path!("/one/two")), PathBuf::from(path!("/one/two/c.rs")), PathBuf::from(path!("/one/two/three")), PathBuf::from(path!("/one/two/three/a.txt")), PathBuf::from(path!("/one/two/three/b..")), PathBuf::from(path!("/one/two/three/four")), ] ); } #[gpui::test] async fn test_multiple_language_server_hovers(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.tsx": "a", }), ) .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(tsx_lang()); let language_server_names = [ "TypeScriptServer", "TailwindServer", "ESLintServer", "NoHoverCapabilitiesServer", ]; let mut language_servers = [ language_registry.register_fake_lsp( "tsx", FakeLspAdapter { name: language_server_names[0], capabilities: lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ), language_registry.register_fake_lsp( "tsx", FakeLspAdapter { name: language_server_names[1], capabilities: lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ), language_registry.register_fake_lsp( "tsx", FakeLspAdapter { name: language_server_names[2], capabilities: lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ), language_registry.register_fake_lsp( "tsx", FakeLspAdapter { name: language_server_names[3], capabilities: lsp::ServerCapabilities { hover_provider: None, ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ), ]; let (buffer, _handle) = project .update(cx, |p, cx| { p.open_local_buffer_with_lsp(path!("/dir/a.tsx"), cx) }) .await .unwrap(); cx.executor().run_until_parked(); let mut servers_with_hover_requests = HashMap::default(); for i in 0..language_server_names.len() { let new_server = language_servers[i].next().await.unwrap_or_else(|| { panic!( "Failed to get language server #{i} with name {}", &language_server_names[i] ) }); let new_server_name = new_server.server.name(); assert!( !servers_with_hover_requests.contains_key(&new_server_name), "Unexpected: initialized server with the same name twice. Name: `{new_server_name}`" ); match new_server_name.as_ref() { "TailwindServer" | "TypeScriptServer" => { servers_with_hover_requests.insert( new_server_name.clone(), new_server.set_request_handler::( move |_, _| { let name = new_server_name.clone(); async move { Ok(Some(lsp::Hover { contents: lsp::HoverContents::Scalar( lsp::MarkedString::String(format!("{name} hover")), ), range: None, })) } }, ), ); } "ESLintServer" => { servers_with_hover_requests.insert( new_server_name, new_server.set_request_handler::( |_, _| async move { Ok(None) }, ), ); } "NoHoverCapabilitiesServer" => { let _never_handled = new_server .set_request_handler::(|_, _| async move { panic!( "Should not call for hovers server with no corresponding capabilities" ) }); } unexpected => panic!("Unexpected server name: {unexpected}"), } } let hover_task = project.update(cx, |project, cx| { project.hover(&buffer, Point::new(0, 0), cx) }); let _: Vec<()> = futures::future::join_all(servers_with_hover_requests.into_values().map( |mut hover_request| async move { hover_request .next() .await .expect("All hover requests should have been triggered") }, )) .await; assert_eq!( vec!["TailwindServer hover", "TypeScriptServer hover"], hover_task .await .into_iter() .flatten() .map(|hover| hover.contents.iter().map(|block| &block.text).join("|")) .sorted() .collect::>(), "Should receive hover responses from all related servers with hover capabilities" ); } #[gpui::test] async fn test_hovers_with_empty_parts(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.ts": "a", }), ) .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(typescript_lang()); let mut fake_language_servers = language_registry.register_fake_lsp( "TypeScript", FakeLspAdapter { capabilities: lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ); let (buffer, _handle) = project .update(cx, |p, cx| { p.open_local_buffer_with_lsp(path!("/dir/a.ts"), cx) }) .await .unwrap(); cx.executor().run_until_parked(); let fake_server = fake_language_servers .next() .await .expect("failed to get the language server"); let mut request_handled = fake_server.set_request_handler::( move |_, _| async move { Ok(Some(lsp::Hover { contents: lsp::HoverContents::Array(vec![ lsp::MarkedString::String("".to_string()), lsp::MarkedString::String(" ".to_string()), lsp::MarkedString::String("\n\n\n".to_string()), ]), range: None, })) }, ); let hover_task = project.update(cx, |project, cx| { project.hover(&buffer, Point::new(0, 0), cx) }); let () = request_handled .next() .await .expect("All hover requests should have been triggered"); assert_eq!( Vec::::new(), hover_task .await .into_iter() .flatten() .map(|hover| hover.contents.iter().map(|block| &block.text).join("|")) .sorted() .collect::>(), "Empty hover parts should be ignored" ); } #[gpui::test] async fn test_code_actions_only_kinds(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.ts": "a", }), ) .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(typescript_lang()); let mut fake_language_servers = language_registry.register_fake_lsp( "TypeScript", FakeLspAdapter { capabilities: lsp::ServerCapabilities { code_action_provider: Some(lsp::CodeActionProviderCapability::Simple(true)), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ); let (buffer, _handle) = project .update(cx, |p, cx| { p.open_local_buffer_with_lsp(path!("/dir/a.ts"), cx) }) .await .unwrap(); cx.executor().run_until_parked(); let fake_server = fake_language_servers .next() .await .expect("failed to get the language server"); let mut request_handled = fake_server .set_request_handler::(move |_, _| async move { Ok(Some(vec![ lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction { title: "organize imports".to_string(), kind: Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS), ..lsp::CodeAction::default() }), lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction { title: "fix code".to_string(), kind: Some(CodeActionKind::SOURCE_FIX_ALL), ..lsp::CodeAction::default() }), ])) }); let code_actions_task = project.update(cx, |project, cx| { project.code_actions( &buffer, 0..buffer.read(cx).len(), Some(vec![CodeActionKind::SOURCE_ORGANIZE_IMPORTS]), cx, ) }); let () = request_handled .next() .await .expect("The code action request should have been triggered"); let code_actions = code_actions_task.await.unwrap().unwrap(); assert_eq!(code_actions.len(), 1); assert_eq!( code_actions[0].lsp_action.action_kind(), Some(CodeActionKind::SOURCE_ORGANIZE_IMPORTS) ); } #[gpui::test] async fn test_code_actions_without_requested_kinds_do_not_send_only_filter( cx: &mut gpui::TestAppContext, ) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.ts": "a", }), ) .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(typescript_lang()); let mut fake_language_servers = language_registry.register_fake_lsp( "TypeScript", FakeLspAdapter { capabilities: lsp::ServerCapabilities { code_action_provider: Some(lsp::CodeActionProviderCapability::Options( lsp::CodeActionOptions { code_action_kinds: Some(vec![ CodeActionKind::SOURCE_ORGANIZE_IMPORTS, "source.doc".into(), ]), ..lsp::CodeActionOptions::default() }, )), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ); let (buffer, _handle) = project .update(cx, |p, cx| { p.open_local_buffer_with_lsp(path!("/dir/a.ts"), cx) }) .await .unwrap(); cx.executor().run_until_parked(); let fake_server = fake_language_servers .next() .await .expect("failed to get the language server"); let mut request_handled = fake_server.set_request_handler::< lsp::request::CodeActionRequest, _, _, >(move |params, _| async move { assert_eq!( params.context.only, None, "Code action requests without explicit kind filters should not send `context.only`" ); Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( lsp::CodeAction { title: "Add test".to_string(), kind: Some("source.addTest".into()), ..lsp::CodeAction::default() }, )])) }); let code_actions_task = project.update(cx, |project, cx| { project.code_actions(&buffer, 0..buffer.read(cx).len(), None, cx) }); let () = request_handled .next() .await .expect("The code action request should have been triggered"); let code_actions = code_actions_task.await.unwrap().unwrap(); assert_eq!(code_actions.len(), 1); assert_eq!( code_actions[0].lsp_action.action_kind(), Some("source.addTest".into()) ); } #[gpui::test] async fn test_multiple_language_server_actions(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "a.tsx": "a", }), ) .await; let project = Project::test(fs, [path!("/dir").as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); language_registry.add(tsx_lang()); let language_server_names = [ "TypeScriptServer", "TailwindServer", "ESLintServer", "NoActionsCapabilitiesServer", ]; let mut language_server_rxs = [ language_registry.register_fake_lsp( "tsx", FakeLspAdapter { name: language_server_names[0], capabilities: lsp::ServerCapabilities { code_action_provider: Some(lsp::CodeActionProviderCapability::Simple(true)), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ), language_registry.register_fake_lsp( "tsx", FakeLspAdapter { name: language_server_names[1], capabilities: lsp::ServerCapabilities { code_action_provider: Some(lsp::CodeActionProviderCapability::Simple(true)), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ), language_registry.register_fake_lsp( "tsx", FakeLspAdapter { name: language_server_names[2], capabilities: lsp::ServerCapabilities { code_action_provider: Some(lsp::CodeActionProviderCapability::Simple(true)), ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ), language_registry.register_fake_lsp( "tsx", FakeLspAdapter { name: language_server_names[3], capabilities: lsp::ServerCapabilities { code_action_provider: None, ..lsp::ServerCapabilities::default() }, ..FakeLspAdapter::default() }, ), ]; let (buffer, _handle) = project .update(cx, |p, cx| { p.open_local_buffer_with_lsp(path!("/dir/a.tsx"), cx) }) .await .unwrap(); cx.executor().run_until_parked(); let mut servers_with_actions_requests = HashMap::default(); for i in 0..language_server_names.len() { let new_server = language_server_rxs[i].next().await.unwrap_or_else(|| { panic!( "Failed to get language server #{i} with name {}", &language_server_names[i] ) }); let new_server_name = new_server.server.name(); assert!( !servers_with_actions_requests.contains_key(&new_server_name), "Unexpected: initialized server with the same name twice. Name: `{new_server_name}`" ); match new_server_name.0.as_ref() { "TailwindServer" | "TypeScriptServer" => { servers_with_actions_requests.insert( new_server_name.clone(), new_server.set_request_handler::( move |_, _| { let name = new_server_name.clone(); async move { Ok(Some(vec![lsp::CodeActionOrCommand::CodeAction( lsp::CodeAction { title: format!("{name} code action"), ..lsp::CodeAction::default() }, )])) } }, ), ); } "ESLintServer" => { servers_with_actions_requests.insert( new_server_name, new_server.set_request_handler::( |_, _| async move { Ok(None) }, ), ); } "NoActionsCapabilitiesServer" => { let _never_handled = new_server .set_request_handler::(|_, _| async move { panic!( "Should not call for code actions server with no corresponding capabilities" ) }); } unexpected => panic!("Unexpected server name: {unexpected}"), } } let code_actions_task = project.update(cx, |project, cx| { project.code_actions(&buffer, 0..buffer.read(cx).len(), None, cx) }); // cx.run_until_parked(); let _: Vec<()> = futures::future::join_all(servers_with_actions_requests.into_values().map( |mut code_actions_request| async move { code_actions_request .next() .await .expect("All code actions requests should have been triggered") }, )) .await; assert_eq!( vec!["TailwindServer code action", "TypeScriptServer code action"], code_actions_task .await .unwrap() .unwrap() .into_iter() .map(|code_action| code_action.lsp_action.title().to_owned()) .sorted() .collect::>(), "Should receive code actions responses from all related servers with hover capabilities" ); } #[gpui::test] async fn test_reordering_worktrees(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( "/dir", json!({ "a.rs": "let a = 1;", "b.rs": "let b = 2;", "c.rs": "let c = 2;", }), ) .await; let project = Project::test( fs, [ "/dir/a.rs".as_ref(), "/dir/b.rs".as_ref(), "/dir/c.rs".as_ref(), ], cx, ) .await; // check the initial state and get the worktrees let (worktree_a, worktree_b, worktree_c) = project.update(cx, |project, cx| { let worktrees = project.visible_worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 3); let worktree_a = worktrees[0].read(cx); let worktree_b = worktrees[1].read(cx); let worktree_c = worktrees[2].read(cx); // check they start in the right order assert_eq!(worktree_a.abs_path().to_str().unwrap(), "/dir/a.rs"); assert_eq!(worktree_b.abs_path().to_str().unwrap(), "/dir/b.rs"); assert_eq!(worktree_c.abs_path().to_str().unwrap(), "/dir/c.rs"); ( worktrees[0].clone(), worktrees[1].clone(), worktrees[2].clone(), ) }); // move first worktree to after the second // [a, b, c] -> [b, a, c] project .update(cx, |project, cx| { let first = worktree_a.read(cx); let second = worktree_b.read(cx); project.move_worktree(first.id(), second.id(), cx) }) .expect("moving first after second"); // check the state after moving project.update(cx, |project, cx| { let worktrees = project.visible_worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 3); let first = worktrees[0].read(cx); let second = worktrees[1].read(cx); let third = worktrees[2].read(cx); // check they are now in the right order assert_eq!(first.abs_path().to_str().unwrap(), "/dir/b.rs"); assert_eq!(second.abs_path().to_str().unwrap(), "/dir/a.rs"); assert_eq!(third.abs_path().to_str().unwrap(), "/dir/c.rs"); }); // move the second worktree to before the first // [b, a, c] -> [a, b, c] project .update(cx, |project, cx| { let second = worktree_a.read(cx); let first = worktree_b.read(cx); project.move_worktree(first.id(), second.id(), cx) }) .expect("moving second before first"); // check the state after moving project.update(cx, |project, cx| { let worktrees = project.visible_worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 3); let first = worktrees[0].read(cx); let second = worktrees[1].read(cx); let third = worktrees[2].read(cx); // check they are now in the right order assert_eq!(first.abs_path().to_str().unwrap(), "/dir/a.rs"); assert_eq!(second.abs_path().to_str().unwrap(), "/dir/b.rs"); assert_eq!(third.abs_path().to_str().unwrap(), "/dir/c.rs"); }); // move the second worktree to after the third // [a, b, c] -> [a, c, b] project .update(cx, |project, cx| { let second = worktree_b.read(cx); let third = worktree_c.read(cx); project.move_worktree(second.id(), third.id(), cx) }) .expect("moving second after third"); // check the state after moving project.update(cx, |project, cx| { let worktrees = project.visible_worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 3); let first = worktrees[0].read(cx); let second = worktrees[1].read(cx); let third = worktrees[2].read(cx); // check they are now in the right order assert_eq!(first.abs_path().to_str().unwrap(), "/dir/a.rs"); assert_eq!(second.abs_path().to_str().unwrap(), "/dir/c.rs"); assert_eq!(third.abs_path().to_str().unwrap(), "/dir/b.rs"); }); // move the third worktree to before the second // [a, c, b] -> [a, b, c] project .update(cx, |project, cx| { let third = worktree_c.read(cx); let second = worktree_b.read(cx); project.move_worktree(third.id(), second.id(), cx) }) .expect("moving third before second"); // check the state after moving project.update(cx, |project, cx| { let worktrees = project.visible_worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 3); let first = worktrees[0].read(cx); let second = worktrees[1].read(cx); let third = worktrees[2].read(cx); // check they are now in the right order assert_eq!(first.abs_path().to_str().unwrap(), "/dir/a.rs"); assert_eq!(second.abs_path().to_str().unwrap(), "/dir/b.rs"); assert_eq!(third.abs_path().to_str().unwrap(), "/dir/c.rs"); }); // move the first worktree to after the third // [a, b, c] -> [b, c, a] project .update(cx, |project, cx| { let first = worktree_a.read(cx); let third = worktree_c.read(cx); project.move_worktree(first.id(), third.id(), cx) }) .expect("moving first after third"); // check the state after moving project.update(cx, |project, cx| { let worktrees = project.visible_worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 3); let first = worktrees[0].read(cx); let second = worktrees[1].read(cx); let third = worktrees[2].read(cx); // check they are now in the right order assert_eq!(first.abs_path().to_str().unwrap(), "/dir/b.rs"); assert_eq!(second.abs_path().to_str().unwrap(), "/dir/c.rs"); assert_eq!(third.abs_path().to_str().unwrap(), "/dir/a.rs"); }); // move the third worktree to before the first // [b, c, a] -> [a, b, c] project .update(cx, |project, cx| { let third = worktree_a.read(cx); let first = worktree_b.read(cx); project.move_worktree(third.id(), first.id(), cx) }) .expect("moving third before first"); // check the state after moving project.update(cx, |project, cx| { let worktrees = project.visible_worktrees(cx).collect::>(); assert_eq!(worktrees.len(), 3); let first = worktrees[0].read(cx); let second = worktrees[1].read(cx); let third = worktrees[2].read(cx); // check they are now in the right order assert_eq!(first.abs_path().to_str().unwrap(), "/dir/a.rs"); assert_eq!(second.abs_path().to_str().unwrap(), "/dir/b.rs"); assert_eq!(third.abs_path().to_str().unwrap(), "/dir/c.rs"); }); } #[gpui::test] async fn test_unstaged_diff_for_buffer(cx: &mut gpui::TestAppContext) { init_test(cx); let staged_contents = r#" fn main() { println!("hello world"); } "# .unindent(); let file_contents = r#" // print goodbye fn main() { println!("goodbye world"); } "# .unindent(); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( "/dir", json!({ ".git": {}, "src": { "main.rs": file_contents, } }), ) .await; fs.set_index_for_repo(Path::new("/dir/.git"), &[("src/main.rs", staged_contents)]); let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer("/dir/src/main.rs", cx) }) .await .unwrap(); let unstaged_diff = project .update(cx, |project, cx| { project.open_unstaged_diff(buffer.clone(), cx) }) .await .unwrap(); cx.run_until_parked(); unstaged_diff.update(cx, |unstaged_diff, cx| { let snapshot = buffer.read(cx).snapshot(); assert_hunks( unstaged_diff.snapshot(cx).hunks(&snapshot), &snapshot, &unstaged_diff.base_text_string(cx).unwrap(), &[ (0..1, "", "// print goodbye\n", DiffHunkStatus::added_none()), ( 2..3, " println!(\"hello world\");\n", " println!(\"goodbye world\");\n", DiffHunkStatus::modified_none(), ), ], ); }); let staged_contents = r#" // print goodbye fn main() { } "# .unindent(); fs.set_index_for_repo(Path::new("/dir/.git"), &[("src/main.rs", staged_contents)]); cx.run_until_parked(); unstaged_diff.update(cx, |unstaged_diff, cx| { let snapshot = buffer.read(cx).snapshot(); assert_hunks( unstaged_diff.snapshot(cx).hunks_intersecting_range( Anchor::min_max_range_for_buffer(snapshot.remote_id()), &snapshot, ), &snapshot, &unstaged_diff.base_text(cx).text(), &[( 2..3, "", " println!(\"goodbye world\");\n", DiffHunkStatus::added_none(), )], ); }); } #[gpui::test] async fn test_uncommitted_diff_for_buffer(cx: &mut gpui::TestAppContext) { init_test(cx); let committed_contents = r#" fn main() { println!("hello world"); } "# .unindent(); let staged_contents = r#" fn main() { println!("goodbye world"); } "# .unindent(); let file_contents = r#" // print goodbye fn main() { println!("goodbye world"); } "# .unindent(); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( "/dir", json!({ ".git": {}, "src": { "modification.rs": file_contents, } }), ) .await; fs.set_head_for_repo( Path::new("/dir/.git"), &[ ("src/modification.rs", committed_contents), ("src/deletion.rs", "// the-deleted-contents\n".into()), ], "deadbeef", ); fs.set_index_for_repo( Path::new("/dir/.git"), &[ ("src/modification.rs", staged_contents), ("src/deletion.rs", "// the-deleted-contents\n".into()), ], ); let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); let language = rust_lang(); language_registry.add(language.clone()); let buffer_1 = project .update(cx, |project, cx| { project.open_local_buffer("/dir/src/modification.rs", cx) }) .await .unwrap(); let diff_1 = project .update(cx, |project, cx| { project.open_uncommitted_diff(buffer_1.clone(), cx) }) .await .unwrap(); diff_1.read_with(cx, |diff, cx| { assert_eq!(diff.base_text(cx).language().cloned(), Some(language)) }); cx.run_until_parked(); diff_1.update(cx, |diff, cx| { let snapshot = buffer_1.read(cx).snapshot(); assert_hunks( diff.snapshot(cx).hunks_intersecting_range( Anchor::min_max_range_for_buffer(snapshot.remote_id()), &snapshot, ), &snapshot, &diff.base_text_string(cx).unwrap(), &[ ( 0..1, "", "// print goodbye\n", DiffHunkStatus::added(DiffHunkSecondaryStatus::HasSecondaryHunk), ), ( 2..3, " println!(\"hello world\");\n", " println!(\"goodbye world\");\n", DiffHunkStatus::modified_none(), ), ], ); }); // Reset HEAD to a version that differs from both the buffer and the index. let committed_contents = r#" // print goodbye fn main() { } "# .unindent(); fs.set_head_for_repo( Path::new("/dir/.git"), &[ ("src/modification.rs", committed_contents.clone()), ("src/deletion.rs", "// the-deleted-contents\n".into()), ], "deadbeef", ); // Buffer now has an unstaged hunk. cx.run_until_parked(); diff_1.update(cx, |diff, cx| { let snapshot = buffer_1.read(cx).snapshot(); assert_hunks( diff.snapshot(cx).hunks_intersecting_range( Anchor::min_max_range_for_buffer(snapshot.remote_id()), &snapshot, ), &snapshot, &diff.base_text(cx).text(), &[( 2..3, "", " println!(\"goodbye world\");\n", DiffHunkStatus::added_none(), )], ); }); // Open a buffer for a file that's been deleted. let buffer_2 = project .update(cx, |project, cx| { project.open_local_buffer("/dir/src/deletion.rs", cx) }) .await .unwrap(); let diff_2 = project .update(cx, |project, cx| { project.open_uncommitted_diff(buffer_2.clone(), cx) }) .await .unwrap(); cx.run_until_parked(); diff_2.update(cx, |diff, cx| { let snapshot = buffer_2.read(cx).snapshot(); assert_hunks( diff.snapshot(cx).hunks_intersecting_range( Anchor::min_max_range_for_buffer(snapshot.remote_id()), &snapshot, ), &snapshot, &diff.base_text_string(cx).unwrap(), &[( 0..0, "// the-deleted-contents\n", "", DiffHunkStatus::deleted(DiffHunkSecondaryStatus::HasSecondaryHunk), )], ); }); // Stage the deletion of this file fs.set_index_for_repo( Path::new("/dir/.git"), &[("src/modification.rs", committed_contents.clone())], ); cx.run_until_parked(); diff_2.update(cx, |diff, cx| { let snapshot = buffer_2.read(cx).snapshot(); assert_hunks( diff.snapshot(cx).hunks_intersecting_range( Anchor::min_max_range_for_buffer(snapshot.remote_id()), &snapshot, ), &snapshot, &diff.base_text_string(cx).unwrap(), &[( 0..0, "// the-deleted-contents\n", "", DiffHunkStatus::deleted(DiffHunkSecondaryStatus::NoSecondaryHunk), )], ); }); } #[gpui::test] async fn test_staging_hunks(cx: &mut gpui::TestAppContext) { use DiffHunkSecondaryStatus::*; init_test(cx); let committed_contents = r#" zero one two three four five "# .unindent(); let file_contents = r#" one TWO three FOUR five "# .unindent(); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( "/dir", json!({ ".git": {}, "file.txt": file_contents.clone() }), ) .await; fs.set_head_and_index_for_repo( path!("/dir/.git").as_ref(), &[("file.txt", committed_contents.clone())], ); let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer("/dir/file.txt", cx) }) .await .unwrap(); let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); let uncommitted_diff = project .update(cx, |project, cx| { project.open_uncommitted_diff(buffer.clone(), cx) }) .await .unwrap(); let mut diff_events = cx.events(&uncommitted_diff); // The hunks are initially unstaged. uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, &diff.base_text_string(cx).unwrap(), &[ ( 0..0, "zero\n", "", DiffHunkStatus::deleted(HasSecondaryHunk), ), ( 1..2, "two\n", "TWO\n", DiffHunkStatus::modified(HasSecondaryHunk), ), ( 3..4, "four\n", "FOUR\n", DiffHunkStatus::modified(HasSecondaryHunk), ), ], ); }); // Stage a hunk. It appears as optimistically staged. uncommitted_diff.update(cx, |diff, cx| { let range = snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_before(Point::new(2, 0)); let hunks = diff .snapshot(cx) .hunks_intersecting_range(range, &snapshot) .collect::>(); diff.stage_or_unstage_hunks(true, &hunks, &snapshot, true, cx); assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, &diff.base_text_string(cx).unwrap(), &[ ( 0..0, "zero\n", "", DiffHunkStatus::deleted(HasSecondaryHunk), ), ( 1..2, "two\n", "TWO\n", DiffHunkStatus::modified(SecondaryHunkRemovalPending), ), ( 3..4, "four\n", "FOUR\n", DiffHunkStatus::modified(HasSecondaryHunk), ), ], ); }); // The diff emits a change event for the range of the staged hunk. assert!(matches!( diff_events.next().await.unwrap(), BufferDiffEvent::HunksStagedOrUnstaged(_) )); let event = diff_events.next().await.unwrap(); if let BufferDiffEvent::DiffChanged(DiffChanged { changed_range: Some(changed_range), base_text_changed_range: _, extended_range: _, }) = event { let changed_range = changed_range.to_point(&snapshot); assert_eq!(changed_range, Point::new(1, 0)..Point::new(2, 0)); } else { panic!("Unexpected event {event:?}"); } // When the write to the index completes, it appears as staged. cx.run_until_parked(); uncommitted_diff.update(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, &diff.base_text_string(cx).unwrap(), &[ ( 0..0, "zero\n", "", DiffHunkStatus::deleted(HasSecondaryHunk), ), ( 1..2, "two\n", "TWO\n", DiffHunkStatus::modified(NoSecondaryHunk), ), ( 3..4, "four\n", "FOUR\n", DiffHunkStatus::modified(HasSecondaryHunk), ), ], ); }); // The diff emits a change event for the changed index text. let event = diff_events.next().await.unwrap(); if let BufferDiffEvent::DiffChanged(DiffChanged { changed_range: Some(changed_range), base_text_changed_range: _, extended_range: _, }) = event { let changed_range = changed_range.to_point(&snapshot); assert_eq!(changed_range, Point::new(0, 0)..Point::new(4, 0)); } else { panic!("Unexpected event {event:?}"); } // Simulate a problem writing to the git index. fs.set_error_message_for_index_write( "/dir/.git".as_ref(), Some("failed to write git index".into()), ); // Stage another hunk. uncommitted_diff.update(cx, |diff, cx| { let range = snapshot.anchor_before(Point::new(3, 0))..snapshot.anchor_before(Point::new(4, 0)); let hunks = diff .snapshot(cx) .hunks_intersecting_range(range, &snapshot) .collect::>(); diff.stage_or_unstage_hunks(true, &hunks, &snapshot, true, cx); assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, &diff.base_text_string(cx).unwrap(), &[ ( 0..0, "zero\n", "", DiffHunkStatus::deleted(HasSecondaryHunk), ), ( 1..2, "two\n", "TWO\n", DiffHunkStatus::modified(NoSecondaryHunk), ), ( 3..4, "four\n", "FOUR\n", DiffHunkStatus::modified(SecondaryHunkRemovalPending), ), ], ); }); assert!(matches!( diff_events.next().await.unwrap(), BufferDiffEvent::HunksStagedOrUnstaged(_) )); let event = diff_events.next().await.unwrap(); if let BufferDiffEvent::DiffChanged(DiffChanged { changed_range: Some(changed_range), base_text_changed_range: _, extended_range: _, }) = event { let changed_range = changed_range.to_point(&snapshot); assert_eq!(changed_range, Point::new(3, 0)..Point::new(4, 0)); } else { panic!("Unexpected event {event:?}"); } // When the write fails, the hunk returns to being unstaged. cx.run_until_parked(); uncommitted_diff.update(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, &diff.base_text_string(cx).unwrap(), &[ ( 0..0, "zero\n", "", DiffHunkStatus::deleted(HasSecondaryHunk), ), ( 1..2, "two\n", "TWO\n", DiffHunkStatus::modified(NoSecondaryHunk), ), ( 3..4, "four\n", "FOUR\n", DiffHunkStatus::modified(HasSecondaryHunk), ), ], ); }); let event = diff_events.next().await.unwrap(); if let BufferDiffEvent::DiffChanged(DiffChanged { changed_range: Some(changed_range), base_text_changed_range: _, extended_range: _, }) = event { let changed_range = changed_range.to_point(&snapshot); assert_eq!(changed_range, Point::new(0, 0)..Point::new(5, 0)); } else { panic!("Unexpected event {event:?}"); } // Allow writing to the git index to succeed again. fs.set_error_message_for_index_write("/dir/.git".as_ref(), None); // Stage two hunks with separate operations. uncommitted_diff.update(cx, |diff, cx| { let hunks = diff.snapshot(cx).hunks(&snapshot).collect::>(); diff.stage_or_unstage_hunks(true, &hunks[0..1], &snapshot, true, cx); diff.stage_or_unstage_hunks(true, &hunks[2..3], &snapshot, true, cx); }); // Both staged hunks appear as pending. uncommitted_diff.update(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, &diff.base_text_string(cx).unwrap(), &[ ( 0..0, "zero\n", "", DiffHunkStatus::deleted(SecondaryHunkRemovalPending), ), ( 1..2, "two\n", "TWO\n", DiffHunkStatus::modified(NoSecondaryHunk), ), ( 3..4, "four\n", "FOUR\n", DiffHunkStatus::modified(SecondaryHunkRemovalPending), ), ], ); }); // Both staging operations take effect. cx.run_until_parked(); uncommitted_diff.update(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, &diff.base_text_string(cx).unwrap(), &[ (0..0, "zero\n", "", DiffHunkStatus::deleted(NoSecondaryHunk)), ( 1..2, "two\n", "TWO\n", DiffHunkStatus::modified(NoSecondaryHunk), ), ( 3..4, "four\n", "FOUR\n", DiffHunkStatus::modified(NoSecondaryHunk), ), ], ); }); } #[gpui::test(iterations = 10)] async fn test_uncommitted_diff_opened_before_unstaged_diff(cx: &mut gpui::TestAppContext) { use DiffHunkSecondaryStatus::*; init_test(cx); let committed_contents = "one\ntwo\nthree\n"; let file_contents = "one\nTWO\nthree\n"; let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( "/dir", json!({ ".git": {}, "file.txt": file_contents, }), ) .await; fs.set_head_and_index_for_repo( path!("/dir/.git").as_ref(), &[("file.txt", committed_contents.into())], ); let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer("/dir/file.txt", cx) }) .await .unwrap(); let uncommitted_diff_task = project.update(cx, |project, cx| { project.open_uncommitted_diff(buffer.clone(), cx) }); let unstaged_diff_task = project.update(cx, |project, cx| { project.open_unstaged_diff(buffer.clone(), cx) }); let (uncommitted_diff, _unstaged_diff) = futures::future::join(uncommitted_diff_task, unstaged_diff_task).await; let uncommitted_diff = uncommitted_diff.unwrap(); let _unstaged_diff = _unstaged_diff.unwrap(); cx.run_until_parked(); uncommitted_diff.read_with(cx, |diff, cx| { let snapshot = buffer.read(cx).snapshot(); assert_hunks( diff.snapshot(cx).hunks_intersecting_range( Anchor::min_max_range_for_buffer(snapshot.remote_id()), &snapshot, ), &snapshot, &diff.base_text_string(cx).unwrap(), &[( 1..2, "two\n", "TWO\n", DiffHunkStatus::modified(HasSecondaryHunk), )], ); }); } #[gpui::test(seeds(340, 472))] async fn test_staging_hunks_with_delayed_fs_event(cx: &mut gpui::TestAppContext) { use DiffHunkSecondaryStatus::*; init_test(cx); let committed_contents = r#" zero one two three four five "# .unindent(); let file_contents = r#" one TWO three FOUR five "# .unindent(); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( "/dir", json!({ ".git": {}, "file.txt": file_contents.clone() }), ) .await; fs.set_head_for_repo( "/dir/.git".as_ref(), &[("file.txt", committed_contents.clone())], "deadbeef", ); fs.set_index_for_repo( "/dir/.git".as_ref(), &[("file.txt", committed_contents.clone())], ); let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer("/dir/file.txt", cx) }) .await .unwrap(); let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); let uncommitted_diff = project .update(cx, |project, cx| { project.open_uncommitted_diff(buffer.clone(), cx) }) .await .unwrap(); // The hunks are initially unstaged. uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, &diff.base_text_string(cx).unwrap(), &[ ( 0..0, "zero\n", "", DiffHunkStatus::deleted(HasSecondaryHunk), ), ( 1..2, "two\n", "TWO\n", DiffHunkStatus::modified(HasSecondaryHunk), ), ( 3..4, "four\n", "FOUR\n", DiffHunkStatus::modified(HasSecondaryHunk), ), ], ); }); // Pause IO events fs.pause_events(); // Stage the first hunk. uncommitted_diff.update(cx, |diff, cx| { let hunk = diff.snapshot(cx).hunks(&snapshot).next().unwrap(); diff.stage_or_unstage_hunks(true, &[hunk], &snapshot, true, cx); assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, &diff.base_text_string(cx).unwrap(), &[ ( 0..0, "zero\n", "", DiffHunkStatus::deleted(SecondaryHunkRemovalPending), ), ( 1..2, "two\n", "TWO\n", DiffHunkStatus::modified(HasSecondaryHunk), ), ( 3..4, "four\n", "FOUR\n", DiffHunkStatus::modified(HasSecondaryHunk), ), ], ); }); // Stage the second hunk *before* receiving the FS event for the first hunk. cx.run_until_parked(); uncommitted_diff.update(cx, |diff, cx| { let hunk = diff.snapshot(cx).hunks(&snapshot).nth(1).unwrap(); diff.stage_or_unstage_hunks(true, &[hunk], &snapshot, true, cx); assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, &diff.base_text_string(cx).unwrap(), &[ ( 0..0, "zero\n", "", DiffHunkStatus::deleted(SecondaryHunkRemovalPending), ), ( 1..2, "two\n", "TWO\n", DiffHunkStatus::modified(SecondaryHunkRemovalPending), ), ( 3..4, "four\n", "FOUR\n", DiffHunkStatus::modified(HasSecondaryHunk), ), ], ); }); // Process the FS event for staging the first hunk (second event is still pending). fs.flush_events(1); cx.run_until_parked(); // Stage the third hunk before receiving the second FS event. uncommitted_diff.update(cx, |diff, cx| { let hunk = diff.snapshot(cx).hunks(&snapshot).nth(2).unwrap(); diff.stage_or_unstage_hunks(true, &[hunk], &snapshot, true, cx); }); // Wait for all remaining IO. cx.run_until_parked(); fs.flush_events(fs.buffered_event_count()); // Now all hunks are staged. cx.run_until_parked(); uncommitted_diff.update(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, &diff.base_text_string(cx).unwrap(), &[ (0..0, "zero\n", "", DiffHunkStatus::deleted(NoSecondaryHunk)), ( 1..2, "two\n", "TWO\n", DiffHunkStatus::modified(NoSecondaryHunk), ), ( 3..4, "four\n", "FOUR\n", DiffHunkStatus::modified(NoSecondaryHunk), ), ], ); }); } #[gpui::test(iterations = 25)] async fn test_staging_random_hunks( mut rng: StdRng, _executor: BackgroundExecutor, cx: &mut gpui::TestAppContext, ) { let operations = env::var("OPERATIONS") .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) .unwrap_or(20); use DiffHunkSecondaryStatus::*; init_test(cx); let committed_text = (0..30).map(|i| format!("line {i}\n")).collect::(); let index_text = committed_text.clone(); let buffer_text = (0..30) .map(|i| match i % 5 { 0 => format!("line {i} (modified)\n"), _ => format!("line {i}\n"), }) .collect::(); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/dir"), json!({ ".git": {}, "file.txt": buffer_text.clone() }), ) .await; fs.set_head_for_repo( path!("/dir/.git").as_ref(), &[("file.txt", committed_text.clone())], "deadbeef", ); fs.set_index_for_repo( path!("/dir/.git").as_ref(), &[("file.txt", index_text.clone())], ); let repo = fs .open_repo(path!("/dir/.git").as_ref(), Some("git".as_ref())) .unwrap(); let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/dir/file.txt"), cx) }) .await .unwrap(); let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); let uncommitted_diff = project .update(cx, |project, cx| { project.open_uncommitted_diff(buffer.clone(), cx) }) .await .unwrap(); let mut hunks = uncommitted_diff.update(cx, |diff, cx| { diff.snapshot(cx).hunks(&snapshot).collect::>() }); assert_eq!(hunks.len(), 6); for _i in 0..operations { let hunk_ix = rng.random_range(0..hunks.len()); let hunk = &mut hunks[hunk_ix]; let row = hunk.range.start.row; if hunk.status().has_secondary_hunk() { log::info!("staging hunk at {row}"); uncommitted_diff.update(cx, |diff, cx| { diff.stage_or_unstage_hunks(true, std::slice::from_ref(hunk), &snapshot, true, cx); }); hunk.secondary_status = SecondaryHunkRemovalPending; } else { log::info!("unstaging hunk at {row}"); uncommitted_diff.update(cx, |diff, cx| { diff.stage_or_unstage_hunks(false, std::slice::from_ref(hunk), &snapshot, true, cx); }); hunk.secondary_status = SecondaryHunkAdditionPending; } for _ in 0..rng.random_range(0..10) { log::info!("yielding"); cx.executor().simulate_random_delay().await; } } cx.executor().run_until_parked(); for hunk in &mut hunks { if hunk.secondary_status == SecondaryHunkRemovalPending { hunk.secondary_status = NoSecondaryHunk; } else if hunk.secondary_status == SecondaryHunkAdditionPending { hunk.secondary_status = HasSecondaryHunk; } } log::info!( "index text:\n{}", repo.load_index_text(RepoPath::from_rel_path(rel_path("file.txt"))) .await .unwrap() ); uncommitted_diff.update(cx, |diff, cx| { let expected_hunks = hunks .iter() .map(|hunk| (hunk.range.start.row, hunk.secondary_status)) .collect::>(); let actual_hunks = diff .snapshot(cx) .hunks(&snapshot) .map(|hunk| (hunk.range.start.row, hunk.secondary_status)) .collect::>(); assert_eq!(actual_hunks, expected_hunks); }); } #[gpui::test] async fn test_single_file_diffs(cx: &mut gpui::TestAppContext) { init_test(cx); let committed_contents = r#" fn main() { println!("hello from HEAD"); } "# .unindent(); let file_contents = r#" fn main() { println!("hello from the working copy"); } "# .unindent(); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( "/dir", json!({ ".git": {}, "src": { "main.rs": file_contents, } }), ) .await; fs.set_head_for_repo( Path::new("/dir/.git"), &[("src/main.rs", committed_contents.clone())], "deadbeef", ); fs.set_index_for_repo( Path::new("/dir/.git"), &[("src/main.rs", committed_contents.clone())], ); let project = Project::test(fs.clone(), ["/dir/src/main.rs".as_ref()], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer("/dir/src/main.rs", cx) }) .await .unwrap(); let uncommitted_diff = project .update(cx, |project, cx| { project.open_uncommitted_diff(buffer.clone(), cx) }) .await .unwrap(); cx.run_until_parked(); uncommitted_diff.update(cx, |uncommitted_diff, cx| { let snapshot = buffer.read(cx).snapshot(); assert_hunks( uncommitted_diff.snapshot(cx).hunks(&snapshot), &snapshot, &uncommitted_diff.base_text_string(cx).unwrap(), &[( 1..2, " println!(\"hello from HEAD\");\n", " println!(\"hello from the working copy\");\n", DiffHunkStatus { kind: DiffHunkStatusKind::Modified, secondary: DiffHunkSecondaryStatus::HasSecondaryHunk, }, )], ); }); } // TODO: Should we test this on Windows also? #[gpui::test] #[cfg(not(windows))] async fn test_staging_hunk_preserve_executable_permission(cx: &mut gpui::TestAppContext) { use std::os::unix::fs::PermissionsExt; init_test(cx); cx.executor().allow_parking(); let committed_contents = "bar\n"; let file_contents = "baz\n"; let root = TempTree::new(json!({ "project": { "foo": committed_contents }, })); let work_dir = root.path().join("project"); let file_path = work_dir.join("foo"); let repo = git_init(work_dir.as_path()); let mut perms = std::fs::metadata(&file_path).unwrap().permissions(); perms.set_mode(0o755); std::fs::set_permissions(&file_path, perms).unwrap(); git_add("foo", &repo); git_commit("Initial commit", &repo); std::fs::write(&file_path, file_contents).unwrap(); let project = Project::test( Arc::new(RealFs::new(None, cx.executor())), [root.path()], cx, ) .await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer(file_path.as_path(), cx) }) .await .unwrap(); let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); let uncommitted_diff = project .update(cx, |project, cx| { project.open_uncommitted_diff(buffer.clone(), cx) }) .await .unwrap(); uncommitted_diff.update(cx, |diff, cx| { let hunks = diff.snapshot(cx).hunks(&snapshot).collect::>(); diff.stage_or_unstage_hunks(true, &hunks, &snapshot, true, cx); }); cx.run_until_parked(); let output = smol::process::Command::new("git") .current_dir(&work_dir) .args(["diff", "--staged"]) .output() .await .unwrap(); let staged_diff = String::from_utf8_lossy(&output.stdout); assert!( !staged_diff.contains("new mode 100644"), "Staging should not change file mode from 755 to 644.\ngit diff --staged:\n{}", staged_diff ); let output = smol::process::Command::new("git") .current_dir(&work_dir) .args(["ls-files", "-s"]) .output() .await .unwrap(); let index_contents = String::from_utf8_lossy(&output.stdout); assert!( index_contents.contains("100755"), "Index should show file as executable (100755).\ngit ls-files -s:\n{}", index_contents ); } #[gpui::test] async fn test_repository_and_path_for_project_path( background_executor: BackgroundExecutor, cx: &mut gpui::TestAppContext, ) { init_test(cx); let fs = FakeFs::new(background_executor); fs.insert_tree( path!("/root"), json!({ "c.txt": "", "dir1": { ".git": {}, "deps": { "dep1": { ".git": {}, "src": { "a.txt": "" } } }, "src": { "b.txt": "" } }, }), ) .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); let tree_id = tree.read_with(cx, |tree, _| tree.id()); project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.run_until_parked(); project.read_with(cx, |project, cx| { let git_store = project.git_store().read(cx); let pairs = [ ("c.txt", None), ("dir1/src/b.txt", Some((path!("/root/dir1"), "src/b.txt"))), ( "dir1/deps/dep1/src/a.txt", Some((path!("/root/dir1/deps/dep1"), "src/a.txt")), ), ]; let expected = pairs .iter() .map(|(path, result)| { ( path, result.map(|(repo, repo_path)| { (Path::new(repo).into(), RepoPath::new(repo_path).unwrap()) }), ) }) .collect::>(); let actual = pairs .iter() .map(|(path, _)| { let project_path = (tree_id, rel_path(path)).into(); let result = maybe!({ let (repo, repo_path) = git_store.repository_and_path_for_project_path(&project_path, cx)?; Some((repo.read(cx).work_directory_abs_path.clone(), repo_path)) }); (path, result) }) .collect::>(); pretty_assertions::assert_eq!(expected, actual); }); fs.remove_dir(path!("/root/dir1/.git").as_ref(), RemoveOptions::default()) .await .unwrap(); cx.run_until_parked(); project.read_with(cx, |project, cx| { let git_store = project.git_store().read(cx); assert_eq!( git_store.repository_and_path_for_project_path( &(tree_id, rel_path("dir1/src/b.txt")).into(), cx ), None ); }); } #[gpui::test] async fn test_home_dir_as_git_repository(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); let home = paths::home_dir(); fs.insert_tree( home, json!({ ".git": {}, "project": { "a.txt": "A" }, }), ) .await; let project = Project::test(fs.clone(), [home.join("project").as_ref()], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); let tree_id = tree.read_with(cx, |tree, _| tree.id()); project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; tree.flush_fs_events(cx).await; project.read_with(cx, |project, cx| { let containing = project .git_store() .read(cx) .repository_and_path_for_project_path(&(tree_id, rel_path("a.txt")).into(), cx); assert!(containing.is_none()); }); let project = Project::test(fs.clone(), [home.as_ref()], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); let tree_id = tree.read_with(cx, |tree, _| tree.id()); project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; tree.flush_fs_events(cx).await; project.read_with(cx, |project, cx| { let containing = project .git_store() .read(cx) .repository_and_path_for_project_path(&(tree_id, rel_path("project/a.txt")).into(), cx); assert_eq!( containing .unwrap() .0 .read(cx) .work_directory_abs_path .as_ref(), home, ); }); } #[gpui::test] async fn test_git_repository_status(cx: &mut gpui::TestAppContext) { init_test(cx); cx.executor().allow_parking(); let root = TempTree::new(json!({ "project": { "a.txt": "a", // Modified "b.txt": "bb", // Added "c.txt": "ccc", // Unchanged "d.txt": "dddd", // Deleted }, })); // Set up git repository before creating the project. let work_dir = root.path().join("project"); let repo = git_init(work_dir.as_path()); git_add("a.txt", &repo); git_add("c.txt", &repo); git_add("d.txt", &repo); git_commit("Initial commit", &repo); std::fs::remove_file(work_dir.join("d.txt")).unwrap(); std::fs::write(work_dir.join("a.txt"), "aa").unwrap(); let project = Project::test( Arc::new(RealFs::new(None, cx.executor())), [root.path()], cx, ) .await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); let repository = project.read_with(cx, |project, cx| { project.repositories(cx).values().next().unwrap().clone() }); // Check that the right git state is observed on startup repository.read_with(cx, |repository, _| { let entries = repository.cached_status().collect::>(); assert_eq!( entries, [ StatusEntry { repo_path: repo_path("a.txt"), status: StatusCode::Modified.worktree(), diff_stat: Some(DiffStat { added: 1, deleted: 1, }), }, StatusEntry { repo_path: repo_path("b.txt"), status: FileStatus::Untracked, diff_stat: None, }, StatusEntry { repo_path: repo_path("d.txt"), status: StatusCode::Deleted.worktree(), diff_stat: Some(DiffStat { added: 0, deleted: 1, }), }, ] ); }); std::fs::write(work_dir.join("c.txt"), "some changes").unwrap(); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); repository.read_with(cx, |repository, _| { let entries = repository.cached_status().collect::>(); assert_eq!( entries, [ StatusEntry { repo_path: repo_path("a.txt"), status: StatusCode::Modified.worktree(), diff_stat: Some(DiffStat { added: 1, deleted: 1, }), }, StatusEntry { repo_path: repo_path("b.txt"), status: FileStatus::Untracked, diff_stat: None, }, StatusEntry { repo_path: repo_path("c.txt"), status: StatusCode::Modified.worktree(), diff_stat: Some(DiffStat { added: 1, deleted: 1, }), }, StatusEntry { repo_path: repo_path("d.txt"), status: StatusCode::Deleted.worktree(), diff_stat: Some(DiffStat { added: 0, deleted: 1, }), }, ] ); }); git_add("a.txt", &repo); git_add("c.txt", &repo); git_remove_index(Path::new("d.txt"), &repo); git_commit("Another commit", &repo); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); std::fs::remove_file(work_dir.join("a.txt")).unwrap(); std::fs::remove_file(work_dir.join("b.txt")).unwrap(); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); repository.read_with(cx, |repository, _cx| { let entries = repository.cached_status().collect::>(); // Deleting an untracked entry, b.txt, should leave no status // a.txt was tracked, and so should have a status assert_eq!( entries, [StatusEntry { repo_path: repo_path("a.txt"), status: StatusCode::Deleted.worktree(), diff_stat: Some(DiffStat { added: 0, deleted: 1, }), }] ); }); } #[gpui::test] #[ignore] async fn test_git_status_postprocessing(cx: &mut gpui::TestAppContext) { init_test(cx); cx.executor().allow_parking(); let root = TempTree::new(json!({ "project": { "sub": {}, "a.txt": "", }, })); let work_dir = root.path().join("project"); let repo = git_init(work_dir.as_path()); // a.txt exists in HEAD and the working copy but is deleted in the index. git_add("a.txt", &repo); git_commit("Initial commit", &repo); git_remove_index("a.txt".as_ref(), &repo); // `sub` is a nested git repository. let _sub = git_init(&work_dir.join("sub")); let project = Project::test( Arc::new(RealFs::new(None, cx.executor())), [root.path()], cx, ) .await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); let repository = project.read_with(cx, |project, cx| { project .repositories(cx) .values() .find(|repo| repo.read(cx).work_directory_abs_path.ends_with("project")) .unwrap() .clone() }); repository.read_with(cx, |repository, _cx| { let entries = repository.cached_status().collect::>(); // `sub` doesn't appear in our computed statuses. // a.txt appears with a combined `DA` status. assert_eq!( entries, [StatusEntry { repo_path: repo_path("a.txt"), status: TrackedStatus { index_status: StatusCode::Deleted, worktree_status: StatusCode::Added } .into(), diff_stat: None, }] ) }); } #[track_caller] /// We merge lhs into rhs. fn merge_pending_ops_snapshots( source: Vec, mut target: Vec, ) -> Vec { for s_ops in source { if let Some(idx) = target.iter().zip(0..).find_map(|(ops, idx)| { if ops.repo_path == s_ops.repo_path { Some(idx) } else { None } }) { let t_ops = &mut target[idx]; for s_op in s_ops.ops { if let Some(op_idx) = t_ops .ops .iter() .zip(0..) .find_map(|(op, idx)| if op.id == s_op.id { Some(idx) } else { None }) { let t_op = &mut t_ops.ops[op_idx]; match (s_op.job_status, t_op.job_status) { (pending_op::JobStatus::Running, _) => {} (s_st, pending_op::JobStatus::Running) => t_op.job_status = s_st, (s_st, t_st) if s_st == t_st => {} _ => unreachable!(), } } else { t_ops.ops.push(s_op); } } t_ops.ops.sort_by_key(|op| op.id); } else { target.push(s_ops); } } target } #[gpui::test] async fn test_repository_pending_ops_staging( executor: gpui::BackgroundExecutor, cx: &mut gpui::TestAppContext, ) { init_test(cx); let fs = FakeFs::new(executor); fs.insert_tree( path!("/root"), json!({ "my-repo": { ".git": {}, "a.txt": "a", } }), ) .await; fs.set_status_for_repo( path!("/root/my-repo/.git").as_ref(), &[("a.txt", FileStatus::Untracked)], ); let project = Project::test(fs.clone(), [path!("/root/my-repo").as_ref()], cx).await; let pending_ops_all = Arc::new(Mutex::new(SumTree::default())); project.update(cx, |project, cx| { let pending_ops_all = pending_ops_all.clone(); cx.subscribe(project.git_store(), move |_, _, e, _| { if let GitStoreEvent::RepositoryUpdated( _, RepositoryEvent::PendingOpsChanged { pending_ops }, _, ) = e { let merged = merge_pending_ops_snapshots( pending_ops.items(()), pending_ops_all.lock().items(()), ); *pending_ops_all.lock() = SumTree::from_iter(merged.into_iter(), ()); } }) .detach(); }); project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; let repo = project.read_with(cx, |project, cx| { project.repositories(cx).values().next().unwrap().clone() }); // Ensure we have no pending ops for any of the untracked files repo.read_with(cx, |repo, _cx| { assert!(repo.pending_ops().next().is_none()); }); let mut id = 1u16; let mut assert_stage = async |path: RepoPath, stage| { let git_status = if stage { pending_op::GitStatus::Staged } else { pending_op::GitStatus::Unstaged }; repo.update(cx, |repo, cx| { let task = if stage { repo.stage_entries(vec![path.clone()], cx) } else { repo.unstage_entries(vec![path.clone()], cx) }; let ops = repo.pending_ops_for_path(&path).unwrap(); assert_eq!( ops.ops.last(), Some(&pending_op::PendingOp { id: id.into(), git_status, job_status: pending_op::JobStatus::Running }) ); task }) .await .unwrap(); repo.read_with(cx, |repo, _cx| { let ops = repo.pending_ops_for_path(&path).unwrap(); assert_eq!( ops.ops.last(), Some(&pending_op::PendingOp { id: id.into(), git_status, job_status: pending_op::JobStatus::Finished }) ); }); id += 1; }; assert_stage(repo_path("a.txt"), true).await; assert_stage(repo_path("a.txt"), false).await; assert_stage(repo_path("a.txt"), true).await; assert_stage(repo_path("a.txt"), false).await; assert_stage(repo_path("a.txt"), true).await; cx.run_until_parked(); assert_eq!( pending_ops_all .lock() .get(&worktree::PathKey(repo_path("a.txt").as_ref().clone()), ()) .unwrap() .ops, vec![ pending_op::PendingOp { id: 1u16.into(), git_status: pending_op::GitStatus::Staged, job_status: pending_op::JobStatus::Finished }, pending_op::PendingOp { id: 2u16.into(), git_status: pending_op::GitStatus::Unstaged, job_status: pending_op::JobStatus::Finished }, pending_op::PendingOp { id: 3u16.into(), git_status: pending_op::GitStatus::Staged, job_status: pending_op::JobStatus::Finished }, pending_op::PendingOp { id: 4u16.into(), git_status: pending_op::GitStatus::Unstaged, job_status: pending_op::JobStatus::Finished }, pending_op::PendingOp { id: 5u16.into(), git_status: pending_op::GitStatus::Staged, job_status: pending_op::JobStatus::Finished } ], ); repo.update(cx, |repo, _cx| { let git_statuses = repo.cached_status().collect::>(); assert_eq!( git_statuses, [StatusEntry { repo_path: repo_path("a.txt"), status: TrackedStatus { index_status: StatusCode::Added, worktree_status: StatusCode::Unmodified } .into(), diff_stat: Some(DiffStat { added: 1, deleted: 0, }), }] ); }); } #[gpui::test] async fn test_repository_pending_ops_long_running_staging( executor: gpui::BackgroundExecutor, cx: &mut gpui::TestAppContext, ) { init_test(cx); let fs = FakeFs::new(executor); fs.insert_tree( path!("/root"), json!({ "my-repo": { ".git": {}, "a.txt": "a", } }), ) .await; fs.set_status_for_repo( path!("/root/my-repo/.git").as_ref(), &[("a.txt", FileStatus::Untracked)], ); let project = Project::test(fs.clone(), [path!("/root/my-repo").as_ref()], cx).await; let pending_ops_all = Arc::new(Mutex::new(SumTree::default())); project.update(cx, |project, cx| { let pending_ops_all = pending_ops_all.clone(); cx.subscribe(project.git_store(), move |_, _, e, _| { if let GitStoreEvent::RepositoryUpdated( _, RepositoryEvent::PendingOpsChanged { pending_ops }, _, ) = e { let merged = merge_pending_ops_snapshots( pending_ops.items(()), pending_ops_all.lock().items(()), ); *pending_ops_all.lock() = SumTree::from_iter(merged.into_iter(), ()); } }) .detach(); }); project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; let repo = project.read_with(cx, |project, cx| { project.repositories(cx).values().next().unwrap().clone() }); repo.update(cx, |repo, cx| { repo.stage_entries(vec![repo_path("a.txt")], cx) }) .detach(); repo.update(cx, |repo, cx| { repo.stage_entries(vec![repo_path("a.txt")], cx) }) .unwrap() .with_timeout(Duration::from_secs(1), &cx.executor()) .await .unwrap(); cx.run_until_parked(); assert_eq!( pending_ops_all .lock() .get(&worktree::PathKey(repo_path("a.txt").as_ref().clone()), ()) .unwrap() .ops, vec![ pending_op::PendingOp { id: 1u16.into(), git_status: pending_op::GitStatus::Staged, job_status: pending_op::JobStatus::Skipped }, pending_op::PendingOp { id: 2u16.into(), git_status: pending_op::GitStatus::Staged, job_status: pending_op::JobStatus::Finished } ], ); repo.update(cx, |repo, _cx| { let git_statuses = repo.cached_status().collect::>(); assert_eq!( git_statuses, [StatusEntry { repo_path: repo_path("a.txt"), status: TrackedStatus { index_status: StatusCode::Added, worktree_status: StatusCode::Unmodified } .into(), diff_stat: Some(DiffStat { added: 1, deleted: 0, }), }] ); }); } #[gpui::test] async fn test_repository_pending_ops_stage_all( executor: gpui::BackgroundExecutor, cx: &mut gpui::TestAppContext, ) { init_test(cx); let fs = FakeFs::new(executor); fs.insert_tree( path!("/root"), json!({ "my-repo": { ".git": {}, "a.txt": "a", "b.txt": "b" } }), ) .await; fs.set_status_for_repo( path!("/root/my-repo/.git").as_ref(), &[ ("a.txt", FileStatus::Untracked), ("b.txt", FileStatus::Untracked), ], ); let project = Project::test(fs.clone(), [path!("/root/my-repo").as_ref()], cx).await; let pending_ops_all = Arc::new(Mutex::new(SumTree::default())); project.update(cx, |project, cx| { let pending_ops_all = pending_ops_all.clone(); cx.subscribe(project.git_store(), move |_, _, e, _| { if let GitStoreEvent::RepositoryUpdated( _, RepositoryEvent::PendingOpsChanged { pending_ops }, _, ) = e { let merged = merge_pending_ops_snapshots( pending_ops.items(()), pending_ops_all.lock().items(()), ); *pending_ops_all.lock() = SumTree::from_iter(merged.into_iter(), ()); } }) .detach(); }); project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; let repo = project.read_with(cx, |project, cx| { project.repositories(cx).values().next().unwrap().clone() }); repo.update(cx, |repo, cx| { repo.stage_entries(vec![repo_path("a.txt")], cx) }) .await .unwrap(); repo.update(cx, |repo, cx| repo.stage_all(cx)) .await .unwrap(); repo.update(cx, |repo, cx| repo.unstage_all(cx)) .await .unwrap(); cx.run_until_parked(); assert_eq!( pending_ops_all .lock() .get(&worktree::PathKey(repo_path("a.txt").as_ref().clone()), ()) .unwrap() .ops, vec![ pending_op::PendingOp { id: 1u16.into(), git_status: pending_op::GitStatus::Staged, job_status: pending_op::JobStatus::Finished }, pending_op::PendingOp { id: 2u16.into(), git_status: pending_op::GitStatus::Unstaged, job_status: pending_op::JobStatus::Finished }, ], ); assert_eq!( pending_ops_all .lock() .get(&worktree::PathKey(repo_path("b.txt").as_ref().clone()), ()) .unwrap() .ops, vec![ pending_op::PendingOp { id: 1u16.into(), git_status: pending_op::GitStatus::Staged, job_status: pending_op::JobStatus::Finished }, pending_op::PendingOp { id: 2u16.into(), git_status: pending_op::GitStatus::Unstaged, job_status: pending_op::JobStatus::Finished }, ], ); repo.update(cx, |repo, _cx| { let git_statuses = repo.cached_status().collect::>(); assert_eq!( git_statuses, [ StatusEntry { repo_path: repo_path("a.txt"), status: FileStatus::Untracked, diff_stat: None, }, StatusEntry { repo_path: repo_path("b.txt"), status: FileStatus::Untracked, diff_stat: None, }, ] ); }); } #[gpui::test] async fn test_repository_subfolder_git_status( executor: gpui::BackgroundExecutor, cx: &mut gpui::TestAppContext, ) { init_test(cx); let fs = FakeFs::new(executor); fs.insert_tree( path!("/root"), json!({ "my-repo": { ".git": {}, "a.txt": "a", "sub-folder-1": { "sub-folder-2": { "c.txt": "cc", "d": { "e.txt": "eee" } }, } }, }), ) .await; const C_TXT: &str = "sub-folder-1/sub-folder-2/c.txt"; const E_TXT: &str = "sub-folder-1/sub-folder-2/d/e.txt"; fs.set_status_for_repo( path!("/root/my-repo/.git").as_ref(), &[(E_TXT, FileStatus::Untracked)], ); let project = Project::test( fs.clone(), [path!("/root/my-repo/sub-folder-1/sub-folder-2").as_ref()], cx, ) .await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.run_until_parked(); let repository = project.read_with(cx, |project, cx| { project.repositories(cx).values().next().unwrap().clone() }); // Ensure that the git status is loaded correctly repository.read_with(cx, |repository, _cx| { assert_eq!( repository.work_directory_abs_path, Path::new(path!("/root/my-repo")).into() ); assert_eq!(repository.status_for_path(&repo_path(C_TXT)), None); assert_eq!( repository .status_for_path(&repo_path(E_TXT)) .unwrap() .status, FileStatus::Untracked ); }); fs.set_status_for_repo(path!("/root/my-repo/.git").as_ref(), &[]); project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.run_until_parked(); repository.read_with(cx, |repository, _cx| { assert_eq!(repository.status_for_path(&repo_path(C_TXT)), None); assert_eq!(repository.status_for_path(&repo_path(E_TXT)), None); }); } // TODO: this test is flaky (especially on Windows but at least sometimes on all platforms). #[cfg(any())] #[gpui::test] async fn test_conflicted_cherry_pick(cx: &mut gpui::TestAppContext) { init_test(cx); cx.executor().allow_parking(); let root = TempTree::new(json!({ "project": { "a.txt": "a", }, })); let root_path = root.path(); let repo = git_init(&root_path.join("project")); git_add("a.txt", &repo); git_commit("init", &repo); let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [root_path], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); let repository = project.read_with(cx, |project, cx| { project.repositories(cx).values().next().unwrap().clone() }); git_branch("other-branch", &repo); git_checkout("refs/heads/other-branch", &repo); std::fs::write(root_path.join("project/a.txt"), "A").unwrap(); git_add("a.txt", &repo); git_commit("capitalize", &repo); let commit = repo .head() .expect("Failed to get HEAD") .peel_to_commit() .expect("HEAD is not a commit"); git_checkout("refs/heads/main", &repo); std::fs::write(root_path.join("project/a.txt"), "b").unwrap(); git_add("a.txt", &repo); git_commit("improve letter", &repo); git_cherry_pick(&commit, &repo); std::fs::read_to_string(root_path.join("project/.git/CHERRY_PICK_HEAD")) .expect("No CHERRY_PICK_HEAD"); pretty_assertions::assert_eq!( git_status(&repo), collections::HashMap::from_iter([("a.txt".to_owned(), git2::Status::CONFLICTED)]) ); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); let conflicts = repository.update(cx, |repository, _| { repository .merge_conflicts .iter() .cloned() .collect::>() }); pretty_assertions::assert_eq!(conflicts, [RepoPath::from("a.txt")]); git_add("a.txt", &repo); // Attempt to manually simulate what `git cherry-pick --continue` would do. git_commit("whatevs", &repo); std::fs::remove_file(root.path().join("project/.git/CHERRY_PICK_HEAD")) .expect("Failed to remove CHERRY_PICK_HEAD"); pretty_assertions::assert_eq!(git_status(&repo), collections::HashMap::default()); tree.flush_fs_events(cx).await; let conflicts = repository.update(cx, |repository, _| { repository .merge_conflicts .iter() .cloned() .collect::>() }); pretty_assertions::assert_eq!(conflicts, []); } #[gpui::test] async fn test_update_gitignore(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/root"), json!({ ".git": {}, ".gitignore": "*.txt\n", "a.xml": "", "b.txt": "Some text" }), ) .await; fs.set_head_and_index_for_repo( path!("/root/.git").as_ref(), &[ (".gitignore", "*.txt\n".into()), ("a.xml", "".into()), ], ); let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); let repository = project.read_with(cx, |project, cx| { project.repositories(cx).values().next().unwrap().clone() }); // One file is unmodified, the other is ignored. cx.read(|cx| { assert_entry_git_state(tree.read(cx), repository.read(cx), "a.xml", None, false); assert_entry_git_state(tree.read(cx), repository.read(cx), "b.txt", None, true); }); // Change the gitignore, and stage the newly non-ignored file. fs.atomic_write(path!("/root/.gitignore").into(), "*.xml\n".into()) .await .unwrap(); fs.set_index_for_repo( Path::new(path!("/root/.git")), &[ (".gitignore", "*.txt\n".into()), ("a.xml", "".into()), ("b.txt", "Some text".into()), ], ); cx.executor().run_until_parked(); cx.read(|cx| { assert_entry_git_state(tree.read(cx), repository.read(cx), "a.xml", None, true); assert_entry_git_state( tree.read(cx), repository.read(cx), "b.txt", Some(StatusCode::Added), false, ); }); } // NOTE: // This test always fails on Windows, because on Windows, unlike on Unix, you can't rename // a directory which some program has already open. // This is a limitation of the Windows. // See: https://stackoverflow.com/questions/41365318/access-is-denied-when-renaming-folder // See: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/ns-ntifs-_file_rename_information #[gpui::test] #[cfg_attr(target_os = "windows", ignore)] async fn test_rename_work_directory(cx: &mut gpui::TestAppContext) { init_test(cx); cx.executor().allow_parking(); let root = TempTree::new(json!({ "projects": { "project1": { "a": "", "b": "", } }, })); let root_path = root.path(); let repo = git_init(&root_path.join("projects/project1")); git_add("a", &repo); git_commit("init", &repo); std::fs::write(root_path.join("projects/project1/a"), "aa").unwrap(); let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [root_path], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); let repository = project.read_with(cx, |project, cx| { project.repositories(cx).values().next().unwrap().clone() }); repository.read_with(cx, |repository, _| { assert_eq!( repository.work_directory_abs_path.as_ref(), root_path.join("projects/project1").as_path() ); assert_eq!( repository .status_for_path(&repo_path("a")) .map(|entry| entry.status), Some(StatusCode::Modified.worktree()), ); assert_eq!( repository .status_for_path(&repo_path("b")) .map(|entry| entry.status), Some(FileStatus::Untracked), ); }); std::fs::rename( root_path.join("projects/project1"), root_path.join("projects/project2"), ) .unwrap(); tree.flush_fs_events(cx).await; repository.read_with(cx, |repository, _| { assert_eq!( repository.work_directory_abs_path.as_ref(), root_path.join("projects/project2").as_path() ); assert_eq!( repository.status_for_path(&repo_path("a")).unwrap().status, StatusCode::Modified.worktree(), ); assert_eq!( repository.status_for_path(&repo_path("b")).unwrap().status, FileStatus::Untracked, ); }); } // NOTE: This test always fails on Windows, because on Windows, unlike on Unix, // you can't rename a directory which some program has already open. This is a // limitation of the Windows. See: // See: https://stackoverflow.com/questions/41365318/access-is-denied-when-renaming-folder // See: https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/ntifs/ns-ntifs-_file_rename_information #[gpui::test] #[cfg_attr(target_os = "windows", ignore)] async fn test_file_status(cx: &mut gpui::TestAppContext) { init_test(cx); cx.executor().allow_parking(); const IGNORE_RULE: &str = "**/target"; let root = TempTree::new(json!({ "project": { "a.txt": "a", "b.txt": "bb", "c": { "d": { "e.txt": "eee" } }, "f.txt": "ffff", "target": { "build_file": "???" }, ".gitignore": IGNORE_RULE }, })); let root_path = root.path(); const A_TXT: &str = "a.txt"; const B_TXT: &str = "b.txt"; const E_TXT: &str = "c/d/e.txt"; const F_TXT: &str = "f.txt"; const DOTGITIGNORE: &str = ".gitignore"; const BUILD_FILE: &str = "target/build_file"; // Set up git repository before creating the worktree. let work_dir = root.path().join("project"); let mut repo = git_init(work_dir.as_path()); repo.add_ignore_rule(IGNORE_RULE).unwrap(); git_add(A_TXT, &repo); git_add(E_TXT, &repo); git_add(DOTGITIGNORE, &repo); git_commit("Initial commit", &repo); let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [root_path], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); let repository = project.read_with(cx, |project, cx| { project.repositories(cx).values().next().unwrap().clone() }); // Check that the right git state is observed on startup repository.read_with(cx, |repository, _cx| { assert_eq!( repository.work_directory_abs_path.as_ref(), root_path.join("project").as_path() ); assert_eq!( repository .status_for_path(&repo_path(B_TXT)) .unwrap() .status, FileStatus::Untracked, ); assert_eq!( repository .status_for_path(&repo_path(F_TXT)) .unwrap() .status, FileStatus::Untracked, ); }); // Modify a file in the working copy. std::fs::write(work_dir.join(A_TXT), "aa").unwrap(); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); // The worktree detects that the file's git status has changed. repository.read_with(cx, |repository, _| { assert_eq!( repository .status_for_path(&repo_path(A_TXT)) .unwrap() .status, StatusCode::Modified.worktree(), ); }); // Create a commit in the git repository. git_add(A_TXT, &repo); git_add(B_TXT, &repo); git_commit("Committing modified and added", &repo); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); // The worktree detects that the files' git status have changed. repository.read_with(cx, |repository, _cx| { assert_eq!( repository .status_for_path(&repo_path(F_TXT)) .unwrap() .status, FileStatus::Untracked, ); assert_eq!(repository.status_for_path(&repo_path(B_TXT)), None); assert_eq!(repository.status_for_path(&repo_path(A_TXT)), None); }); // Modify files in the working copy and perform git operations on other files. git_reset(0, &repo); git_remove_index(Path::new(B_TXT), &repo); git_stash(&mut repo); std::fs::write(work_dir.join(E_TXT), "eeee").unwrap(); std::fs::write(work_dir.join(BUILD_FILE), "this should be ignored").unwrap(); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); // Check that more complex repo changes are tracked repository.read_with(cx, |repository, _cx| { assert_eq!(repository.status_for_path(&repo_path(A_TXT)), None); assert_eq!( repository .status_for_path(&repo_path(B_TXT)) .unwrap() .status, FileStatus::Untracked, ); assert_eq!( repository .status_for_path(&repo_path(E_TXT)) .unwrap() .status, StatusCode::Modified.worktree(), ); }); std::fs::remove_file(work_dir.join(B_TXT)).unwrap(); std::fs::remove_dir_all(work_dir.join("c")).unwrap(); std::fs::write( work_dir.join(DOTGITIGNORE), [IGNORE_RULE, "f.txt"].join("\n"), ) .unwrap(); git_add(Path::new(DOTGITIGNORE), &repo); git_commit("Committing modified git ignore", &repo); tree.flush_fs_events(cx).await; cx.executor().run_until_parked(); let mut renamed_dir_name = "first_directory/second_directory"; const RENAMED_FILE: &str = "rf.txt"; std::fs::create_dir_all(work_dir.join(renamed_dir_name)).unwrap(); std::fs::write( work_dir.join(renamed_dir_name).join(RENAMED_FILE), "new-contents", ) .unwrap(); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); repository.read_with(cx, |repository, _cx| { assert_eq!( repository .status_for_path(&RepoPath::from_rel_path( &rel_path(renamed_dir_name).join(rel_path(RENAMED_FILE)) )) .unwrap() .status, FileStatus::Untracked, ); }); renamed_dir_name = "new_first_directory/second_directory"; std::fs::rename( work_dir.join("first_directory"), work_dir.join("new_first_directory"), ) .unwrap(); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); repository.read_with(cx, |repository, _cx| { assert_eq!( repository .status_for_path(&RepoPath::from_rel_path( &rel_path(renamed_dir_name).join(rel_path(RENAMED_FILE)) )) .unwrap() .status, FileStatus::Untracked, ); }); } #[gpui::test] #[ignore] async fn test_ignored_dirs_events(cx: &mut gpui::TestAppContext) { init_test(cx); cx.executor().allow_parking(); const IGNORE_RULE: &str = "**/target"; let root = TempTree::new(json!({ "project": { "src": { "main.rs": "fn main() {}" }, "target": { "debug": { "important_text.txt": "important text", }, }, ".gitignore": IGNORE_RULE }, })); let root_path = root.path(); // Set up git repository before creating the worktree. let work_dir = root.path().join("project"); let repo = git_init(work_dir.as_path()); repo.add_ignore_rule(IGNORE_RULE).unwrap(); git_add("src/main.rs", &repo); git_add(".gitignore", &repo); git_commit("Initial commit", &repo); let project = Project::test(Arc::new(RealFs::new(None, cx.executor())), [root_path], cx).await; let repository_updates = Arc::new(Mutex::new(Vec::new())); let project_events = Arc::new(Mutex::new(Vec::new())); project.update(cx, |project, cx| { let repo_events = repository_updates.clone(); cx.subscribe(project.git_store(), move |_, _, e, _| { if let GitStoreEvent::RepositoryUpdated(_, e, _) = e { repo_events.lock().push(e.clone()); } }) .detach(); let project_events = project_events.clone(); cx.subscribe_self(move |_, e, _| { if let Event::WorktreeUpdatedEntries(_, updates) = e { project_events.lock().extend( updates .iter() .map(|(path, _, change)| (path.as_unix_str().to_string(), *change)) .filter(|(path, _)| path != "fs-event-sentinel"), ); } }) .detach(); }); let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; tree.update(cx, |tree, cx| { tree.load_file(rel_path("project/target/debug/important_text.txt"), cx) }) .await .unwrap(); tree.update(cx, |tree, _| { assert_eq!( tree.entries(true, 0) .map(|entry| (entry.path.as_ref(), entry.is_ignored)) .collect::>(), vec![ (rel_path(""), false), (rel_path("project/"), false), (rel_path("project/.gitignore"), false), (rel_path("project/src"), false), (rel_path("project/src/main.rs"), false), (rel_path("project/target"), true), (rel_path("project/target/debug"), true), (rel_path("project/target/debug/important_text.txt"), true), ] ); }); assert_eq!( repository_updates.lock().drain(..).collect::>(), vec![RepositoryEvent::StatusesChanged,], "Initial worktree scan should produce a repo update event" ); assert_eq!( project_events.lock().drain(..).collect::>(), vec![ ("project/target".to_string(), PathChange::Loaded), ("project/target/debug".to_string(), PathChange::Loaded), ( "project/target/debug/important_text.txt".to_string(), PathChange::Loaded ), ], "Initial project changes should show that all not-ignored and all opened files are loaded" ); let deps_dir = work_dir.join("target").join("debug").join("deps"); std::fs::create_dir_all(&deps_dir).unwrap(); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); std::fs::write(deps_dir.join("aa.tmp"), "something tmp").unwrap(); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); std::fs::remove_dir_all(&deps_dir).unwrap(); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); tree.update(cx, |tree, _| { assert_eq!( tree.entries(true, 0) .map(|entry| (entry.path.as_ref(), entry.is_ignored)) .collect::>(), vec![ (rel_path(""), false), (rel_path("project/"), false), (rel_path("project/.gitignore"), false), (rel_path("project/src"), false), (rel_path("project/src/main.rs"), false), (rel_path("project/target"), true), (rel_path("project/target/debug"), true), (rel_path("project/target/debug/important_text.txt"), true), ], "No stray temp files should be left after the flycheck changes" ); }); assert_eq!( repository_updates .lock() .iter() .cloned() .collect::>(), Vec::new(), "No further RepositoryUpdated events should happen, as only ignored dirs' contents was changed", ); assert_eq!( project_events.lock().as_slice(), vec![ ("project/target/debug/deps".to_string(), PathChange::Added), ("project/target/debug/deps".to_string(), PathChange::Removed), ], "Due to `debug` directory being tracked, it should get updates for entries inside it. No updates for more nested directories should happen as those are ignored", ); } // todo(jk): turning this test off until we rework it in such a way so that it is not so susceptible // to different timings/ordering of events. #[ignore] #[gpui::test] async fn test_odd_events_for_ignored_dirs( executor: BackgroundExecutor, cx: &mut gpui::TestAppContext, ) { init_test(cx); let fs = FakeFs::new(executor); fs.insert_tree( path!("/root"), json!({ ".git": {}, ".gitignore": "**/target/", "src": { "main.rs": "fn main() {}", }, "target": { "debug": { "foo.txt": "foo", "deps": {} } } }), ) .await; fs.set_head_and_index_for_repo( path!("/root/.git").as_ref(), &[ (".gitignore", "**/target/".into()), ("src/main.rs", "fn main() {}".into()), ], ); let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let repository_updates = Arc::new(Mutex::new(Vec::new())); let project_events = Arc::new(Mutex::new(Vec::new())); project.update(cx, |project, cx| { let repository_updates = repository_updates.clone(); cx.subscribe(project.git_store(), move |_, _, e, _| { if let GitStoreEvent::RepositoryUpdated(_, e, _) = e { repository_updates.lock().push(e.clone()); } }) .detach(); let project_events = project_events.clone(); cx.subscribe_self(move |_, e, _| { if let Event::WorktreeUpdatedEntries(_, updates) = e { project_events.lock().extend( updates .iter() .map(|(path, _, change)| (path.as_unix_str().to_string(), *change)) .filter(|(path, _)| path != "fs-event-sentinel"), ); } }) .detach(); }); let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.update(cx, |tree, cx| { tree.load_file(rel_path("target/debug/foo.txt"), cx) }) .await .unwrap(); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.run_until_parked(); tree.update(cx, |tree, _| { assert_eq!( tree.entries(true, 0) .map(|entry| (entry.path.as_ref(), entry.is_ignored)) .collect::>(), vec![ (rel_path(""), false), (rel_path(".gitignore"), false), (rel_path("src"), false), (rel_path("src/main.rs"), false), (rel_path("target"), true), (rel_path("target/debug"), true), (rel_path("target/debug/deps"), true), (rel_path("target/debug/foo.txt"), true), ] ); }); assert_eq!( repository_updates.lock().drain(..).collect::>(), vec![ RepositoryEvent::HeadChanged, RepositoryEvent::StatusesChanged, RepositoryEvent::StatusesChanged, ], "Initial worktree scan should produce a repo update event" ); assert_eq!( project_events.lock().drain(..).collect::>(), vec![ ("target".to_string(), PathChange::Loaded), ("target/debug".to_string(), PathChange::Loaded), ("target/debug/deps".to_string(), PathChange::Loaded), ("target/debug/foo.txt".to_string(), PathChange::Loaded), ], "All non-ignored entries and all opened firs should be getting a project event", ); // Emulate a flycheck spawn: it emits a `INODE_META_MOD`-flagged FS event on target/debug/deps, then creates and removes temp files inside. // This may happen multiple times during a single flycheck, but once is enough for testing. fs.emit_fs_event("/root/target/debug/deps", None); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); assert_eq!( repository_updates .lock() .iter() .cloned() .collect::>(), Vec::new(), "No further RepositoryUpdated events should happen, as only ignored dirs received FS events", ); assert_eq!( project_events.lock().as_slice(), Vec::new(), "No further project events should happen, as only ignored dirs received FS events", ); } #[gpui::test] async fn test_repos_in_invisible_worktrees( executor: BackgroundExecutor, cx: &mut gpui::TestAppContext, ) { init_test(cx); let fs = FakeFs::new(executor); fs.insert_tree( path!("/root"), json!({ "dir1": { ".git": {}, "dep1": { ".git": {}, "src": { "a.txt": "", }, }, "b.txt": "", }, }), ) .await; let project = Project::test(fs.clone(), [path!("/root/dir1/dep1").as_ref()], cx).await; let _visible_worktree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; let repos = project.read_with(cx, |project, cx| { project .repositories(cx) .values() .map(|repo| repo.read(cx).work_directory_abs_path.clone()) .collect::>() }); pretty_assertions::assert_eq!(repos, [Path::new(path!("/root/dir1/dep1")).into()]); let (_invisible_worktree, _) = project .update(cx, |project, cx| { project.worktree_store().update(cx, |worktree_store, cx| { worktree_store.find_or_create_worktree(path!("/root/dir1/b.txt"), false, cx) }) }) .await .expect("failed to create worktree"); project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; let repos = project.read_with(cx, |project, cx| { project .repositories(cx) .values() .map(|repo| repo.read(cx).work_directory_abs_path.clone()) .collect::>() }); pretty_assertions::assert_eq!(repos, [Path::new(path!("/root/dir1/dep1")).into()]); } #[gpui::test(iterations = 10)] async fn test_rescan_with_gitignore(cx: &mut gpui::TestAppContext) { init_test(cx); cx.update(|cx| { cx.update_global::(|store, cx| { store.update_user_settings(cx, |settings| { settings.project.worktree.file_scan_exclusions = Some(Vec::new()); }); }); }); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/root"), json!({ ".gitignore": "ancestor-ignored-file1\nancestor-ignored-file2\n", "tree": { ".git": {}, ".gitignore": "ignored-dir\n", "tracked-dir": { "tracked-file1": "", "ancestor-ignored-file1": "", }, "ignored-dir": { "ignored-file1": "" } } }), ) .await; fs.set_head_and_index_for_repo( path!("/root/tree/.git").as_ref(), &[ (".gitignore", "ignored-dir\n".into()), ("tracked-dir/tracked-file1", "".into()), ], ); let project = Project::test(fs.clone(), [path!("/root/tree").as_ref()], cx).await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); let repository = project.read_with(cx, |project, cx| { project.repositories(cx).values().next().unwrap().clone() }); tree.read_with(cx, |tree, _| { tree.as_local() .unwrap() .manually_refresh_entries_for_paths(vec![rel_path("ignored-dir").into()]) }) .recv() .await; cx.read(|cx| { assert_entry_git_state( tree.read(cx), repository.read(cx), "tracked-dir/tracked-file1", None, false, ); assert_entry_git_state( tree.read(cx), repository.read(cx), "tracked-dir/ancestor-ignored-file1", None, false, ); assert_entry_git_state( tree.read(cx), repository.read(cx), "ignored-dir/ignored-file1", None, true, ); }); fs.create_file( path!("/root/tree/tracked-dir/tracked-file2").as_ref(), Default::default(), ) .await .unwrap(); fs.set_index_for_repo( path!("/root/tree/.git").as_ref(), &[ (".gitignore", "ignored-dir\n".into()), ("tracked-dir/tracked-file1", "".into()), ("tracked-dir/tracked-file2", "".into()), ], ); fs.create_file( path!("/root/tree/tracked-dir/ancestor-ignored-file2").as_ref(), Default::default(), ) .await .unwrap(); fs.create_file( path!("/root/tree/ignored-dir/ignored-file2").as_ref(), Default::default(), ) .await .unwrap(); cx.executor().run_until_parked(); cx.read(|cx| { assert_entry_git_state( tree.read(cx), repository.read(cx), "tracked-dir/tracked-file2", Some(StatusCode::Added), false, ); assert_entry_git_state( tree.read(cx), repository.read(cx), "tracked-dir/ancestor-ignored-file2", None, false, ); assert_entry_git_state( tree.read(cx), repository.read(cx), "ignored-dir/ignored-file2", None, true, ); assert!( tree.read(cx) .entry_for_path(&rel_path(".git")) .unwrap() .is_ignored ); }); } #[gpui::test] async fn test_git_worktrees_and_submodules(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/project"), json!({ ".git": { "worktrees": { "some-worktree": { "commondir": "../..\n", // For is_git_dir "HEAD": "", "config": "" } }, "modules": { "subdir": { "some-submodule": { // For is_git_dir "HEAD": "", "config": "", } } } }, "src": { "a.txt": "A", }, "some-worktree": { ".git": "gitdir: ../.git/worktrees/some-worktree\n", "src": { "b.txt": "B", } }, "subdir": { "some-submodule": { ".git": "gitdir: ../../.git/modules/subdir/some-submodule\n", "c.txt": "C", } } }), ) .await; let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; let scan_complete = project.update(cx, |project, cx| project.git_scans_complete(cx)); scan_complete.await; let mut repositories = project.update(cx, |project, cx| { project .repositories(cx) .values() .map(|repo| repo.read(cx).work_directory_abs_path.clone()) .collect::>() }); repositories.sort(); pretty_assertions::assert_eq!( repositories, [ Path::new(path!("/project")).into(), Path::new(path!("/project/some-worktree")).into(), Path::new(path!("/project/subdir/some-submodule")).into(), ] ); // Generate a git-related event for the worktree and check that it's refreshed. fs.with_git_state( path!("/project/some-worktree/.git").as_ref(), true, |state| { state .head_contents .insert(repo_path("src/b.txt"), "b".to_owned()); state .index_contents .insert(repo_path("src/b.txt"), "b".to_owned()); }, ) .unwrap(); cx.run_until_parked(); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/project/some-worktree/src/b.txt"), cx) }) .await .unwrap(); let (worktree_repo, barrier) = project.update(cx, |project, cx| { let (repo, _) = project .git_store() .read(cx) .repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx) .unwrap(); pretty_assertions::assert_eq!( repo.read(cx).work_directory_abs_path, Path::new(path!("/project/some-worktree")).into(), ); pretty_assertions::assert_eq!( repo.read(cx).main_worktree_abs_path(), Some(Path::new(path!("/project"))), ); assert!( repo.read(cx).linked_worktree_path().is_some(), "linked worktree should be detected as a linked worktree" ); let barrier = repo.update(cx, |repo, _| repo.barrier()); (repo.clone(), barrier) }); barrier.await.unwrap(); worktree_repo.update(cx, |repo, _| { pretty_assertions::assert_eq!( repo.status_for_path(&repo_path("src/b.txt")) .unwrap() .status, StatusCode::Modified.worktree(), ); }); // The same for the submodule. fs.with_git_state( path!("/project/subdir/some-submodule/.git").as_ref(), true, |state| { state .head_contents .insert(repo_path("c.txt"), "c".to_owned()); state .index_contents .insert(repo_path("c.txt"), "c".to_owned()); }, ) .unwrap(); cx.run_until_parked(); let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/project/subdir/some-submodule/c.txt"), cx) }) .await .unwrap(); let (submodule_repo, barrier) = project.update(cx, |project, cx| { let (repo, _) = project .git_store() .read(cx) .repository_and_path_for_buffer_id(buffer.read(cx).remote_id(), cx) .unwrap(); pretty_assertions::assert_eq!( repo.read(cx).work_directory_abs_path, Path::new(path!("/project/subdir/some-submodule")).into(), ); pretty_assertions::assert_eq!( repo.read(cx).main_worktree_abs_path(), Some(Path::new(path!("/project/subdir/some-submodule"))), ); assert!( repo.read(cx).linked_worktree_path().is_none(), "submodule should not be detected as a linked worktree" ); let barrier = repo.update(cx, |repo, _| repo.barrier()); (repo.clone(), barrier) }); barrier.await.unwrap(); submodule_repo.update(cx, |repo, _| { pretty_assertions::assert_eq!( repo.status_for_path(&repo_path("c.txt")).unwrap().status, StatusCode::Modified.worktree(), ); }); } #[gpui::test] async fn test_repository_deduplication(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/root"), json!({ "project": { ".git": {}, "child1": { "a.txt": "A", }, "child2": { "b.txt": "B", } } }), ) .await; let project = Project::test( fs.clone(), [ path!("/root/project/child1").as_ref(), path!("/root/project/child2").as_ref(), ], cx, ) .await; let tree = project.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); tree.flush_fs_events(cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); let repos = project.read_with(cx, |project, cx| { project .repositories(cx) .values() .map(|repo| repo.read(cx).work_directory_abs_path.clone()) .collect::>() }); pretty_assertions::assert_eq!(repos, [Path::new(path!("/root/project")).into()]); } #[gpui::test] async fn test_buffer_changed_file_path_updates_git_diff(cx: &mut gpui::TestAppContext) { init_test(cx); let file_1_committed = String::from(r#"file_1_committed"#); let file_1_staged = String::from(r#"file_1_staged"#); let file_2_committed = String::from(r#"file_2_committed"#); let file_2_staged = String::from(r#"file_2_staged"#); let buffer_contents = String::from(r#"buffer"#); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/dir"), json!({ ".git": {}, "src": { "file_1.rs": file_1_committed.clone(), "file_2.rs": file_2_committed.clone(), } }), ) .await; fs.set_head_for_repo( path!("/dir/.git").as_ref(), &[ ("src/file_1.rs", file_1_committed.clone()), ("src/file_2.rs", file_2_committed.clone()), ], "deadbeef", ); fs.set_index_for_repo( path!("/dir/.git").as_ref(), &[ ("src/file_1.rs", file_1_staged.clone()), ("src/file_2.rs", file_2_staged.clone()), ], ); let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/dir/src/file_1.rs"), cx) }) .await .unwrap(); buffer.update(cx, |buffer, cx| { buffer.edit([(0..buffer.len(), buffer_contents.as_str())], None, cx); }); let unstaged_diff = project .update(cx, |project, cx| { project.open_unstaged_diff(buffer.clone(), cx) }) .await .unwrap(); cx.run_until_parked(); unstaged_diff.update(cx, |unstaged_diff, cx| { let base_text = unstaged_diff.base_text_string(cx).unwrap(); assert_eq!(base_text, file_1_staged, "Should start with file_1 staged"); }); // Save the buffer as `file_2.rs`, which should trigger the // `BufferChangedFilePath` event. project .update(cx, |project, cx| { let worktree_id = project.worktrees(cx).next().unwrap().read(cx).id(); let path = ProjectPath { worktree_id, path: rel_path("src/file_2.rs").into(), }; project.save_buffer_as(buffer.clone(), path, cx) }) .await .unwrap(); cx.run_until_parked(); // Verify that the diff bases have been updated to file_2's contents due to // the `BufferChangedFilePath` event being handled. unstaged_diff.update(cx, |unstaged_diff, cx| { let snapshot = buffer.read(cx).snapshot(); let base_text = unstaged_diff.base_text_string(cx).unwrap(); assert_eq!( base_text, file_2_staged, "Diff bases should be automatically updated to file_2 staged content" ); let hunks: Vec<_> = unstaged_diff.snapshot(cx).hunks(&snapshot).collect(); assert!(!hunks.is_empty(), "Should have diff hunks for file_2"); }); let uncommitted_diff = project .update(cx, |project, cx| { project.open_uncommitted_diff(buffer.clone(), cx) }) .await .unwrap(); cx.run_until_parked(); uncommitted_diff.update(cx, |uncommitted_diff, cx| { let base_text = uncommitted_diff.base_text_string(cx).unwrap(); assert_eq!( base_text, file_2_committed, "Uncommitted diff should compare against file_2 committed content" ); }); } async fn search( project: &Entity, query: SearchQuery, cx: &mut gpui::TestAppContext, ) -> Result>>> { let search_rx = project.update(cx, |project, cx| project.search(query, cx)); let mut results = HashMap::default(); while let Ok(search_result) = search_rx.rx.recv().await { match search_result { SearchResult::Buffer { buffer, ranges } => { results.entry(buffer).or_insert(ranges); } SearchResult::LimitReached | SearchResult::WaitingForScan | SearchResult::Searching => { } } } Ok(results .into_iter() .map(|(buffer, ranges)| { buffer.update(cx, |buffer, cx| { let path = buffer .file() .unwrap() .full_path(cx) .to_string_lossy() .to_string(); let ranges = ranges .into_iter() .map(|range| range.to_offset(buffer)) .collect::>(); (path, ranges) }) }) .collect()) } #[gpui::test] async fn test_undo_encoding_change(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); // Create a file with ASCII content "Hi" - this will be detected as UTF-8 // When reinterpreted as UTF-16LE, the bytes 0x48 0x69 become a single character let ascii_bytes: Vec = vec![0x48, 0x69]; fs.insert_tree(path!("/dir"), json!({})).await; fs.insert_file(path!("/dir/test.txt"), ascii_bytes).await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let buffer = project .update(cx, |p, cx| p.open_local_buffer(path!("/dir/test.txt"), cx)) .await .unwrap(); let (initial_encoding, initial_text, initial_dirty) = buffer.read_with(cx, |buffer, _| { (buffer.encoding(), buffer.text(), buffer.is_dirty()) }); assert_eq!(initial_encoding, encoding_rs::UTF_8); assert_eq!(initial_text, "Hi"); assert!(!initial_dirty); let reload_receiver = buffer.update(cx, |buffer, cx| { buffer.reload_with_encoding(encoding_rs::UTF_16LE, cx) }); cx.executor().run_until_parked(); // Wait for reload to complete let _ = reload_receiver.await; // Verify the encoding changed, text is different, and still not dirty (we reloaded from disk) let (reloaded_encoding, reloaded_text, reloaded_dirty) = buffer.read_with(cx, |buffer, _| { (buffer.encoding(), buffer.text(), buffer.is_dirty()) }); assert_eq!(reloaded_encoding, encoding_rs::UTF_16LE); assert_eq!(reloaded_text, "楈"); assert!(!reloaded_dirty); // Undo the reload buffer.update(cx, |buffer, cx| { buffer.undo(cx); }); buffer.read_with(cx, |buffer, _| { assert_eq!(buffer.encoding(), encoding_rs::UTF_8); assert_eq!(buffer.text(), "Hi"); assert!(!buffer.is_dirty()); }); buffer.update(cx, |buffer, cx| { buffer.redo(cx); }); buffer.read_with(cx, |buffer, _| { assert_eq!(buffer.encoding(), encoding_rs::UTF_16LE); assert_ne!(buffer.text(), "Hi"); assert!(!buffer.is_dirty()); }); } #[gpui::test] async fn test_initial_scan_complete(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), json!({ "a": { ".git": {}, ".zed": { "tasks.json": r#"[{"label": "task-a", "command": "echo a"}]"# }, "src": { "main.rs": "" } }, "b": { ".git": {}, ".zed": { "tasks.json": r#"[{"label": "task-b", "command": "echo b"}]"# }, "src": { "lib.rs": "" } }, }), ) .await; let repos_created = Rc::new(RefCell::new(Vec::new())); let _observe = { let repos_created = repos_created.clone(); cx.update(|cx| { cx.observe_new::(move |repo, _, cx| { repos_created.borrow_mut().push(cx.entity().downgrade()); let _ = repo; }) }) }; let project = Project::test( fs.clone(), [path!("/root/a").as_ref(), path!("/root/b").as_ref()], cx, ) .await; let scan_complete = project.read_with(cx, |project, cx| project.wait_for_initial_scan(cx)); scan_complete.await; project.read_with(cx, |project, cx| { assert!( project.worktree_store().read(cx).initial_scan_completed(), "Expected initial scan to be completed after awaiting wait_for_initial_scan" ); }); let created_repos_len = repos_created.borrow().len(); assert_eq!( created_repos_len, 2, "Expected 2 repositories to be created during scan, got {}", created_repos_len ); project.read_with(cx, |project, cx| { let git_store = project.git_store().read(cx); assert_eq!( git_store.repositories().len(), 2, "Expected 2 repositories in GitStore" ); }); } pub fn init_test(cx: &mut gpui::TestAppContext) { zlog::init_test(); cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); release_channel::init(semver::Version::new(0, 0, 0), cx); }); } fn json_lang() -> Arc { Arc::new(Language::new( LanguageConfig { name: "JSON".into(), matcher: LanguageMatcher { path_suffixes: vec!["json".to_string()], ..Default::default() }, ..Default::default() }, None, )) } fn js_lang() -> Arc { Arc::new(Language::new( LanguageConfig { name: "JavaScript".into(), matcher: LanguageMatcher { path_suffixes: vec!["js".to_string()], ..Default::default() }, ..Default::default() }, None, )) } fn python_lang(fs: Arc) -> Arc { struct PythonMootToolchainLister(Arc); #[async_trait] impl ToolchainLister for PythonMootToolchainLister { async fn list( &self, worktree_root: PathBuf, subroot_relative_path: Arc, _: Option>, ) -> ToolchainList { // This lister will always return a path .venv directories within ancestors let ancestors = subroot_relative_path.ancestors().collect::>(); let mut toolchains = vec![]; for ancestor in ancestors { let venv_path = worktree_root.join(ancestor.as_std_path()).join(".venv"); if self.0.is_dir(&venv_path).await { toolchains.push(Toolchain { name: SharedString::new_static("Python Venv"), path: venv_path.to_string_lossy().into_owned().into(), language_name: LanguageName(SharedString::new_static("Python")), as_json: serde_json::Value::Null, }) } } ToolchainList { toolchains, ..Default::default() } } async fn resolve( &self, _: PathBuf, _: Option>, ) -> anyhow::Result { Err(anyhow::anyhow!("Not implemented")) } fn meta(&self) -> ToolchainMetadata { ToolchainMetadata { term: SharedString::new_static("Virtual Environment"), new_toolchain_placeholder: SharedString::new_static( "A path to the python3 executable within a virtual environment, or path to virtual environment itself", ), manifest_name: ManifestName::from(SharedString::new_static("pyproject.toml")), } } fn activation_script( &self, _: &Toolchain, _: ShellKind, _: &gpui::App, ) -> futures::future::BoxFuture<'static, Vec> { Box::pin(async { vec![] }) } } Arc::new( Language::new( LanguageConfig { name: "Python".into(), matcher: LanguageMatcher { path_suffixes: vec!["py".to_string()], ..Default::default() }, ..Default::default() }, None, // We're not testing Python parsing with this language. ) .with_manifest(Some(ManifestName::from(SharedString::new_static( "pyproject.toml", )))) .with_toolchain_lister(Some(Arc::new(PythonMootToolchainLister(fs)))), ) } fn typescript_lang() -> Arc { Arc::new(Language::new( LanguageConfig { name: "TypeScript".into(), matcher: LanguageMatcher { path_suffixes: vec!["ts".to_string()], ..Default::default() }, ..Default::default() }, Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), )) } fn tsx_lang() -> Arc { Arc::new(Language::new( LanguageConfig { name: "tsx".into(), matcher: LanguageMatcher { path_suffixes: vec!["tsx".to_string()], ..Default::default() }, ..Default::default() }, Some(tree_sitter_typescript::LANGUAGE_TSX.into()), )) } fn get_all_tasks( project: &Entity, task_contexts: Arc, cx: &mut App, ) -> Task> { let new_tasks = project.update(cx, |project, cx| { project.task_store().update(cx, |task_store, cx| { task_store.task_inventory().unwrap().update(cx, |this, cx| { this.used_and_current_resolved_tasks(task_contexts, cx) }) }) }); cx.background_spawn(async move { let (mut old, new) = new_tasks.await; old.extend(new); old }) } #[track_caller] fn assert_entry_git_state( tree: &Worktree, repository: &Repository, path: &str, index_status: Option, is_ignored: bool, ) { assert_eq!(tree.abs_path(), repository.work_directory_abs_path); let entry = tree .entry_for_path(&rel_path(path)) .unwrap_or_else(|| panic!("entry {path} not found")); let status = repository .status_for_path(&repo_path(path)) .map(|entry| entry.status); let expected = index_status.map(|index_status| { TrackedStatus { index_status, worktree_status: StatusCode::Unmodified, } .into() }); assert_eq!( status, expected, "expected {path} to have git status: {expected:?}" ); assert_eq!( entry.is_ignored, is_ignored, "expected {path} to have is_ignored: {is_ignored}" ); } #[track_caller] fn git_init(path: &Path) -> git2::Repository { let mut init_opts = RepositoryInitOptions::new(); init_opts.initial_head("main"); git2::Repository::init_opts(path, &init_opts).expect("Failed to initialize git repository") } #[track_caller] fn git_add>(path: P, repo: &git2::Repository) { let path = path.as_ref(); let mut index = repo.index().expect("Failed to get index"); index.add_path(path).expect("Failed to add file"); index.write().expect("Failed to write index"); } #[track_caller] fn git_remove_index(path: &Path, repo: &git2::Repository) { let mut index = repo.index().expect("Failed to get index"); index.remove_path(path).expect("Failed to add file"); index.write().expect("Failed to write index"); } #[track_caller] fn git_commit(msg: &'static str, repo: &git2::Repository) { use git2::Signature; let signature = Signature::now("test", "test@zed.dev").unwrap(); let oid = repo.index().unwrap().write_tree().unwrap(); let tree = repo.find_tree(oid).unwrap(); if let Ok(head) = repo.head() { let parent_obj = head.peel(git2::ObjectType::Commit).unwrap(); let parent_commit = parent_obj.as_commit().unwrap(); repo.commit( Some("HEAD"), &signature, &signature, msg, &tree, &[parent_commit], ) .expect("Failed to commit with parent"); } else { repo.commit(Some("HEAD"), &signature, &signature, msg, &tree, &[]) .expect("Failed to commit"); } } #[cfg(any())] #[track_caller] fn git_cherry_pick(commit: &git2::Commit<'_>, repo: &git2::Repository) { repo.cherrypick(commit, None).expect("Failed to cherrypick"); } #[track_caller] fn git_stash(repo: &mut git2::Repository) { use git2::Signature; let signature = Signature::now("test", "test@zed.dev").unwrap(); repo.stash_save(&signature, "N/A", None) .expect("Failed to stash"); } #[track_caller] fn git_reset(offset: usize, repo: &git2::Repository) { let head = repo.head().expect("Couldn't get repo head"); let object = head.peel(git2::ObjectType::Commit).unwrap(); let commit = object.as_commit().unwrap(); let new_head = commit .parents() .inspect(|parnet| { parnet.message(); }) .nth(offset) .expect("Not enough history"); repo.reset(new_head.as_object(), git2::ResetType::Soft, None) .expect("Could not reset"); } #[cfg(any())] #[track_caller] fn git_branch(name: &str, repo: &git2::Repository) { let head = repo .head() .expect("Couldn't get repo head") .peel_to_commit() .expect("HEAD is not a commit"); repo.branch(name, &head, false).expect("Failed to commit"); } #[cfg(any())] #[track_caller] fn git_checkout(name: &str, repo: &git2::Repository) { repo.set_head(name).expect("Failed to set head"); repo.checkout_head(None).expect("Failed to check out head"); } #[cfg(any())] #[track_caller] fn git_status(repo: &git2::Repository) -> collections::HashMap { repo.statuses(None) .unwrap() .iter() .map(|status| (status.path().unwrap().to_string(), status.status())) .collect() } #[gpui::test] async fn test_find_project_path_abs( background_executor: BackgroundExecutor, cx: &mut gpui::TestAppContext, ) { // find_project_path should work with absolute paths init_test(cx); let fs = FakeFs::new(background_executor); fs.insert_tree( path!("/root"), json!({ "project1": { "file1.txt": "content1", "subdir": { "file2.txt": "content2" } }, "project2": { "file3.txt": "content3" } }), ) .await; let project = Project::test( fs.clone(), [ path!("/root/project1").as_ref(), path!("/root/project2").as_ref(), ], cx, ) .await; // Make sure the worktrees are fully initialized project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.run_until_parked(); let (project1_abs_path, project1_id, project2_abs_path, project2_id) = project.read_with(cx, |project, cx| { let worktrees: Vec<_> = project.worktrees(cx).collect(); let abs_path1 = worktrees[0].read(cx).abs_path().to_path_buf(); let id1 = worktrees[0].read(cx).id(); let abs_path2 = worktrees[1].read(cx).abs_path().to_path_buf(); let id2 = worktrees[1].read(cx).id(); (abs_path1, id1, abs_path2, id2) }); project.update(cx, |project, cx| { let abs_path = project1_abs_path.join("file1.txt"); let found_path = project.find_project_path(abs_path, cx).unwrap(); assert_eq!(found_path.worktree_id, project1_id); assert_eq!(&*found_path.path, rel_path("file1.txt")); let abs_path = project1_abs_path.join("subdir").join("file2.txt"); let found_path = project.find_project_path(abs_path, cx).unwrap(); assert_eq!(found_path.worktree_id, project1_id); assert_eq!(&*found_path.path, rel_path("subdir/file2.txt")); let abs_path = project2_abs_path.join("file3.txt"); let found_path = project.find_project_path(abs_path, cx).unwrap(); assert_eq!(found_path.worktree_id, project2_id); assert_eq!(&*found_path.path, rel_path("file3.txt")); let abs_path = project1_abs_path.join("nonexistent.txt"); let found_path = project.find_project_path(abs_path, cx); assert!( found_path.is_some(), "Should find project path for nonexistent file in worktree" ); // Test with an absolute path outside any worktree let abs_path = Path::new("/some/other/path"); let found_path = project.find_project_path(abs_path, cx); assert!( found_path.is_none(), "Should not find project path for path outside any worktree" ); }); } #[gpui::test] async fn test_git_worktree_remove(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), json!({ "a": { ".git": {}, "src": { "main.rs": "fn main() {}", } }, "b": { ".git": {}, "src": { "main.rs": "fn main() {}", }, "script": { "run.sh": "#!/bin/bash" } } }), ) .await; let project = Project::test( fs.clone(), [ path!("/root/a").as_ref(), path!("/root/b/script").as_ref(), path!("/root/b").as_ref(), ], cx, ) .await; let scan_complete = project.update(cx, |project, cx| project.git_scans_complete(cx)); scan_complete.await; let worktrees = project.update(cx, |project, cx| project.worktrees(cx).collect::>()); assert_eq!(worktrees.len(), 3); let worktree_id_by_abs_path = worktrees .into_iter() .map(|worktree| worktree.read_with(cx, |w, _| (w.abs_path(), w.id()))) .collect::>(); let worktree_id = worktree_id_by_abs_path .get(Path::new(path!("/root/b/script"))) .unwrap(); let repos = project.update(cx, |p, cx| p.git_store().read(cx).repositories().clone()); assert_eq!(repos.len(), 2); project.update(cx, |project, cx| { project.remove_worktree(*worktree_id, cx); }); cx.run_until_parked(); let mut repo_paths = project .update(cx, |p, cx| p.git_store().read(cx).repositories().clone()) .values() .map(|repo| repo.read_with(cx, |r, _| r.work_directory_abs_path.clone())) .collect::>(); repo_paths.sort(); pretty_assertions::assert_eq!( repo_paths, [ Path::new(path!("/root/a")).into(), Path::new(path!("/root/b")).into(), ] ); let active_repo_path = project .read_with(cx, |p, cx| { p.active_repository(cx) .map(|r| r.read(cx).work_directory_abs_path.clone()) }) .unwrap(); assert_eq!(active_repo_path.as_ref(), Path::new(path!("/root/a"))); let worktree_id = worktree_id_by_abs_path .get(Path::new(path!("/root/a"))) .unwrap(); project.update(cx, |project, cx| { project.remove_worktree(*worktree_id, cx); }); cx.run_until_parked(); let active_repo_path = project .read_with(cx, |p, cx| { p.active_repository(cx) .map(|r| r.read(cx).work_directory_abs_path.clone()) }) .unwrap(); assert_eq!(active_repo_path.as_ref(), Path::new(path!("/root/b"))); let worktree_id = worktree_id_by_abs_path .get(Path::new(path!("/root/b"))) .unwrap(); project.update(cx, |project, cx| { project.remove_worktree(*worktree_id, cx); }); cx.run_until_parked(); let active_repo_path = project.read_with(cx, |p, cx| { p.active_repository(cx) .map(|r| r.read(cx).work_directory_abs_path.clone()) }); assert!(active_repo_path.is_none()); } #[gpui::test] async fn test_optimistic_hunks_in_staged_files(cx: &mut gpui::TestAppContext) { use DiffHunkSecondaryStatus::*; init_test(cx); let committed_contents = r#" one two three "# .unindent(); let file_contents = r#" one TWO three "# .unindent(); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/dir"), json!({ ".git": {}, "file.txt": file_contents.clone() }), ) .await; fs.set_head_and_index_for_repo( path!("/dir/.git").as_ref(), &[("file.txt", committed_contents.clone())], ); let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/dir/file.txt"), cx) }) .await .unwrap(); let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); let uncommitted_diff = project .update(cx, |project, cx| { project.open_uncommitted_diff(buffer.clone(), cx) }) .await .unwrap(); // The hunk is initially unstaged. uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, &diff.base_text_string(cx).unwrap(), &[( 1..2, "two\n", "TWO\n", DiffHunkStatus::modified(HasSecondaryHunk), )], ); }); // Get the repository handle. let repo = project.read_with(cx, |project, cx| { project.repositories(cx).values().next().unwrap().clone() }); // Stage the file. let stage_task = repo.update(cx, |repo, cx| { repo.stage_entries(vec![repo_path("file.txt")], cx) }); // Run a few ticks to let the job start and mark hunks as pending, // but don't run_until_parked which would complete the entire operation. for _ in 0..10 { cx.executor().tick(); let [hunk]: [_; 1] = uncommitted_diff .read_with(cx, |diff, cx| { diff.snapshot(cx).hunks(&snapshot).collect::>() }) .try_into() .unwrap(); match hunk.secondary_status { HasSecondaryHunk => {} SecondaryHunkRemovalPending => break, NoSecondaryHunk => panic!("hunk was not optimistically staged"), _ => panic!("unexpected hunk state"), } } uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, &diff.base_text_string(cx).unwrap(), &[( 1..2, "two\n", "TWO\n", DiffHunkStatus::modified(SecondaryHunkRemovalPending), )], ); }); // Let the staging complete. stage_task.await.unwrap(); cx.run_until_parked(); // The hunk is now fully staged. uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, &diff.base_text_string(cx).unwrap(), &[( 1..2, "two\n", "TWO\n", DiffHunkStatus::modified(NoSecondaryHunk), )], ); }); // Simulate a commit by updating HEAD to match the current file contents. // The FakeGitRepository's commit method is a no-op, so we need to manually // update HEAD to simulate the commit completing. fs.set_head_for_repo( path!("/dir/.git").as_ref(), &[("file.txt", file_contents.clone())], "newhead", ); cx.run_until_parked(); // After committing, there are no more hunks. uncommitted_diff.read_with(cx, |diff, cx| { assert_hunks( diff.snapshot(cx).hunks(&snapshot), &snapshot, &diff.base_text_string(cx).unwrap(), &[] as &[(Range, &str, &str, DiffHunkStatus)], ); }); } #[gpui::test] async fn test_read_only_files_setting(cx: &mut gpui::TestAppContext) { init_test(cx); // Configure read_only_files setting cx.update(|cx| { cx.update_global::(|store, cx| { store.update_user_settings(cx, |settings| { settings.project.worktree.read_only_files = Some(vec![ "**/generated/**".to_string(), "**/*.gen.rs".to_string(), ]); }); }); }); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/root"), json!({ "src": { "main.rs": "fn main() {}", "types.gen.rs": "// Generated file", }, "generated": { "schema.rs": "// Auto-generated schema", } }), ) .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; // Open a regular file - should be read-write let regular_buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/root/src/main.rs"), cx) }) .await .unwrap(); regular_buffer.read_with(cx, |buffer, _| { assert!(!buffer.read_only(), "Regular file should not be read-only"); }); // Open a file matching *.gen.rs pattern - should be read-only let gen_buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/root/src/types.gen.rs"), cx) }) .await .unwrap(); gen_buffer.read_with(cx, |buffer, _| { assert!( buffer.read_only(), "File matching *.gen.rs pattern should be read-only" ); }); // Open a file in generated directory - should be read-only let generated_buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/root/generated/schema.rs"), cx) }) .await .unwrap(); generated_buffer.read_with(cx, |buffer, _| { assert!( buffer.read_only(), "File in generated directory should be read-only" ); }); } #[gpui::test] async fn test_read_only_files_empty_setting(cx: &mut gpui::TestAppContext) { init_test(cx); // Explicitly set read_only_files to empty (default behavior) cx.update(|cx| { cx.update_global::(|store, cx| { store.update_user_settings(cx, |settings| { settings.project.worktree.read_only_files = Some(vec![]); }); }); }); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/root"), json!({ "src": { "main.rs": "fn main() {}", }, "generated": { "schema.rs": "// Auto-generated schema", } }), ) .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; // All files should be read-write when read_only_files is empty let main_buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/root/src/main.rs"), cx) }) .await .unwrap(); main_buffer.read_with(cx, |buffer, _| { assert!( !buffer.read_only(), "Files should not be read-only when read_only_files is empty" ); }); let generated_buffer = project .update(cx, |project, cx| { project.open_local_buffer(path!("/root/generated/schema.rs"), cx) }) .await .unwrap(); generated_buffer.read_with(cx, |buffer, _| { assert!( !buffer.read_only(), "Generated files should not be read-only when read_only_files is empty" ); }); } #[gpui::test] async fn test_read_only_files_with_lock_files(cx: &mut gpui::TestAppContext) { init_test(cx); // Configure to make lock files read-only cx.update(|cx| { cx.update_global::(|store, cx| { store.update_user_settings(cx, |settings| { settings.project.worktree.read_only_files = Some(vec![ "**/*.lock".to_string(), "**/package-lock.json".to_string(), ]); }); }); }); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/root"), json!({ "Cargo.lock": "# Lock file", "Cargo.toml": "[package]", "package-lock.json": "{}", "package.json": "{}", }), ) .await; let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; // Cargo.lock should be read-only let cargo_lock = project .update(cx, |project, cx| { project.open_local_buffer(path!("/root/Cargo.lock"), cx) }) .await .unwrap(); cargo_lock.read_with(cx, |buffer, _| { assert!(buffer.read_only(), "Cargo.lock should be read-only"); }); // Cargo.toml should be read-write let cargo_toml = project .update(cx, |project, cx| { project.open_local_buffer(path!("/root/Cargo.toml"), cx) }) .await .unwrap(); cargo_toml.read_with(cx, |buffer, _| { assert!(!buffer.read_only(), "Cargo.toml should not be read-only"); }); // package-lock.json should be read-only let package_lock = project .update(cx, |project, cx| { project.open_local_buffer(path!("/root/package-lock.json"), cx) }) .await .unwrap(); package_lock.read_with(cx, |buffer, _| { assert!(buffer.read_only(), "package-lock.json should be read-only"); }); // package.json should be read-write let package_json = project .update(cx, |project, cx| { project.open_local_buffer(path!("/root/package.json"), cx) }) .await .unwrap(); package_json.read_with(cx, |buffer, _| { assert!(!buffer.read_only(), "package.json should not be read-only"); }); } mod disable_ai_settings_tests { use gpui::TestAppContext; use project::*; use settings::{Settings, SettingsStore}; #[gpui::test] async fn test_disable_ai_settings_security(cx: &mut TestAppContext) { cx.update(|cx| { settings::init(cx); // Test 1: Default is false (AI enabled) assert!( !DisableAiSettings::get_global(cx).disable_ai, "Default should allow AI" ); }); let disable_true = serde_json::json!({ "disable_ai": true }) .to_string(); let disable_false = serde_json::json!({ "disable_ai": false }) .to_string(); cx.update_global::(|store, cx| { store.set_user_settings(&disable_false, cx).unwrap(); store.set_global_settings(&disable_true, cx).unwrap(); }); cx.update(|cx| { assert!( DisableAiSettings::get_global(cx).disable_ai, "Local false cannot override global true" ); }); cx.update_global::(|store, cx| { store.set_global_settings(&disable_false, cx).unwrap(); store.set_user_settings(&disable_true, cx).unwrap(); }); cx.update(|cx| { assert!( DisableAiSettings::get_global(cx).disable_ai, "Local false cannot override global true" ); }); } #[gpui::test] async fn test_disable_ai_project_level_settings(cx: &mut TestAppContext) { use settings::{LocalSettingsKind, LocalSettingsPath, SettingsLocation, SettingsStore}; use worktree::WorktreeId; cx.update(|cx| { settings::init(cx); // Default should allow AI assert!( !DisableAiSettings::get_global(cx).disable_ai, "Default should allow AI" ); }); let worktree_id = WorktreeId::from_usize(1); let rel_path = |path: &str| -> std::sync::Arc { std::sync::Arc::from(util::rel_path::RelPath::unix(path).unwrap()) }; let project_path = rel_path("project"); let settings_location = SettingsLocation { worktree_id, path: project_path.as_ref(), }; // Test: Project-level disable_ai=true should disable AI for files in that project cx.update_global::(|store, cx| { store .set_local_settings( worktree_id, LocalSettingsPath::InWorktree(project_path.clone()), LocalSettingsKind::Settings, Some(r#"{ "disable_ai": true }"#), cx, ) .unwrap(); }); cx.update(|cx| { let settings = DisableAiSettings::get(Some(settings_location), cx); assert!( settings.disable_ai, "Project-level disable_ai=true should disable AI for files in that project" ); // Global should now also be true since project-level disable_ai is merged into global assert!( DisableAiSettings::get_global(cx).disable_ai, "Global setting should be affected by project-level disable_ai=true" ); }); // Test: Setting project-level to false should allow AI for that project cx.update_global::(|store, cx| { store .set_local_settings( worktree_id, LocalSettingsPath::InWorktree(project_path.clone()), LocalSettingsKind::Settings, Some(r#"{ "disable_ai": false }"#), cx, ) .unwrap(); }); cx.update(|cx| { let settings = DisableAiSettings::get(Some(settings_location), cx); assert!( !settings.disable_ai, "Project-level disable_ai=false should allow AI" ); // Global should also be false now assert!( !DisableAiSettings::get_global(cx).disable_ai, "Global setting should be false when project-level is false" ); }); // Test: User-level true + project-level false = AI disabled (saturation) let disable_true = serde_json::json!({ "disable_ai": true }).to_string(); cx.update_global::(|store, cx| { store.set_user_settings(&disable_true, cx).unwrap(); store .set_local_settings( worktree_id, LocalSettingsPath::InWorktree(project_path.clone()), LocalSettingsKind::Settings, Some(r#"{ "disable_ai": false }"#), cx, ) .unwrap(); }); cx.update(|cx| { let settings = DisableAiSettings::get(Some(settings_location), cx); assert!( settings.disable_ai, "Project-level false cannot override user-level true (SaturatingBool)" ); }); } }