From 64c48eedc9808d2dea5c033736cd4ed2f4605025 Mon Sep 17 00:00:00 2001 From: upstream Date: Fri, 2 Oct 2026 16:47:40 +0200 Subject: [PATCH] Upstream zed-industries/zed state of the touched files before ce42164fb9 --- crates/diagnostics/src/diagnostics_tests.rs | 2558 +++++++++++++++++++ crates/editor/src/diagnostics.rs | 898 +++++++ 2 files changed, 3456 insertions(+) create mode 100644 crates/diagnostics/src/diagnostics_tests.rs create mode 100644 crates/editor/src/diagnostics.rs diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs new file mode 100644 index 0000000..a18719d --- /dev/null +++ b/crates/diagnostics/src/diagnostics_tests.rs @@ -0,0 +1,2558 @@ +use super::*; +use collections::{HashMap, HashSet}; +use editor::{ + DisplayPoint, EditorSettings, Inlay, MultiBufferOffset, + actions::{GoToDiagnostic, GoToPreviousDiagnostic, Hover, MoveToBeginning}, + display_map::DisplayRow, + test::{ + editor_content_with_blocks, editor_lsp_test_context::EditorLspTestContext, + editor_test_context::EditorTestContext, + }, +}; +use gpui::{TestAppContext, VisualTestContext}; +use indoc::indoc; +use language::{BufferId, Diagnostic, DiagnosticEntryRef, DiagnosticSourceKind, Rope}; +use lsp::LanguageServerId; +use pretty_assertions::assert_eq; +use project::{ + FakeFs, + project_settings::{GoToDiagnosticSeverity, GoToDiagnosticSeverityFilter}, +}; +use rand::{Rng, rngs::StdRng, seq::IteratorRandom as _}; +use serde_json::json; +use settings::SettingsStore; +use std::{ + env, + path::{Path, PathBuf}, + str::FromStr, +}; +use unindent::Unindent as _; +use util::{RandomCharIter, path, post_inc, rel_path::rel_path}; +use workspace::MultiWorkspace; + +#[ctor::ctor(unsafe)] +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| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let uri = lsp::Uri::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: lsp::DiagnosticMessage::from( + "use of moved value\nvalue used here after move", + ), + 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: lsp::DiagnosticMessage::from( + "use of moved value\nvalue used here after move", + ), + 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_DEBOUNCE + 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 (back) + // 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(&editor.display_snapshot(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::Uri::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: lsp::DiagnosticMessage::from( + "mismatched types expected `usize`, found `char`", + ), + ..Default::default() + }], + version: None, + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap(); + lsp_store.disk_based_diagnostics_finished(language_server_id, cx); + }); + + diagnostics + .next_notification(DIAGNOSTICS_UPDATE_DEBOUNCE + 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 (back) + // 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(&editor.display_snapshot(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::Uri::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: lsp::DiagnosticMessage::from( + "mismatched types expected `usize`, found `char`", + ), + ..Default::default() + }, + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(1, 15), + lsp::Position::new(1, 15), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::from("unresolved name `c`"), + ..Default::default() + }, + ], + version: None, + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap(); + lsp_store.disk_based_diagnostics_finished(language_server_id, cx); + }); + + diagnostics + .next_notification(DIAGNOSTICS_UPDATE_DEBOUNCE + 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 (back) + // 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| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .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::Uri::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: lsp::DiagnosticMessage::from("no method `tset`"), + related_information: Some(vec![lsp::DiagnosticRelatedInformation { + location: lsp::Location::new( + lsp::Uri::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_DEBOUNCE + 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 (back) + 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| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .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::Uri::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: lsp::DiagnosticMessage::from("error 1"), + ..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_DEBOUNCE + 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::Uri::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: lsp::DiagnosticMessage::from("warning 1"), + ..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_DEBOUNCE + 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::Uri::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: lsp::DiagnosticMessage::from("warning 2"), + ..Default::default() + }], + version: None, + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap(); + lsp_store + .update_diagnostics( + server_id_2, + lsp::PublishDiagnosticsParams { + uri: lsp::Uri::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_DEBOUNCE + 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::Uri::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: lsp::DiagnosticMessage::from("warning 2"), + ..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_DEBOUNCE + 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| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .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.random_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.random_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.random_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.random_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.random_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::Uri::from_file_path(&path).unwrap_or_else(|_| { + lsp::Uri::from_str("file:///test/fallback.rs").unwrap() + }), + diagnostics: diagnostics.clone(), + version: None, + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap() + }); + cx.executor() + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + 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_DEBOUNCE + 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| { + // ignore $ ---- and $ .rs + !line.starts_with('§') + || line.starts_with("§ diagnostic") + || line.starts_with("§ related info") + }); + 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('§') + // ignore $ ---- and $ .rs + && (!mut_line.starts_with('§') + || mut_line.starts_with("§ diagnostic") + || mut_line.starts_with("§ related info")) + { + 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| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .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.random_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.random_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().is_empty() { + let position = rng + .random_range(MultiBufferOffset(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::edit_prediction( + post_inc(&mut next_inlay_id), + snapshot.buffer_snapshot().anchor_before(position), + Rope::from_iter(["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.random_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.random_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.random_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::Uri::from_file_path(&path).unwrap_or_else(|_| { + lsp::Uri::from_str("file:///test/fallback.rs").unwrap() + }), + diagnostics: diagnostics.clone(), + version: None, + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap() + }); + cx.executor() + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + 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_DEBOUNCE + 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().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::Uri::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: lsp::DiagnosticMessage::from(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::default(), window, cx); + assert_eq!( + editor.active_diagnostic_message(), + 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::Uri::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: lsp::DiagnosticMessage::from(lsp::MarkupContent { + kind: lsp::MarkupKind::Markdown, + value: message.to_string(), + }), + ..Default::default() + }], + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap() + }); + }); + cx.run_until_parked(); + cx.update_editor(|editor, _, _| { + assert_eq!( + editor.active_diagnostic_message(), + None, + "changing only the diagnostic's Markdown rendering should invalidate the active group" + ); + }); + + cx.update_editor(|editor, window, cx| { + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); + assert_eq!(editor.active_diagnostic_message(), Some(message)); + }); + + cx.update(|_, cx| { + lsp_store.update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostics( + LanguageServerId(0), + lsp::PublishDiagnosticsParams { + uri: lsp::Uri::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_message(), None); + }); + cx.assert_editor_state(indoc! {" + fn func(abcˇ def: i32) -> u32 { + } + "}); + + cx.update_editor(|editor, window, cx| { + editor.go_to_diagnostic(&GoToDiagnostic::default(), window, cx); + assert_eq!(editor.active_diagnostic_message(), 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().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::Uri::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::default(), 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::default(), 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::default(), 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::default(), 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::default(), 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::default(), 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::default(), 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::default(), 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::default(), 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::default(), 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().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::Uri::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: lsp::DiagnosticMessage::from( + "we've had problems with , and is broken", + ), + ..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] +fn test_nearby_related_diagnostic_links_back_to_primary(cx: &mut TestAppContext) { + init_test(cx); + + let buffer_id = BufferId::new(1).expect("test buffer ID should be nonzero"); + let group_id = 1; + let diagnostic_group = [ + DiagnosticEntryRef { + range: text::Point::new(10, 4)..text::Point::new(10, 16), + diagnostic: &Diagnostic { + message: "the trait bound `(): HasTest` is not satisfied".into(), + is_primary: true, + group_id, + ..Default::default() + }, + }, + DiagnosticEntryRef { + range: text::Point::new(13, 0)..text::Point::new(13, 22), + diagnostic: &Diagnostic { + message: "required by a bound in `Test::TestAssoc`".into(), + group_id, + ..Default::default() + }, + }, + ]; + + let primary_index = diagnostic_group + .iter() + .position(|entry| entry.diagnostic.is_primary) + .expect("test diagnostic group should contain a primary diagnostic"); + let expected_back_link = + format!("([back](file://#diagnostic-{buffer_id}-{group_id}-{primary_index}))"); + + cx.update(|cx| { + let blocks = diagnostic_renderer::DiagnosticRenderer::diagnostic_blocks_for_group( + diagnostic_group.to_vec(), + buffer_id, + None, + None, + cx, + ); + + let [_primary_block, related_block] = blocks.as_slice() else { + panic!("expected one primary and one related diagnostic block"); + }; + + let markdown = related_block.markdown.read(cx).source(); + + assert!( + markdown.contains(&expected_back_link), + "related diagnostic should link back to the primary diagnostic, got: {}", + markdown + ); + }); +} + +#[gpui::test] +async fn test_markup_content_diagnostic_messages_render_as_markdown(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().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::Uri::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: lsp::DiagnosticMessage::MarkupContent(lsp::MarkupContent { + kind: lsp::MarkupKind::Markdown, + value: "`abc` is **not** defined".to_string(), + }), + ..Default::default() + }, + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 17), + lsp::Position::new(0, 20), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::from("`i32` is **not** a type"), + ..Default::default() + }, + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 21), + lsp::Position::new(0, 24), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + source: Some("rustc".to_string()), + code: Some(lsp::NumberOrString::String("E0425".to_string())), + message: lsp::DiagnosticMessage::from(lsp::MarkupContent { + kind: lsp::MarkupKind::Markdown, + value: "```rust\nlet x = unknown;\n```".to_string(), + }), + ..Default::default() + }, + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 25), + lsp::Position::new(0, 28), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::MarkupContent(lsp::MarkupContent { + kind: lsp::MarkupKind::PlainText, + value: "`u32` is **not** a type".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())); + + let buffer_id = cx.update_buffer(|buffer, _| buffer.remote_id()); + cx.update_editor(|editor, window, cx| { + let snapshot = editor.snapshot(window, cx); + + // Markdown MarkupContent is rendered as the markdown the server sent, + // while PlainText MarkupContent and plain string messages are escaped. + let group = snapshot + .buffer_snapshot() + .diagnostic_group(buffer_id, 0) + .collect::>(); + let blocks = diagnostic_renderer::DiagnosticRenderer::diagnostic_blocks_for_group( + group, buffer_id, None, None, cx, + ); + assert_eq!( + blocks[0].markdown.read(cx).source(), + "`abc` is **not** defined" + ); + + let group = snapshot + .buffer_snapshot() + .diagnostic_group(buffer_id, 1) + .collect::>(); + let blocks = diagnostic_renderer::DiagnosticRenderer::diagnostic_blocks_for_group( + group, buffer_id, None, None, cx, + ); + assert_eq!( + blocks[0].markdown.read(cx).source(), + markdown::Markdown::escape("`i32` is **not** a type").as_ref(), + ); + + let group = snapshot + .buffer_snapshot() + .diagnostic_group(buffer_id, 2) + .collect::>(); + let blocks = diagnostic_renderer::DiagnosticRenderer::diagnostic_blocks_for_group( + group, buffer_id, None, None, cx, + ); + assert_eq!( + blocks[0].markdown.read(cx).source(), + "```rust\nlet x = unknown;\n```\n\n(rustc E0425)" + ); + + let group = snapshot + .buffer_snapshot() + .diagnostic_group(buffer_id, 3) + .collect::>(); + let blocks = diagnostic_renderer::DiagnosticRenderer::diagnostic_blocks_for_group( + group, buffer_id, None, None, cx, + ); + assert_eq!( + blocks[0].markdown.read(cx).source(), + markdown::Markdown::escape("`u32` is **not** a type").as_ref(), + ); + }); +} + +#[gpui::test] +async fn test_diagnostics_views_update_when_only_markdown_changes(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree(path!("/test"), json!({ "main.rs": "x\n" })) + .await; + + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let workspace = window + .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone()) + .unwrap(); + let project_path = project::ProjectPath { + worktree_id: project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }), + path: rel_path("main.rs").into(), + }; + let buffer = project + .update(cx, |project, cx| { + project.open_buffer(project_path.clone(), cx) + }) + .await + .unwrap(); + let uri = lsp::Uri::from_file_path(path!("/test/main.rs")).unwrap(); + let language_server_id = LanguageServerId(0); + + lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.update_diagnostics( + language_server_id, + lsp::PublishDiagnosticsParams { + uri: uri.clone(), + version: None, + diagnostics: vec![lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 1)), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::from("`x`"), + ..Default::default() + }], + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + }) + .unwrap(); + + let project_diagnostics = window.build_entity(cx, |window, cx| { + ProjectDiagnosticsEditor::new(true, project.clone(), workspace.downgrade(), window, cx) + }); + let project_editor = + project_diagnostics.update(cx, |diagnostics, _| diagnostics.editor.clone()); + let buffer_diagnostics = window.build_entity(cx, |window, cx| { + BufferDiagnosticsEditor::new( + project_path, + project.clone(), + Some(buffer), + true, + window, + cx, + ) + }); + let buffer_editor = + buffer_diagnostics.update(cx, |diagnostics, _| diagnostics.editor().clone()); + + project_diagnostics + .next_notification(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10), cx) + .await; + cx.executor() + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); + cx.run_until_parked(); + + let expected_content = indoc! {" + § main.rs + § ----- + x § `x`"}; + assert_eq!( + editor_content_with_blocks(&project_editor, cx), + expected_content + ); + assert_eq!( + editor_content_with_blocks(&buffer_editor, cx), + expected_content + ); + + lsp_store + .update(cx, |lsp_store, cx| { + lsp_store.update_diagnostics( + language_server_id, + lsp::PublishDiagnosticsParams { + uri, + version: None, + diagnostics: vec![lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 1)), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::from(lsp::MarkupContent { + kind: lsp::MarkupKind::Markdown, + value: "`x`".to_string(), + }), + ..Default::default() + }], + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + }) + .unwrap(); + + project_diagnostics + .next_notification(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10), cx) + .await; + cx.executor() + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); + cx.run_until_parked(); + + let expected_content = indoc! {" + § main.rs + § ----- + x § x"}; + assert_eq!( + editor_content_with_blocks(&project_editor, cx), + expected_content + ); + assert_eq!( + editor_content_with_blocks(&buffer_editor, cx), + expected_content + ); +} + +#[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().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::Uri::from_file_path(path!("/root/dir/file.rs")).unwrap(), + version: None, + diagnostics: vec![lsp::Diagnostic { + range, + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::from("A test diagnostic message."), + ..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.0 + 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| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + let uri = lsp::Uri::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: lsp::DiagnosticMessage::from( + "'x' is assigned a value but never used", + ), + ..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: lsp::DiagnosticMessage::from( + "'y' is assigned a value but never used", + ), + ..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_DEBOUNCE + 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; + }" + } + ); +} + +#[gpui::test] +async fn go_to_diagnostic_with_severity(cx: &mut TestAppContext) { + init_test(cx); + + let mut cx = EditorTestContext::new(cx).await; + let lsp_store = + cx.update_editor(|editor, _, cx| editor.project().unwrap().read(cx).lsp_store()); + + cx.set_state(indoc! {"error warning info hiˇnt"}); + + cx.update(|_, cx| { + lsp_store.update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostics( + LanguageServerId(0), + lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path!("/root/file")).unwrap(), + version: None, + diagnostics: vec![ + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 0), + lsp::Position::new(0, 5), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + ..Default::default() + }, + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 6), + lsp::Position::new(0, 13), + ), + severity: Some(lsp::DiagnosticSeverity::WARNING), + ..Default::default() + }, + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 14), + lsp::Position::new(0, 18), + ), + severity: Some(lsp::DiagnosticSeverity::INFORMATION), + ..Default::default() + }, + lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 19), + lsp::Position::new(0, 23), + ), + severity: Some(lsp::DiagnosticSeverity::HINT), + ..Default::default() + }, + ], + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap() + }); + }); + cx.run_until_parked(); + + macro_rules! go { + ($severity:expr) => { + cx.update_editor(|editor, window, cx| { + editor.go_to_diagnostic( + &GoToDiagnostic { + severity: $severity, + }, + window, + cx, + ); + }); + }; + } + + // Default, should cycle through all diagnostics + go!(GoToDiagnosticSeverityFilter::default()); + cx.assert_editor_state(indoc! {"error warning info ˇhint"}); + go!(GoToDiagnosticSeverityFilter::default()); + cx.assert_editor_state(indoc! {"ˇerror warning info hint"}); + go!(GoToDiagnosticSeverityFilter::default()); + cx.assert_editor_state(indoc! {"error ˇwarning info hint"}); + go!(GoToDiagnosticSeverityFilter::default()); + cx.assert_editor_state(indoc! {"error warning ˇinfo hint"}); + go!(GoToDiagnosticSeverityFilter::default()); + cx.assert_editor_state(indoc! {"error warning info ˇhint"}); + go!(GoToDiagnosticSeverityFilter::default()); + cx.assert_editor_state(indoc! {"ˇerror warning info hint"}); + + let only_info = GoToDiagnosticSeverityFilter::Only(GoToDiagnosticSeverity::Information); + go!(only_info); + cx.assert_editor_state(indoc! {"error warning ˇinfo hint"}); + go!(only_info); + cx.assert_editor_state(indoc! {"error warning ˇinfo hint"}); + + let no_hints = GoToDiagnosticSeverityFilter::Range { + min: GoToDiagnosticSeverity::Information, + max: GoToDiagnosticSeverity::Error, + }; + + go!(no_hints); + cx.assert_editor_state(indoc! {"ˇerror warning info hint"}); + go!(no_hints); + cx.assert_editor_state(indoc! {"error ˇwarning info hint"}); + go!(no_hints); + cx.assert_editor_state(indoc! {"error warning ˇinfo hint"}); + go!(no_hints); + cx.assert_editor_state(indoc! {"ˇerror warning info hint"}); + + let warning_info = GoToDiagnosticSeverityFilter::Range { + min: GoToDiagnosticSeverity::Information, + max: GoToDiagnosticSeverity::Warning, + }; + + go!(warning_info); + cx.assert_editor_state(indoc! {"error ˇwarning info hint"}); + go!(warning_info); + cx.assert_editor_state(indoc! {"error warning ˇinfo hint"}); + go!(warning_info); + cx.assert_editor_state(indoc! {"error ˇwarning info hint"}); +} + +#[gpui::test] +async fn test_buffer_diagnostics(cx: &mut TestAppContext) { + init_test(cx); + + // We'll be creating two different files, both with diagnostics, so we can + // later verify that, since the `BufferDiagnosticsEditor` only shows + // diagnostics for the provided path, the diagnostics for the other file + // will not be shown, contrary to what happens with + // `ProjectDiagnosticsEditor`. + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/test"), + json!({ + "main.rs": " + fn main() { + let x = vec![]; + let y = vec![]; + a(x); + b(y); + c(y); + d(x); + } + " + .unindent(), + "other.rs": " + fn other() { + let unused = 42; + undefined_function(); + } + " + .unindent(), + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let project_path = project::ProjectPath { + worktree_id: project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }), + path: rel_path("main.rs").into(), + }; + let buffer = project + .update(cx, |project, cx| { + project.open_buffer(project_path.clone(), cx) + }) + .await + .ok(); + + // Create the diagnostics for `main.rs`. + let language_server_id = LanguageServerId(0); + let uri = lsp::Uri::from_file_path(path!("/test/main.rs")).unwrap(); + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + + 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(5, 6), lsp::Position::new(5, 7)), + severity: Some(lsp::DiagnosticSeverity::WARNING), + message: lsp::DiagnosticMessage::from( + "use of moved value\nvalue used here after move", + ), + 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(6, 6), lsp::Position::new(6, 7)), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::from( + "use of moved value\nvalue used here after move", + ), + 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(); + + // Create diagnostics for other.rs to ensure that the file and + // diagnostics are not included in `BufferDiagnosticsEditor` when it is + // deployed for main.rs. + lsp_store.update_diagnostics(language_server_id, lsp::PublishDiagnosticsParams { + uri: lsp::Uri::from_file_path(path!("/test/other.rs")).unwrap(), + diagnostics: vec![ + lsp::Diagnostic{ + range: lsp::Range::new(lsp::Position::new(1, 8), lsp::Position::new(1, 14)), + severity: Some(lsp::DiagnosticSeverity::WARNING), + message: lsp::DiagnosticMessage::from("unused variable: `unused`"), + ..Default::default() + }, + lsp::Diagnostic{ + range: lsp::Range::new(lsp::Position::new(2, 4), lsp::Position::new(2, 22)), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::from( + "cannot find function `undefined_function` in this scope", + ), + ..Default::default() + } + ], + version: None + }, None, DiagnosticSourceKind::Pushed, &[], cx).unwrap(); + }); + + let buffer_diagnostics = window.build_entity(cx, |window, cx| { + BufferDiagnosticsEditor::new( + project_path.clone(), + project.clone(), + buffer, + true, + window, + cx, + ) + }); + let editor = buffer_diagnostics.update(cx, |buffer_diagnostics, _| { + buffer_diagnostics.editor().clone() + }); + + // Since the excerpt updates is handled by a background task, we need to + // wait a little bit to ensure that the buffer diagnostic's editor content + // is rendered. + cx.executor() + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); + + 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 (back) + c(y); + § use of moved value + § value used here after move + 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 + }" + } + ); +} + +#[gpui::test] +async fn test_buffer_diagnostics_without_warnings(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/test"), + json!({ + "main.rs": " + fn main() { + let x = vec![]; + let y = vec![]; + a(x); + b(y); + c(y); + d(x); + } + " + .unindent(), + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let project_path = project::ProjectPath { + worktree_id: project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }), + path: rel_path("main.rs").into(), + }; + let buffer = project + .update(cx, |project, cx| { + project.open_buffer(project_path.clone(), cx) + }) + .await + .ok(); + + let language_server_id = LanguageServerId(0); + let uri = lsp::Uri::from_file_path(path!("/test/main.rs")).unwrap(); + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + + 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(5, 6), lsp::Position::new(5, 7)), + severity: Some(lsp::DiagnosticSeverity::WARNING), + message: lsp::DiagnosticMessage::from( + "use of moved value\nvalue used here after move", + ), + 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(6, 6), lsp::Position::new(6, 7)), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::from( + "use of moved value\nvalue used here after move", + ), + 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(); + }); + + let include_warnings = false; + let buffer_diagnostics = window.build_entity(cx, |window, cx| { + BufferDiagnosticsEditor::new( + project_path.clone(), + project.clone(), + buffer, + include_warnings, + window, + cx, + ) + }); + + let editor = buffer_diagnostics.update(cx, |buffer_diagnostics, _cx| { + buffer_diagnostics.editor().clone() + }); + + // Since the excerpt updates is handled by a background task, we need to + // wait a little bit to ensure that the buffer diagnostic's editor content + // is rendered. + cx.executor() + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); + + 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![]; + a(x); § value moved here (back) + b(y); + c(y); + 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 + }" + } + ); +} + +#[gpui::test] +async fn test_buffer_diagnostics_multiple_servers(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/test"), + json!({ + "main.rs": " + fn main() { + let x = vec![]; + let y = vec![]; + a(x); + b(y); + c(y); + d(x); + } + " + .unindent(), + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/test").as_ref()], cx).await; + let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let project_path = project::ProjectPath { + worktree_id: project.read_with(cx, |project, cx| { + project.worktrees(cx).next().unwrap().read(cx).id() + }), + path: rel_path("main.rs").into(), + }; + let buffer = project + .update(cx, |project, cx| { + project.open_buffer(project_path.clone(), cx) + }) + .await + .ok(); + + // Create the diagnostics for `main.rs`. + // Two warnings are being created, one for each language server, in order to + // assert that both warnings are rendered in the editor. + let language_server_id_a = LanguageServerId(0); + let language_server_id_b = LanguageServerId(1); + let uri = lsp::Uri::from_file_path(path!("/test/main.rs")).unwrap(); + let lsp_store = project.read_with(cx, |project, _| project.lsp_store()); + + lsp_store.update(cx, |lsp_store, cx| { + lsp_store + .update_diagnostics( + language_server_id_a, + lsp::PublishDiagnosticsParams { + uri: uri.clone(), + diagnostics: vec![lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(5, 6), lsp::Position::new(5, 7)), + severity: Some(lsp::DiagnosticSeverity::WARNING), + message: lsp::DiagnosticMessage::from( + "use of moved value\nvalue used here after move", + ), + related_information: None, + ..Default::default() + }], + version: None, + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap(); + + lsp_store + .update_diagnostics( + language_server_id_b, + lsp::PublishDiagnosticsParams { + uri: uri.clone(), + diagnostics: vec![lsp::Diagnostic { + range: lsp::Range::new(lsp::Position::new(6, 6), lsp::Position::new(6, 7)), + severity: Some(lsp::DiagnosticSeverity::WARNING), + message: lsp::DiagnosticMessage::from( + "use of moved value\nvalue used here after move", + ), + related_information: None, + ..Default::default() + }], + version: None, + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap(); + }); + + let buffer_diagnostics = window.build_entity(cx, |window, cx| { + BufferDiagnosticsEditor::new( + project_path.clone(), + project.clone(), + buffer, + true, + window, + cx, + ) + }); + let editor = buffer_diagnostics.update(cx, |buffer_diagnostics, _| { + buffer_diagnostics.editor().clone() + }); + + // Since the excerpt updates is handled by a background task, we need to + // wait a little bit to ensure that the buffer diagnostic's editor content + // is rendered. + cx.executor() + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); + + pretty_assertions::assert_eq!( + editor_content_with_blocks(&editor, cx), + indoc::indoc! { + "§ main.rs + § ----- + a(x); + b(y); + c(y); + § use of moved value + § value used here after move + d(x); + § use of moved value + § value used here after move + }" + } + ); + + buffer_diagnostics.update(cx, |buffer_diagnostics, _cx| { + assert_eq!( + *buffer_diagnostics.summary(), + DiagnosticSummary { + warning_count: 2, + error_count: 0 + } + ); + }) +} + +fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + zlog::init_test(); + let settings = SettingsStore::test(cx); + cx.set_global(settings); + theme_settings::init(theme::LoadThemes::JustBase, 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.random_range(1..=3); + for _ in 0..mutation_count { + if rng.random_bool(0.3) && !diagnostics.is_empty() { + let idx = rng.random_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.random_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.random_range(0..file_text.len().saturating_add(ERROR_MARGIN)); + let end = rng.random_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.random_bool(0.5) { + Some(lsp::DiagnosticSeverity::ERROR) + } else { + Some(lsp::DiagnosticSeverity::WARNING) + }; + + let message = format!("diagnostic {unique_id}"); + + let related_information = if rng.random_bool(0.3) { + let info_count = rng.random_range(1..=3); + let mut related_info = Vec::with_capacity(info_count); + + for i in 0..info_count { + let info_start = rng.random_range(0..file_text.len().saturating_add(ERROR_MARGIN)); + let info_end = + rng.random_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::Uri::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: lsp::DiagnosticMessage::from(message), + related_information, + data: None, + ..Default::default() + } +} diff --git a/crates/editor/src/diagnostics.rs b/crates/editor/src/diagnostics.rs new file mode 100644 index 0000000..34f42c6 --- /dev/null +++ b/crates/editor/src/diagnostics.rs @@ -0,0 +1,898 @@ +use super::*; + +pub trait DiagnosticRenderer { + fn render_group( + &self, + diagnostic_group: Vec>, + buffer_id: BufferId, + snapshot: EditorSnapshot, + editor: WeakEntity, + language_registry: Option>, + cx: &mut App, + ) -> Vec>; + + fn render_hover( + &self, + diagnostic_group: Vec>, + range: Range, + buffer_id: BufferId, + language_registry: Option>, + cx: &mut App, + ) -> Option>; + + fn open_link( + &self, + editor: &mut Editor, + link: SharedString, + window: &mut Window, + cx: &mut Context, + ); +} + +pub fn set_diagnostic_renderer(renderer: impl DiagnosticRenderer + 'static, cx: &mut App) { + cx.set_global(GlobalDiagnosticRenderer(Arc::new(renderer))); +} + +pub(super) struct GlobalDiagnosticRenderer(Arc); + +impl GlobalDiagnosticRenderer { + pub(super) fn global(cx: &App) -> Option> { + cx.try_global::().map(|g| g.0.clone()) + } +} + +impl gpui::Global for GlobalDiagnosticRenderer {} + +#[derive(Debug, Clone)] +pub(super) struct InlineDiagnostic { + pub(super) message: SharedString, + pub(super) group_id: usize, + pub(super) is_primary: bool, + pub(super) start: Point, + pub(super) severity: lsp::DiagnosticSeverity, +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) struct ActiveDiagnosticGroup { + active_range: Range, + active_message: language::DiagnosticMessage, + group_id: usize, + blocks: HashSet, +} + +#[derive(Debug, PartialEq, Eq)] +pub(super) enum ActiveDiagnostic { + None, + All, + Group(ActiveDiagnosticGroup), +} + +impl Editor { + pub fn go_to_diagnostic( + &mut self, + action: &GoToDiagnostic, + window: &mut Window, + cx: &mut Context, + ) { + if !self.diagnostics_enabled() { + return; + } + + self.go_to_diagnostic_at_cursor(Direction::Next, action.severity, window, cx); + } + + pub fn go_to_prev_diagnostic( + &mut self, + action: &GoToPreviousDiagnostic, + window: &mut Window, + cx: &mut Context, + ) { + if !self.diagnostics_enabled() { + return; + } + + self.go_to_diagnostic_at_cursor(Direction::Prev, action.severity, window, cx); + } + + fn diagnostics_before_cursor<'a>( + buffer: &'a MultiBufferSnapshot, + cursor: MultiBufferOffset, + severity: GoToDiagnosticSeverityFilter, + ) -> impl Iterator> { + buffer + .diagnostics_in_range(MultiBufferOffset(0)..cursor) + .filter(move |entry| entry.range.start <= cursor) + .filter(move |entry| severity.matches(entry.diagnostic.severity)) + .filter(|entry| entry.range.start != entry.range.end) + .filter(|entry| !entry.diagnostic.is_unnecessary) + } + + fn diagnostics_after_cursor<'a>( + buffer: &'a MultiBufferSnapshot, + cursor: MultiBufferOffset, + severity: GoToDiagnosticSeverityFilter, + ) -> impl Iterator> { + buffer + .diagnostics_in_range(cursor..buffer.len()) + .filter(move |entry| entry.range.start >= cursor) + .filter(move |entry| severity.matches(entry.diagnostic.severity)) + .filter(|entry| entry.range.start != entry.range.end) + .filter(|entry| !entry.diagnostic.is_unnecessary) + } + + /// Attempts to expand the diagnostic at the current cursor position, + /// updating the cursor position to the diagnostic's start point. + /// + /// In case there's no diagnostic at the current cursor position, this will + /// fallback to finding the next or previous diagnostic instead, depending + /// on the provided `direction`. + pub fn go_to_diagnostic_at_cursor( + &mut self, + direction: Direction, + severity: GoToDiagnosticSeverityFilter, + window: &mut Window, + cx: &mut Context, + ) { + let buffer = self.buffer.read(cx).snapshot(cx); + let selection = self + .selections + .newest::(&self.display_snapshot(cx)); + + let before = Self::diagnostics_before_cursor(&buffer, selection.start, severity); + let after = Self::diagnostics_after_cursor(&buffer, selection.start, severity); + let active_group_id = match &self.active_diagnostics { + ActiveDiagnostic::Group(group) => Some(group.group_id), + _ => None, + }; + + let mut cursor_on_active = false; + let mut target = None; + + for diagnostic in after.chain(before) { + let contains_cursor = diagnostic.range.contains(&selection.start) + || diagnostic.range.end == selection.head(); + + if !contains_cursor { + continue; + } + + if active_group_id == Some(diagnostic.diagnostic.group_id) { + cursor_on_active = true; + } else if target.is_none() { + target = Some(diagnostic); + } + } + + match (target, cursor_on_active) { + (Some(diagnostic), false) => self.activate_diagnostic(&buffer, diagnostic, window, cx), + _ => self.go_to_diagnostic_in_direction( + &buffer, &selection, direction, severity, window, cx, + ), + } + } + + fn activate_diagnostic( + &mut self, + buffer: &MultiBufferSnapshot, + diagnostic: DiagnosticEntryRef, + window: &mut Window, + cx: &mut Context, + ) { + let diagnostic_start = buffer.anchor_after(diagnostic.range.start); + let Some((buffer_anchor, _)) = buffer.anchor_to_buffer_anchor(diagnostic_start) else { + return; + }; + let buffer_id = buffer_anchor.buffer_id; + let snapshot = self.snapshot(window, cx); + if snapshot.intersects_fold(diagnostic.range.start) { + self.unfold_ranges(std::slice::from_ref(&diagnostic.range), true, false, cx); + } + self.change_selections(Default::default(), window, cx, |s| { + s.select_ranges(vec![diagnostic.range.start..diagnostic.range.start]) + }); + self.activate_diagnostics(buffer_id, diagnostic, window, cx); + self.refresh_edit_prediction( + true, + false, + EditPredictionRequestTrigger::DiagnosticNavigation, + window, + cx, + ); + } + + pub fn go_to_diagnostic_in_direction( + &mut self, + buffer: &MultiBufferSnapshot, + selection: &Selection, + direction: Direction, + severity: GoToDiagnosticSeverityFilter, + window: &mut Window, + cx: &mut Context, + ) { + let mut active_group_id = None; + if let ActiveDiagnostic::Group(active_group) = &self.active_diagnostics + && active_group.active_range.start.to_offset(&buffer) == selection.start + { + active_group_id = Some(active_group.group_id); + } + + let before = Self::diagnostics_before_cursor(&buffer, selection.start, severity); + let after = Self::diagnostics_after_cursor(&buffer, selection.start, severity); + + let mut found: Option> = None; + if direction == Direction::Prev { + 'outer: for prev_diagnostics in [before.collect::>(), after.collect::>()] + { + for diagnostic in prev_diagnostics.into_iter().rev() { + if diagnostic.range.start != selection.start + || active_group_id + .is_some_and(|active| diagnostic.diagnostic.group_id < active) + { + found = Some(diagnostic); + break 'outer; + } + } + } + } else { + for diagnostic in after.chain(before) { + if diagnostic.range.start != selection.start + || active_group_id.is_some_and(|active| diagnostic.diagnostic.group_id > active) + { + found = Some(diagnostic); + break; + } + } + } + + let Some(next_diagnostic) = found else { + return; + }; + + self.activate_diagnostic(&buffer, next_diagnostic, window, cx); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn active_diagnostic_message(&self) -> Option<&str> { + match &self.active_diagnostics { + ActiveDiagnostic::Group(group) => Some(group.active_message.as_str()), + _ => None, + } + } + + pub fn set_all_diagnostics_active(&mut self, cx: &mut Context) { + if !self.diagnostics_enabled() { + return; + } + self.dismiss_diagnostics(cx); + self.active_diagnostics = ActiveDiagnostic::All; + } + + /// Disable inline diagnostics rendering for this editor. + pub fn disable_inline_diagnostics(&mut self) { + self.inline_diagnostics_enabled = false; + self.inline_diagnostics_update = Task::ready(()); + self.inline_diagnostics.clear(); + } + + pub fn disable_diagnostics(&mut self, cx: &mut Context) { + self.diagnostics_enabled = false; + self.dismiss_diagnostics(cx); + self.inline_diagnostics_update = Task::ready(()); + self.inline_diagnostics.clear(); + } + + pub fn diagnostics_enabled(&self) -> bool { + self.diagnostics_enabled && self.lsp_data_enabled() + } + + pub fn inline_diagnostics_enabled(&self) -> bool { + self.inline_diagnostics_enabled && self.diagnostics_enabled() + } + + pub fn show_inline_diagnostics(&self) -> bool { + self.show_inline_diagnostics + } + + pub fn toggle_inline_diagnostics( + &mut self, + _: &ToggleInlineDiagnostics, + window: &mut Window, + cx: &mut Context, + ) { + self.show_inline_diagnostics = !self.show_inline_diagnostics; + self.refresh_inline_diagnostics(false, window, cx); + } + + pub fn set_max_diagnostics_severity(&mut self, severity: DiagnosticSeverity, cx: &mut App) { + self.diagnostics_max_severity = severity; + self.display_map.update(cx, |display_map, _| { + display_map.diagnostics_max_severity = self.diagnostics_max_severity; + }); + } + + pub fn toggle_diagnostics( + &mut self, + _: &ToggleDiagnostics, + window: &mut Window, + cx: &mut Context, + ) { + let diagnostics_enabled = + self.diagnostics_enabled() && self.diagnostics_max_severity != DiagnosticSeverity::Off; + self.diagnostics_enabled = !diagnostics_enabled; + + let new_severity = if self.diagnostics_enabled { + EditorSettings::get_global(cx) + .diagnostics_max_severity + .filter(|severity| severity != &DiagnosticSeverity::Off) + .unwrap_or(DiagnosticSeverity::Hint) + } else { + DiagnosticSeverity::Off + }; + self.set_max_diagnostics_severity(new_severity, cx); + if self.diagnostics_enabled { + self.active_diagnostics = ActiveDiagnostic::None; + self.refresh_inline_diagnostics(false, window, cx); + } else { + self.inline_diagnostics_update = Task::ready(()); + self.inline_diagnostics.clear(); + cx.notify(); + } + } + + pub(super) fn all_diagnostics_active(&self) -> bool { + self.active_diagnostics == ActiveDiagnostic::All + } + + pub(super) fn active_diagnostic_group_id(&self) -> Option { + match &self.active_diagnostics { + ActiveDiagnostic::Group(group) => Some(group.group_id), + _ => None, + } + } + + pub(super) fn has_active_diagnostic_group(&self) -> bool { + matches!(self.active_diagnostics, ActiveDiagnostic::Group(_)) + } + + pub(super) fn refresh_active_diagnostics(&mut self, cx: &mut Context) { + if !self.diagnostics_enabled() { + return; + } + + if let ActiveDiagnostic::Group(active_diagnostics) = &mut self.active_diagnostics { + let buffer = self.buffer.read(cx).snapshot(cx); + let primary_range_start = active_diagnostics.active_range.start.to_offset(&buffer); + let primary_range_end = active_diagnostics.active_range.end.to_offset(&buffer); + let is_valid = buffer + .diagnostics_in_range::(primary_range_start..primary_range_end) + .any(|entry| { + entry.diagnostic.is_primary + && !entry.range.is_empty() + && entry.range.start == primary_range_start + && entry + .diagnostic + .message + .rendered_eq(&active_diagnostics.active_message) + }); + + if !is_valid { + self.dismiss_diagnostics(cx); + } + } + } + + pub(super) fn activate_diagnostics( + &mut self, + buffer_id: BufferId, + diagnostic: DiagnosticEntryRef<'_, MultiBufferOffset>, + window: &mut Window, + cx: &mut Context, + ) { + if !self.diagnostics_enabled() || matches!(self.active_diagnostics, ActiveDiagnostic::All) { + return; + } + self.dismiss_diagnostics(cx); + let buffer = self.buffer.read(cx).snapshot(cx); + + let blocks = if let Some(renderer) = GlobalDiagnosticRenderer::global(cx) { + let snapshot = self.snapshot(window, cx); + let diagnostic_group = buffer + .diagnostic_group(buffer_id, diagnostic.diagnostic.group_id) + .collect::>(); + + let language_registry = self + .project() + .map(|project| project.read(cx).languages().clone()); + + let blocks = renderer.render_group( + diagnostic_group, + buffer_id, + snapshot, + cx.weak_entity(), + language_registry, + cx, + ); + + self.display_map.update(cx, |display_map, cx| { + display_map.insert_blocks(blocks, cx).into_iter().collect() + }) + } else { + // Ensure that, even if there's no global renderer set, we still use + // an empty set of blocks, such that we can record the active group + // below instead of bailing out. + HashSet::default() + }; + + self.active_diagnostics = ActiveDiagnostic::Group(ActiveDiagnosticGroup { + active_range: buffer.anchor_before(diagnostic.range.start) + ..buffer.anchor_after(diagnostic.range.end), + active_message: diagnostic.diagnostic.message.clone(), + group_id: diagnostic.diagnostic.group_id, + blocks, + }); + cx.notify(); + } + + pub(super) fn dismiss_diagnostics(&mut self, cx: &mut Context) { + if matches!(self.active_diagnostics, ActiveDiagnostic::All) { + return; + }; + + let prev = mem::replace(&mut self.active_diagnostics, ActiveDiagnostic::None); + if let ActiveDiagnostic::Group(group) = prev { + self.display_map.update(cx, |display_map, cx| { + display_map.remove_blocks(group.blocks, cx); + }); + cx.notify(); + } + } + + pub(super) fn refresh_inline_diagnostics( + &mut self, + debounce: bool, + window: &mut Window, + cx: &mut Context, + ) { + let max_severity = ProjectSettings::get_global(cx) + .diagnostics + .inline + .max_severity + .unwrap_or(self.diagnostics_max_severity); + + if !self.inline_diagnostics_enabled() + || !self.diagnostics_enabled() + || !self.show_inline_diagnostics + || max_severity == DiagnosticSeverity::Off + { + self.inline_diagnostics_update = Task::ready(()); + self.inline_diagnostics.clear(); + cx.notify(); + return; + } + + let debounce_ms = ProjectSettings::get_global(cx) + .diagnostics + .inline + .update_debounce_ms; + let debounce = if debounce && debounce_ms > 0 { + Some(Duration::from_millis(debounce_ms)) + } else { + None + }; + self.inline_diagnostics_update = cx.spawn_in(window, async move |editor, cx| { + if let Some(debounce) = debounce { + cx.background_executor().timer(debounce).await; + } + let Some(snapshot) = editor.upgrade().map(|editor| { + editor.update(cx, |editor, cx| editor.buffer().read(cx).snapshot(cx)) + }) else { + return; + }; + + let new_inline_diagnostics = cx + .background_spawn(async move { + let mut inline_diagnostics = Vec::<(Anchor, InlineDiagnostic)>::new(); + for diagnostic_entry in + snapshot.diagnostics_in_range(MultiBufferOffset(0)..snapshot.len()) + { + let message = diagnostic_entry + .diagnostic + .message + .as_str() + .split_once('\n') + .map(|(line, _)| line) + .map(SharedString::new) + .unwrap_or_else(|| { + diagnostic_entry + .diagnostic + .message + .as_shared_string() + .clone() + }); + let start_anchor = snapshot.anchor_before(diagnostic_entry.range.start); + let (Ok(i) | Err(i)) = inline_diagnostics + .binary_search_by(|(probe, _)| probe.cmp(&start_anchor, &snapshot)); + inline_diagnostics.insert( + i, + ( + start_anchor, + InlineDiagnostic { + message, + group_id: diagnostic_entry.diagnostic.group_id, + start: diagnostic_entry.range.start.to_point(&snapshot), + is_primary: diagnostic_entry.diagnostic.is_primary, + severity: diagnostic_entry.diagnostic.severity, + }, + ), + ); + } + inline_diagnostics + }) + .await; + + editor + .update(cx, |editor, cx| { + editor.inline_diagnostics = new_inline_diagnostics; + cx.notify(); + }) + .ok(); + }); + } + + pub(super) fn pull_diagnostics( + &mut self, + buffer_id: BufferId, + _window: &Window, + cx: &mut Context, + ) -> Option<()> { + // `ActiveDiagnostic::All` is a special mode where editor's diagnostics are managed by the external view, + // skip any LSP updates for it. + + if self.active_diagnostics == ActiveDiagnostic::All || !self.diagnostics_enabled() { + return None; + } + let pull_diagnostics_settings = ProjectSettings::get_global(cx) + .diagnostics + .lsp_pull_diagnostics; + if !pull_diagnostics_settings.enabled { + return None; + } + let debounce = Duration::from_millis(pull_diagnostics_settings.debounce_ms); + let project = self.project()?.downgrade(); + let buffer = self.buffer().read(cx).buffer(buffer_id)?; + + self.pull_diagnostics_task = cx.spawn(async move |_, cx| { + cx.background_executor().timer(debounce).await; + if let Ok(task) = project.update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.pull_diagnostics_for_buffer(buffer, cx) + }) + }) { + task.await.log_err(); + } + project + .update(cx, |project, cx| { + project.lsp_store().update(cx, |lsp_store, cx| { + lsp_store.pull_document_diagnostics_for_buffer_edit(buffer_id, cx); + }) + }) + .log_err(); + }); + + Some(()) + } + + pub(super) fn update_diagnostics_state( + &mut self, + window: &mut Window, + cx: &mut Context<'_, Editor>, + ) { + if !self.diagnostics_enabled() { + return; + } + self.refresh_active_diagnostics(cx); + self.refresh_inline_diagnostics(true, window, cx); + self.scrollbar_marker_state.dirty = true; + cx.notify(); + } + + pub fn any_active_diagnostics(&self) -> bool { + match &self.active_diagnostics { + ActiveDiagnostic::None => false, + ActiveDiagnostic::All => true, + ActiveDiagnostic::Group(_) => true, + } + } +} + +#[cfg(test)] +mod tests { + use crate::{ + actions::{ + ToggleCodeLens, ToggleDiagnostics, ToggleInlayHints, ToggleInlineDiagnostics, + ToggleSemanticHighlights, + }, + editor_tests::init_test, + test::editor_test_context::EditorTestContext, + }; + use gpui::{Action, TestAppContext, UpdateGlobal}; + use indoc::indoc; + use language::DiagnosticSourceKind; + use lsp::LanguageServerId; + use settings::{DelayMs, SettingsStore}; + use std::sync::{ + Arc, + atomic::{self, AtomicUsize}, + }; + use util::path; + + fn setup_inline_diagnostics(cx: &mut EditorTestContext) { + cx.update(|_, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + let inline = settings + .diagnostics + .get_or_insert_default() + .inline + .get_or_insert_default(); + inline.enabled = Some(true); + inline.update_debounce_ms = Some(DelayMs(0)); + }); + }); + }); + + cx.set_state(indoc! {" + fn func(abc dˇef: i32) -> u32 { + } + "}); + + let lsp_store = + cx.update_editor(|editor, _, cx| editor.project().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::Uri::from_file_path(path!("/root/file")).unwrap(), + version: None, + diagnostics: vec![lsp::Diagnostic { + range: lsp::Range::new( + lsp::Position::new(0, 12), + lsp::Position::new(0, 15), + ), + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: lsp::DiagnosticMessage::from("cannot find value `def`"), + ..Default::default() + }], + }, + None, + DiagnosticSourceKind::Pushed, + &[], + cx, + ) + .unwrap() + }); + }); + cx.run_until_parked(); + + cx.update_editor(|editor, _, _| { + assert_eq!( + editor.inline_diagnostics.len(), + 1, + "inline diagnostics should appear after the language server publishes them" + ); + }); + } + + #[gpui::test] + async fn test_toggle_diagnostics_refreshes_inline_diagnostics(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + setup_inline_diagnostics(&mut cx); + + cx.update_editor(|editor, window, cx| { + editor.toggle_diagnostics(&ToggleDiagnostics, window, cx); + }); + cx.run_until_parked(); + cx.update_editor(|editor, _, _| { + assert_eq!( + editor.inline_diagnostics.len(), + 0, + "inline diagnostics should be cleared after disabling diagnostics" + ); + }); + + cx.update_editor(|editor, window, cx| { + editor.toggle_diagnostics(&ToggleDiagnostics, window, cx); + }); + cx.run_until_parked(); + cx.update_editor(|editor, _, _| { + assert_eq!( + editor.inline_diagnostics.len(), + 1, + "inline diagnostics should reappear after re-enabling diagnostics, without further editor events" + ); + }); + } + + #[gpui::test] + async fn test_toggle_inline_diagnostics_notifies_on_hide(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + setup_inline_diagnostics(&mut cx); + + let notify_count = Arc::new(AtomicUsize::new(0)); + let editor = cx.editor.clone(); + let _subscription = cx.update({ + let notify_count = notify_count.clone(); + move |_, cx| { + cx.observe(&editor, move |_, _| { + notify_count.fetch_add(1, atomic::Ordering::SeqCst); + }) + } + }); + + cx.update_editor(|editor, window, cx| { + editor.toggle_inline_diagnostics(&ToggleInlineDiagnostics, window, cx); + }); + cx.update_editor(|editor, _, _| { + assert_eq!( + editor.inline_diagnostics.len(), + 0, + "inline diagnostics should be cleared after toggling them off" + ); + }); + assert_eq!( + notify_count.load(atomic::Ordering::SeqCst), + 1, + "toggling inline diagnostics off should notify to repaint the editor" + ); + + cx.update_editor(|editor, window, cx| { + editor.toggle_inline_diagnostics(&ToggleInlineDiagnostics, window, cx); + }); + cx.run_until_parked(); + cx.update_editor(|editor, _, _| { + assert_eq!( + editor.inline_diagnostics.len(), + 1, + "inline diagnostics should reappear after toggling them back on" + ); + }); + } + + #[gpui::test] + async fn test_actions_gated_by_lsp_data(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let lsp_data_actions: [&dyn Action; 4] = [ + &ToggleDiagnostics, + &ToggleInlayHints, + &ToggleCodeLens, + &ToggleSemanticHighlights, + ]; + + cx.update_editor(|editor, _, cx| { + editor.enable_lsp_data = false; + cx.notify(); + }); + cx.update(|window, cx| { + for action in lsp_data_actions { + assert!( + !window.is_action_available(action, cx), + "{} should not be available when LSP data is disabled", + action.name() + ); + } + }); + + cx.update_editor(|editor, _, cx| { + editor.enable_lsp_data = true; + cx.notify(); + }); + cx.update(|window, cx| { + for action in lsp_data_actions { + assert!( + window.is_action_available(action, cx), + "{} should be available again after re-enabling LSP data", + action.name() + ); + } + }); + } + + #[gpui::test] + async fn test_toggle_diagnostics_remains_available_after_disabling_diagnostics( + cx: &mut TestAppContext, + ) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.dispatch_action(ToggleDiagnostics); + cx.update_editor(|editor, window, cx| { + assert!( + !editor.diagnostics_enabled(), + "diagnostics should be disabled after dispatching ToggleDiagnostics" + ); + assert!( + window.is_action_available(&ToggleDiagnostics, cx), + "ToggleDiagnostics should still be available after disabling diagnostics, \ + so the user can re-enable it" + ); + }); + + cx.dispatch_action(ToggleDiagnostics); + cx.update_editor(|editor, window, cx| { + assert!( + editor.diagnostics_enabled(), + "diagnostics should be re-enabled after a second dispatch of ToggleDiagnostics" + ); + assert!( + window.is_action_available(&ToggleDiagnostics, cx), + "ToggleDiagnostics should be available again after re-enabling diagnostics" + ); + }); + } + + #[gpui::test] + async fn test_toggle_inline_diagnostics_availability(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + cx.dispatch_action(ToggleDiagnostics); + cx.update(|window, cx| { + assert!( + !window.is_action_available(&ToggleInlineDiagnostics, cx), + "ToggleInlineDiagnostics should not be available when diagnostics are disabled" + ); + }); + + cx.dispatch_action(ToggleDiagnostics); + cx.update(|window, cx| { + assert!( + window.is_action_available(&ToggleInlineDiagnostics, cx), + "ToggleInlineDiagnostics should be available again after re-enabling diagnostics" + ); + }); + + let initial_show = cx.update_editor(|editor, _, _| editor.show_inline_diagnostics()); + cx.dispatch_action(ToggleInlineDiagnostics); + cx.update_editor(|editor, window, cx| { + assert_eq!( + editor.show_inline_diagnostics(), + !initial_show, + "inline diagnostics visibility should flip after dispatching ToggleInlineDiagnostics" + ); + assert!( + window.is_action_available(&ToggleInlineDiagnostics, cx), + "ToggleInlineDiagnostics should still be available after toggling it, \ + so the user can toggle it back" + ); + }); + + cx.dispatch_action(ToggleInlineDiagnostics); + cx.update_editor(|editor, window, cx| { + assert_eq!( + editor.show_inline_diagnostics(), + initial_show, + "inline diagnostics visibility should flip back after a second dispatch of ToggleInlineDiagnostics" + ); + assert!( + window.is_action_available(&ToggleInlineDiagnostics, cx), + "ToggleInlineDiagnostics should remain available after toggling it back" + ); + }); + + cx.update_editor(|editor, _, cx| { + editor.disable_inline_diagnostics(); + cx.notify(); + }); + cx.update(|window, cx| { + assert!( + !window.is_action_available(&ToggleInlineDiagnostics, cx), + "ToggleInlineDiagnostics should not be available in editors with inline diagnostics permanently disabled" + ); + }); + } +}