use super::*; use collections::{HashMap, HashSet}; use editor::{ DisplayPoint, EditorSettings, actions::{GoToDiagnostic, GoToPreviousDiagnostic, Hover, MoveToBeginning}, display_map::{DisplayRow, Inlay}, test::{ editor_content_with_blocks, editor_lsp_test_context::EditorLspTestContext, editor_test_context::EditorTestContext, }, }; use gpui::{TestAppContext, VisualTestContext}; use indoc::indoc; use language::{DiagnosticSourceKind, Rope}; use lsp::LanguageServerId; use pretty_assertions::assert_eq; use project::FakeFs; use rand::{Rng, rngs::StdRng, seq::IteratorRandom as _}; use serde_json::json; use settings::SettingsStore; use std::{ env, path::{Path, PathBuf}, }; use unindent::Unindent as _; use util::{RandomCharIter, path, post_inc}; #[ctor::ctor] fn init_logger() { zlog::init_test(); } #[gpui::test] async fn test_diagnostics(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/test"), json!({ "consts.rs": " const a: i32 = 'a'; const b: i32 = c; " .unindent(), "main.rs": " fn main() { let x = vec![]; let y = vec![]; a(x); b(y); // comment 1 // comment 2 c(y); d(x); } " .unindent(), }), ) .await; let language_server_id = LanguageServerId(0); let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let workspace = window.root(cx).unwrap(); let uri = lsp::Url::from_file_path(path!("/test/main.rs")).unwrap(); // Create some diagnostics lsp_store.update(cx, |lsp_store, cx| { lsp_store.update_diagnostics(language_server_id, lsp::PublishDiagnosticsParams { uri: uri.clone(), diagnostics: vec![lsp::Diagnostic{ range: lsp::Range::new(lsp::Position::new(7, 6),lsp::Position::new(7, 7)), severity:Some(lsp::DiagnosticSeverity::ERROR), message: "use of moved value\nvalue used here after move".to_string(), related_information: Some(vec![lsp::DiagnosticRelatedInformation { location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(2,8),lsp::Position::new(2,9))), message: "move occurs because `y` has type `Vec`, which does not implement the `Copy` trait".to_string() }, lsp::DiagnosticRelatedInformation { location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(4,6),lsp::Position::new(4,7))), message: "value moved here".to_string() }, ]), ..Default::default() }, lsp::Diagnostic{ range: lsp::Range::new(lsp::Position::new(8, 6),lsp::Position::new(8, 7)), severity:Some(lsp::DiagnosticSeverity::ERROR), message: "use of moved value\nvalue used here after move".to_string(), related_information: Some(vec![lsp::DiagnosticRelatedInformation { location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(1,8),lsp::Position::new(1,9))), message: "move occurs because `x` has type `Vec`, which does not implement the `Copy` trait".to_string() }, lsp::DiagnosticRelatedInformation { location: lsp::Location::new(uri.clone(), lsp::Range::new(lsp::Position::new(3,6),lsp::Position::new(3,7))), message: "value moved here".to_string() }, ]), ..Default::default() } ], version: None }, None, DiagnosticSourceKind::Pushed, &[], cx).unwrap(); }); // Open the project diagnostics view while there are already diagnostics. let diagnostics = window.build_entity(cx, |window, cx| { ProjectDiagnosticsEditor::new(true, project.clone(), workspace.downgrade(), window, cx) }); let editor = diagnostics.update(cx, |diagnostics, _| diagnostics.editor.clone()); diagnostics .next_notification(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10), cx) .await; pretty_assertions::assert_eq!( editor_content_with_blocks(&editor, cx), indoc::indoc! { "§ main.rs § ----- fn main() { let x = vec![]; § move occurs because `x` has type `Vec`, which does not implement § the `Copy` trait (back) let y = vec![]; § move occurs because `y` has type `Vec`, which does not implement § the `Copy` trait (back) a(x); § value moved here (back) b(y); § value moved here // comment 1 // comment 2 c(y); § use of moved value § value used here after move § hint: move occurs because `y` has type `Vec`, which does not § implement the `Copy` trait d(x); § use of moved value § value used here after move § hint: move occurs because `x` has type `Vec`, which does not § implement the `Copy` trait § hint: value moved here }" } ); // Cursor is at the first diagnostic editor.update(cx, |editor, cx| { assert_eq!( editor.selections.display_ranges(cx), [DisplayPoint::new(DisplayRow(3), 8)..DisplayPoint::new(DisplayRow(3), 8)] ); }); // Diagnostics are added for another earlier path. lsp_store.update(cx, |lsp_store, cx| { lsp_store.disk_based_diagnostics_started(language_server_id, cx); lsp_store .update_diagnostics( language_server_id, lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path(path!("/test/consts.rs")).unwrap(), diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(0, 15), lsp::Position::new(0, 15), ), severity: Some(lsp::DiagnosticSeverity::ERROR), message: "mismatched types expected `usize`, found `char`".to_string(), ..Default::default() }], version: None, }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap(); lsp_store.disk_based_diagnostics_finished(language_server_id, cx); }); diagnostics .next_notification(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10), cx) .await; pretty_assertions::assert_eq!( editor_content_with_blocks(&editor, cx), indoc::indoc! { "§ consts.rs § ----- const a: i32 = 'a'; § mismatched types expected `usize`, found `char` const b: i32 = c; § main.rs § ----- fn main() { let x = vec![]; § move occurs because `x` has type `Vec`, which does not implement § the `Copy` trait (back) let y = vec![]; § move occurs because `y` has type `Vec`, which does not implement § the `Copy` trait (back) a(x); § value moved here (back) b(y); § value moved here // comment 1 // comment 2 c(y); § use of moved value § value used here after move § hint: move occurs because `y` has type `Vec`, which does not § implement the `Copy` trait d(x); § use of moved value § value used here after move § hint: move occurs because `x` has type `Vec`, which does not § implement the `Copy` trait § hint: value moved here }" } ); // Cursor keeps its position. editor.update(cx, |editor, cx| { assert_eq!( editor.selections.display_ranges(cx), [DisplayPoint::new(DisplayRow(8), 8)..DisplayPoint::new(DisplayRow(8), 8)] ); }); // Diagnostics are added to the first path lsp_store.update(cx, |lsp_store, cx| { lsp_store.disk_based_diagnostics_started(language_server_id, cx); lsp_store .update_diagnostics( language_server_id, lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path(path!("/test/consts.rs")).unwrap(), diagnostics: vec![ lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(0, 15), lsp::Position::new(0, 15), ), severity: Some(lsp::DiagnosticSeverity::ERROR), message: "mismatched types expected `usize`, found `char`".to_string(), ..Default::default() }, lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(1, 15), lsp::Position::new(1, 15), ), severity: Some(lsp::DiagnosticSeverity::ERROR), message: "unresolved name `c`".to_string(), ..Default::default() }, ], version: None, }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap(); lsp_store.disk_based_diagnostics_finished(language_server_id, cx); }); diagnostics .next_notification(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10), cx) .await; pretty_assertions::assert_eq!( editor_content_with_blocks(&editor, cx), indoc::indoc! { "§ consts.rs § ----- const a: i32 = 'a'; § mismatched types expected `usize`, found `char` const b: i32 = c; § unresolved name `c` § main.rs § ----- fn main() { let x = vec![]; § move occurs because `x` has type `Vec`, which does not implement § the `Copy` trait (back) let y = vec![]; § move occurs because `y` has type `Vec`, which does not implement § the `Copy` trait (back) a(x); § value moved here (back) b(y); § value moved here // comment 1 // comment 2 c(y); § use of moved value § value used here after move § hint: move occurs because `y` has type `Vec`, which does not § implement the `Copy` trait d(x); § use of moved value § value used here after move § hint: move occurs because `x` has type `Vec`, which does not § implement the `Copy` trait § hint: value moved here }" } ); } #[gpui::test] async fn test_diagnostics_with_folds(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/test"), json!({ "main.js": " function test() { return 1 }; tset(); ".unindent() }), ) .await; let server_id_1 = LanguageServerId(100); let server_id_2 = LanguageServerId(101); let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let workspace = window.root(cx).unwrap(); let diagnostics = window.build_entity(cx, |window, cx| { ProjectDiagnosticsEditor::new(true, project.clone(), workspace.downgrade(), window, cx) }); let editor = diagnostics.update(cx, |diagnostics, _| diagnostics.editor.clone()); // Two language servers start updating diagnostics lsp_store.update(cx, |lsp_store, cx| { lsp_store.disk_based_diagnostics_started(server_id_1, cx); lsp_store.disk_based_diagnostics_started(server_id_2, cx); lsp_store .update_diagnostics( server_id_1, lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path(path!("/test/main.js")).unwrap(), diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(4, 0), lsp::Position::new(4, 4)), severity: Some(lsp::DiagnosticSeverity::WARNING), message: "no method `tset`".to_string(), related_information: Some(vec![lsp::DiagnosticRelatedInformation { location: lsp::Location::new( lsp::Url::from_file_path(path!("/test/main.js")).unwrap(), lsp::Range::new( lsp::Position::new(0, 9), lsp::Position::new(0, 13), ), ), message: "method `test` defined here".to_string(), }]), ..Default::default() }], version: None, }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap(); }); // The first language server finishes lsp_store.update(cx, |lsp_store, cx| { lsp_store.disk_based_diagnostics_finished(server_id_1, cx); }); // Only the first language server's diagnostics are shown. cx.executor() .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); cx.executor().run_until_parked(); editor.update_in(cx, |editor, window, cx| { editor.fold_ranges(vec![Point::new(0, 0)..Point::new(3, 0)], false, window, cx); }); pretty_assertions::assert_eq!( editor_content_with_blocks(&editor, cx), indoc::indoc! { "§ main.js § ----- ⋯ tset(); § no method `tset`" } ); editor.update(cx, |editor, cx| { editor.unfold_ranges(&[Point::new(0, 0)..Point::new(3, 0)], false, false, cx); }); pretty_assertions::assert_eq!( editor_content_with_blocks(&editor, cx), indoc::indoc! { "§ main.js § ----- function test() { § method `test` defined here return 1 }; tset(); § no method `tset`" } ); } #[gpui::test] async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/test"), json!({ "main.js": " a(); b(); c(); d(); e(); ".unindent() }), ) .await; let server_id_1 = LanguageServerId(100); let server_id_2 = LanguageServerId(101); let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let workspace = window.root(cx).unwrap(); let diagnostics = window.build_entity(cx, |window, cx| { ProjectDiagnosticsEditor::new(true, project.clone(), workspace.downgrade(), window, cx) }); let editor = diagnostics.update(cx, |diagnostics, _| diagnostics.editor.clone()); // Two language servers start updating diagnostics lsp_store.update(cx, |lsp_store, cx| { lsp_store.disk_based_diagnostics_started(server_id_1, cx); lsp_store.disk_based_diagnostics_started(server_id_2, cx); lsp_store .update_diagnostics( server_id_1, lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path(path!("/test/main.js")).unwrap(), diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 1)), severity: Some(lsp::DiagnosticSeverity::WARNING), message: "error 1".to_string(), ..Default::default() }], version: None, }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap(); }); // The first language server finishes lsp_store.update(cx, |lsp_store, cx| { lsp_store.disk_based_diagnostics_finished(server_id_1, cx); }); // Only the first language server's diagnostics are shown. cx.executor() .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); cx.executor().run_until_parked(); pretty_assertions::assert_eq!( editor_content_with_blocks(&editor, cx), indoc::indoc! { "§ main.js § ----- a(); § error 1 b(); c();" } ); // The second language server finishes lsp_store.update(cx, |lsp_store, cx| { lsp_store .update_diagnostics( server_id_2, lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path(path!("/test/main.js")).unwrap(), diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 1)), severity: Some(lsp::DiagnosticSeverity::ERROR), message: "warning 1".to_string(), ..Default::default() }], version: None, }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap(); lsp_store.disk_based_diagnostics_finished(server_id_2, cx); }); // Both language server's diagnostics are shown. cx.executor() .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); cx.executor().run_until_parked(); pretty_assertions::assert_eq!( editor_content_with_blocks(&editor, cx), indoc::indoc! { "§ main.js § ----- a(); § error 1 b(); § warning 1 c(); d();" } ); // Both language servers start updating diagnostics, and the first server finishes. lsp_store.update(cx, |lsp_store, cx| { lsp_store.disk_based_diagnostics_started(server_id_1, cx); lsp_store.disk_based_diagnostics_started(server_id_2, cx); lsp_store .update_diagnostics( server_id_1, lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path(path!("/test/main.js")).unwrap(), diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(2, 0), lsp::Position::new(2, 1)), severity: Some(lsp::DiagnosticSeverity::WARNING), message: "warning 2".to_string(), ..Default::default() }], version: None, }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap(); lsp_store .update_diagnostics( server_id_2, lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path(path!("/test/main.rs")).unwrap(), diagnostics: vec![], version: None, }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap(); lsp_store.disk_based_diagnostics_finished(server_id_1, cx); }); // Only the first language server's diagnostics are updated. cx.executor() .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); cx.executor().run_until_parked(); pretty_assertions::assert_eq!( editor_content_with_blocks(&editor, cx), indoc::indoc! { "§ main.js § ----- a(); b(); § warning 1 c(); § warning 2 d(); e();" } ); // The second language server finishes. lsp_store.update(cx, |lsp_store, cx| { lsp_store .update_diagnostics( server_id_2, lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path(path!("/test/main.js")).unwrap(), diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(3, 0), lsp::Position::new(3, 1)), severity: Some(lsp::DiagnosticSeverity::WARNING), message: "warning 2".to_string(), ..Default::default() }], version: None, }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap(); lsp_store.disk_based_diagnostics_finished(server_id_2, cx); }); // Both language servers' diagnostics are updated. cx.executor() .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); cx.executor().run_until_parked(); pretty_assertions::assert_eq!( editor_content_with_blocks(&editor, cx), indoc::indoc! { "§ main.js § ----- a(); b(); c(); § warning 2 d(); § warning 2 e();" } ); } #[gpui::test(iterations = 20)] async fn test_random_diagnostics_blocks(cx: &mut TestAppContext, mut rng: StdRng) { init_test(cx); let operations = env::var("OPERATIONS") .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) .unwrap_or(10); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/test"), json!({})).await; let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let workspace = window.root(cx).unwrap(); let mutated_diagnostics = window.build_entity(cx, |window, cx| { ProjectDiagnosticsEditor::new(true, project.clone(), workspace.downgrade(), window, cx) }); workspace.update_in(cx, |workspace, window, cx| { workspace.add_item_to_center(Box::new(mutated_diagnostics.clone()), window, cx); }); mutated_diagnostics.update_in(cx, |diagnostics, window, _cx| { assert!(diagnostics.focus_handle.is_focused(window)); }); let mut next_id = 0; let mut next_filename = 0; let mut language_server_ids = vec![LanguageServerId(0)]; let mut updated_language_servers = HashSet::default(); let mut current_diagnostics: HashMap<(PathBuf, LanguageServerId), Vec> = Default::default(); for _ in 0..operations { match rng.gen_range(0..100) { // language server completes its diagnostic check 0..=20 if !updated_language_servers.is_empty() => { let server_id = *updated_language_servers.iter().choose(&mut rng).unwrap(); log::info!("finishing diagnostic check for language server {server_id}"); lsp_store.update(cx, |lsp_store, cx| { lsp_store.disk_based_diagnostics_finished(server_id, cx) }); if rng.gen_bool(0.5) { cx.run_until_parked(); } } // language server updates diagnostics _ => { let (path, server_id, diagnostics) = match current_diagnostics.iter_mut().choose(&mut rng) { // update existing set of diagnostics Some(((path, server_id), diagnostics)) if rng.gen_bool(0.5) => { (path.clone(), *server_id, diagnostics) } // insert a set of diagnostics for a new path _ => { let path: PathBuf = format!(path!("/test/{}.rs"), post_inc(&mut next_filename)).into(); let len = rng.gen_range(128..256); let content = RandomCharIter::new(&mut rng).take(len).collect::(); fs.insert_file(&path, content.into_bytes()).await; let server_id = match language_server_ids.iter().choose(&mut rng) { Some(server_id) if rng.gen_bool(0.5) => *server_id, _ => { let id = LanguageServerId(language_server_ids.len()); language_server_ids.push(id); id } }; ( path.clone(), server_id, current_diagnostics.entry((path, server_id)).or_default(), ) } }; updated_language_servers.insert(server_id); lsp_store.update(cx, |lsp_store, cx| { log::info!("updating diagnostics. language server {server_id} path {path:?}"); randomly_update_diagnostics_for_path( &fs, &path, diagnostics, &mut next_id, &mut rng, ); lsp_store .update_diagnostics( server_id, lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path(&path).unwrap_or_else(|_| { lsp::Url::parse("file:///test/fallback.rs").unwrap() }), diagnostics: diagnostics.clone(), version: None, }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap() }); cx.executor() .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); cx.run_until_parked(); } } } log::info!("updating mutated diagnostics view"); mutated_diagnostics.update_in(cx, |diagnostics, window, cx| { diagnostics.update_stale_excerpts(window, cx) }); log::info!("constructing reference diagnostics view"); let reference_diagnostics = window.build_entity(cx, |window, cx| { ProjectDiagnosticsEditor::new(true, project.clone(), workspace.downgrade(), window, cx) }); cx.executor() .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); cx.run_until_parked(); let mutated_excerpts = editor_content_with_blocks(&mutated_diagnostics.update(cx, |d, _| d.editor.clone()), cx); let reference_excerpts = editor_content_with_blocks( &reference_diagnostics.update(cx, |d, _| d.editor.clone()), cx, ); // The mutated view may contain more than the reference view as // we don't currently shrink excerpts when diagnostics were removed. let mut ref_iter = reference_excerpts.lines().filter(|line| *line != "§ -----"); let mut next_ref_line = ref_iter.next(); let mut skipped_block = false; for mut_line in mutated_excerpts.lines() { if let Some(ref_line) = next_ref_line { if mut_line == ref_line { next_ref_line = ref_iter.next(); } else if mut_line.contains('§') && mut_line != "§ -----" { skipped_block = true; } } } if next_ref_line.is_some() || skipped_block { pretty_assertions::assert_eq!(mutated_excerpts, reference_excerpts); } } // similar to above, but with inlays. Used to find panics when mixing diagnostics and inlays. #[gpui::test] async fn test_random_diagnostics_with_inlays(cx: &mut TestAppContext, mut rng: StdRng) { init_test(cx); let operations = env::var("OPERATIONS") .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) .unwrap_or(10); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/test"), json!({})).await; let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let workspace = window.root(cx).unwrap(); let mutated_diagnostics = window.build_entity(cx, |window, cx| { ProjectDiagnosticsEditor::new(true, project.clone(), workspace.downgrade(), window, cx) }); workspace.update_in(cx, |workspace, window, cx| { workspace.add_item_to_center(Box::new(mutated_diagnostics.clone()), window, cx); }); mutated_diagnostics.update_in(cx, |diagnostics, window, _cx| { assert!(diagnostics.focus_handle.is_focused(window)); }); let mut next_id = 0; let mut next_filename = 0; let mut language_server_ids = vec![LanguageServerId(0)]; let mut updated_language_servers = HashSet::default(); let mut current_diagnostics: HashMap<(PathBuf, LanguageServerId), Vec> = Default::default(); let mut next_inlay_id = 0; for _ in 0..operations { match rng.gen_range(0..100) { // language server completes its diagnostic check 0..=20 if !updated_language_servers.is_empty() => { let server_id = *updated_language_servers.iter().choose(&mut rng).unwrap(); log::info!("finishing diagnostic check for language server {server_id}"); lsp_store.update(cx, |lsp_store, cx| { lsp_store.disk_based_diagnostics_finished(server_id, cx) }); if rng.gen_bool(0.5) { cx.run_until_parked(); } } 21..=50 => mutated_diagnostics.update_in(cx, |diagnostics, window, cx| { diagnostics.editor.update(cx, |editor, cx| { let snapshot = editor.snapshot(window, cx); if snapshot.buffer_snapshot.len() > 0 { let position = rng.gen_range(0..snapshot.buffer_snapshot.len()); let position = snapshot.buffer_snapshot.clip_offset(position, Bias::Left); log::info!( "adding inlay at {position}/{}: {:?}", snapshot.buffer_snapshot.len(), snapshot.buffer_snapshot.text(), ); editor.splice_inlays( &[], vec![Inlay::inline_completion( post_inc(&mut next_inlay_id), snapshot.buffer_snapshot.anchor_before(position), format!("Test inlay {next_inlay_id}"), )], cx, ); } }); }), // language server updates diagnostics _ => { let (path, server_id, diagnostics) = match current_diagnostics.iter_mut().choose(&mut rng) { // update existing set of diagnostics Some(((path, server_id), diagnostics)) if rng.gen_bool(0.5) => { (path.clone(), *server_id, diagnostics) } // insert a set of diagnostics for a new path _ => { let path: PathBuf = format!(path!("/test/{}.rs"), post_inc(&mut next_filename)).into(); let len = rng.gen_range(128..256); let content = RandomCharIter::new(&mut rng).take(len).collect::(); fs.insert_file(&path, content.into_bytes()).await; let server_id = match language_server_ids.iter().choose(&mut rng) { Some(server_id) if rng.gen_bool(0.5) => *server_id, _ => { let id = LanguageServerId(language_server_ids.len()); language_server_ids.push(id); id } }; ( path.clone(), server_id, current_diagnostics.entry((path, server_id)).or_default(), ) } }; updated_language_servers.insert(server_id); lsp_store.update(cx, |lsp_store, cx| { log::info!("updating diagnostics. language server {server_id} path {path:?}"); randomly_update_diagnostics_for_path( &fs, &path, diagnostics, &mut next_id, &mut rng, ); lsp_store .update_diagnostics( server_id, lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path(&path).unwrap_or_else(|_| { lsp::Url::parse("file:///test/fallback.rs").unwrap() }), diagnostics: diagnostics.clone(), version: None, }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap() }); cx.executor() .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); cx.run_until_parked(); } } } log::info!("updating mutated diagnostics view"); mutated_diagnostics.update_in(cx, |diagnostics, window, cx| { diagnostics.update_stale_excerpts(window, cx) }); cx.executor() .advance_clock(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10)); cx.run_until_parked(); } #[gpui::test] async fn active_diagnostics_dismiss_after_invalidation(cx: &mut TestAppContext) { init_test(cx); let mut cx = EditorTestContext::new(cx).await; let lsp_store = cx.update_editor(|editor, _, cx| editor.project.as_ref().unwrap().read(cx).lsp_store()); cx.set_state(indoc! {" ˇfn func(abc def: i32) -> u32 { } "}); let message = "Something's wrong!"; cx.update(|_, cx| { lsp_store.update(cx, |lsp_store, cx| { lsp_store .update_diagnostics( LanguageServerId(0), lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path(path!("/root/file")).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(0, 11), lsp::Position::new(0, 12), ), severity: Some(lsp::DiagnosticSeverity::ERROR), message: message.to_string(), ..Default::default() }], }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap() }); }); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { editor.go_to_diagnostic(&GoToDiagnostic, window, cx); assert_eq!( editor .active_diagnostic_group() .map(|diagnostics_group| diagnostics_group.active_message.as_str()), Some(message), "Should have a diagnostics group activated" ); }); cx.assert_editor_state(indoc! {" fn func(abcˇ def: i32) -> u32 { } "}); cx.update(|_, cx| { lsp_store.update(cx, |lsp_store, cx| { lsp_store .update_diagnostics( LanguageServerId(0), lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path(path!("/root/file")).unwrap(), version: None, diagnostics: Vec::new(), }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap() }); }); cx.run_until_parked(); cx.update_editor(|editor, _, _| { assert_eq!(editor.active_diagnostic_group(), None); }); cx.assert_editor_state(indoc! {" fn func(abcˇ def: i32) -> u32 { } "}); cx.update_editor(|editor, window, cx| { editor.go_to_diagnostic(&GoToDiagnostic, window, cx); assert_eq!(editor.active_diagnostic_group(), None); }); cx.assert_editor_state(indoc! {" fn func(abcˇ def: i32) -> u32 { } "}); } #[gpui::test] async fn cycle_through_same_place_diagnostics(cx: &mut TestAppContext) { init_test(cx); let mut cx = EditorTestContext::new(cx).await; let lsp_store = cx.update_editor(|editor, _, cx| editor.project.as_ref().unwrap().read(cx).lsp_store()); cx.set_state(indoc! {" ˇfn func(abc def: i32) -> u32 { } "}); cx.update(|_, cx| { lsp_store.update(cx, |lsp_store, cx| { lsp_store .update_diagnostics( LanguageServerId(0), lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path(path!("/root/file")).unwrap(), version: None, diagnostics: vec![ lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(0, 11), lsp::Position::new(0, 12), ), severity: Some(lsp::DiagnosticSeverity::ERROR), ..Default::default() }, lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(0, 12), lsp::Position::new(0, 15), ), severity: Some(lsp::DiagnosticSeverity::ERROR), ..Default::default() }, lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(0, 12), lsp::Position::new(0, 15), ), severity: Some(lsp::DiagnosticSeverity::ERROR), ..Default::default() }, lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(0, 25), lsp::Position::new(0, 28), ), severity: Some(lsp::DiagnosticSeverity::ERROR), ..Default::default() }, ], }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap() }); }); cx.run_until_parked(); //// Backward // Fourth diagnostic cx.update_editor(|editor, window, cx| { editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic, window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc def: i32) -> ˇu32 { } "}); // Third diagnostic cx.update_editor(|editor, window, cx| { editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic, window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc ˇdef: i32) -> u32 { } "}); // Second diagnostic, same place cx.update_editor(|editor, window, cx| { editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic, window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc ˇdef: i32) -> u32 { } "}); // First diagnostic cx.update_editor(|editor, window, cx| { editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic, window, cx); }); cx.assert_editor_state(indoc! {" fn func(abcˇ def: i32) -> u32 { } "}); // Wrapped over, fourth diagnostic cx.update_editor(|editor, window, cx| { editor.go_to_prev_diagnostic(&GoToPreviousDiagnostic, window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc def: i32) -> ˇu32 { } "}); cx.update_editor(|editor, window, cx| { editor.move_to_beginning(&MoveToBeginning, window, cx); }); cx.assert_editor_state(indoc! {" ˇfn func(abc def: i32) -> u32 { } "}); //// Forward // First diagnostic cx.update_editor(|editor, window, cx| { editor.go_to_diagnostic(&GoToDiagnostic, window, cx); }); cx.assert_editor_state(indoc! {" fn func(abcˇ def: i32) -> u32 { } "}); // Second diagnostic cx.update_editor(|editor, window, cx| { editor.go_to_diagnostic(&GoToDiagnostic, window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc ˇdef: i32) -> u32 { } "}); // Third diagnostic, same place cx.update_editor(|editor, window, cx| { editor.go_to_diagnostic(&GoToDiagnostic, window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc ˇdef: i32) -> u32 { } "}); // Fourth diagnostic cx.update_editor(|editor, window, cx| { editor.go_to_diagnostic(&GoToDiagnostic, window, cx); }); cx.assert_editor_state(indoc! {" fn func(abc def: i32) -> ˇu32 { } "}); // Wrapped around, first diagnostic cx.update_editor(|editor, window, cx| { editor.go_to_diagnostic(&GoToDiagnostic, window, cx); }); cx.assert_editor_state(indoc! {" fn func(abcˇ def: i32) -> u32 { } "}); } #[gpui::test] async fn test_diagnostics_with_links(cx: &mut TestAppContext) { init_test(cx); let mut cx = EditorTestContext::new(cx).await; cx.set_state(indoc! {" fn func(abˇc def: i32) -> u32 { } "}); let lsp_store = cx.update_editor(|editor, _, cx| editor.project.as_ref().unwrap().read(cx).lsp_store()); cx.update(|_, cx| { lsp_store.update(cx, |lsp_store, cx| { lsp_store.update_diagnostics( LanguageServerId(0), lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path(path!("/root/file")).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { range: lsp::Range::new(lsp::Position::new(0, 8), lsp::Position::new(0, 12)), severity: Some(lsp::DiagnosticSeverity::ERROR), message: "we've had problems with , and is broken".to_string(), ..Default::default() }], }, None, DiagnosticSourceKind::Pushed, &[], cx, ) }) }).unwrap(); cx.run_until_parked(); cx.update_editor(|editor, window, cx| { editor::hover_popover::hover(editor, &Default::default(), window, cx) }); cx.run_until_parked(); cx.update_editor(|editor, _, _| assert!(editor.hover_state.diagnostic_popover.is_some())) } #[gpui::test] async fn test_hover_diagnostic_and_info_popovers(cx: &mut gpui::TestAppContext) { init_test(cx); let mut cx = EditorLspTestContext::new_rust( lsp::ServerCapabilities { hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), ..Default::default() }, cx, ) .await; // Hover with just diagnostic, pops DiagnosticPopover immediately and then // info popover once request completes cx.set_state(indoc! {" fn teˇst() { println!(); } "}); // Send diagnostic to client let range = cx.lsp_range(indoc! {" fn «test»() { println!(); } "}); let lsp_store = cx.update_editor(|editor, _, cx| editor.project.as_ref().unwrap().read(cx).lsp_store()); cx.update(|_, cx| { lsp_store.update(cx, |lsp_store, cx| { lsp_store.update_diagnostics( LanguageServerId(0), lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path(path!("/root/dir/file.rs")).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { range, severity: Some(lsp::DiagnosticSeverity::ERROR), message: "A test diagnostic message.".to_string(), ..Default::default() }], }, None, DiagnosticSourceKind::Pushed, &[], cx, ) }) }) .unwrap(); cx.run_until_parked(); // Hover pops diagnostic immediately cx.update_editor(|editor, window, cx| editor::hover_popover::hover(editor, &Hover, window, cx)); cx.background_executor.run_until_parked(); cx.editor(|Editor { hover_state, .. }, _, _| { assert!(hover_state.diagnostic_popover.is_some()); assert!(hover_state.info_popovers.is_empty()); }); // Info Popover shows after request responded to let range = cx.lsp_range(indoc! {" fn «test»() { println!(); } "}); cx.set_request_handler::(move |_, _, _| async move { Ok(Some(lsp::Hover { contents: lsp::HoverContents::Markup(lsp::MarkupContent { kind: lsp::MarkupKind::Markdown, value: "some new docs".to_string(), }), range: Some(range), })) }); let delay = cx.update(|_, cx| EditorSettings::get_global(cx).hover_popover_delay + 1); cx.background_executor .advance_clock(Duration::from_millis(delay)); cx.background_executor.run_until_parked(); cx.editor(|Editor { hover_state, .. }, _, _| { hover_state.diagnostic_popover.is_some() && hover_state.info_task.is_some() }); } #[gpui::test] async fn test_diagnostics_with_code(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), json!({ "main.js": " function test() { const x = 10; const y = 20; return 1; } test(); " .unindent(), }), ) .await; let language_server_id = LanguageServerId(0); let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); let cx = &mut VisualTestContext::from_window(*window, cx); let workspace = window.root(cx).unwrap(); let uri = lsp::Url::from_file_path(path!("/root/main.js")).unwrap(); // Create diagnostics with code fields lsp_store.update(cx, |lsp_store, cx| { lsp_store .update_diagnostics( language_server_id, lsp::PublishDiagnosticsParams { uri: uri.clone(), diagnostics: vec![ lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(1, 4), lsp::Position::new(1, 14), ), severity: Some(lsp::DiagnosticSeverity::WARNING), code: Some(lsp::NumberOrString::String("no-unused-vars".to_string())), source: Some("eslint".to_string()), message: "'x' is assigned a value but never used".to_string(), ..Default::default() }, lsp::Diagnostic { range: lsp::Range::new( lsp::Position::new(2, 4), lsp::Position::new(2, 14), ), severity: Some(lsp::DiagnosticSeverity::WARNING), code: Some(lsp::NumberOrString::String("no-unused-vars".to_string())), source: Some("eslint".to_string()), message: "'y' is assigned a value but never used".to_string(), ..Default::default() }, ], version: None, }, None, DiagnosticSourceKind::Pushed, &[], cx, ) .unwrap(); }); // Open the project diagnostics view let diagnostics = window.build_entity(cx, |window, cx| { ProjectDiagnosticsEditor::new(true, project.clone(), workspace.downgrade(), window, cx) }); let editor = diagnostics.update(cx, |diagnostics, _| diagnostics.editor.clone()); diagnostics .next_notification(DIAGNOSTICS_UPDATE_DELAY + Duration::from_millis(10), cx) .await; // Verify that the diagnostic codes are displayed correctly pretty_assertions::assert_eq!( editor_content_with_blocks(&editor, cx), indoc::indoc! { "§ main.js § ----- function test() { const x = 10; § 'x' is assigned a value but never used (eslint no-unused-vars) const y = 20; § 'y' is assigned a value but never used (eslint no-unused-vars) return 1; }" } ); } fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { zlog::init_test(); let settings = SettingsStore::test(cx); cx.set_global(settings); theme::init(theme::LoadThemes::JustBase, cx); language::init(cx); client::init_settings(cx); workspace::init_settings(cx); Project::init_settings(cx); crate::init(cx); editor::init(cx); }); } fn randomly_update_diagnostics_for_path( fs: &FakeFs, path: &Path, diagnostics: &mut Vec, next_id: &mut usize, rng: &mut impl Rng, ) { let mutation_count = rng.gen_range(1..=3); for _ in 0..mutation_count { if rng.gen_bool(0.3) && !diagnostics.is_empty() { let idx = rng.gen_range(0..diagnostics.len()); log::info!(" removing diagnostic at index {idx}"); diagnostics.remove(idx); } else { let unique_id = *next_id; *next_id += 1; let new_diagnostic = random_lsp_diagnostic(rng, fs, path, unique_id); let ix = rng.gen_range(0..=diagnostics.len()); log::info!( " inserting {} at index {ix}. {},{}..{},{}", new_diagnostic.message, new_diagnostic.range.start.line, new_diagnostic.range.start.character, new_diagnostic.range.end.line, new_diagnostic.range.end.character, ); for related in new_diagnostic.related_information.iter().flatten() { log::info!( " {}. {},{}..{},{}", related.message, related.location.range.start.line, related.location.range.start.character, related.location.range.end.line, related.location.range.end.character, ); } diagnostics.insert(ix, new_diagnostic); } } } fn random_lsp_diagnostic( rng: &mut impl Rng, fs: &FakeFs, path: &Path, unique_id: usize, ) -> lsp::Diagnostic { // Intentionally allow erroneous ranges some of the time (that run off the end of the file), // because language servers can potentially give us those, and we should handle them gracefully. const ERROR_MARGIN: usize = 10; let file_content = fs.read_file_sync(path).unwrap(); let file_text = Rope::from(String::from_utf8_lossy(&file_content).as_ref()); let start = rng.gen_range(0..file_text.len().saturating_add(ERROR_MARGIN)); let end = rng.gen_range(start..file_text.len().saturating_add(ERROR_MARGIN)); let start_point = file_text.offset_to_point_utf16(start); let end_point = file_text.offset_to_point_utf16(end); let range = lsp::Range::new( lsp::Position::new(start_point.row, start_point.column), lsp::Position::new(end_point.row, end_point.column), ); let severity = if rng.gen_bool(0.5) { Some(lsp::DiagnosticSeverity::ERROR) } else { Some(lsp::DiagnosticSeverity::WARNING) }; let message = format!("diagnostic {unique_id}"); let related_information = if rng.gen_bool(0.3) { let info_count = rng.gen_range(1..=3); let mut related_info = Vec::with_capacity(info_count); for i in 0..info_count { let info_start = rng.gen_range(0..file_text.len().saturating_add(ERROR_MARGIN)); let info_end = rng.gen_range(info_start..file_text.len().saturating_add(ERROR_MARGIN)); let info_start_point = file_text.offset_to_point_utf16(info_start); let info_end_point = file_text.offset_to_point_utf16(info_end); let info_range = lsp::Range::new( lsp::Position::new(info_start_point.row, info_start_point.column), lsp::Position::new(info_end_point.row, info_end_point.column), ); related_info.push(lsp::DiagnosticRelatedInformation { location: lsp::Location::new(lsp::Url::from_file_path(path).unwrap(), info_range), message: format!("related info {i} for diagnostic {unique_id}"), }); } Some(related_info) } else { None }; lsp::Diagnostic { range, severity, message, related_information, data: None, ..Default::default() } }