diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 9f5ca8f99d..6095ababf4 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -5,6 +5,9 @@ mod lsp_command; pub mod search; pub mod worktree; +#[cfg(test)] +mod project_tests; + use anyhow::{anyhow, Context, Result}; use client::{proto, Client, PeerId, TypedEnvelope, User, UserStore}; use clock::ReplicaId; @@ -6043,3021 +6046,3 @@ impl Item for Buffer { File::from_dyn(self.file()).and_then(|file| file.project_entry_id(cx)) } } - -#[cfg(test)] -mod tests { - use crate::worktree::WorktreeHandle; - - use super::{Event, *}; - use fs::RealFs; - use futures::{future, StreamExt}; - use gpui::{executor::Deterministic, test::subscribe}; - use language::{ - tree_sitter_rust, tree_sitter_typescript, Diagnostic, FakeLspAdapter, LanguageConfig, - NewlineStyle, OffsetRangeExt, Point, ToPoint, - }; - use lsp::Url; - use serde_json::json; - use std::{cell::RefCell, os::unix, path::PathBuf, rc::Rc, task::Poll}; - use unindent::Unindent as _; - use util::{assert_set_eq, test::temp_tree}; - - #[gpui::test] - async fn test_populate_and_search(cx: &mut gpui::TestAppContext) { - let dir = temp_tree(json!({ - "root": { - "apple": "", - "banana": { - "carrot": { - "date": "", - "endive": "", - } - }, - "fennel": { - "grape": "", - } - } - })); - - let root_link_path = dir.path().join("root_link"); - unix::fs::symlink(&dir.path().join("root"), &root_link_path).unwrap(); - unix::fs::symlink( - &dir.path().join("root/fennel"), - &dir.path().join("root/finnochio"), - ) - .unwrap(); - - let project = Project::test(Arc::new(RealFs), [root_link_path.as_ref()], cx).await; - - project.read_with(cx, |project, cx| { - let tree = project.worktrees(cx).next().unwrap().read(cx); - assert_eq!(tree.file_count(), 5); - assert_eq!( - tree.inode_for_path("fennel/grape"), - tree.inode_for_path("finnochio/grape") - ); - }); - - let cancel_flag = Default::default(); - let results = project - .read_with(cx, |project, cx| { - project.match_paths("bna", false, false, 10, &cancel_flag, cx) - }) - .await; - assert_eq!( - results - .into_iter() - .map(|result| result.path) - .collect::>>(), - vec![ - PathBuf::from("banana/carrot/date").into(), - PathBuf::from("banana/carrot/endive").into(), - ] - ); - } - - #[gpui::test] - async fn test_managing_language_servers(cx: &mut gpui::TestAppContext) { - cx.foreground().forbid_parking(); - - let mut rust_language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - let mut json_language = Language::new( - LanguageConfig { - name: "JSON".into(), - path_suffixes: vec!["json".to_string()], - ..Default::default() - }, - None, - ); - let mut fake_rust_servers = rust_language.set_fake_lsp_adapter(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() - }); - let mut fake_json_servers = json_language.set_fake_lsp_adapter(FakeLspAdapter { - name: "the-json-language-server", - capabilities: lsp::ServerCapabilities { - completion_provider: Some(lsp::CompletionOptions { - trigger_characters: Some(vec![":".to_string()]), - ..Default::default() - }), - ..Default::default() - }, - ..Default::default() - }); - - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/the-root", - json!({ - "test.rs": "const A: i32 = 1;", - "test2.rs": "", - "Cargo.toml": "a = 1", - "package.json": "{\"a\": 1}", - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/the-root".as_ref()], cx).await; - project.update(cx, |project, _| { - project.languages.add(Arc::new(rust_language)); - project.languages.add(Arc::new(json_language)); - }); - - // Open a buffer without an associated language server. - let toml_buffer = project - .update(cx, |project, cx| { - project.open_local_buffer("/the-root/Cargo.toml", cx) - }) - .await - .unwrap(); - - // Open a buffer with an associated language server. - let rust_buffer = project - .update(cx, |project, cx| { - project.open_local_buffer("/the-root/test.rs", 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::Url::from_file_path("/the-root/test.rs").unwrap(), - version: 0, - text: "const A: i32 = 1;".to_string(), - language_id: Default::default() - } - ); - - // The buffer is configured based on the language server's capabilities. - rust_buffer.read_with(cx, |buffer, _| { - assert_eq!( - buffer.completion_triggers(), - &[".".to_string(), "::".to_string()] - ); - }); - toml_buffer.read_with(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")], cx)); - assert_eq!( - fake_rust_server - .receive_notification::() - .await - .text_document, - lsp::VersionedTextDocumentIdentifier::new( - lsp::Url::from_file_path("/the-root/test.rs").unwrap(), - 1 - ) - ); - - // Open a third buffer with a different associated language server. - let json_buffer = project - .update(cx, |project, cx| { - project.open_local_buffer("/the-root/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::Url::from_file_path("/the-root/package.json").unwrap(), - version: 0, - text: "{\"a\": 1}".to_string(), - language_id: Default::default() - } - ); - - // This buffer is configured based on the second language server's - // capabilities. - json_buffer.read_with(cx, |buffer, _| { - assert_eq!(buffer.completion_triggers(), &[":".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 = project - .update(cx, |project, cx| { - project.open_local_buffer("/the-root/test2.rs", cx) - }) - .await - .unwrap(); - rust_buffer2.read_with(cx, |buffer, _| { - assert_eq!( - buffer.completion_triggers(), - &[".".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")], cx)); - rust_buffer2.update(cx, |buffer, cx| buffer.edit([(0..0, "let x = 1;")], cx)); - assert_eq!( - fake_rust_server - .receive_notification::() - .await - .text_document, - lsp::VersionedTextDocumentIdentifier::new( - lsp::Url::from_file_path("/the-root/test2.rs").unwrap(), - 1 - ) - ); - - // Save notifications are reported to all servers. - toml_buffer - .update(cx, |buffer, cx| buffer.save(cx)) - .await - .unwrap(); - assert_eq!( - fake_rust_server - .receive_notification::() - .await - .text_document, - lsp::TextDocumentIdentifier::new( - lsp::Url::from_file_path("/the-root/Cargo.toml").unwrap() - ) - ); - assert_eq!( - fake_json_server - .receive_notification::() - .await - .text_document, - lsp::TextDocumentIdentifier::new( - lsp::Url::from_file_path("/the-root/Cargo.toml").unwrap() - ) - ); - - // Renames are reported only to servers matching the buffer's language. - fs.rename( - Path::new("/the-root/test2.rs"), - Path::new("/the-root/test3.rs"), - Default::default(), - ) - .await - .unwrap(); - assert_eq!( - fake_rust_server - .receive_notification::() - .await - .text_document, - lsp::TextDocumentIdentifier::new( - lsp::Url::from_file_path("/the-root/test2.rs").unwrap() - ), - ); - assert_eq!( - fake_rust_server - .receive_notification::() - .await - .text_document, - lsp::TextDocumentItem { - uri: lsp::Url::from_file_path("/the-root/test3.rs").unwrap(), - version: 0, - text: rust_buffer2.read_with(cx, |buffer, _| buffer.text()), - language_id: Default::default() - }, - ); - - rust_buffer2.update(cx, |buffer, cx| { - buffer.update_diagnostics( - DiagnosticSet::from_sorted_entries( - vec![DiagnosticEntry { - diagnostic: Default::default(), - range: Anchor::MIN..Anchor::MAX, - }], - &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("/the-root/test3.rs"), - Path::new("/the-root/test3.json"), - Default::default(), - ) - .await - .unwrap(); - assert_eq!( - fake_rust_server - .receive_notification::() - .await - .text_document, - lsp::TextDocumentIdentifier::new( - lsp::Url::from_file_path("/the-root/test3.rs").unwrap(), - ), - ); - assert_eq!( - fake_json_server - .receive_notification::() - .await - .text_document, - lsp::TextDocumentItem { - uri: lsp::Url::from_file_path("/the-root/test3.json").unwrap(), - version: 0, - text: rust_buffer2.read_with(cx, |buffer, _| buffer.text()), - language_id: Default::default() - }, - ); - - // We clear the diagnostics, since the language has changed. - rust_buffer2.read_with(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, "// ")], cx)); - assert_eq!( - fake_json_server - .receive_notification::() - .await - .text_document, - lsp::VersionedTextDocumentIdentifier::new( - lsp::Url::from_file_path("/the-root/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()], - cx, - ); - }); - - let mut rust_shutdown_requests = fake_rust_server - .handle_request::(|_, _| future::ready(Ok(()))); - let mut json_shutdown_requests = fake_json_server - .handle_request::(|_, _| 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::Url::from_file_path("/the-root/test.rs").unwrap(), - version: 1, - text: rust_buffer.read_with(cx, |buffer, _| buffer.text()), - language_id: Default::default() - } - ); - - // 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::Url::from_file_path("/the-root/package.json").unwrap(), - version: 0, - text: json_buffer.read_with(cx, |buffer, _| buffer.text()), - language_id: Default::default() - }, - lsp::TextDocumentItem { - uri: lsp::Url::from_file_path("/the-root/test3.json").unwrap(), - version: 1, - text: rust_buffer2.read_with(cx, |buffer, _| buffer.text()), - language_id: Default::default() - } - ] - ); - - // Close notifications are reported only to servers matching the buffer's language. - cx.update(|_| drop(json_buffer)); - let close_message = lsp::DidCloseTextDocumentParams { - text_document: lsp::TextDocumentIdentifier::new( - lsp::Url::from_file_path("/the-root/package.json").unwrap(), - ), - }; - assert_eq!( - fake_json_server - .receive_notification::() - .await, - close_message, - ); - } - - #[gpui::test] - async fn test_single_file_worktrees_diagnostics(cx: &mut gpui::TestAppContext) { - cx.foreground().forbid_parking(); - - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/dir", - json!({ - "a.rs": "let a = 1;", - "b.rs": "let b = 2;" - }), - ) - .await; - - let project = Project::test(fs, ["/dir/a.rs".as_ref(), "/dir/b.rs".as_ref()], cx).await; - - let buffer_a = project - .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx)) - .await - .unwrap(); - let buffer_b = project - .update(cx, |project, cx| project.open_local_buffer("/dir/b.rs", cx)) - .await - .unwrap(); - - project.update(cx, |project, cx| { - project - .update_diagnostics( - 0, - lsp::PublishDiagnosticsParams { - uri: Url::from_file_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() - }], - }, - &[], - cx, - ) - .unwrap(); - project - .update_diagnostics( - 0, - lsp::PublishDiagnosticsParams { - uri: Url::from_file_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(lsp::DiagnosticSeverity::WARNING), - message: "error 2".to_string(), - ..Default::default() - }], - }, - &[], - cx, - ) - .unwrap(); - }); - - buffer_a.read_with(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.read_with(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_hidden_worktrees_diagnostics(cx: &mut gpui::TestAppContext) { - cx.foreground().forbid_parking(); - - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/root", - json!({ - "dir": { - "a.rs": "let a = 1;", - }, - "other.rs": "let b = c;" - }), - ) - .await; - - let project = Project::test(fs, ["/root/dir".as_ref()], cx).await; - - let (worktree, _) = project - .update(cx, |project, cx| { - project.find_or_create_local_worktree("/root/other.rs", false, cx) - }) - .await - .unwrap(); - let worktree_id = worktree.read_with(cx, |tree, _| tree.id()); - - project.update(cx, |project, cx| { - project - .update_diagnostics( - 0, - lsp::PublishDiagnosticsParams { - uri: Url::from_file_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() - }], - }, - &[], - cx, - ) - .unwrap(); - }); - - let buffer = project - .update(cx, |project, cx| project.open_buffer((worktree_id, ""), cx)) - .await - .unwrap(); - buffer.read_with(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), - ] - ); - }); - - project.read_with(cx, |project, cx| { - assert_eq!(project.diagnostic_summaries(cx).next(), None); - assert_eq!(project.diagnostic_summary(cx).error_count, 0); - }); - } - - #[gpui::test] - async fn test_disk_based_diagnostics_progress(cx: &mut gpui::TestAppContext) { - cx.foreground().forbid_parking(); - - let progress_token = "the-progress-token"; - let mut language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - let mut fake_servers = language.set_fake_lsp_adapter(FakeLspAdapter { - disk_based_diagnostics_progress_token: Some(progress_token), - disk_based_diagnostics_sources: &["disk"], - ..Default::default() - }); - - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/dir", - json!({ - "a.rs": "fn a() { A }", - "b.rs": "const y: i32 = 1", - }), - ) - .await; - - let project = Project::test(fs, ["/dir".as_ref()], cx).await; - project.update(cx, |project, _| project.languages.add(Arc::new(language))); - let worktree_id = - project.read_with(cx, |p, cx| p.worktrees(cx).next().unwrap().read(cx).id()); - - // Cause worktree to start the fake language server - let _buffer = project - .update(cx, |project, cx| project.open_local_buffer("/dir/b.rs", cx)) - .await - .unwrap(); - - let mut events = subscribe(&project, cx); - - let fake_server = fake_servers.next().await.unwrap(); - fake_server.start_progress(progress_token).await; - assert_eq!( - events.next().await.unwrap(), - Event::DiskBasedDiagnosticsStarted { - language_server_id: 0, - } - ); - - fake_server.notify::( - lsp::PublishDiagnosticsParams { - uri: Url::from_file_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: 0, - path: (worktree_id, Path::new("a.rs")).into() - } - ); - - fake_server.end_progress(progress_token); - assert_eq!( - events.next().await.unwrap(), - Event::DiskBasedDiagnosticsFinished { - language_server_id: 0 - } - ); - - let buffer = project - .update(cx, |p, cx| p.open_local_buffer("/dir/a.rs", cx)) - .await - .unwrap(); - - buffer.read_with(cx, |buffer, _| { - let snapshot = buffer.snapshot(); - let diagnostics = snapshot - .diagnostics_in_range::<_, Point>(0..buffer.len(), false) - .collect::>(); - assert_eq!( - diagnostics, - &[DiagnosticEntry { - 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, - ..Default::default() - } - }] - ) - }); - - // Ensure publishing empty diagnostics twice only results in one update event. - fake_server.notify::( - lsp::PublishDiagnosticsParams { - uri: Url::from_file_path("/dir/a.rs").unwrap(), - version: None, - diagnostics: Default::default(), - }, - ); - assert_eq!( - events.next().await.unwrap(), - Event::DiagnosticsUpdated { - language_server_id: 0, - path: (worktree_id, Path::new("a.rs")).into() - } - ); - - fake_server.notify::( - lsp::PublishDiagnosticsParams { - uri: Url::from_file_path("/dir/a.rs").unwrap(), - version: None, - diagnostics: Default::default(), - }, - ); - cx.foreground().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) { - cx.foreground().forbid_parking(); - - let progress_token = "the-progress-token"; - let mut language = Language::new( - LanguageConfig { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - None, - ); - let mut fake_servers = language.set_fake_lsp_adapter(FakeLspAdapter { - disk_based_diagnostics_sources: &["disk"], - disk_based_diagnostics_progress_token: Some(progress_token), - ..Default::default() - }); - - let fs = FakeFs::new(cx.background()); - fs.insert_tree("/dir", json!({ "a.rs": "" })).await; - - let project = Project::test(fs, ["/dir".as_ref()], cx).await; - project.update(cx, |project, _| project.languages.add(Arc::new(language))); - - let buffer = project - .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx)) - .await - .unwrap(); - - // Simulate diagnostics starting to update. - let fake_server = fake_servers.next().await.unwrap(); - 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([buffer], cx); - }); - let mut events = subscribe(&project, cx); - - // Simulate the newly started server sending more diagnostics. - let fake_server = fake_servers.next().await.unwrap(); - fake_server.start_progress(progress_token).await; - assert_eq!( - events.next().await.unwrap(), - Event::DiskBasedDiagnosticsStarted { - language_server_id: 1 - } - ); - project.read_with(cx, |project, _| { - assert_eq!( - project - .language_servers_running_disk_based_diagnostics() - .collect::>(), - [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: 1 - } - ); - project.read_with(cx, |project, _| { - assert_eq!( - project - .language_servers_running_disk_based_diagnostics() - .collect::>(), - [0; 0] - ); - }); - } - - #[gpui::test] - async fn test_toggling_enable_language_server( - deterministic: Arc, - cx: &mut gpui::TestAppContext, - ) { - deterministic.forbid_parking(); - - let mut rust = Language::new( - LanguageConfig { - name: Arc::from("Rust"), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - None, - ); - let mut fake_rust_servers = rust.set_fake_lsp_adapter(FakeLspAdapter { - name: "rust-lsp", - ..Default::default() - }); - let mut js = Language::new( - LanguageConfig { - name: Arc::from("JavaScript"), - path_suffixes: vec!["js".to_string()], - ..Default::default() - }, - None, - ); - let mut fake_js_servers = js.set_fake_lsp_adapter(FakeLspAdapter { - name: "js-lsp", - ..Default::default() - }); - - let fs = FakeFs::new(cx.background()); - fs.insert_tree("/dir", json!({ "a.rs": "", "b.js": "" })) - .await; - - let project = Project::test(fs, ["/dir".as_ref()], cx).await; - project.update(cx, |project, _| { - project.languages.add(Arc::new(rust)); - project.languages.add(Arc::new(js)); - }); - - let _rs_buffer = project - .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx)) - .await - .unwrap(); - let _js_buffer = project - .update(cx, |project, cx| project.open_local_buffer("/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(), - "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(), - "file:///dir/b.js" - ); - - // Disable Rust language server, ensuring only that server gets stopped. - cx.update(|cx| { - cx.update_global(|settings: &mut Settings, _| { - settings.language_overrides.insert( - Arc::from("Rust"), - settings::LanguageSettings { - 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| { - cx.update_global(|settings: &mut Settings, _| { - settings.language_overrides.insert( - Arc::from("Rust"), - settings::LanguageSettings { - enable_language_server: Some(true), - ..Default::default() - }, - ); - settings.language_overrides.insert( - Arc::from("JavaScript"), - settings::LanguageSettings { - 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(), - "file:///dir/a.rs" - ); - fake_js_server - .receive_notification::() - .await; - } - - #[gpui::test] - async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) { - cx.foreground().forbid_parking(); - - let mut language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - let mut fake_servers = language.set_fake_lsp_adapter(FakeLspAdapter { - disk_based_diagnostics_sources: &["disk"], - ..Default::default() - }); - - let text = " - fn a() { A } - fn b() { BB } - fn c() { CCC } - " - .unindent(); - - let fs = FakeFs::new(cx.background()); - fs.insert_tree("/dir", json!({ "a.rs": text })).await; - - let project = Project::test(fs, ["/dir".as_ref()], cx).await; - project.update(cx, |project, _| project.languages.add(Arc::new(language))); - - let buffer = project - .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx)) - .await - .unwrap(); - - 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")], 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::Url::from_file_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. - buffer.next_notification(cx).await; - buffer.read_with(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 { - severity: DiagnosticSeverity::ERROR, - message: "undefined variable 'BB'".to_string(), - is_disk_based: true, - group_id: 1, - is_primary: true, - ..Default::default() - }, - }, - DiagnosticEntry { - range: Point::new(4, 9)..Point::new(4, 12), - diagnostic: Diagnostic { - severity: DiagnosticSeverity::ERROR, - message: "undefined variable 'CCC'".to_string(), - is_disk_based: true, - group_id: 2, - is_primary: true, - ..Default::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::Url::from_file_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() - }, - ], - }, - ); - - buffer.next_notification(cx).await; - buffer.read_with(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 { - severity: DiagnosticSeverity::WARNING, - message: "unreachable statement".to_string(), - is_disk_based: true, - group_id: 4, - is_primary: true, - ..Default::default() - } - }, - DiagnosticEntry { - range: Point::new(2, 9)..Point::new(2, 10), - diagnostic: Diagnostic { - severity: DiagnosticSeverity::ERROR, - message: "undefined variable 'A'".to_string(), - is_disk_based: true, - group_id: 3, - is_primary: true, - ..Default::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), " ")], cx); - buffer.edit([(Point::new(2, 8)..Point::new(2, 10), "(x: usize)")], cx); - buffer.edit([(Point::new(3, 10)..Point::new(3, 10), "xxx")], 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::Url::from_file_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() - }, - ], - }, - ); - - buffer.next_notification(cx).await; - buffer.read_with(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 { - severity: DiagnosticSeverity::WARNING, - message: "undefined variable 'A'".to_string(), - is_disk_based: true, - group_id: 6, - is_primary: true, - ..Default::default() - } - }, - DiagnosticEntry { - range: Point::new(3, 9)..Point::new(3, 14), - diagnostic: Diagnostic { - severity: DiagnosticSeverity::ERROR, - message: "undefined variable 'BB'".to_string(), - is_disk_based: true, - group_id: 5, - is_primary: true, - ..Default::default() - }, - } - ] - ); - }); - } - - #[gpui::test] - async fn test_empty_diagnostic_ranges(cx: &mut gpui::TestAppContext) { - cx.foreground().forbid_parking(); - - let text = concat!( - "let one = ;\n", // - "let two = \n", - "let three = 3;\n", - ); - - let fs = FakeFs::new(cx.background()); - fs.insert_tree("/dir", json!({ "a.rs": text })).await; - - let project = Project::test(fs, ["/dir".as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx)) - .await - .unwrap(); - - project.update(cx, |project, cx| { - project - .update_buffer_diagnostics( - &buffer, - vec![ - DiagnosticEntry { - range: PointUtf16::new(0, 10)..PointUtf16::new(0, 10), - diagnostic: Diagnostic { - severity: DiagnosticSeverity::ERROR, - message: "syntax error 1".to_string(), - ..Default::default() - }, - }, - DiagnosticEntry { - range: PointUtf16::new(1, 10)..PointUtf16::new(1, 10), - diagnostic: Diagnostic { - severity: DiagnosticSeverity::ERROR, - message: "syntax error 2".to_string(), - ..Default::default() - }, - }, - ], - None, - 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.read_with(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_edits_from_lsp_with_past_version(cx: &mut gpui::TestAppContext) { - cx.foreground().forbid_parking(); - - let mut language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - let mut fake_servers = language.set_fake_lsp_adapter(Default::default()); - - let text = " - fn a() { - f1(); - } - fn b() { - f2(); - } - fn c() { - f3(); - } - " - .unindent(); - - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/dir", - json!({ - "a.rs": text.clone(), - }), - ) - .await; - - let project = Project::test(fs, ["/dir".as_ref()], cx).await; - project.update(cx, |project, _| project.languages.add(Arc::new(language))); - let buffer = project - .update(cx, |project, cx| project.open_local_buffer("/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", - )], - cx, - ); - buffer.edit( - [( - Point::new(2, 0)..Point::new(2, 0), - " // inside first function\n", - )], - cx, - ); - buffer.edit( - [( - Point::new(6, 4)..Point::new(6, 4), - "// inside second function ", - )], - 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 = project - .update(cx, |project, cx| { - project.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(), - }, - ], - Some(lsp_document_version), - cx, - ) - }) - .await - .unwrap(); - - buffer.update(cx, |buffer, cx| { - for (range, new_text) in edits { - buffer.edit([(range, new_text)], 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_lsp_with_edits_on_adjacent_lines(cx: &mut gpui::TestAppContext) { - cx.foreground().forbid_parking(); - - let text = " - use a::b; - use a::c; - - fn f() { - b(); - c(); - } - " - .unindent(); - - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/dir", - json!({ - "a.rs": text.clone(), - }), - ) - .await; - - let project = Project::test(fs, ["/dir".as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| project.open_local_buffer("/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 = project - .update(cx, |project, cx| { - project.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(), - }, - ], - 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)], cx); - } - assert_eq!( - buffer.text(), - " - use a::{b, c}; - - fn f() { - b(); - c(); - } - " - .unindent() - ); - }); - } - - #[gpui::test] - async fn test_invalid_edits_from_lsp(cx: &mut gpui::TestAppContext) { - cx.foreground().forbid_parking(); - - let text = " - use a::b; - use a::c; - - fn f() { - b(); - c(); - } - " - .unindent(); - - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/dir", - json!({ - "a.rs": text.clone(), - }), - ) - .await; - - let project = Project::test(fs, ["/dir".as_ref()], cx).await; - let buffer = project - .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx)) - .await - .unwrap(); - - // Simulate the language server sending us edits in a non-ordered fashion, - // with ranges sometimes being inverted. - let edits = project - .update(cx, |project, cx| { - project.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(7, 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(), - }, - ], - 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)], 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, true) { - if chunks.last().map_or(false, |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] - async fn test_search_worktree_without_files(cx: &mut gpui::TestAppContext) { - let dir = temp_tree(json!({ - "root": { - "dir1": {}, - "dir2": { - "dir3": {} - } - } - })); - - let project = Project::test(Arc::new(RealFs), [dir.path()], cx).await; - let cancel_flag = Default::default(); - let results = project - .read_with(cx, |project, cx| { - project.match_paths("dir", false, false, 10, &cancel_flag, cx) - }) - .await; - - assert!(results.is_empty()); - } - - #[gpui::test(iterations = 10)] - async fn test_definition(cx: &mut gpui::TestAppContext) { - let mut language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - let mut fake_servers = language.set_fake_lsp_adapter(Default::default()); - - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/dir", - json!({ - "a.rs": "const fn a() { A }", - "b.rs": "const y: i32 = crate::a()", - }), - ) - .await; - - let project = Project::test(fs, ["/dir/b.rs".as_ref()], cx).await; - project.update(cx, |project, _| project.languages.add(Arc::new(language))); - - let buffer = project - .update(cx, |project, cx| project.open_local_buffer("/dir/b.rs", cx)) - .await - .unwrap(); - - let fake_server = fake_servers.next().await.unwrap(); - fake_server.handle_request::(|params, _| async move { - let params = params.text_document_position_params; - assert_eq!( - params.text_document.uri.to_file_path().unwrap(), - Path::new("/dir/b.rs"), - ); - assert_eq!(params.position, lsp::Position::new(0, 22)); - - Ok(Some(lsp::GotoDefinitionResponse::Scalar( - lsp::Location::new( - lsp::Url::from_file_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.definition(&buffer, 22, cx)) - .await - .unwrap(); - - // Assert no new language server started - cx.foreground().run_until_parked(); - assert!(fake_servers.try_next().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("/dir/a.rs"), - ); - assert_eq!(definition.target.range.to_offset(target_buffer), 9..10); - assert_eq!( - list_worktrees(&project, cx), - [("/dir/b.rs".as_ref(), true), ("/dir/a.rs".as_ref(), false)] - ); - - drop(definition); - }); - cx.read(|cx| { - assert_eq!(list_worktrees(&project, cx), [("/dir/b.rs".as_ref(), true)]); - }); - - fn list_worktrees<'a>( - project: &'a ModelHandle, - cx: &'a AppContext, - ) -> 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_without_edit_ranges(cx: &mut gpui::TestAppContext) { - let mut language = Language::new( - LanguageConfig { - name: "TypeScript".into(), - path_suffixes: vec!["ts".to_string()], - ..Default::default() - }, - Some(tree_sitter_typescript::language_typescript()), - ); - let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); - - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/dir", - json!({ - "a.ts": "", - }), - ) - .await; - - let project = Project::test(fs, ["/dir".as_ref()], cx).await; - project.update(cx, |project, _| project.languages.add(Arc::new(language))); - let buffer = project - .update(cx, |p, cx| p.open_local_buffer("/dir/a.ts", cx)) - .await - .unwrap(); - - let fake_server = fake_language_servers.next().await.unwrap(); - - 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(), cx) - }); - - fake_server - .handle_request::(|_, _| 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(); - let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - assert_eq!(completions.len(), 1); - assert_eq!(completions[0].new_text, "fullyQualifiedName"); - assert_eq!( - completions[0].old_range.to_offset(&snapshot), - text.len() - 3..text.len() - ); - - 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, cx) - }); - - fake_server - .handle_request::(|_, _| async move { - Ok(Some(lsp::CompletionResponse::Array(vec![ - lsp::CompletionItem { - label: "component".into(), - ..Default::default() - }, - ]))) - }) - .next() - .await; - let completions = completions.await.unwrap(); - let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); - assert_eq!(completions.len(), 1); - assert_eq!(completions[0].new_text, "component"); - assert_eq!( - completions[0].old_range.to_offset(&snapshot), - text.len() - 4..text.len() - 1 - ); - } - - #[gpui::test(iterations = 10)] - async fn test_apply_code_actions_with_commands(cx: &mut gpui::TestAppContext) { - let mut language = Language::new( - LanguageConfig { - name: "TypeScript".into(), - path_suffixes: vec!["ts".to_string()], - ..Default::default() - }, - None, - ); - let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); - - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/dir", - json!({ - "a.ts": "a", - }), - ) - .await; - - let project = Project::test(fs, ["/dir".as_ref()], cx).await; - project.update(cx, |project, _| project.languages.add(Arc::new(language))); - let buffer = project - .update(cx, |p, cx| p.open_local_buffer("/dir/a.ts", cx)) - .await - .unwrap(); - - let fake_server = fake_language_servers.next().await.unwrap(); - - // Language server returns code actions that contain commands, and not edits. - let actions = project.update(cx, |project, cx| project.code_actions(&buffer, 0..0, cx)); - fake_server - .handle_request::(|_, _| async move { - Ok(Some(vec![ - lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction { - title: "The code action".into(), - command: Some(lsp::Command { - title: "The command".into(), - command: "_the/command".into(), - arguments: Some(vec![json!("the-argument")]), - }), - ..Default::default() - }), - lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction { - title: "two".into(), - ..Default::default() - }), - ])) - }) - .next() - .await; - - let action = actions.await.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.handle_request::( - |action, _| async move { Ok(action) }, - ); - - // While executing the command, the language server sends the editor - // a `workspaceEdit` request. - fake_server - .handle_request::({ - 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::Url::from_file_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() - }, - }, - ) - .await - .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_save_file(cx: &mut gpui::TestAppContext) { - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/dir", - json!({ - "file1": "the old contents", - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; - let buffer = project - .update(cx, |p, cx| p.open_local_buffer("/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))], cx); - buffer.save(cx) - }) - .await - .unwrap(); - - let new_text = fs.load(Path::new("/dir/file1")).await.unwrap(); - assert_eq!(new_text, buffer.read_with(cx, |buffer, _| buffer.text())); - } - - #[gpui::test] - async fn test_save_in_single_file_worktree(cx: &mut gpui::TestAppContext) { - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/dir", - json!({ - "file1": "the old contents", - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/dir/file1".as_ref()], cx).await; - let buffer = project - .update(cx, |p, cx| p.open_local_buffer("/dir/file1", cx)) - .await - .unwrap(); - buffer - .update(cx, |buffer, cx| { - buffer.edit([(0..0, "a line of text.\n".repeat(10 * 1024))], cx); - buffer.save(cx) - }) - .await - .unwrap(); - - let new_text = fs.load(Path::new("/dir/file1")).await.unwrap(); - assert_eq!(new_text, buffer.read_with(cx, |buffer, _| buffer.text())); - } - - #[gpui::test] - async fn test_save_as(cx: &mut gpui::TestAppContext) { - let fs = FakeFs::new(cx.background()); - fs.insert_tree("/dir", json!({})).await; - - let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; - let buffer = project.update(cx, |project, cx| { - project.create_buffer("", None, cx).unwrap() - }); - buffer.update(cx, |buffer, cx| { - buffer.edit([(0..0, "abc")], cx); - assert!(buffer.is_dirty()); - assert!(!buffer.has_conflict()); - }); - project - .update(cx, |project, cx| { - project.save_buffer_as(buffer.clone(), "/dir/file1".into(), cx) - }) - .await - .unwrap(); - assert_eq!(fs.load(Path::new("/dir/file1")).await.unwrap(), "abc"); - buffer.read_with(cx, |buffer, cx| { - assert_eq!(buffer.file().unwrap().full_path(cx), Path::new("dir/file1")); - assert!(!buffer.is_dirty()); - assert!(!buffer.has_conflict()); - }); - - let opened_buffer = project - .update(cx, |project, cx| { - project.open_local_buffer("/dir/file1", cx) - }) - .await - .unwrap(); - assert_eq!(opened_buffer, buffer); - } - - #[gpui::test(retries = 5)] - async fn test_rescan_and_remote_updates( - deterministic: Arc, - cx: &mut gpui::TestAppContext, - ) { - let dir = temp_tree(json!({ - "a": { - "file1": "", - "file2": "", - "file3": "", - }, - "b": { - "c": { - "file4": "", - "file5": "", - } - } - })); - - let project = Project::test(Arc::new(RealFs), [dir.path()], cx).await; - let rpc = project.read_with(cx, |p, _| p.client.clone()); - - 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: &gpui::TestAppContext| { - project.read_with(cx, |project, cx| { - let tree = project.worktrees(cx).next().unwrap(); - tree.read(cx) - .entry_for_path(path) - .expect(&format!("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.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); - let initial_snapshot = tree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot()); - let remote = cx.update(|cx| { - Worktree::remote( - 1, - 1, - proto::WorktreeMetadata { - id: initial_snapshot.id().to_proto(), - root_name: initial_snapshot.root_name().into(), - visible: true, - }, - rpc.clone(), - cx, - ) - }); - remote.update(cx, |remote, _| { - let update = initial_snapshot.build_initial_update(1); - remote.as_remote_mut().unwrap().update_from_remote(update); - }); - deterministic.run_until_parked(); - - cx.read(|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; - - let expected_paths = vec![ - "a", - "a/file1", - "a/file2.new", - "b", - "d", - "d/file3", - "d/file4", - ]; - - cx.read(|app| { - assert_eq!( - tree.read(app) - .paths() - .map(|p| p.to_str().unwrap()) - .collect::>(), - expected_paths - ); - - 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); - - assert_eq!( - buffer2.read(app).file().unwrap().path().as_ref(), - Path::new("a/file2.new") - ); - assert_eq!( - buffer3.read(app).file().unwrap().path().as_ref(), - Path::new("d/file3") - ); - assert_eq!( - buffer4.read(app).file().unwrap().path().as_ref(), - Path::new("d/file4") - ); - assert_eq!( - buffer5.read(app).file().unwrap().path().as_ref(), - Path::new("b/c/file5") - ); - - assert!(!buffer2.read(app).file().unwrap().is_deleted()); - assert!(!buffer3.read(app).file().unwrap().is_deleted()); - assert!(!buffer4.read(app).file().unwrap().is_deleted()); - assert!(buffer5.read(app).file().unwrap().is_deleted()); - }); - - // Update the remote worktree. Check that it becomes consistent with the - // local worktree. - remote.update(cx, |remote, cx| { - let update = tree.read(cx).as_local().unwrap().snapshot().build_update( - &initial_snapshot, - 1, - 1, - true, - ); - remote.as_remote_mut().unwrap().update_from_remote(update); - }); - deterministic.run_until_parked(); - remote.read_with(cx, |remote, _| { - assert_eq!( - remote - .paths() - .map(|p| p.to_str().unwrap()) - .collect::>(), - expected_paths - ); - }); - } - - #[gpui::test] - async fn test_buffer_deduping(cx: &mut gpui::TestAppContext) { - let fs = FakeFs::new(cx.background()); - 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.read_with(cx, |b, _| b.text()), "a-contents"); - assert_eq!(buffer_b.read_with(cx, |b, _| b.text()), "b-contents"); - - // There is only one buffer per path. - let buffer_a_id = buffer_a_1.id(); - assert_eq!(buffer_a_2.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.id(), buffer_a_id); - } - - #[gpui::test] - async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/dir", - json!({ - "file1": "abc", - "file2": "def", - "file3": "ghi", - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; - - let buffer1 = project - .update(cx, |p, cx| p.open_local_buffer("/dir/file1", cx)) - .await - .unwrap(); - let events = Rc::new(RefCell::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.borrow_mut().push(event.clone()), - } - }) - .detach(); - - assert!(!buffer.is_dirty()); - assert!(events.borrow().is_empty()); - - buffer.edit([(1..2, "")], 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.borrow(), - &[language::Event::Edited, language::Event::DirtyChanged] - ); - events.borrow_mut().clear(); - buffer.did_save( - buffer.version(), - buffer.as_rope().fingerprint(), - buffer.file().unwrap().mtime(), - None, - 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.borrow(), &[language::Event::Saved]); - events.borrow_mut().clear(); - - buffer.edit([(1..1, "B")], cx); - buffer.edit([(2..2, "D")], 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.borrow(), - &[ - language::Event::Edited, - language::Event::DirtyChanged, - language::Event::Edited, - ], - ); - events.borrow_mut().clear(); - - // After restoring the buffer to its previously-saved state, - // the buffer is not considered dirty anymore. - buffer.edit([(1..3, "")], cx); - assert!(buffer.text() == "ac"); - assert!(!buffer.is_dirty()); - }); - - assert_eq!( - *events.borrow(), - &[language::Event::Edited, language::Event::DirtyChanged] - ); - - // When a file is deleted, the buffer is considered dirty. - let events = Rc::new(RefCell::new(Vec::new())); - let buffer2 = project - .update(cx, |p, cx| p.open_local_buffer("/dir/file2", cx)) - .await - .unwrap(); - buffer2.update(cx, |_, cx| { - cx.subscribe(&buffer2, { - let events = events.clone(); - move |_, _, event, _| events.borrow_mut().push(event.clone()) - }) - .detach(); - }); - - fs.remove_file("/dir/file2".as_ref(), Default::default()) - .await - .unwrap(); - buffer2.condition(&cx, |b, _| b.is_dirty()).await; - assert_eq!( - *events.borrow(), - &[ - language::Event::DirtyChanged, - language::Event::FileHandleChanged - ] - ); - - // When a file is already dirty when deleted, we don't emit a Dirtied event. - let events = Rc::new(RefCell::new(Vec::new())); - let buffer3 = project - .update(cx, |p, cx| p.open_local_buffer("/dir/file3", cx)) - .await - .unwrap(); - buffer3.update(cx, |_, cx| { - cx.subscribe(&buffer3, { - let events = events.clone(); - move |_, _, event, _| events.borrow_mut().push(event.clone()) - }) - .detach(); - }); - - buffer3.update(cx, |buffer, cx| { - buffer.edit([(0..0, "x")], cx); - }); - events.borrow_mut().clear(); - fs.remove_file("/dir/file3".as_ref(), Default::default()) - .await - .unwrap(); - buffer3 - .condition(&cx, |_, _| !events.borrow().is_empty()) - .await; - assert_eq!(*events.borrow(), &[language::Event::FileHandleChanged]); - cx.read(|cx| assert!(buffer3.read(cx).is_dirty())); - } - - #[gpui::test] - async fn test_buffer_file_changes_on_disk(cx: &mut gpui::TestAppContext) { - let initial_contents = "aaa\nbbbbb\nc\n"; - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/dir", - json!({ - "the-file": initial_contents, - }), - ) - .await; - let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; - let buffer = project - .update(cx, |p, cx| p.open_local_buffer("/dir/the-file", cx)) - .await - .unwrap(); - - let anchors = (0..3) - .map(|row| buffer.read_with(cx, |b, _| b.anchor_before(Point::new(row, 1)))) - .collect::>(); - - // Change the file on disk, adding two new lines of text, and removing - // one line. - buffer.read_with(cx, |buffer, _| { - assert!(!buffer.is_dirty()); - assert!(!buffer.has_conflict()); - }); - let new_contents = "AAAA\naaa\nBB\nbbbbb\n"; - fs.save( - "/dir/the-file".as_ref(), - &new_contents.into(), - NewlineStyle::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. - buffer - .condition(&cx, |buffer, _| buffer.text() == new_contents) - .await; - - buffer.update(cx, |buffer, _| { - assert_eq!(buffer.text(), new_contents); - assert!(!buffer.is_dirty()); - assert!(!buffer.has_conflict()); - - let anchor_positions = anchors - .iter() - .map(|anchor| anchor.to_point(&*buffer)) - .collect::>(); - assert_eq!( - anchor_positions, - [Point::new(1, 1), Point::new(3, 1), Point::new(4, 0)] - ); - }); - - // Modify the buffer - buffer.update(cx, |buffer, cx| { - buffer.edit([(0..0, " ")], cx); - assert!(buffer.is_dirty()); - assert!(!buffer.has_conflict()); - }); - - // Change the file on disk again, adding blank lines to the beginning. - fs.save( - "/dir/the-file".as_ref(), - &"\n\n\nAAAA\naaa\nBB\nbbbbb\n".into(), - NewlineStyle::Unix, - ) - .await - .unwrap(); - - // Because the buffer is modified, it doesn't reload from disk, but is - // marked as having a conflict. - buffer - .condition(&cx, |buffer, _| buffer.has_conflict()) - .await; - } - - #[gpui::test] - async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) { - cx.foreground().forbid_parking(); - - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/the-dir", - json!({ - "a.rs": " - fn foo(mut v: Vec) { - for x in &v { - v.push(1); - } - } - " - .unindent(), - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/the-dir".as_ref()], cx).await; - let buffer = project - .update(cx, |p, cx| p.open_local_buffer("/the-dir/a.rs", cx)) - .await - .unwrap(); - - let buffer_uri = Url::from_file_path("/the-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.clone(), - range: lsp::Range::new( - lsp::Position::new(2, 8), - lsp::Position::new(2, 17), - ), - }, - message: "original diagnostic".to_string(), - }]), - ..Default::default() - }, - ], - version: None, - }; - - project - .update(cx, |p, cx| p.update_diagnostics(0, message, &[], cx)) - .unwrap(); - let buffer = buffer.read_with(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: 0, - is_primary: true, - ..Default::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: 0, - is_primary: false, - ..Default::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: 1, - is_primary: false, - ..Default::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: 1, - is_primary: false, - ..Default::default() - } - }, - DiagnosticEntry { - range: Point::new(2, 8)..Point::new(2, 17), - diagnostic: Diagnostic { - severity: DiagnosticSeverity::ERROR, - message: "error 2".to_string(), - group_id: 1, - is_primary: true, - ..Default::default() - } - } - ] - ); - - assert_eq!( - buffer.diagnostic_group::(0).collect::>(), - &[ - DiagnosticEntry { - range: Point::new(1, 8)..Point::new(1, 9), - diagnostic: Diagnostic { - severity: DiagnosticSeverity::WARNING, - message: "error 1".to_string(), - group_id: 0, - is_primary: true, - ..Default::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: 0, - is_primary: false, - ..Default::default() - } - }, - ] - ); - assert_eq!( - buffer.diagnostic_group::(1).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: 1, - is_primary: false, - ..Default::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: 1, - is_primary: false, - ..Default::default() - } - }, - DiagnosticEntry { - range: Point::new(2, 8)..Point::new(2, 17), - diagnostic: Diagnostic { - severity: DiagnosticSeverity::ERROR, - message: "error 2".to_string(), - group_id: 1, - is_primary: true, - ..Default::default() - } - } - ] - ); - } - - #[gpui::test] - async fn test_rename(cx: &mut gpui::TestAppContext) { - cx.foreground().forbid_parking(); - - let mut language = Language::new( - LanguageConfig { - name: "Rust".into(), - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - Some(tree_sitter_rust::language()), - ); - let mut fake_servers = language.set_fake_lsp_adapter(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 fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/dir", - json!({ - "one.rs": "const ONE: usize = 1;", - "two.rs": "const TWO: usize = one::ONE + one::ONE;" - }), - ) - .await; - - let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; - project.update(cx, |project, _| project.languages.add(Arc::new(language))); - let buffer = project - .update(cx, |project, cx| { - project.open_local_buffer("/dir/one.rs", cx) - }) - .await - .unwrap(); - - let fake_server = fake_servers.next().await.unwrap(); - - let response = project.update(cx, |project, cx| { - project.prepare_rename(buffer.clone(), 7, cx) - }); - fake_server - .handle_request::(|params, _| async move { - assert_eq!(params.text_document.uri.as_str(), "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 range = response.await.unwrap().unwrap(); - let range = buffer.read_with(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(), true, cx) - }); - fake_server - .handle_request::(|params, _| async move { - assert_eq!( - params.text_document_position.text_document.uri.as_str(), - "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::Url::from_file_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::Url::from_file_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 - .read_with(cx, |buffer, _| buffer.text()), - "const THREE: usize = 1;" - ); - assert_eq!( - transaction - .into_keys() - .next() - .unwrap() - .read_with(cx, |buffer, _| buffer.text()), - "const TWO: usize = one::THREE + one::THREE;" - ); - } - - #[gpui::test] - async fn test_search(cx: &mut gpui::TestAppContext) { - let fs = FakeFs::new(cx.background()); - fs.insert_tree( - "/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(), ["/dir".as_ref()], cx).await; - assert_eq!( - search(&project, SearchQuery::text("TWO", false, true), cx) - .await - .unwrap(), - HashMap::from_iter([ - ("two.rs".to_string(), vec![6..9]), - ("three.rs".to_string(), vec![37..40]) - ]) - ); - - let buffer_4 = project - .update(cx, |project, cx| { - project.open_local_buffer("/dir/four.rs", cx) - }) - .await - .unwrap(); - buffer_4.update(cx, |buffer, cx| { - let text = "two::TWO"; - buffer.edit([(20..28, text), (31..43, text)], cx); - }); - - assert_eq!( - search(&project, SearchQuery::text("TWO", false, true), cx) - .await - .unwrap(), - HashMap::from_iter([ - ("two.rs".to_string(), vec![6..9]), - ("three.rs".to_string(), vec![37..40]), - ("four.rs".to_string(), vec![25..28, 36..39]) - ]) - ); - - async fn search( - project: &ModelHandle, - query: SearchQuery, - cx: &mut gpui::TestAppContext, - ) -> Result>>> { - let results = project - .update(cx, |project, cx| project.search(query, cx)) - .await?; - - Ok(results - .into_iter() - .map(|(buffer, ranges)| { - buffer.read_with(cx, |buffer, _| { - let path = buffer.file().unwrap().path().to_string_lossy().to_string(); - let ranges = ranges - .into_iter() - .map(|range| range.to_offset(buffer)) - .collect::>(); - (path, ranges) - }) - }) - .collect()) - } - } -} diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs new file mode 100644 index 0000000000..921c7553a6 --- /dev/null +++ b/crates/project/src/project_tests.rs @@ -0,0 +1,2928 @@ +use crate::{worktree::WorktreeHandle, Event, *}; +use fs::RealFs; +use futures::{future, StreamExt}; +use gpui::{executor::Deterministic, test::subscribe}; +use language::{ + tree_sitter_rust, tree_sitter_typescript, Diagnostic, FakeLspAdapter, LanguageConfig, + NewlineStyle, OffsetRangeExt, Point, ToPoint, +}; +use lsp::Url; +use serde_json::json; +use std::{cell::RefCell, os::unix, path::PathBuf, rc::Rc, task::Poll}; +use unindent::Unindent as _; +use util::{assert_set_eq, test::temp_tree}; + +#[gpui::test] +async fn test_populate_and_search(cx: &mut gpui::TestAppContext) { + let dir = temp_tree(json!({ + "root": { + "apple": "", + "banana": { + "carrot": { + "date": "", + "endive": "", + } + }, + "fennel": { + "grape": "", + } + } + })); + + let root_link_path = dir.path().join("root_link"); + unix::fs::symlink(&dir.path().join("root"), &root_link_path).unwrap(); + unix::fs::symlink( + &dir.path().join("root/fennel"), + &dir.path().join("root/finnochio"), + ) + .unwrap(); + + let project = Project::test(Arc::new(RealFs), [root_link_path.as_ref()], cx).await; + + project.read_with(cx, |project, cx| { + let tree = project.worktrees(cx).next().unwrap().read(cx); + assert_eq!(tree.file_count(), 5); + assert_eq!( + tree.inode_for_path("fennel/grape"), + tree.inode_for_path("finnochio/grape") + ); + }); + + let cancel_flag = Default::default(); + let results = project + .read_with(cx, |project, cx| { + project.match_paths("bna", false, false, 10, &cancel_flag, cx) + }) + .await; + assert_eq!( + results + .into_iter() + .map(|result| result.path) + .collect::>>(), + vec![ + PathBuf::from("banana/carrot/date").into(), + PathBuf::from("banana/carrot/endive").into(), + ] + ); +} + +#[gpui::test] +async fn test_managing_language_servers(cx: &mut gpui::TestAppContext) { + cx.foreground().forbid_parking(); + + let mut rust_language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut json_language = Language::new( + LanguageConfig { + name: "JSON".into(), + path_suffixes: vec!["json".to_string()], + ..Default::default() + }, + None, + ); + let mut fake_rust_servers = rust_language.set_fake_lsp_adapter(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() + }); + let mut fake_json_servers = json_language.set_fake_lsp_adapter(FakeLspAdapter { + name: "the-json-language-server", + capabilities: lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![":".to_string()]), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/the-root", + json!({ + "test.rs": "const A: i32 = 1;", + "test2.rs": "", + "Cargo.toml": "a = 1", + "package.json": "{\"a\": 1}", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/the-root".as_ref()], cx).await; + project.update(cx, |project, _| { + project.languages.add(Arc::new(rust_language)); + project.languages.add(Arc::new(json_language)); + }); + + // Open a buffer without an associated language server. + let toml_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/the-root/Cargo.toml", cx) + }) + .await + .unwrap(); + + // Open a buffer with an associated language server. + let rust_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/the-root/test.rs", 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::Url::from_file_path("/the-root/test.rs").unwrap(), + version: 0, + text: "const A: i32 = 1;".to_string(), + language_id: Default::default() + } + ); + + // The buffer is configured based on the language server's capabilities. + rust_buffer.read_with(cx, |buffer, _| { + assert_eq!( + buffer.completion_triggers(), + &[".".to_string(), "::".to_string()] + ); + }); + toml_buffer.read_with(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")], cx)); + assert_eq!( + fake_rust_server + .receive_notification::() + .await + .text_document, + lsp::VersionedTextDocumentIdentifier::new( + lsp::Url::from_file_path("/the-root/test.rs").unwrap(), + 1 + ) + ); + + // Open a third buffer with a different associated language server. + let json_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/the-root/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::Url::from_file_path("/the-root/package.json").unwrap(), + version: 0, + text: "{\"a\": 1}".to_string(), + language_id: Default::default() + } + ); + + // This buffer is configured based on the second language server's + // capabilities. + json_buffer.read_with(cx, |buffer, _| { + assert_eq!(buffer.completion_triggers(), &[":".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 = project + .update(cx, |project, cx| { + project.open_local_buffer("/the-root/test2.rs", cx) + }) + .await + .unwrap(); + rust_buffer2.read_with(cx, |buffer, _| { + assert_eq!( + buffer.completion_triggers(), + &[".".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")], cx)); + rust_buffer2.update(cx, |buffer, cx| buffer.edit([(0..0, "let x = 1;")], cx)); + assert_eq!( + fake_rust_server + .receive_notification::() + .await + .text_document, + lsp::VersionedTextDocumentIdentifier::new( + lsp::Url::from_file_path("/the-root/test2.rs").unwrap(), + 1 + ) + ); + + // Save notifications are reported to all servers. + toml_buffer + .update(cx, |buffer, cx| buffer.save(cx)) + .await + .unwrap(); + assert_eq!( + fake_rust_server + .receive_notification::() + .await + .text_document, + lsp::TextDocumentIdentifier::new(lsp::Url::from_file_path("/the-root/Cargo.toml").unwrap()) + ); + assert_eq!( + fake_json_server + .receive_notification::() + .await + .text_document, + lsp::TextDocumentIdentifier::new(lsp::Url::from_file_path("/the-root/Cargo.toml").unwrap()) + ); + + // Renames are reported only to servers matching the buffer's language. + fs.rename( + Path::new("/the-root/test2.rs"), + Path::new("/the-root/test3.rs"), + Default::default(), + ) + .await + .unwrap(); + assert_eq!( + fake_rust_server + .receive_notification::() + .await + .text_document, + lsp::TextDocumentIdentifier::new(lsp::Url::from_file_path("/the-root/test2.rs").unwrap()), + ); + assert_eq!( + fake_rust_server + .receive_notification::() + .await + .text_document, + lsp::TextDocumentItem { + uri: lsp::Url::from_file_path("/the-root/test3.rs").unwrap(), + version: 0, + text: rust_buffer2.read_with(cx, |buffer, _| buffer.text()), + language_id: Default::default() + }, + ); + + rust_buffer2.update(cx, |buffer, cx| { + buffer.update_diagnostics( + DiagnosticSet::from_sorted_entries( + vec![DiagnosticEntry { + diagnostic: Default::default(), + range: Anchor::MIN..Anchor::MAX, + }], + &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("/the-root/test3.rs"), + Path::new("/the-root/test3.json"), + Default::default(), + ) + .await + .unwrap(); + assert_eq!( + fake_rust_server + .receive_notification::() + .await + .text_document, + lsp::TextDocumentIdentifier::new(lsp::Url::from_file_path("/the-root/test3.rs").unwrap(),), + ); + assert_eq!( + fake_json_server + .receive_notification::() + .await + .text_document, + lsp::TextDocumentItem { + uri: lsp::Url::from_file_path("/the-root/test3.json").unwrap(), + version: 0, + text: rust_buffer2.read_with(cx, |buffer, _| buffer.text()), + language_id: Default::default() + }, + ); + + // We clear the diagnostics, since the language has changed. + rust_buffer2.read_with(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, "// ")], cx)); + assert_eq!( + fake_json_server + .receive_notification::() + .await + .text_document, + lsp::VersionedTextDocumentIdentifier::new( + lsp::Url::from_file_path("/the-root/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()], + cx, + ); + }); + + let mut rust_shutdown_requests = fake_rust_server + .handle_request::(|_, _| future::ready(Ok(()))); + let mut json_shutdown_requests = fake_json_server + .handle_request::(|_, _| 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::Url::from_file_path("/the-root/test.rs").unwrap(), + version: 1, + text: rust_buffer.read_with(cx, |buffer, _| buffer.text()), + language_id: Default::default() + } + ); + + // 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::Url::from_file_path("/the-root/package.json").unwrap(), + version: 0, + text: json_buffer.read_with(cx, |buffer, _| buffer.text()), + language_id: Default::default() + }, + lsp::TextDocumentItem { + uri: lsp::Url::from_file_path("/the-root/test3.json").unwrap(), + version: 1, + text: rust_buffer2.read_with(cx, |buffer, _| buffer.text()), + language_id: Default::default() + } + ] + ); + + // Close notifications are reported only to servers matching the buffer's language. + cx.update(|_| drop(json_buffer)); + let close_message = lsp::DidCloseTextDocumentParams { + text_document: lsp::TextDocumentIdentifier::new( + lsp::Url::from_file_path("/the-root/package.json").unwrap(), + ), + }; + assert_eq!( + fake_json_server + .receive_notification::() + .await, + close_message, + ); +} + +#[gpui::test] +async fn test_single_file_worktrees_diagnostics(cx: &mut gpui::TestAppContext) { + cx.foreground().forbid_parking(); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "a.rs": "let a = 1;", + "b.rs": "let b = 2;" + }), + ) + .await; + + let project = Project::test(fs, ["/dir/a.rs".as_ref(), "/dir/b.rs".as_ref()], cx).await; + + let buffer_a = project + .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx)) + .await + .unwrap(); + let buffer_b = project + .update(cx, |project, cx| project.open_local_buffer("/dir/b.rs", cx)) + .await + .unwrap(); + + project.update(cx, |project, cx| { + project + .update_diagnostics( + 0, + lsp::PublishDiagnosticsParams { + uri: Url::from_file_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() + }], + }, + &[], + cx, + ) + .unwrap(); + project + .update_diagnostics( + 0, + lsp::PublishDiagnosticsParams { + uri: Url::from_file_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(lsp::DiagnosticSeverity::WARNING), + message: "error 2".to_string(), + ..Default::default() + }], + }, + &[], + cx, + ) + .unwrap(); + }); + + buffer_a.read_with(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.read_with(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_hidden_worktrees_diagnostics(cx: &mut gpui::TestAppContext) { + cx.foreground().forbid_parking(); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/root", + json!({ + "dir": { + "a.rs": "let a = 1;", + }, + "other.rs": "let b = c;" + }), + ) + .await; + + let project = Project::test(fs, ["/root/dir".as_ref()], cx).await; + + let (worktree, _) = project + .update(cx, |project, cx| { + project.find_or_create_local_worktree("/root/other.rs", false, cx) + }) + .await + .unwrap(); + let worktree_id = worktree.read_with(cx, |tree, _| tree.id()); + + project.update(cx, |project, cx| { + project + .update_diagnostics( + 0, + lsp::PublishDiagnosticsParams { + uri: Url::from_file_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() + }], + }, + &[], + cx, + ) + .unwrap(); + }); + + let buffer = project + .update(cx, |project, cx| project.open_buffer((worktree_id, ""), cx)) + .await + .unwrap(); + buffer.read_with(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), + ] + ); + }); + + project.read_with(cx, |project, cx| { + assert_eq!(project.diagnostic_summaries(cx).next(), None); + assert_eq!(project.diagnostic_summary(cx).error_count, 0); + }); +} + +#[gpui::test] +async fn test_disk_based_diagnostics_progress(cx: &mut gpui::TestAppContext) { + cx.foreground().forbid_parking(); + + let progress_token = "the-progress-token"; + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_servers = language.set_fake_lsp_adapter(FakeLspAdapter { + disk_based_diagnostics_progress_token: Some(progress_token), + disk_based_diagnostics_sources: &["disk"], + ..Default::default() + }); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "a.rs": "fn a() { A }", + "b.rs": "const y: i32 = 1", + }), + ) + .await; + + let project = Project::test(fs, ["/dir".as_ref()], cx).await; + project.update(cx, |project, _| project.languages.add(Arc::new(language))); + let worktree_id = project.read_with(cx, |p, cx| p.worktrees(cx).next().unwrap().read(cx).id()); + + // Cause worktree to start the fake language server + let _buffer = project + .update(cx, |project, cx| project.open_local_buffer("/dir/b.rs", cx)) + .await + .unwrap(); + + let mut events = subscribe(&project, cx); + + let fake_server = fake_servers.next().await.unwrap(); + fake_server.start_progress(progress_token).await; + assert_eq!( + events.next().await.unwrap(), + Event::DiskBasedDiagnosticsStarted { + language_server_id: 0, + } + ); + + fake_server.notify::(lsp::PublishDiagnosticsParams { + uri: Url::from_file_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: 0, + path: (worktree_id, Path::new("a.rs")).into() + } + ); + + fake_server.end_progress(progress_token); + assert_eq!( + events.next().await.unwrap(), + Event::DiskBasedDiagnosticsFinished { + language_server_id: 0 + } + ); + + let buffer = project + .update(cx, |p, cx| p.open_local_buffer("/dir/a.rs", cx)) + .await + .unwrap(); + + buffer.read_with(cx, |buffer, _| { + let snapshot = buffer.snapshot(); + let diagnostics = snapshot + .diagnostics_in_range::<_, Point>(0..buffer.len(), false) + .collect::>(); + assert_eq!( + diagnostics, + &[DiagnosticEntry { + 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, + ..Default::default() + } + }] + ) + }); + + // Ensure publishing empty diagnostics twice only results in one update event. + fake_server.notify::(lsp::PublishDiagnosticsParams { + uri: Url::from_file_path("/dir/a.rs").unwrap(), + version: None, + diagnostics: Default::default(), + }); + assert_eq!( + events.next().await.unwrap(), + Event::DiagnosticsUpdated { + language_server_id: 0, + path: (worktree_id, Path::new("a.rs")).into() + } + ); + + fake_server.notify::(lsp::PublishDiagnosticsParams { + uri: Url::from_file_path("/dir/a.rs").unwrap(), + version: None, + diagnostics: Default::default(), + }); + cx.foreground().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) { + cx.foreground().forbid_parking(); + + let progress_token = "the-progress-token"; + let mut language = Language::new( + LanguageConfig { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + None, + ); + let mut fake_servers = language.set_fake_lsp_adapter(FakeLspAdapter { + disk_based_diagnostics_sources: &["disk"], + disk_based_diagnostics_progress_token: Some(progress_token), + ..Default::default() + }); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree("/dir", json!({ "a.rs": "" })).await; + + let project = Project::test(fs, ["/dir".as_ref()], cx).await; + project.update(cx, |project, _| project.languages.add(Arc::new(language))); + + let buffer = project + .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx)) + .await + .unwrap(); + + // Simulate diagnostics starting to update. + let fake_server = fake_servers.next().await.unwrap(); + 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([buffer], cx); + }); + let mut events = subscribe(&project, cx); + + // Simulate the newly started server sending more diagnostics. + let fake_server = fake_servers.next().await.unwrap(); + fake_server.start_progress(progress_token).await; + assert_eq!( + events.next().await.unwrap(), + Event::DiskBasedDiagnosticsStarted { + language_server_id: 1 + } + ); + project.read_with(cx, |project, _| { + assert_eq!( + project + .language_servers_running_disk_based_diagnostics() + .collect::>(), + [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: 1 + } + ); + project.read_with(cx, |project, _| { + assert_eq!( + project + .language_servers_running_disk_based_diagnostics() + .collect::>(), + [0; 0] + ); + }); +} + +#[gpui::test] +async fn test_toggling_enable_language_server( + deterministic: Arc, + cx: &mut gpui::TestAppContext, +) { + deterministic.forbid_parking(); + + let mut rust = Language::new( + LanguageConfig { + name: Arc::from("Rust"), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + None, + ); + let mut fake_rust_servers = rust.set_fake_lsp_adapter(FakeLspAdapter { + name: "rust-lsp", + ..Default::default() + }); + let mut js = Language::new( + LanguageConfig { + name: Arc::from("JavaScript"), + path_suffixes: vec!["js".to_string()], + ..Default::default() + }, + None, + ); + let mut fake_js_servers = js.set_fake_lsp_adapter(FakeLspAdapter { + name: "js-lsp", + ..Default::default() + }); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree("/dir", json!({ "a.rs": "", "b.js": "" })) + .await; + + let project = Project::test(fs, ["/dir".as_ref()], cx).await; + project.update(cx, |project, _| { + project.languages.add(Arc::new(rust)); + project.languages.add(Arc::new(js)); + }); + + let _rs_buffer = project + .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx)) + .await + .unwrap(); + let _js_buffer = project + .update(cx, |project, cx| project.open_local_buffer("/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(), + "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(), + "file:///dir/b.js" + ); + + // Disable Rust language server, ensuring only that server gets stopped. + cx.update(|cx| { + cx.update_global(|settings: &mut Settings, _| { + settings.language_overrides.insert( + Arc::from("Rust"), + settings::LanguageSettings { + 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| { + cx.update_global(|settings: &mut Settings, _| { + settings.language_overrides.insert( + Arc::from("Rust"), + settings::LanguageSettings { + enable_language_server: Some(true), + ..Default::default() + }, + ); + settings.language_overrides.insert( + Arc::from("JavaScript"), + settings::LanguageSettings { + 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(), + "file:///dir/a.rs" + ); + fake_js_server + .receive_notification::() + .await; +} + +#[gpui::test] +async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) { + cx.foreground().forbid_parking(); + + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_servers = language.set_fake_lsp_adapter(FakeLspAdapter { + disk_based_diagnostics_sources: &["disk"], + ..Default::default() + }); + + let text = " + fn a() { A } + fn b() { BB } + fn c() { CCC } + " + .unindent(); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree("/dir", json!({ "a.rs": text })).await; + + let project = Project::test(fs, ["/dir".as_ref()], cx).await; + project.update(cx, |project, _| project.languages.add(Arc::new(language))); + + let buffer = project + .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx)) + .await + .unwrap(); + + 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")], 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::Url::from_file_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. + buffer.next_notification(cx).await; + buffer.read_with(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 { + severity: DiagnosticSeverity::ERROR, + message: "undefined variable 'BB'".to_string(), + is_disk_based: true, + group_id: 1, + is_primary: true, + ..Default::default() + }, + }, + DiagnosticEntry { + range: Point::new(4, 9)..Point::new(4, 12), + diagnostic: Diagnostic { + severity: DiagnosticSeverity::ERROR, + message: "undefined variable 'CCC'".to_string(), + is_disk_based: true, + group_id: 2, + is_primary: true, + ..Default::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::Url::from_file_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() + }, + ], + }); + + buffer.next_notification(cx).await; + buffer.read_with(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 { + severity: DiagnosticSeverity::WARNING, + message: "unreachable statement".to_string(), + is_disk_based: true, + group_id: 4, + is_primary: true, + ..Default::default() + } + }, + DiagnosticEntry { + range: Point::new(2, 9)..Point::new(2, 10), + diagnostic: Diagnostic { + severity: DiagnosticSeverity::ERROR, + message: "undefined variable 'A'".to_string(), + is_disk_based: true, + group_id: 3, + is_primary: true, + ..Default::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), " ")], cx); + buffer.edit([(Point::new(2, 8)..Point::new(2, 10), "(x: usize)")], cx); + buffer.edit([(Point::new(3, 10)..Point::new(3, 10), "xxx")], 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::Url::from_file_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() + }, + ], + }); + + buffer.next_notification(cx).await; + buffer.read_with(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 { + severity: DiagnosticSeverity::WARNING, + message: "undefined variable 'A'".to_string(), + is_disk_based: true, + group_id: 6, + is_primary: true, + ..Default::default() + } + }, + DiagnosticEntry { + range: Point::new(3, 9)..Point::new(3, 14), + diagnostic: Diagnostic { + severity: DiagnosticSeverity::ERROR, + message: "undefined variable 'BB'".to_string(), + is_disk_based: true, + group_id: 5, + is_primary: true, + ..Default::default() + }, + } + ] + ); + }); +} + +#[gpui::test] +async fn test_empty_diagnostic_ranges(cx: &mut gpui::TestAppContext) { + cx.foreground().forbid_parking(); + + let text = concat!( + "let one = ;\n", // + "let two = \n", + "let three = 3;\n", + ); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree("/dir", json!({ "a.rs": text })).await; + + let project = Project::test(fs, ["/dir".as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx)) + .await + .unwrap(); + + project.update(cx, |project, cx| { + project + .update_buffer_diagnostics( + &buffer, + vec![ + DiagnosticEntry { + range: PointUtf16::new(0, 10)..PointUtf16::new(0, 10), + diagnostic: Diagnostic { + severity: DiagnosticSeverity::ERROR, + message: "syntax error 1".to_string(), + ..Default::default() + }, + }, + DiagnosticEntry { + range: PointUtf16::new(1, 10)..PointUtf16::new(1, 10), + diagnostic: Diagnostic { + severity: DiagnosticSeverity::ERROR, + message: "syntax error 2".to_string(), + ..Default::default() + }, + }, + ], + None, + 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.read_with(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_edits_from_lsp_with_past_version(cx: &mut gpui::TestAppContext) { + cx.foreground().forbid_parking(); + + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_servers = language.set_fake_lsp_adapter(Default::default()); + + let text = " + fn a() { + f1(); + } + fn b() { + f2(); + } + fn c() { + f3(); + } + " + .unindent(); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "a.rs": text.clone(), + }), + ) + .await; + + let project = Project::test(fs, ["/dir".as_ref()], cx).await; + project.update(cx, |project, _| project.languages.add(Arc::new(language))); + let buffer = project + .update(cx, |project, cx| project.open_local_buffer("/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", + )], + cx, + ); + buffer.edit( + [( + Point::new(2, 0)..Point::new(2, 0), + " // inside first function\n", + )], + cx, + ); + buffer.edit( + [( + Point::new(6, 4)..Point::new(6, 4), + "// inside second function ", + )], + 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 = project + .update(cx, |project, cx| { + project.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(), + }, + ], + Some(lsp_document_version), + cx, + ) + }) + .await + .unwrap(); + + buffer.update(cx, |buffer, cx| { + for (range, new_text) in edits { + buffer.edit([(range, new_text)], 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_lsp_with_edits_on_adjacent_lines(cx: &mut gpui::TestAppContext) { + cx.foreground().forbid_parking(); + + let text = " + use a::b; + use a::c; + + fn f() { + b(); + c(); + } + " + .unindent(); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "a.rs": text.clone(), + }), + ) + .await; + + let project = Project::test(fs, ["/dir".as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| project.open_local_buffer("/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 = project + .update(cx, |project, cx| { + project.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(), + }, + ], + 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)], cx); + } + assert_eq!( + buffer.text(), + " + use a::{b, c}; + + fn f() { + b(); + c(); + } + " + .unindent() + ); + }); +} + +#[gpui::test] +async fn test_invalid_edits_from_lsp(cx: &mut gpui::TestAppContext) { + cx.foreground().forbid_parking(); + + let text = " + use a::b; + use a::c; + + fn f() { + b(); + c(); + } + " + .unindent(); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "a.rs": text.clone(), + }), + ) + .await; + + let project = Project::test(fs, ["/dir".as_ref()], cx).await; + let buffer = project + .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx)) + .await + .unwrap(); + + // Simulate the language server sending us edits in a non-ordered fashion, + // with ranges sometimes being inverted. + let edits = project + .update(cx, |project, cx| { + project.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(7, 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(), + }, + ], + 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)], 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, true) { + if chunks.last().map_or(false, |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] +async fn test_search_worktree_without_files(cx: &mut gpui::TestAppContext) { + let dir = temp_tree(json!({ + "root": { + "dir1": {}, + "dir2": { + "dir3": {} + } + } + })); + + let project = Project::test(Arc::new(RealFs), [dir.path()], cx).await; + let cancel_flag = Default::default(); + let results = project + .read_with(cx, |project, cx| { + project.match_paths("dir", false, false, 10, &cancel_flag, cx) + }) + .await; + + assert!(results.is_empty()); +} + +#[gpui::test(iterations = 10)] +async fn test_definition(cx: &mut gpui::TestAppContext) { + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_servers = language.set_fake_lsp_adapter(Default::default()); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "a.rs": "const fn a() { A }", + "b.rs": "const y: i32 = crate::a()", + }), + ) + .await; + + let project = Project::test(fs, ["/dir/b.rs".as_ref()], cx).await; + project.update(cx, |project, _| project.languages.add(Arc::new(language))); + + let buffer = project + .update(cx, |project, cx| project.open_local_buffer("/dir/b.rs", cx)) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + fake_server.handle_request::(|params, _| async move { + let params = params.text_document_position_params; + assert_eq!( + params.text_document.uri.to_file_path().unwrap(), + Path::new("/dir/b.rs"), + ); + assert_eq!(params.position, lsp::Position::new(0, 22)); + + Ok(Some(lsp::GotoDefinitionResponse::Scalar( + lsp::Location::new( + lsp::Url::from_file_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.definition(&buffer, 22, cx)) + .await + .unwrap(); + + // Assert no new language server started + cx.foreground().run_until_parked(); + assert!(fake_servers.try_next().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("/dir/a.rs"), + ); + assert_eq!(definition.target.range.to_offset(target_buffer), 9..10); + assert_eq!( + list_worktrees(&project, cx), + [("/dir/b.rs".as_ref(), true), ("/dir/a.rs".as_ref(), false)] + ); + + drop(definition); + }); + cx.read(|cx| { + assert_eq!(list_worktrees(&project, cx), [("/dir/b.rs".as_ref(), true)]); + }); + + fn list_worktrees<'a>( + project: &'a ModelHandle, + cx: &'a AppContext, + ) -> 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_without_edit_ranges(cx: &mut gpui::TestAppContext) { + let mut language = Language::new( + LanguageConfig { + name: "TypeScript".into(), + path_suffixes: vec!["ts".to_string()], + ..Default::default() + }, + Some(tree_sitter_typescript::language_typescript()), + ); + let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "a.ts": "", + }), + ) + .await; + + let project = Project::test(fs, ["/dir".as_ref()], cx).await; + project.update(cx, |project, _| project.languages.add(Arc::new(language))); + let buffer = project + .update(cx, |p, cx| p.open_local_buffer("/dir/a.ts", cx)) + .await + .unwrap(); + + let fake_server = fake_language_servers.next().await.unwrap(); + + 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(), cx) + }); + + fake_server + .handle_request::(|_, _| 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(); + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + assert_eq!(completions.len(), 1); + assert_eq!(completions[0].new_text, "fullyQualifiedName"); + assert_eq!( + completions[0].old_range.to_offset(&snapshot), + text.len() - 3..text.len() + ); + + 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, cx) + }); + + fake_server + .handle_request::(|_, _| async move { + Ok(Some(lsp::CompletionResponse::Array(vec![ + lsp::CompletionItem { + label: "component".into(), + ..Default::default() + }, + ]))) + }) + .next() + .await; + let completions = completions.await.unwrap(); + let snapshot = buffer.read_with(cx, |buffer, _| buffer.snapshot()); + assert_eq!(completions.len(), 1); + assert_eq!(completions[0].new_text, "component"); + assert_eq!( + completions[0].old_range.to_offset(&snapshot), + text.len() - 4..text.len() - 1 + ); +} + +#[gpui::test(iterations = 10)] +async fn test_apply_code_actions_with_commands(cx: &mut gpui::TestAppContext) { + let mut language = Language::new( + LanguageConfig { + name: "TypeScript".into(), + path_suffixes: vec!["ts".to_string()], + ..Default::default() + }, + None, + ); + let mut fake_language_servers = language.set_fake_lsp_adapter(Default::default()); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "a.ts": "a", + }), + ) + .await; + + let project = Project::test(fs, ["/dir".as_ref()], cx).await; + project.update(cx, |project, _| project.languages.add(Arc::new(language))); + let buffer = project + .update(cx, |p, cx| p.open_local_buffer("/dir/a.ts", cx)) + .await + .unwrap(); + + let fake_server = fake_language_servers.next().await.unwrap(); + + // Language server returns code actions that contain commands, and not edits. + let actions = project.update(cx, |project, cx| project.code_actions(&buffer, 0..0, cx)); + fake_server + .handle_request::(|_, _| async move { + Ok(Some(vec![ + lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction { + title: "The code action".into(), + command: Some(lsp::Command { + title: "The command".into(), + command: "_the/command".into(), + arguments: Some(vec![json!("the-argument")]), + }), + ..Default::default() + }), + lsp::CodeActionOrCommand::CodeAction(lsp::CodeAction { + title: "two".into(), + ..Default::default() + }), + ])) + }) + .next() + .await; + + let action = actions.await.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.handle_request::( + |action, _| async move { Ok(action) }, + ); + + // While executing the command, the language server sends the editor + // a `workspaceEdit` request. + fake_server + .handle_request::({ + 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::Url::from_file_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() + }, + }, + ) + .await + .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_save_file(cx: &mut gpui::TestAppContext) { + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "file1": "the old contents", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + let buffer = project + .update(cx, |p, cx| p.open_local_buffer("/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))], cx); + buffer.save(cx) + }) + .await + .unwrap(); + + let new_text = fs.load(Path::new("/dir/file1")).await.unwrap(); + assert_eq!(new_text, buffer.read_with(cx, |buffer, _| buffer.text())); +} + +#[gpui::test] +async fn test_save_in_single_file_worktree(cx: &mut gpui::TestAppContext) { + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "file1": "the old contents", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/dir/file1".as_ref()], cx).await; + let buffer = project + .update(cx, |p, cx| p.open_local_buffer("/dir/file1", cx)) + .await + .unwrap(); + buffer + .update(cx, |buffer, cx| { + buffer.edit([(0..0, "a line of text.\n".repeat(10 * 1024))], cx); + buffer.save(cx) + }) + .await + .unwrap(); + + let new_text = fs.load(Path::new("/dir/file1")).await.unwrap(); + assert_eq!(new_text, buffer.read_with(cx, |buffer, _| buffer.text())); +} + +#[gpui::test] +async fn test_save_as(cx: &mut gpui::TestAppContext) { + let fs = FakeFs::new(cx.background()); + fs.insert_tree("/dir", json!({})).await; + + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + let buffer = project.update(cx, |project, cx| { + project.create_buffer("", None, cx).unwrap() + }); + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, "abc")], cx); + assert!(buffer.is_dirty()); + assert!(!buffer.has_conflict()); + }); + project + .update(cx, |project, cx| { + project.save_buffer_as(buffer.clone(), "/dir/file1".into(), cx) + }) + .await + .unwrap(); + assert_eq!(fs.load(Path::new("/dir/file1")).await.unwrap(), "abc"); + buffer.read_with(cx, |buffer, cx| { + assert_eq!(buffer.file().unwrap().full_path(cx), Path::new("dir/file1")); + assert!(!buffer.is_dirty()); + assert!(!buffer.has_conflict()); + }); + + let opened_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/dir/file1", cx) + }) + .await + .unwrap(); + assert_eq!(opened_buffer, buffer); +} + +#[gpui::test(retries = 5)] +async fn test_rescan_and_remote_updates( + deterministic: Arc, + cx: &mut gpui::TestAppContext, +) { + let dir = temp_tree(json!({ + "a": { + "file1": "", + "file2": "", + "file3": "", + }, + "b": { + "c": { + "file4": "", + "file5": "", + } + } + })); + + let project = Project::test(Arc::new(RealFs), [dir.path()], cx).await; + let rpc = project.read_with(cx, |p, _| p.client.clone()); + + 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: &gpui::TestAppContext| { + project.read_with(cx, |project, cx| { + let tree = project.worktrees(cx).next().unwrap(); + tree.read(cx) + .entry_for_path(path) + .expect(&format!("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.read_with(cx, |project, cx| project.worktrees(cx).next().unwrap()); + let initial_snapshot = tree.read_with(cx, |tree, _| tree.as_local().unwrap().snapshot()); + let remote = cx.update(|cx| { + Worktree::remote( + 1, + 1, + proto::WorktreeMetadata { + id: initial_snapshot.id().to_proto(), + root_name: initial_snapshot.root_name().into(), + visible: true, + }, + rpc.clone(), + cx, + ) + }); + remote.update(cx, |remote, _| { + let update = initial_snapshot.build_initial_update(1); + remote.as_remote_mut().unwrap().update_from_remote(update); + }); + deterministic.run_until_parked(); + + cx.read(|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; + + let expected_paths = vec![ + "a", + "a/file1", + "a/file2.new", + "b", + "d", + "d/file3", + "d/file4", + ]; + + cx.read(|app| { + assert_eq!( + tree.read(app) + .paths() + .map(|p| p.to_str().unwrap()) + .collect::>(), + expected_paths + ); + + 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); + + assert_eq!( + buffer2.read(app).file().unwrap().path().as_ref(), + Path::new("a/file2.new") + ); + assert_eq!( + buffer3.read(app).file().unwrap().path().as_ref(), + Path::new("d/file3") + ); + assert_eq!( + buffer4.read(app).file().unwrap().path().as_ref(), + Path::new("d/file4") + ); + assert_eq!( + buffer5.read(app).file().unwrap().path().as_ref(), + Path::new("b/c/file5") + ); + + assert!(!buffer2.read(app).file().unwrap().is_deleted()); + assert!(!buffer3.read(app).file().unwrap().is_deleted()); + assert!(!buffer4.read(app).file().unwrap().is_deleted()); + assert!(buffer5.read(app).file().unwrap().is_deleted()); + }); + + // Update the remote worktree. Check that it becomes consistent with the + // local worktree. + remote.update(cx, |remote, cx| { + let update = tree.read(cx).as_local().unwrap().snapshot().build_update( + &initial_snapshot, + 1, + 1, + true, + ); + remote.as_remote_mut().unwrap().update_from_remote(update); + }); + deterministic.run_until_parked(); + remote.read_with(cx, |remote, _| { + assert_eq!( + remote + .paths() + .map(|p| p.to_str().unwrap()) + .collect::>(), + expected_paths + ); + }); +} + +#[gpui::test] +async fn test_buffer_deduping(cx: &mut gpui::TestAppContext) { + let fs = FakeFs::new(cx.background()); + 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.read_with(cx, |b, _| b.text()), "a-contents"); + assert_eq!(buffer_b.read_with(cx, |b, _| b.text()), "b-contents"); + + // There is only one buffer per path. + let buffer_a_id = buffer_a_1.id(); + assert_eq!(buffer_a_2.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.id(), buffer_a_id); +} + +#[gpui::test] +async fn test_buffer_is_dirty(cx: &mut gpui::TestAppContext) { + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "file1": "abc", + "file2": "def", + "file3": "ghi", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + + let buffer1 = project + .update(cx, |p, cx| p.open_local_buffer("/dir/file1", cx)) + .await + .unwrap(); + let events = Rc::new(RefCell::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.borrow_mut().push(event.clone()), + } + }) + .detach(); + + assert!(!buffer.is_dirty()); + assert!(events.borrow().is_empty()); + + buffer.edit([(1..2, "")], 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.borrow(), + &[language::Event::Edited, language::Event::DirtyChanged] + ); + events.borrow_mut().clear(); + buffer.did_save( + buffer.version(), + buffer.as_rope().fingerprint(), + buffer.file().unwrap().mtime(), + None, + 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.borrow(), &[language::Event::Saved]); + events.borrow_mut().clear(); + + buffer.edit([(1..1, "B")], cx); + buffer.edit([(2..2, "D")], 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.borrow(), + &[ + language::Event::Edited, + language::Event::DirtyChanged, + language::Event::Edited, + ], + ); + events.borrow_mut().clear(); + + // After restoring the buffer to its previously-saved state, + // the buffer is not considered dirty anymore. + buffer.edit([(1..3, "")], cx); + assert!(buffer.text() == "ac"); + assert!(!buffer.is_dirty()); + }); + + assert_eq!( + *events.borrow(), + &[language::Event::Edited, language::Event::DirtyChanged] + ); + + // When a file is deleted, the buffer is considered dirty. + let events = Rc::new(RefCell::new(Vec::new())); + let buffer2 = project + .update(cx, |p, cx| p.open_local_buffer("/dir/file2", cx)) + .await + .unwrap(); + buffer2.update(cx, |_, cx| { + cx.subscribe(&buffer2, { + let events = events.clone(); + move |_, _, event, _| events.borrow_mut().push(event.clone()) + }) + .detach(); + }); + + fs.remove_file("/dir/file2".as_ref(), Default::default()) + .await + .unwrap(); + buffer2.condition(&cx, |b, _| b.is_dirty()).await; + assert_eq!( + *events.borrow(), + &[ + language::Event::DirtyChanged, + language::Event::FileHandleChanged + ] + ); + + // When a file is already dirty when deleted, we don't emit a Dirtied event. + let events = Rc::new(RefCell::new(Vec::new())); + let buffer3 = project + .update(cx, |p, cx| p.open_local_buffer("/dir/file3", cx)) + .await + .unwrap(); + buffer3.update(cx, |_, cx| { + cx.subscribe(&buffer3, { + let events = events.clone(); + move |_, _, event, _| events.borrow_mut().push(event.clone()) + }) + .detach(); + }); + + buffer3.update(cx, |buffer, cx| { + buffer.edit([(0..0, "x")], cx); + }); + events.borrow_mut().clear(); + fs.remove_file("/dir/file3".as_ref(), Default::default()) + .await + .unwrap(); + buffer3 + .condition(&cx, |_, _| !events.borrow().is_empty()) + .await; + assert_eq!(*events.borrow(), &[language::Event::FileHandleChanged]); + cx.read(|cx| assert!(buffer3.read(cx).is_dirty())); +} + +#[gpui::test] +async fn test_buffer_file_changes_on_disk(cx: &mut gpui::TestAppContext) { + let initial_contents = "aaa\nbbbbb\nc\n"; + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "the-file": initial_contents, + }), + ) + .await; + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + let buffer = project + .update(cx, |p, cx| p.open_local_buffer("/dir/the-file", cx)) + .await + .unwrap(); + + let anchors = (0..3) + .map(|row| buffer.read_with(cx, |b, _| b.anchor_before(Point::new(row, 1)))) + .collect::>(); + + // Change the file on disk, adding two new lines of text, and removing + // one line. + buffer.read_with(cx, |buffer, _| { + assert!(!buffer.is_dirty()); + assert!(!buffer.has_conflict()); + }); + let new_contents = "AAAA\naaa\nBB\nbbbbb\n"; + fs.save( + "/dir/the-file".as_ref(), + &new_contents.into(), + NewlineStyle::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. + buffer + .condition(&cx, |buffer, _| buffer.text() == new_contents) + .await; + + buffer.update(cx, |buffer, _| { + assert_eq!(buffer.text(), new_contents); + assert!(!buffer.is_dirty()); + assert!(!buffer.has_conflict()); + + let anchor_positions = anchors + .iter() + .map(|anchor| anchor.to_point(&*buffer)) + .collect::>(); + assert_eq!( + anchor_positions, + [Point::new(1, 1), Point::new(3, 1), Point::new(4, 0)] + ); + }); + + // Modify the buffer + buffer.update(cx, |buffer, cx| { + buffer.edit([(0..0, " ")], cx); + assert!(buffer.is_dirty()); + assert!(!buffer.has_conflict()); + }); + + // Change the file on disk again, adding blank lines to the beginning. + fs.save( + "/dir/the-file".as_ref(), + &"\n\n\nAAAA\naaa\nBB\nbbbbb\n".into(), + NewlineStyle::Unix, + ) + .await + .unwrap(); + + // Because the buffer is modified, it doesn't reload from disk, but is + // marked as having a conflict. + buffer + .condition(&cx, |buffer, _| buffer.has_conflict()) + .await; +} + +#[gpui::test] +async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) { + cx.foreground().forbid_parking(); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/the-dir", + json!({ + "a.rs": " + fn foo(mut v: Vec) { + for x in &v { + v.push(1); + } + } + " + .unindent(), + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/the-dir".as_ref()], cx).await; + let buffer = project + .update(cx, |p, cx| p.open_local_buffer("/the-dir/a.rs", cx)) + .await + .unwrap(); + + let buffer_uri = Url::from_file_path("/the-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.clone(), + range: lsp::Range::new(lsp::Position::new(2, 8), lsp::Position::new(2, 17)), + }, + message: "original diagnostic".to_string(), + }]), + ..Default::default() + }, + ], + version: None, + }; + + project + .update(cx, |p, cx| p.update_diagnostics(0, message, &[], cx)) + .unwrap(); + let buffer = buffer.read_with(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: 0, + is_primary: true, + ..Default::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: 0, + is_primary: false, + ..Default::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: 1, + is_primary: false, + ..Default::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: 1, + is_primary: false, + ..Default::default() + } + }, + DiagnosticEntry { + range: Point::new(2, 8)..Point::new(2, 17), + diagnostic: Diagnostic { + severity: DiagnosticSeverity::ERROR, + message: "error 2".to_string(), + group_id: 1, + is_primary: true, + ..Default::default() + } + } + ] + ); + + assert_eq!( + buffer.diagnostic_group::(0).collect::>(), + &[ + DiagnosticEntry { + range: Point::new(1, 8)..Point::new(1, 9), + diagnostic: Diagnostic { + severity: DiagnosticSeverity::WARNING, + message: "error 1".to_string(), + group_id: 0, + is_primary: true, + ..Default::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: 0, + is_primary: false, + ..Default::default() + } + }, + ] + ); + assert_eq!( + buffer.diagnostic_group::(1).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: 1, + is_primary: false, + ..Default::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: 1, + is_primary: false, + ..Default::default() + } + }, + DiagnosticEntry { + range: Point::new(2, 8)..Point::new(2, 17), + diagnostic: Diagnostic { + severity: DiagnosticSeverity::ERROR, + message: "error 2".to_string(), + group_id: 1, + is_primary: true, + ..Default::default() + } + } + ] + ); +} + +#[gpui::test] +async fn test_rename(cx: &mut gpui::TestAppContext) { + cx.foreground().forbid_parking(); + + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ); + let mut fake_servers = language.set_fake_lsp_adapter(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 fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + "one.rs": "const ONE: usize = 1;", + "two.rs": "const TWO: usize = one::ONE + one::ONE;" + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + project.update(cx, |project, _| project.languages.add(Arc::new(language))); + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/dir/one.rs", cx) + }) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + + let response = project.update(cx, |project, cx| { + project.prepare_rename(buffer.clone(), 7, cx) + }); + fake_server + .handle_request::(|params, _| async move { + assert_eq!(params.text_document.uri.as_str(), "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 range = response.await.unwrap().unwrap(); + let range = buffer.read_with(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(), true, cx) + }); + fake_server + .handle_request::(|params, _| async move { + assert_eq!( + params.text_document_position.text_document.uri.as_str(), + "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::Url::from_file_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::Url::from_file_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 + .read_with(cx, |buffer, _| buffer.text()), + "const THREE: usize = 1;" + ); + assert_eq!( + transaction + .into_keys() + .next() + .unwrap() + .read_with(cx, |buffer, _| buffer.text()), + "const TWO: usize = one::THREE + one::THREE;" + ); +} + +#[gpui::test] +async fn test_search(cx: &mut gpui::TestAppContext) { + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/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(), ["/dir".as_ref()], cx).await; + assert_eq!( + search(&project, SearchQuery::text("TWO", false, true), cx) + .await + .unwrap(), + HashMap::from_iter([ + ("two.rs".to_string(), vec![6..9]), + ("three.rs".to_string(), vec![37..40]) + ]) + ); + + let buffer_4 = project + .update(cx, |project, cx| { + project.open_local_buffer("/dir/four.rs", cx) + }) + .await + .unwrap(); + buffer_4.update(cx, |buffer, cx| { + let text = "two::TWO"; + buffer.edit([(20..28, text), (31..43, text)], cx); + }); + + assert_eq!( + search(&project, SearchQuery::text("TWO", false, true), cx) + .await + .unwrap(), + HashMap::from_iter([ + ("two.rs".to_string(), vec![6..9]), + ("three.rs".to_string(), vec![37..40]), + ("four.rs".to_string(), vec![25..28, 36..39]) + ]) + ); + + async fn search( + project: &ModelHandle, + query: SearchQuery, + cx: &mut gpui::TestAppContext, + ) -> Result>>> { + let results = project + .update(cx, |project, cx| project.search(query, cx)) + .await?; + + Ok(results + .into_iter() + .map(|(buffer, ranges)| { + buffer.read_with(cx, |buffer, _| { + let path = buffer.file().unwrap().path().to_string_lossy().to_string(); + let ranges = ranges + .into_iter() + .map(|range| range.to_offset(buffer)) + .collect::>(); + (path, ranges) + }) + }) + .collect()) + } +}