#![allow(clippy::format_collect)] mod agent_registry_store; mod bookmark_store; mod color_extractor; mod context_server_store; mod debugger; 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::{ BufferDiff, DiffHunkSecondaryStatus, DiffHunkStatus, DiffHunkStatusKind, assert_hunks, }; use collections::{BTreeSet, HashMap, HashSet}; use encoding_rs; use fs::{FakeFs, PathEventKind, RealFs}; use futures::{StreamExt, future}; use git::{ GitHostingProviderRegistry, repository::{RepoPath, repo_path}, status::{DiffStat, FileStatus, StatusCode, TrackedStatus}, }; 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(target_os = "linux")] use settings::{LocalSettingsKind, LocalSettingsPath}; #[cfg(not(windows))] use std::os; use std::{ cell::RefCell, env, mem, num::NonZeroU32, ops::Range, path::{Path, PathBuf}, process::Command, 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::{ RandomCharIter, 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}", }, "e": { ".editorconfig": r#" [*.rs] indent_size = 5 indent_style = space max_line_length = "#, "e.rs": "fn e() {\n E\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_e = settings_for("e/e.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)); // Non-empty values in e/ are parsed and applied as usual. assert_eq!(Some(settings_e.tab_size), NonZeroU32::new(5)); assert_eq!(settings_e.hard_tabs, false); // An empty value opts out of the inherited `max_line_length = 120`, // falling back to .zed/settings.json instead of rejecting the whole file. assert_eq!(settings_e.preferred_line_length, 64); // "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(), true, 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_dynamic_semantic_tokens_registration(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/the-root"), json!({ "a.rs": "fn main() {}", }), ) .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 fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { name: "the-language-server", // Crucially, no `semantic_tokens_provider` is advertised statically; the // server only offers it through dynamic registration (as Roslyn does). ..Default::default() }, ); let _buffer = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/the-root/a.rs"), cx) }) .await .unwrap(); let fake_server = fake_servers.next().await.unwrap(); let server_id = fake_server.server.server_id(); cx.executor().run_until_parked(); let semantic_tokens_provider = |cx: &mut gpui::TestAppContext| { project.read_with(cx, |project, cx| { project .lsp_store() .read(cx) .lsp_server_capabilities .get(&server_id) .and_then(|capabilities| capabilities.semantic_tokens_provider.clone()) }) }; assert!( semantic_tokens_provider(cx).is_none(), "server should not advertise semantic tokens before dynamic registration" ); fake_server .request::( lsp::RegistrationParams { registrations: vec![lsp::Registration { id: "semantic-tokens".to_string(), method: "textDocument/semanticTokens".to_string(), register_options: serde_json::to_value( lsp::SemanticTokensRegistrationOptions { text_document_registration_options: lsp::TextDocumentRegistrationOptions { document_selector: None, }, semantic_tokens_options: lsp::SemanticTokensOptions { legend: lsp::SemanticTokensLegend { token_types: vec!["keyword".into(), "variable".into()], token_modifiers: vec![], }, full: Some(lsp::SemanticTokensFullOptions::Bool(true)), ..Default::default() }, static_registration_options: lsp::StaticRegistrationOptions { id: None, }, }, ) .ok(), }], }, DEFAULT_LSP_REQUEST_TIMEOUT, ) .await .into_response() .unwrap(); cx.executor().run_until_parked(); let provider = semantic_tokens_provider(cx) .expect("semantic tokens provider should be set after dynamic registration"); // The capability round-trips through capability-sync serialization, which may // normalize the registration options into plain options; either shape is fine // as long as the legend survives. let legend = match provider { lsp::SemanticTokensServerCapabilities::SemanticTokensOptions(options) => options.legend, lsp::SemanticTokensServerCapabilities::SemanticTokensRegistrationOptions(options) => { options.semantic_tokens_options.legend } }; assert_eq!( legend.token_types, vec!["keyword".into(), "variable".into()], ); fake_server .request::( lsp::UnregistrationParams { unregisterations: vec![lsp::Unregistration { id: "semantic-tokens".to_string(), method: "textDocument/semanticTokens".to_string(), }], }, DEFAULT_LSP_REQUEST_TIMEOUT, ) .await .into_response() .unwrap(); cx.executor().run_until_parked(); assert!( semantic_tokens_provider(cx).is_none(), "semantic tokens provider should be cleared after unregistration" ); } #[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_multiple_did_change_watched_files_registrations(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), json!({ "src": { "a.rs": "", "b.rs": "", }, "docs": { "readme.md": "", }, }), ) .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 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!("/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())); // Register two separate watched file registrations. fake_server .request::( lsp::RegistrationParams { registrations: vec![lsp::Registration { id: "reg-1".to_string(), method: "workspace/didChangeWatchedFiles".to_string(), register_options: serde_json::to_value( lsp::DidChangeWatchedFilesRegistrationOptions { watchers: vec![lsp::FileSystemWatcher { glob_pattern: lsp::GlobPattern::String( path!("/root/src/*.rs").to_string(), ), kind: None, }], }, ) .ok(), }], }, DEFAULT_LSP_REQUEST_TIMEOUT, ) .await .into_response() .unwrap(); fake_server .request::( lsp::RegistrationParams { registrations: vec![lsp::Registration { id: "reg-2".to_string(), method: "workspace/didChangeWatchedFiles".to_string(), register_options: serde_json::to_value( lsp::DidChangeWatchedFilesRegistrationOptions { watchers: vec![lsp::FileSystemWatcher { glob_pattern: lsp::GlobPattern::String( path!("/root/docs/*.md").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(); // Both registrations should match their respective patterns. fs.create_file(path!("/root/src/c.rs").as_ref(), Default::default()) .await .unwrap(); fs.create_file(path!("/root/docs/guide.md").as_ref(), Default::default()) .await .unwrap(); cx.executor().run_until_parked(); assert_eq!( &*file_changes.lock(), &[ lsp::FileEvent { uri: lsp::Uri::from_file_path(path!("/root/docs/guide.md")).unwrap(), typ: lsp::FileChangeType::CREATED, }, lsp::FileEvent { uri: lsp::Uri::from_file_path(path!("/root/src/c.rs")).unwrap(), typ: lsp::FileChangeType::CREATED, }, ] ); file_changes.lock().clear(); // Unregister the first registration. fake_server .request::( lsp::UnregistrationParams { unregisterations: vec![lsp::Unregistration { id: "reg-1".to_string(), method: "workspace/didChangeWatchedFiles".to_string(), }], }, DEFAULT_LSP_REQUEST_TIMEOUT, ) .await .into_response() .unwrap(); cx.executor().run_until_parked(); // Only the second registration should still match. fs.create_file(path!("/root/src/d.rs").as_ref(), Default::default()) .await .unwrap(); fs.create_file(path!("/root/docs/notes.md").as_ref(), Default::default()) .await .unwrap(); cx.executor().run_until_parked(); assert_eq!( &*file_changes.lock(), &[lsp::FileEvent { uri: lsp::Uri::from_file_path(path!("/root/docs/notes.md")).unwrap(), typ: lsp::FileChangeType::CREATED, }] ); } #[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(), true, 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(), true, 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(), true, 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] async fn test_updating_lsp_settings_sends_one_did_change_configuration( 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()); let mut fake_rust_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { name: "rust-lsp", ..Default::default() }, ); language_registry.add(rust_lang()); let _rs_buffer = project .update(cx, |project, cx| { project.open_local_buffer_with_lsp(path!("/dir/a.rs"), cx) }) .await .unwrap(); let fake_rust_server = fake_rust_servers.next().await.unwrap(); let did_change_count = Arc::new(atomic::AtomicUsize::new(0)); fake_rust_server.handle_notification::({ let did_change_count = did_change_count.clone(); move |_, _| { did_change_count.fetch_add(1, atomic::Ordering::SeqCst); } }); cx.executor().run_until_parked(); did_change_count.store(0, atomic::Ordering::SeqCst); cx.update(|cx| { SettingsStore::update_global(cx, |settings, cx| { settings.update_user_settings(cx, |settings| { settings.project.lsp.0.insert( "rust-lsp".into(), settings::LspSettings { settings: Some(json!({ "foo": true })), ..Default::default() }, ); }); }) }); cx.executor().run_until_parked(); assert_eq!( did_change_count.load(atomic::Ordering::SeqCst), 1, "expected exactly one workspace/didChangeConfiguration after a settings change" ); } #[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(), true, 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_diagnostic_summaries_cleared_on_buffer_close_without_workspace_diagnostics( 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!("/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 { diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( lsp::DiagnosticOptions { identifier: Some("test-close-no-ws".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| { fake_server.set_request_handler::( move |_, _| async move { Ok(lsp::DocumentDiagnosticReportResult::Report( lsp::DocumentDiagnosticReport::Full( lsp::RelatedFullDocumentDiagnosticReport { related_documents: None, full_document_diagnostic_report: lsp::FullDocumentDiagnosticReport { result_id: None, items: vec![lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(0, 0), lsp::Position::new(0, 3), ), severity: Some(lsp::DiagnosticSeverity::ERROR), message: "pulled error no-ws".to_string(), ..Default::default() }], }, }, ), )) }, ); })), ..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(); let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); lsp_store.update(cx, |lsp_store, cx| { lsp_store .pull_diagnostics_for_buffer(buffer.clone(), cx) .detach(); }); cx.executor().run_until_parked(); project.update(cx, |project, cx| { assert_eq!( project.diagnostic_summary(false, cx), DiagnosticSummary { error_count: 1, warning_count: 0, } ); }); cx.update(|_| { drop(buffer); drop(handle); }); cx.executor().run_until_parked(); 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_retained_on_buffer_close_with_workspace_diagnostics( 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!("/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 { diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( lsp::DiagnosticOptions { identifier: Some("test-close-ws".to_string()), inter_file_dependencies: true, workspace_diagnostics: true, work_done_progress_options: Default::default(), }, )), ..lsp::ServerCapabilities::default() }, initializer: Some(Box::new(move |fake_server| { fake_server.set_request_handler::( move |_, _| async move { Ok(lsp::DocumentDiagnosticReportResult::Report( lsp::DocumentDiagnosticReport::Full( lsp::RelatedFullDocumentDiagnosticReport { related_documents: None, full_document_diagnostic_report: lsp::FullDocumentDiagnosticReport { result_id: None, items: vec![lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(0, 0), lsp::Position::new(0, 3), ), severity: Some(lsp::DiagnosticSeverity::ERROR), message: "pulled error ws".to_string(), ..Default::default() }], }, }, ), )) }, ); fake_server.set_request_handler::( move |_, _| async move { Ok(lsp::WorkspaceDiagnosticReportResult::Report( lsp::WorkspaceDiagnosticReport { 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(); let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); lsp_store.update(cx, |lsp_store, cx| { lsp_store .pull_diagnostics_for_buffer(buffer.clone(), cx) .detach(); }); cx.executor().run_until_parked(); project.update(cx, |project, cx| { assert_eq!( project.diagnostic_summary(false, cx), DiagnosticSummary { error_count: 1, warning_count: 0, } ); }); cx.update(|_| { drop(buffer); drop(handle); }); cx.executor().run_until_parked(); project.update(cx, |project, cx| { assert_eq!( project.diagnostic_summary(false, cx), DiagnosticSummary { error_count: 1, warning_count: 0, } ); }); } #[gpui::test] async fn test_workspace_diagnostics_pull_timeout_releases_waiters(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!("/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 { diagnostic_provider: Some(lsp::DiagnosticServerCapabilities::Options( lsp::DiagnosticOptions { identifier: Some("test-ws-timeout".to_string()), inter_file_dependencies: true, workspace_diagnostics: true, work_done_progress_options: Default::default(), }, )), ..lsp::ServerCapabilities::default() }, initializer: Some(Box::new(move |fake_server| { // Simulate a server that holds workspace diagnostic pull requests // open forever without responding or streaming partial results. fake_server.set_request_handler::( move |_, _| async move { future::pending::<()>().await; Err(anyhow::anyhow!("should never respond")) }, ); })), ..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(); let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); let pull_task = lsp_store.update(cx, |lsp_store, cx| { lsp_store.pull_workspace_diagnostics_once(cx) }); // The waiter must be released after the request times out, instead of // being held through every retry of the background refresh loop. cx.executor().advance_clock(DEFAULT_LSP_REQUEST_TIMEOUT * 2); let refreshed = pull_task.await; assert!( !refreshed, "pull should report a failed refresh when the server never responds" ); } #[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 { source: language::BufferEditSource::User }, 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 { source: language::BufferEditSource::User }, language::BufferEvent::DirtyChanged, language::BufferEvent::Edited { source: language::BufferEditSource::User }, ], ); 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 { source: language::BufferEditSource::User }, 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 { source: language::BufferEditSource::User }, 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 { source: language::BufferEditSource::User }, 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;" ); } // Regression test for https://github.com/zed-industries/zed/issues/59077: // a "rename symbol" whose workspace edit also renames the file used to swap the // two files' contents. The edited content must end up in the renamed file. #[gpui::test] async fn test_rename_that_also_renames_file(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;", }), ) .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.perform_rename(buffer.clone(), 7, "THREE".to_string(), cx) }); fake_server .set_request_handler::(|_params, _| async move { Ok(Some(lsp::WorkspaceEdit { changes: None, document_changes: Some(DocumentChanges::Operations(vec![ lsp::DocumentChangeOperation::Edit(lsp::TextDocumentEdit { text_document: lsp::OptionalVersionedTextDocumentIdentifier { uri: Uri::from_file_path(path!("/dir/one.rs")).unwrap(), version: None, }, edits: vec![lsp::Edit::Plain(lsp::TextEdit::new( lsp::Range::new(lsp::Position::new(0, 6), lsp::Position::new(0, 9)), "THREE".to_string(), ))], }), lsp::DocumentChangeOperation::Op(lsp::ResourceOp::Rename(lsp::RenameFile { old_uri: Uri::from_file_path(path!("/dir/one.rs")).unwrap(), new_uri: Uri::from_file_path(path!("/dir/three.rs")).unwrap(), options: None, annotation_id: None, })), ])), change_annotations: None, })) }) .next() .await .unwrap(); response.await.unwrap(); cx.executor().run_until_parked(); // The renamed file must contain the edited content, not the stale pre-edit // content, and the old file must be gone (no content swap). assert_eq!( fs.load(Path::new(path!("/dir/three.rs"))).await.unwrap(), "const THREE: usize = 1;" ); assert!( fs.load(Path::new(path!("/dir/one.rs"))).await.is_err(), "old file should not exist after the rename" ); // The buffer should follow the rename to the new path, hold the edited // content, and be saved (so it can't be written back to the old path). buffer.read_with(cx, |buffer, _cx| { assert_eq!(buffer.text(), "const THREE: usize = 1;"); assert_eq!( buffer.file().unwrap().path().as_ref(), rel_path("three.rs"), "buffer should be associated with the renamed file" ); assert!( !buffer.is_dirty(), "buffer should be saved after the rename" ); }); } #[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_multiline_crlf(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ "crlf.rs": "alpha\r\nbeta\r\ngamma", "lf.rs": "alpha\nbeta\ngamma", }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; // A query with CRLF line endings should match buffers regardless of their // on-disk line endings (buffers always store normalized `\n`). assert_eq!( search( &project, SearchQuery::text( "alpha\r\nbeta", false, true, false, Default::default(), Default::default(), false, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([ (path!("dir/crlf.rs").to_string(), vec![0..10]), (path!("dir/lf.rs").to_string(), vec![0..10]), ]) ); // The reverse: a query with LF line endings should match a file that is // stored with CRLF line endings on disk. assert_eq!( search( &project, SearchQuery::text( "alpha\nbeta", false, true, false, Default::default(), Default::default(), false, None, ) .unwrap(), cx ) .await .unwrap(), HashMap::from_iter([ (path!("dir/crlf.rs").to_string(), vec![0..10]), (path!("dir/lf.rs").to_string(), vec![0..10]), ]) ); } #[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_visibility_for_paths_in_gitignored_dirs(cx: &mut gpui::TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/dir"), json!({ ".git": {}, ".gitignore": ".checkouts/\n", "src": { "main.rs": "fn main() {}" }, ".checkouts": { "worktrees": { "foo": { "README.md": "hello" } } }, }), ) .await; let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; cx.run_until_parked(); project.read_with(cx, |project, cx| { let root = PathBuf::from(path!("/dir")); let file = PathBuf::from(path!("/dir/src/main.rs")); let ignored_dir = PathBuf::from(path!("/dir/.checkouts/worktrees/foo")); let file_in_ignored_dir = PathBuf::from(path!("/dir/.checkouts/worktrees/foo/README.md")); assert_eq!( project.visibility_for_paths(std::slice::from_ref(&root), true, cx), Some(true) ); assert_eq!( project.visibility_for_paths(std::slice::from_ref(&file), true, cx), Some(true) ); assert_eq!( project.visibility_for_subpaths(std::slice::from_ref(&file), cx), Some(true) ); // Paths inside gitignored directories aren't scanned, so they aren't // considered contained by the project; opening one should behave like // opening an unrelated path. for path in [&ignored_dir, &file_in_ignored_dir] { assert_eq!( project.visibility_for_paths(std::slice::from_ref(path), true, cx), None, "{path:?} should not be considered contained" ); assert_eq!( project.visibility_for_paths(std::slice::from_ref(path), false, cx), None, "{path:?} should not be considered contained" ); assert_eq!( project.visibility_for_subpaths(std::slice::from_ref(path), cx), None, "{path:?} should not be considered a contained subpath" ); } }); } #[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_reopening_unstaged_diff_after_drop(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 buffer_id = buffer.read_with(cx, |buffer, _| buffer.remote_id()); let unstaged_diff = project .update(cx, |project, cx| { project.open_unstaged_diff(buffer.clone(), cx) }) .await .unwrap(); cx.run_until_parked(); // Drop the diff while the buffer (and its git state) stays alive. drop(unstaged_diff); cx.run_until_parked(); project.read_with(cx, |project, cx| { assert!( project .git_store() .read(cx) .get_unstaged_diff(buffer_id, cx) .is_none(), "unstaged diff should have been released" ); }); // Reopen the diff. The new entity must be registered in the git store, // and its hunks must be recalculated. let unstaged_diff = project .update(cx, |project, cx| { project.open_unstaged_diff(buffer.clone(), cx) }) .await .unwrap(); cx.run_until_parked(); project.read_with(cx, |project, cx| { let registered = project .git_store() .read(cx) .get_unstaged_diff(buffer_id, cx); assert_eq!( registered.as_ref(), Some(&unstaged_diff), "reopened unstaged diff should be registered in the git store" ); }); 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(), ), ], ); }); } #[gpui::test] async fn test_staged_diff_for_buffer(cx: &mut gpui::TestAppContext) { init_test(cx); let committed_contents = r#" fn main() { println!("hello world"); } "# .unindent(); let staged_contents = r#" // print goodbye fn main() { println!("goodbye world"); } "# .unindent(); let file_contents = r#" // print goodbye fn main() { println!("working copy only"); } "# .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)], "deadbeef", ); 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 language_registry = project.read_with(cx, |project, _| project.languages().clone()); let language = rust_lang(); language_registry.add(language.clone()); 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.read_with(cx, |diff, cx| { assert_eq!( diff.base_text(cx).language().cloned(), Some(language.clone()) ); }); let (staged_diff, index_text_buffer) = project .update(cx, |project, cx| { project.open_staged_diff(buffer.clone(), cx) }) .await .unwrap(); cx.run_until_parked(); index_text_buffer.read_with(cx, |buffer, cx| { let file = buffer.file().unwrap(); assert_eq!(file.file_name(cx), "main.rs"); assert_eq!( file.disk_state(), DiskState::Historic { was_deleted: false } ); }); unstaged_diff.read_with(cx, |diff, cx| { assert_eq!( diff.base_text(cx).language().cloned(), Some(language.clone()) ); }); staged_diff.update(cx, |staged_diff, cx| { let snapshot = staged_diff.snapshot(cx); let buffer_snapshot = snapshot.buffer_snapshot(); assert_hunks( snapshot.hunks(buffer_snapshot), buffer_snapshot, &staged_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(); staged_diff.update(cx, |staged_diff, cx| { let snapshot = staged_diff.snapshot(cx); let buffer_snapshot = snapshot.buffer_snapshot(); assert_hunks( snapshot.hunks(buffer_snapshot), buffer_snapshot, &staged_diff.base_text_string(cx).unwrap(), &[ (0..1, "", "// print goodbye\n", DiffHunkStatus::added_none()), ( 2..2, " println!(\"hello world\");\n", "", DiffHunkStatus::deleted_none(), ), ], ); }); } #[gpui::test] async fn test_base_text_buffers_released_when_diffs_dropped(cx: &mut gpui::TestAppContext) { init_test(cx); let committed_contents = "one\ntwo\nthree\n"; let staged_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": {}, "src": { "main.rs": file_contents, } }), ) .await; fs.set_head_for_repo( Path::new("/dir/.git"), &[("src/main.rs", committed_contents.to_owned())], "deadbeef", ); fs.set_index_for_repo( Path::new("/dir/.git"), &[("src/main.rs", staged_contents.to_owned())], ); 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 uncommitted_diff = project .update(cx, |project, cx| { project.open_uncommitted_diff(buffer.clone(), cx) }) .await .unwrap(); let unstaged_diff = project .update(cx, |project, cx| { project.open_unstaged_diff(buffer.clone(), cx) }) .await .unwrap(); cx.run_until_parked(); let weak_head_text_buffer = uncommitted_diff.read_with(cx, |diff, _| diff.base_text_buffer().downgrade()); let weak_index_text_buffer = unstaged_diff.read_with(cx, |diff, _| diff.base_text_buffer().downgrade()); drop(uncommitted_diff); cx.run_until_parked(); cx.update(|_| {}); weak_head_text_buffer.assert_released(); assert!( weak_index_text_buffer.upgrade().is_some(), "index text buffer should stay alive while the unstaged diff is open" ); drop(unstaged_diff); cx.run_until_parked(); cx.update(|_| {}); weak_index_text_buffer.assert_released(); 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| { assert_eq!( uncommitted_diff.base_text_string(cx).as_deref(), Some(committed_contents), ); let snapshot = buffer.read(cx).snapshot(); assert_hunks( uncommitted_diff.snapshot(cx).hunks(&snapshot), &snapshot, &uncommitted_diff.base_text_string(cx).unwrap(), &[( 1..3, "two\nthree\n", "TWO\nTHREE\n", DiffHunkStatus::modified(DiffHunkSecondaryStatus::OverlapsWithSecondaryHunk), )], ); }); } #[gpui::test] async fn test_staged_diff_without_unstaged_diff(cx: &mut gpui::TestAppContext) { init_test(cx); let committed_contents = "one\ntwo\nthree\n"; let staged_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": {}, "src": { "main.rs": file_contents, } }), ) .await; fs.set_head_for_repo( Path::new("/dir/.git"), &[("src/main.rs", committed_contents.to_owned())], "deadbeef", ); fs.set_index_for_repo( Path::new("/dir/.git"), &[("src/main.rs", staged_contents.to_owned())], ); 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 (staged_diff, _) = project .update(cx, |project, cx| { project.open_staged_diff(buffer.clone(), cx) }) .await .unwrap(); cx.run_until_parked(); staged_diff.update(cx, |staged_diff, cx| { let snapshot = staged_diff.snapshot(cx); let buffer_snapshot = snapshot.buffer_snapshot(); assert_hunks( snapshot.hunks(buffer_snapshot), buffer_snapshot, &staged_diff.base_text_string(cx).unwrap(), &[(1..2, "two\n", "TWO\n", DiffHunkStatus::modified_none())], ); }); fs.set_index_for_repo( Path::new("/dir/.git"), &[("src/main.rs", "one\nTWO\nTHREE\n".to_owned())], ); cx.run_until_parked(); staged_diff.update(cx, |staged_diff, cx| { let snapshot = staged_diff.snapshot(cx); let buffer_snapshot = snapshot.buffer_snapshot(); assert_hunks( snapshot.hunks(buffer_snapshot), buffer_snapshot, &staged_diff.base_text_string(cx).unwrap(), &[( 1..3, "two\nthree\n", "TWO\nTHREE\n", DiffHunkStatus::modified_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(); // 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. let range = snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_before(Point::new(2, 0)); project .update(cx, |project, cx| { let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx) }) .unwrap(); 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(SecondaryHunkRemovalPending), ), ( 3..4, "four\n", "FOUR\n", DiffHunkStatus::modified(HasSecondaryHunk), ), ], ); }); // When the write to the index completes, it appears as staged. cx.run_until_parked(); 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(NoSecondaryHunk), ), ( 3..4, "four\n", "FOUR\n", DiffHunkStatus::modified(HasSecondaryHunk), ), ], ); }); // 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. let range = snapshot.anchor_before(Point::new(3, 0))..snapshot.anchor_before(Point::new(4, 0)); project .update(cx, |project, cx| { let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx) }) .unwrap(); 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(NoSecondaryHunk), ), ( 3..4, "four\n", "FOUR\n", DiffHunkStatus::modified(SecondaryHunkRemovalPending), ), ], ); }); // When the write fails, the hunk returns to being unstaged. cx.run_until_parked(); 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(NoSecondaryHunk), ), ( 3..4, "four\n", "FOUR\n", DiffHunkStatus::modified(HasSecondaryHunk), ), ], ); }); // 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. let (range0, range2) = uncommitted_diff.read_with(cx, |diff, cx| { let hunks = diff.snapshot(cx).hunks(&snapshot).collect::>(); (hunks[0].buffer_range.clone(), hunks[2].buffer_range.clone()) }); project .update(cx, |project, cx| { let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); project.stage_hunks(buffer.clone(), unstaged_diff, vec![range0], cx) }) .unwrap(); project .update(cx, |project, cx| { let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); project.stage_hunks(buffer.clone(), unstaged_diff, vec![range2], cx) }) .unwrap(); // Both staged hunks appear as pending. 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(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.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(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, _| { assert_eq!( diff.secondary_diff(), Some(unstaged_diff.clone()), "the unstaged diff returned to callers should be the uncommitted diff's secondary" ); }); project.read_with(cx, |project, cx| { let buffer_id = buffer.read(cx).remote_id(); assert_eq!( project .git_store() .read(cx) .get_unstaged_diff(buffer_id, cx), Some(unstaged_diff.clone()), "the unstaged diff returned to callers should be the registered one" ); }); 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. let range = uncommitted_diff.read_with(cx, |diff, cx| { diff.snapshot(cx) .hunks(&snapshot) .next() .unwrap() .buffer_range }); project .update(cx, |project, cx| { let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx) }) .unwrap(); 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(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(); let range = uncommitted_diff.read_with(cx, |diff, cx| { diff.snapshot(cx) .hunks(&snapshot) .nth(1) .unwrap() .buffer_range }); project .update(cx, |project, cx| { let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx) }) .unwrap(); 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(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. let range = uncommitted_diff.read_with(cx, |diff, cx| { diff.snapshot(cx) .hunks(&snapshot) .nth(2) .unwrap() .buffer_range }); project .update(cx, |project, cx| { let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx) }) .unwrap(); // 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.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(NoSecondaryHunk)), ( 1..2, "two\n", "TWO\n", DiffHunkStatus::modified(NoSecondaryHunk), ), ( 3..4, "four\n", "FOUR\n", DiffHunkStatus::modified(NoSecondaryHunk), ), ], ); }); } #[gpui::test] async fn test_restaging_hunk_after_optimistic_unstage(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; // The hunk starts out staged: the index already matches the worktree. fs.set_head_for_repo( path!("/dir/.git").as_ref(), &[("file.txt", committed_contents.clone())], "deadbeef", ); fs.set_index_for_repo( path!("/dir/.git").as_ref(), &[("file.txt", file_contents.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 hunk_range = 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), )], ); diff.snapshot(cx) .hunks(&snapshot) .next() .unwrap() .buffer_range }); // Hold back FS events so that the index writes below stay un-reconciled: // the second operation must run while the first one's optimistic state is // still pending. fs.pause_events(); // Unstage the hunk. It appears as optimistically unstaged. project .update(cx, |project, cx| { project.unstage_uncommitted_hunks( buffer.clone(), uncommitted_diff.clone(), vec![hunk_range.clone()], cx, ) }) .unwrap(); 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(SecondaryHunkAdditionPending), )], ); }); // Re-stage the same hunk before the unstage write has been reconciled. project .update(cx, |project, cx| { let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); project.stage_hunks(buffer.clone(), unstaged_diff, vec![hunk_range.clone()], cx) }) .unwrap(); 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), )], ); }); cx.run_until_parked(); assert_eq!( repo.load_index_text(RepoPath::from_rel_path(rel_path("file.txt"))) .await .unwrap(), file_contents, "the re-stage should have overridden the in-flight unstage" ); fs.unpause_events_and_flush(); cx.run_until_parked(); // Once everything settles, the hunk is staged again. 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), )], ); }); assert_eq!( repo.load_index_text(RepoPath::from_rel_path(rel_path("file.txt"))) .await .unwrap(), file_contents ); } #[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..40).map(|i| format!("line {i}\n")).collect::(); let index_text = committed_text.clone(); let buffer_text = (0..40) .map(|i| match i { 35 => format!("line {i}\ninserted after {i}\n"), _ if i < 30 && 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(), 7); let insertion_hunk = hunks .iter() .find(|hunk| hunk.diff_base_byte_range.is_empty()) .unwrap() .clone(); fs.pause_events(); project .update(cx, |project, cx| { let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); project.stage_hunks( buffer.clone(), unstaged_diff, vec![insertion_hunk.buffer_range.clone()], cx, ) }) .unwrap(); project .update(cx, |project, cx| { project.unstage_uncommitted_hunks( buffer.clone(), uncommitted_diff.clone(), vec![insertion_hunk.buffer_range], cx, ) }) .unwrap(); cx.executor().run_until_parked(); assert_eq!( repo.load_index_text(RepoPath::from_rel_path(rel_path("file.txt"))) .await .unwrap(), index_text ); fs.unpause_events_and_flush(); cx.run_until_parked(); hunks = uncommitted_diff.update(cx, |diff, cx| { diff.snapshot(cx).hunks(&snapshot).collect::>() }); assert_eq!(hunks.len(), 7); 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; let range = hunk.buffer_range.clone(); if hunk.status().has_secondary_hunk() { log::info!("staging hunk at {row}"); project .update(cx, |project, cx| { let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); project.stage_hunks(buffer.clone(), unstaged_diff, vec![range], cx) }) .unwrap(); hunk.secondary_status = SecondaryHunkRemovalPending; } else { log::info!("unstaging hunk at {row}"); project .update(cx, |project, cx| { project.unstage_uncommitted_hunks( buffer.clone(), uncommitted_diff.clone(), vec![range], cx, ) }) .unwrap(); 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(iterations = 25)] async fn test_staging_random_hunks_with_edits( mut rng: StdRng, _executor: BackgroundExecutor, cx: &mut gpui::TestAppContext, ) { let operations = env::var("OPERATIONS") .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) .unwrap_or(30); init_test(cx); fn disk_index_text(fs: &FakeFs) -> String { fs.with_git_state(path!("/dir/.git").as_ref(), false, |state| { state .index_contents .get(&repo_path("file.txt")) .cloned() .expect("file is always present in the index") }) .unwrap() } fn random_row_range( snapshot: &text::BufferSnapshot, rng: &mut StdRng, ) -> (Range, Range) { let max_row = snapshot.max_point().row; let start_row = rng.random_range(0..=max_row); let end_row = rng.random_range(start_row..=max_row.min(start_row + 5)); let start = Point::new(start_row, 0); let end = Point::new(end_row, snapshot.line_len(end_row)); ( snapshot.anchor_before(start)..snapshot.anchor_after(end), start_row..end_row, ) } #[allow(clippy::type_complexity)] fn capture_diff_state( diff: &Entity, cx: &mut gpui::TestAppContext, ) -> ( String, Option, Vec<(Range, Range, DiffHunkSecondaryStatus)>, ) { diff.read_with(cx, |diff, cx| { let snapshot = diff.snapshot(cx); let buffer_snapshot = snapshot.buffer_snapshot().clone(); let hunks = snapshot .hunks(&buffer_snapshot) .map(|hunk| { ( hunk.range.clone(), hunk.diff_base_byte_range.clone(), hunk.secondary_status, ) }) .collect(); (buffer_snapshot.text(), snapshot.base_text_string(), hunks) }) } fn assert_settled(diff: &Entity, name: &str, cx: &mut gpui::TestAppContext) { diff.read_with(cx, |diff, cx| { let snapshot = diff.snapshot(cx); let buffer_snapshot = snapshot.buffer_snapshot().clone(); let cooked = snapshot .hunks(&buffer_snapshot) .map(|hunk| (hunk.range.clone(), hunk.secondary_status)) .collect::>(); for (range, status) in &cooked { assert!( !matches!( status, DiffHunkSecondaryStatus::SecondaryHunkAdditionPending | DiffHunkSecondaryStatus::SecondaryHunkRemovalPending ), "{name} hunk at {range:?} still has pending status {status:?} after settling" ); } let full_range = Anchor::min_max_range_for_buffer(buffer_snapshot.remote_id()); let raw_ranges = snapshot .raw_hunks_intersecting_range(full_range, &buffer_snapshot) .map(|hunk| hunk.range) .collect::>(); let cooked_ranges = cooked .into_iter() .map(|(range, _)| range) .collect::>(); assert_eq!( raw_ranges, cooked_ranges, "{name} still has suppressed hunks after settling" ); }) } let mut committed_text = (0..40) .map(|i| match i { _ if i % 5 == 0 => format!("line {i} αβ🍐\n"), _ => format!("line {i}\n"), }) .collect::(); let index_text = (0..40) .map(|i| match i { 10 => "line 10 (modified ✅)\n".to_string(), 20 => "line 20 (staged-only ⭐)\n".to_string(), _ if i % 5 == 0 => format!("line {i} αβ🍐\n"), _ => format!("line {i}\n"), }) .collect::(); let buffer_text = (0..40) .map(|i| match i { 35 => format!("line {i} αβ🍐\ninserted after {i}\n"), _ if i < 30 && i % 5 == 0 => format!("line {i} (modified ✅)\n"), _ if i % 5 == 0 => format!("line {i} αβ🍐\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)]); 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 unstaged_diff = project .update(cx, |project, cx| { project.open_unstaged_diff(buffer.clone(), cx) }) .await .unwrap(); let (staged_diff, index_buffer) = project .update(cx, |project, cx| { project.open_staged_diff(buffer.clone(), cx) }) .await .unwrap(); let uncommitted_diff = project .update(cx, |project, cx| { project.open_uncommitted_diff(buffer.clone(), cx) }) .await .unwrap(); cx.run_until_parked(); let mut events_paused = false; let mut commit_count = 0; for _ in 0..operations { match rng.random_range(0..100) { 0..20 => { let snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); let (range, rows) = random_row_range(&snapshot, &mut rng); log::info!("staging rows {rows:?}"); project .update(cx, |project, cx| { project.stage_hunks(buffer.clone(), unstaged_diff.clone(), vec![range], cx) }) .unwrap(); } 20..35 => { let snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); let (range, rows) = random_row_range(&snapshot, &mut rng); log::info!("unstaging rows {rows:?} via the uncommitted diff"); project .update(cx, |project, cx| { project.unstage_uncommitted_hunks( buffer.clone(), uncommitted_diff.clone(), vec![range], cx, ) }) .unwrap(); } 35..50 => { let snapshot = index_buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); let (range, rows) = random_row_range(&snapshot, &mut rng); log::info!("unstaging index rows {rows:?} via the staged diff"); project .update(cx, |project, cx| { project.unstage_staged_hunks(staged_diff.clone(), vec![range], cx) }) .unwrap(); } // Line-oriented edits keep the file hunk-rich; uniform random // splices (`randomly_edit`) delete a quarter of the file on // average and quickly collapse it into a single hunk. 50..75 => { let snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot()); let max_row = snapshot.max_point().row; let row = rng.random_range(0..=max_row); let row_start = snapshot.point_to_offset(Point::new(row, 0)); let row_end = snapshot.point_to_offset(Point::new(row, snapshot.line_len(row))); let new_text_len = rng.random_range(0..8); let new_text = RandomCharIter::new(&mut rng) .take(new_text_len) .collect::(); let (range, new_text) = match rng.random_range(0..10) { 0..3 => (row_start..row_start, format!("{new_text}\n")), 3..5 if max_row > 0 => { let end = if row < max_row { snapshot.point_to_offset(Point::new(row + 1, 0)) } else { snapshot.len() }; (row_start..end, String::new()) } 5..8 => (row_start..row_end, new_text), _ => { let start = snapshot .clip_offset(rng.random_range(0..=snapshot.len()), text::Bias::Left); let end = snapshot.clip_offset( (start + rng.random_range(0..10)).min(snapshot.len()), text::Bias::Right, ); (start..end, new_text) } }; log::info!("editing buffer: {range:?} -> {new_text:?}"); buffer.update(cx, |buffer, cx| buffer.edit([(range, new_text)], None, cx)); } // Commits and whole-file round-trip checkpoints. The round trips // settle first so the resulting index bytes are exactly // predictable, which catches wrong-content writes that the // settled-consistency checks can't see (a corrupted write becomes // the on-disk truth that everything else then agrees with). 75..82 => { let buffer_full_range = Anchor::min_max_range_for_buffer( buffer.read_with(cx, |buffer, _| buffer.remote_id()), ); let index_full_range = Anchor::min_max_range_for_buffer( index_buffer.read_with(cx, |buffer, _| buffer.remote_id()), ); match rng.random_range(0..4) { // Commit: HEAD becomes whatever the index currently // contains on disk, which may lag in-flight optimistic // writes, just like running `git commit` in a terminal. 0 => { let new_head = disk_index_text(&fs); commit_count += 1; log::info!("committing (commit {commit_count})"); fs.set_head_for_repo( path!("/dir/.git").as_ref(), &[("file.txt", new_head.clone())], format!("sha-{commit_count}"), ); committed_text = new_head; } // Stage everything and commit it, like `git add -A && // git commit`. 1 => { log::info!("checkpoint: stage-all, then commit"); if events_paused { fs.unpause_events_and_flush(); events_paused = false; } cx.run_until_parked(); project .update(cx, |project, cx| { project.stage_hunks( buffer.clone(), unstaged_diff.clone(), vec![buffer_full_range], cx, ) }) .unwrap(); cx.run_until_parked(); let disk = disk_index_text(&fs); let buffer_text = buffer.read_with(cx, |buffer, _| buffer.text()); assert_eq!( disk, buffer_text, "after staging everything, the index must equal the worktree" ); commit_count += 1; log::info!("committing (commit {commit_count})"); fs.set_head_for_repo( path!("/dir/.git").as_ref(), &[("file.txt", disk.clone())], format!("sha-{commit_count}"), ); committed_text = disk; } // Unstage everything via the staged diff, which by // definition covers every index-vs-HEAD difference. 2 => { log::info!("checkpoint: unstage-all"); if events_paused { fs.unpause_events_and_flush(); events_paused = false; } cx.run_until_parked(); project .update(cx, |project, cx| { project.unstage_staged_hunks( staged_diff.clone(), vec![index_full_range], cx, ) }) .unwrap(); cx.run_until_parked(); let disk = disk_index_text(&fs); assert_eq!( disk, committed_text, "after unstaging everything, the index must equal HEAD" ); } // Unstage everything and immediately re-stage everything // with no settling in between: the re-stage must supersede // the in-flight unstage (the footprint mechanism), leaving // the index equal to the worktree. _ => { log::info!("checkpoint: unstage-all then stage-all"); if events_paused { fs.unpause_events_and_flush(); events_paused = false; } cx.run_until_parked(); project .update(cx, |project, cx| { project.unstage_staged_hunks( staged_diff.clone(), vec![index_full_range], cx, ) }) .unwrap(); project .update(cx, |project, cx| { project.stage_hunks( buffer.clone(), unstaged_diff.clone(), vec![buffer_full_range], cx, ) }) .unwrap(); cx.run_until_parked(); let disk = disk_index_text(&fs); let buffer_text = buffer.read_with(cx, |buffer, _| buffer.text()); assert_eq!( disk, buffer_text, "re-staging everything must supersede the in-flight unstage" ); } } } 82..90 => { if events_paused { log::info!("unpausing fs events"); fs.unpause_events_and_flush(); events_paused = false; } else { log::info!("pausing fs events"); fs.pause_events(); events_paused = true; } } _ => { log::info!("settling"); if events_paused { fs.unpause_events_and_flush(); events_paused = false; } cx.run_until_parked(); } } for _ in 0..rng.random_range(0..5) { cx.executor().simulate_random_delay().await; } } if events_paused { fs.unpause_events_and_flush(); } cx.run_until_parked(); assert_settled(&unstaged_diff, "unstaged diff", cx); assert_settled(&staged_diff, "staged diff", cx); assert_settled(&uncommitted_diff, "uncommitted diff", cx); let old_unstaged_state = capture_diff_state(&unstaged_diff, cx); let old_staged_state = capture_diff_state(&staged_diff, cx); let old_uncommitted_state = capture_diff_state(&uncommitted_diff, cx); let disk_index_text = repo .load_index_text(RepoPath::from_rel_path(rel_path("file.txt"))) .await .unwrap(); assert_eq!( old_unstaged_state.1.as_deref(), Some(disk_index_text.as_str()), "unstaged diff base text differs from the index on disk" ); assert_eq!( old_staged_state.0, disk_index_text, "staged diff buffer differs from the index on disk" ); assert_eq!( old_staged_state.1.as_deref(), Some(committed_text.as_str()), "staged diff base text differs from HEAD" ); assert_eq!( old_uncommitted_state.1.as_deref(), Some(committed_text.as_str()), "uncommitted diff base text differs from HEAD" ); // Differential check: a from-scratch computation over (HEAD, index, // worktree) must agree with the incrementally-maintained state that just // settled. let old_entity_ids = [ unstaged_diff.entity_id(), staged_diff.entity_id(), uncommitted_diff.entity_id(), ]; drop(unstaged_diff); drop(staged_diff); drop(uncommitted_diff); // An empty update flushes effects so that the dropped entities are // actually released before we reopen. cx.update(|_| {}); cx.run_until_parked(); let unstaged_diff = project .update(cx, |project, cx| { project.open_unstaged_diff(buffer.clone(), cx) }) .await .unwrap(); let (staged_diff, _) = project .update(cx, |project, cx| { project.open_staged_diff(buffer.clone(), cx) }) .await .unwrap(); let uncommitted_diff = project .update(cx, |project, cx| { project.open_uncommitted_diff(buffer.clone(), cx) }) .await .unwrap(); cx.run_until_parked(); assert_ne!( unstaged_diff.entity_id(), old_entity_ids[0], "the unstaged diff was not actually closed" ); assert_ne!( staged_diff.entity_id(), old_entity_ids[1], "the staged diff was not actually closed" ); assert_ne!( uncommitted_diff.entity_id(), old_entity_ids[2], "the uncommitted diff was not actually closed" ); assert_eq!( capture_diff_state(&unstaged_diff, cx), old_unstaged_state, "reopened unstaged diff differs from the settled one" ); assert_eq!( capture_diff_state(&staged_diff, cx), old_staged_state, "reopened staged diff differs from the settled one" ); assert_eq!( capture_diff_state(&uncommitted_diff, cx), old_uncommitted_state, "reopened uncommitted diff differs from the settled one" ); } #[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(); let ranges = uncommitted_diff.read_with(cx, |diff, cx| { diff.snapshot(cx) .hunks(&snapshot) .map(|hunk| hunk.buffer_range) .collect::>() }); project .update(cx, |project, cx| { let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); project.stage_hunks(buffer.clone(), unstaged_diff, ranges, cx) }) .unwrap(); 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, }), staged_diff_stat: None, unstaged_diff_stat: Some(DiffStat { added: 1, deleted: 1, }), }, StatusEntry { repo_path: repo_path("b.txt"), status: FileStatus::Untracked, diff_stat: None, staged_diff_stat: None, unstaged_diff_stat: None, }, StatusEntry { repo_path: repo_path("d.txt"), status: StatusCode::Deleted.worktree(), diff_stat: Some(DiffStat { added: 0, deleted: 1, }), staged_diff_stat: None, unstaged_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, }), staged_diff_stat: None, unstaged_diff_stat: Some(DiffStat { added: 1, deleted: 1, }), }, StatusEntry { repo_path: repo_path("b.txt"), status: FileStatus::Untracked, diff_stat: None, staged_diff_stat: None, unstaged_diff_stat: None, }, StatusEntry { repo_path: repo_path("c.txt"), status: StatusCode::Modified.worktree(), diff_stat: Some(DiffStat { added: 1, deleted: 1, }), staged_diff_stat: None, unstaged_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, }), staged_diff_stat: None, unstaged_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, }), staged_diff_stat: None, unstaged_diff_stat: Some(DiffStat { added: 0, deleted: 1, }), }] ); }); } #[gpui::test] async fn test_git_repository_status_removes_directory_descendants(cx: &mut gpui::TestAppContext) { init_test(cx); cx.executor().allow_parking(); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( path!("/root"), json!({ "project": { ".git": {}, "ci2": { "Dockerfile.namespace": "untracked", }, }, }), ) .await; fs.set_status_for_repo( path!("/root/project/.git").as_ref(), &[("ci2/Dockerfile.namespace", FileStatus::Untracked)], ); let project = Project::test(fs.clone(), [path!("/root/project").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() }); repository.read_with(cx, |repository, _| { assert_eq!( repository.cached_status().collect::>(), [StatusEntry { repo_path: repo_path("ci2/Dockerfile.namespace"), status: FileStatus::Untracked, diff_stat: None, staged_diff_stat: None, unstaged_diff_stat: None, }] ); }); fs.pause_events(); fs.create_dir(path!("/root/project/ci3").as_ref()) .await .unwrap(); fs.copy_file( path!("/root/project/ci2/Dockerfile.namespace").as_ref(), path!("/root/project/ci3/Dockerfile.namespace").as_ref(), Default::default(), ) .await .unwrap(); fs.remove_dir( path!("/root/project/ci2").as_ref(), RemoveOptions { recursive: true, ..Default::default() }, ) .await .unwrap(); fs.clear_buffered_events(); fs.unpause_events_and_flush(); fs.emit_fs_event(path!("/root/project/ci2"), Some(PathEventKind::Removed)); fs.emit_fs_event(path!("/root/project/ci3"), Some(PathEventKind::Created)); 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, _| { assert_eq!( repository.cached_status().collect::>(), [StatusEntry { repo_path: repo_path("ci3/Dockerfile.namespace"), status: FileStatus::Untracked, diff_stat: None, staged_diff_stat: None, unstaged_diff_stat: None, }] ); }); } #[cfg(target_os = "linux")] #[gpui::test(retries = 5)] async fn test_git_events_after_project_excludes_dot_git(cx: &mut gpui::TestAppContext) { init_test(cx); cx.executor().allow_parking(); cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { store.update_user_settings(cx, |settings| { settings.project.worktree.file_scan_exclusions = Some(vec!["foo".to_string()]); }); }); }); let root = TempTree::new(json!({ "project": { "a.txt": "a", }, })); let work_dir = root.path().join("project"); let repo = git_init(&work_dir); git_add("a.txt", &repo); git_commit("Initial commit", &repo); git_branch("other-branch", &repo); let project = Project::test( Arc::new(RealFs::new(None, cx.executor())), [work_dir.as_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() }); let branch = repository.read_with(cx, |repository, _| { repository .snapshot() .branch .as_ref() .map(|branch| branch.ref_name.to_string()) }); assert_eq!(branch.as_deref(), Some("refs/heads/main")); let worktree_id = tree.read_with(cx, |tree, _| tree.id()); cx.update_global::(|store, cx| { store .set_local_settings( worktree_id, LocalSettingsPath::InWorktree(Arc::from(RelPath::empty())), LocalSettingsKind::Settings, Some(r#"{ "file_scan_exclusions": ["**/.git"] }"#), cx, ) .unwrap(); }); cx.read(|cx| tree.read(cx).as_local().unwrap().scan_complete()) .await; cx.executor().run_until_parked(); cx.update(|cx| { assert!(tree.read(cx).entry_for_path(rel_path(".git")).is_none()); }); git_checkout("other-branch", &repo); let mut events = cx.events::(&repository); let timeout = futures::FutureExt::fuse(cx.background_executor.timer(Duration::from_secs(5))); futures::pin_mut!(timeout); loop { let branch = repository.read_with(cx, |repository, _| { repository .snapshot() .branch .as_ref() .map(|branch| branch.ref_name.to_string()) }); if branch.as_deref() == Some("refs/heads/other-branch") { break; } futures::select_biased! { _ = events.next() => {} _ = timeout => panic!("timed out waiting for repository HEAD update after .git was excluded"), } } } #[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, staged_diff_stat: Some(DiffStat { added: 0, deleted: 0, }), unstaged_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, }), staged_diff_stat: Some(DiffStat { added: 1, deleted: 0, }), unstaged_diff_stat: None, }] ); }); } #[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, }), staged_diff_stat: Some(DiffStat { added: 1, deleted: 0, }), unstaged_diff_stat: None, }] ); }); } #[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, staged_diff_stat: None, unstaged_diff_stat: None, }, StatusEntry { repo_path: repo_path("b.txt"), status: FileStatus::Untracked, diff_stat: None, staged_diff_stat: None, unstaged_diff_stat: None, }, ] ); }); } #[gpui::test] async fn test_project_group_keys_remain_distinct_for_sibling_repo_subdirectories( executor: gpui::BackgroundExecutor, cx: &mut gpui::TestAppContext, ) { init_test(cx); let fs = FakeFs::new(executor); fs.insert_tree( path!("/root"), json!({ "my-repo": { ".git": {}, "packages": { "a": { "file.txt": "a" }, "b": { "file.txt": "b" }, }, }, }), ) .await; let project_a = Project::test(fs.clone(), [path!("/root/my-repo/packages/a").as_ref()], cx).await; let project_b = Project::test(fs, [path!("/root/my-repo/packages/b").as_ref()], cx).await; project_a .update(cx, |project, cx| project.git_scans_complete(cx)) .await; project_b .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.run_until_parked(); let key_a = project_a.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx)); let key_b = project_b.read_with(cx, |project, cx| ProjectGroupKey::from_project(project, cx)); assert_ne!(key_a, key_b); assert_eq!( key_a .path_list() .ordered_paths() .map(|path| path.as_path()) .collect::>(), vec![Path::new(path!("/root/my-repo/packages/a"))] ); assert_eq!( key_b .path_list() .ordered_paths() .map(|path| path.as_path()) .collect::>(), vec![Path::new(path!("/root/my-repo/packages/b"))] ); } fn project_group_key_paths(project: &Entity, cx: &TestAppContext) -> Vec { project.read_with(cx, |project, cx| { ProjectGroupKey::from_project(project, cx) .path_list() .ordered_paths() .cloned() .collect() }) } fn project_worktree_paths( project: &Entity, cx: &TestAppContext, ) -> (Vec, Vec) { project.read_with(cx, |project, cx| { let paths = project.worktree_paths(cx); ( paths.folder_path_list().ordered_paths().cloned().collect(), paths .main_worktree_path_list() .ordered_paths() .cloned() .collect(), ) }) } #[gpui::test] async fn test_project_group_keys_match_for_bare_repo_linked_worktrees( executor: gpui::BackgroundExecutor, cx: &mut gpui::TestAppContext, ) { init_test(cx); let fs = FakeFs::new(executor); fs.insert_tree( path!("/root"), json!({ "monty": { ".git": "gitdir: ./.bare\n", ".bare": { "HEAD": "ref: refs/heads/main\n", "worktrees": { "main": { "commondir": "../..", "gitdir": "/root/monty/main/.git", }, "feature-a": { "commondir": "../..", "gitdir": "/root/monty/feature-a/.git", }, "feature-b": { "commondir": "../..", "gitdir": "/root/monty/feature-b/.git", }, }, }, "main": { ".git": "gitdir: /root/monty/.bare/worktrees/main\n", "file.txt": "main", }, "feature-a": { ".git": "gitdir: /root/monty/.bare/worktrees/feature-a\n", "file.txt": "a", }, "feature-b": { ".git": "gitdir: /root/monty/.bare/worktrees/feature-b\n", "file.txt": "b", }, }, }), ) .await; let project_root = Project::test(fs.clone(), [path!("/root/monty").as_ref()], cx).await; let project_main = Project::test(fs.clone(), [path!("/root/monty/main").as_ref()], cx).await; let project_a = Project::test(fs.clone(), [path!("/root/monty/feature-a").as_ref()], cx).await; let project_b = Project::test(fs, [path!("/root/monty/feature-b").as_ref()], cx).await; project_root .update(cx, |project, cx| project.git_scans_complete(cx)) .await; project_main .update(cx, |project, cx| project.git_scans_complete(cx)) .await; project_a .update(cx, |project, cx| project.git_scans_complete(cx)) .await; project_b .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.run_until_parked(); for project in [&project_root, &project_main, &project_a, &project_b] { assert_eq!( project_group_key_paths(project, cx), vec![PathBuf::from(path!("/root/monty"))] ); } assert_eq!( project_worktree_paths(&project_root, cx), ( vec![PathBuf::from(path!("/root/monty"))], vec![PathBuf::from(path!("/root/monty"))], ) ); assert_eq!( project_worktree_paths(&project_main, cx), ( vec![PathBuf::from(path!("/root/monty/main"))], vec![PathBuf::from(path!("/root/monty"))], ) ); assert_eq!( project_worktree_paths(&project_a, cx), ( vec![PathBuf::from(path!("/root/monty/feature-a"))], vec![PathBuf::from(path!("/root/monty"))], ) ); assert_eq!( project_worktree_paths(&project_b, cx), ( vec![PathBuf::from(path!("/root/monty/feature-b"))], vec![PathBuf::from(path!("/root/monty"))], ) ); } #[gpui::test] async fn test_project_group_key_groups_nested_linked_worktree_under_main_repo( executor: gpui::BackgroundExecutor, cx: &mut gpui::TestAppContext, ) { init_test(cx); let fs = FakeFs::new(executor); fs.insert_tree( path!("/root"), json!({ "my-repo": { ".git": {}, "file.txt": "content", }, }), ) .await; let linked_worktree_path = PathBuf::from(path!("/root/my-repo/.zed/worktrees/feature")); fs.add_linked_worktree_for_repo( Path::new(path!("/root/my-repo/.git")), false, git::repository::Worktree { path: linked_worktree_path.clone(), ref_name: Some("refs/heads/feature".into()), sha: "abc123".into(), is_main: false, is_bare: false, }, ) .await; let project = Project::test(fs, [linked_worktree_path.as_path()], cx).await; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.run_until_parked(); assert_eq!( project_group_key_paths(&project, cx), vec![PathBuf::from(path!("/root/my-repo"))] ); assert_eq!( project_worktree_paths(&project, cx), ( vec![PathBuf::from(path!("/root/my-repo/.zed/worktrees/feature"))], vec![PathBuf::from(path!("/root/my-repo"))], ) ); } #[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() }); let worktree_paths = project.read_with(cx, |project, cx| project.worktree_paths(cx)); assert_eq!( worktree_paths .main_worktree_path_list() .ordered_paths() .map(|path| path.as_path()) .collect::>(), vec![Path::new(path!("/root/my-repo/sub-folder-1/sub-folder-2"))] ); // 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 work_dir = &root_path.join("project"); let repo = git_init(work_dir); 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("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 = git_rev_parse("HEAD", &repo); git_checkout("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_expect_conflict(&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(), GIT_STATUS_CONFLICTED.to_owned())]) ); 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; project .update(cx, |project, cx| project.git_scans_complete(cx)) .await; cx.executor().run_until_parked(); 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 repo = git_init(work_dir.as_path()); 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(&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()); 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}" ); } #[cfg(any())] const GIT_STATUS_CONFLICTED: &str = "UU"; #[allow(clippy::disallowed_methods)] fn git_cmd(work_dir: &Path) -> Command { let mut cmd = Command::new("git"); cmd.current_dir(work_dir) .env("GIT_CONFIG_GLOBAL", "") .env("GIT_CONFIG_SYSTEM", "") .env("GIT_AUTHOR_NAME", "test") .env("GIT_AUTHOR_EMAIL", "test@zed.dev") .env("GIT_COMMITTER_NAME", "test") .env("GIT_COMMITTER_EMAIL", "test@zed.dev"); cmd } #[allow(clippy::disallowed_methods)] #[track_caller] fn git_init(path: &Path) -> PathBuf { let output = git_cmd(path) .args(["init", "-b", "main"]) .output() .expect("Failed to run git init"); assert!( output.status.success(), "git init failed: {}", String::from_utf8_lossy(&output.stderr) ); path.to_path_buf() } #[allow(clippy::disallowed_methods)] #[track_caller] fn git_add>(path: P, work_dir: &Path) { let output = git_cmd(work_dir) .args(["add"]) .arg(path.as_ref()) .output() .expect("Failed to run git add"); assert!( output.status.success(), "git add failed: {}", String::from_utf8_lossy(&output.stderr) ); } #[allow(clippy::disallowed_methods)] #[track_caller] fn git_remove_index(path: &Path, work_dir: &Path) { let output = git_cmd(work_dir) .args(["rm", "--cached"]) .arg(path) .output() .expect("Failed to run git rm"); assert!( output.status.success(), "git rm --cached failed: {}", String::from_utf8_lossy(&output.stderr) ); } #[allow(clippy::disallowed_methods)] #[track_caller] fn git_commit(msg: &str, work_dir: &Path) { let output = git_cmd(work_dir) .args(["commit", "-m", msg]) .output() .expect("Failed to run git commit"); assert!( output.status.success(), "git commit failed: {}", String::from_utf8_lossy(&output.stderr) ); } #[allow(clippy::disallowed_methods)] #[track_caller] fn git_stash(work_dir: &Path) { let output = git_cmd(work_dir) .args(["stash"]) .output() .expect("Failed to run git stash"); assert!( output.status.success(), "git stash failed: {}", String::from_utf8_lossy(&output.stderr) ); } #[allow(clippy::disallowed_methods)] #[track_caller] fn git_reset(offset: usize, work_dir: &Path) { let target = format!("HEAD~{}", offset + 1); let output = git_cmd(work_dir) .args(["reset", "--soft", &target]) .output() .expect("Failed to run git reset"); assert!( output.status.success(), "git reset failed: {}", String::from_utf8_lossy(&output.stderr) ); } #[cfg(target_os = "linux")] #[allow(clippy::disallowed_methods)] #[track_caller] fn git_branch(name: &str, work_dir: &Path) { let output = git_cmd(work_dir) .args(["branch", name]) .output() .expect("Failed to run git branch"); assert!( output.status.success(), "git branch failed: {}", String::from_utf8_lossy(&output.stderr) ); } #[cfg(target_os = "linux")] #[allow(clippy::disallowed_methods)] #[track_caller] fn git_checkout(name: &str, work_dir: &Path) { let output = git_cmd(work_dir) .args(["checkout", name]) .output() .expect("Failed to run git checkout"); assert!( output.status.success(), "git checkout failed: {}", String::from_utf8_lossy(&output.stderr) ); } #[cfg(any())] #[allow(clippy::disallowed_methods)] #[track_caller] fn git_rev_parse(rev: &str, work_dir: &Path) -> String { let output = git_cmd(work_dir) .args(["rev-parse", rev]) .output() .expect("Failed to run git rev-parse"); assert!( output.status.success(), "git rev-parse failed: {}", String::from_utf8_lossy(&output.stderr) ); String::from_utf8(output.stdout).unwrap().trim().to_string() } #[cfg(any())] #[allow(clippy::disallowed_methods)] #[track_caller] fn git_cherry_pick_expect_conflict(commit: &str, work_dir: &Path) { let output = git_cmd(work_dir) .args(["cherry-pick", "--no-commit", commit]) .output() .expect("Failed to run git cherry-pick"); assert!( !output.status.success(), "git cherry-pick unexpectedly succeeded" ); } #[cfg(any())] #[allow(clippy::disallowed_methods)] #[track_caller] fn git_status(work_dir: &Path) -> collections::HashMap { let output = git_cmd(work_dir) .args(["status", "--porcelain=v1"]) .output() .expect("Failed to run git status"); assert!( output.status.success(), "git status failed: {}", String::from_utf8_lossy(&output.stderr) ); let stdout = String::from_utf8(output.stdout).unwrap(); stdout .lines() .filter(|line| !line.is_empty()) .map(|line| { let status = line[..2].to_string(); let path = line[3..].to_string(); (path, 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] #[cfg(not(windows))] async fn test_os_read_only_files_open_as_read_only(cx: &mut gpui::TestAppContext) { init_test(cx); cx.executor().allow_parking(); let root = TempTree::new(json!({ "project": { "test.txt": "hello", }, })); let file_path = root.path().join("project/test.txt"); let mut permissions = std::fs::metadata(&file_path).unwrap().permissions(); permissions.set_readonly(true); std::fs::set_permissions(&file_path, permissions).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(); buffer.read_with(cx, |buffer, _| { assert!( buffer.read_only(), "OS read-only files should open as read-only" ); }); } #[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::from_unix_str(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)" ); }); } } /// Regression test for https://github.com/zed-industries/zed/issues/60424. /// /// The committed text contains repeated `end\n\n` line runs, so the deletion /// hunks have ambiguous placements. Before hunk placement was canonicalized, /// the uncommitted diff (HEAD vs worktree) and the recomputed unstaged diff /// (index vs worktree) could anchor the same logical deletion at different /// rows after a partial stage. The mismatched hunk then rendered as staged /// while git still had it unstaged, and unstaging it inserted a duplicate /// copy of its contents into the index on every click. #[gpui::test] async fn test_staging_hunks_with_ambiguous_placement(cx: &mut gpui::TestAppContext) { init_test(cx); let committed_contents = concat!( "function is_empty(str)\n", " assert(type(str) == string)\n", " return str == nil or str == \"\"\n", "end\n", "\n", "function string:starts_with(str)\n", " return self:sub(1, #str) == str\n", "end\n", "\n", "function string:ends_with(str)\n", " return self:sub(-#str) == str\n", "end\n", "\n", "function table:append(other)\n", " for _, val in ipairs(other) do table.insert(self, val) end\n", "end\n", "\n", "function table:contains(elem)\n", " assert(type(self) == \"table\")\n", "\n", " for _, val in ipairs(self) do\n", " if val == elem then return true end\n", " end\n", " return false\n", "end\n", "\n", "function table:keys()\n", " local keys = {}\n", " for key, _ in pairs(self) do\n", " table.insert(keys, key)\n", " end\n", " return keys\n", "end\n", "\n", "function has_class(el, class) return el.classes[0] == class end\n", ) .to_string(); let file_contents = concat!( "function table:append(other)\n", " for _, val in ipairs(other) do table.insert(self, val) end\n", "end\n", "\n", "function table:keys()\n", " local keys = {}\n", " for key, _ in pairs(self) do\n", " table.insert(keys, key)\n", " end\n", " return keys\n", "end\n", "\n", "function has_class(el, class) return el.classes:includes(class) end\n", ) .to_string(); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( "/dir", json!({ ".git": {}, "test.txt": file_contents.clone() }), ) .await; fs.set_head_and_index_for_repo( path!("/dir/.git").as_ref(), &[("test.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/test.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(); fn hunk_states( uncommitted_diff: &Entity, snapshot: &language::BufferSnapshot, cx: &mut gpui::TestAppContext, ) -> Vec<(Range, DiffHunkSecondaryStatus)> { uncommitted_diff.read_with(cx, |diff, cx| { diff.snapshot(cx) .hunks(&snapshot.text) .map(|hunk| (hunk.range.clone(), hunk.secondary_status)) .collect() }) } fn index_text(fs: &FakeFs) -> String { fs.with_git_state(path!("/dir/.git").as_ref(), false, |state| { state .index_contents .get(&repo_path("test.txt")) .cloned() .expect("file is present in the index") }) .unwrap() } use DiffHunkSecondaryStatus::*; let row = |start: u32, end: u32| Point::new(start, 0)..Point::new(end, 0); // Two deletions and one modification, all unstaged. assert_eq!( hunk_states(&uncommitted_diff, &snapshot, cx), vec![ (row(0, 0), HasSecondaryHunk), (row(4, 4), HasSecondaryHunk), (row(12, 13), HasSecondaryHunk), ] ); // Stage the first deletion (the three string functions). let hunks = uncommitted_diff.read_with(cx, |diff, cx| { diff.snapshot(cx).hunks(&snapshot.text).collect::>() }); project .update(cx, |project, cx| { let unstaged_diff = uncommitted_diff.read(cx).secondary_diff().unwrap(); project.stage_hunks( buffer.clone(), unstaged_diff, vec![hunks[0].buffer_range.clone()], cx, ) }) .unwrap(); cx.run_until_parked(); // Only the first hunk is staged; the other hunks must not be affected, // even though the unstaged diff was recomputed against a new index text // in which the remaining deletion has an ambiguous placement. assert_eq!( hunk_states(&uncommitted_diff, &snapshot, cx), vec![ (row(0, 0), NoSecondaryHunk), (row(4, 4), HasSecondaryHunk), (row(12, 13), HasSecondaryHunk), ] ); let table_contains_offset = committed_contents .find("function table:append") .expect("committed text contains table:append"); let expected_index = &committed_contents[table_contains_offset..]; assert_eq!(index_text(&fs), expected_index); // Unstaging the second deletion is a no-op, since it isn't staged. Before // the fix, each such request inserted another copy of the deleted content // into the index. for _ in 0..3 { let hunks = uncommitted_diff.read_with(cx, |diff, cx| { diff.snapshot(cx).hunks(&snapshot.text).collect::>() }); project .update(cx, |project, cx| { project.unstage_uncommitted_hunks( buffer.clone(), uncommitted_diff.clone(), vec![hunks[1].buffer_range.clone()], cx, ) }) .unwrap(); cx.run_until_parked(); } assert_eq!(index_text(&fs), expected_index); assert_eq!( hunk_states(&uncommitted_diff, &snapshot, cx), vec![ (row(0, 0), NoSecondaryHunk), (row(4, 4), HasSecondaryHunk), (row(12, 13), HasSecondaryHunk), ] ); }