For a long time, we've had problems where diagnostics can end up showing
up inconsistently in different views. This PR is my attempt to prevent
that, and to simplify the system in the process. There are some UX
changes.
Diagnostic behaviors that have *not* changed:
* In-buffer diagnostics update immediately when LSPs send diagnostics
updates.
* The diagnostic counts in the status bar indicator also update
immediately.
Diagnostic behaviors that this PR changes:
* [x] The tab title for the project diagnostics view now simply shows
the same counts as the status bar indicator - the project's current
totals. Previously, this tab title showed something slightly different -
the numbers of diagnostics *currently shown* in the diagnostics view's
excerpts. But it was pretty confusing that you could sometimes see two
different diagnostic counts.
* [x] The project diagnostics view **never** updates its excerpts while
the user might be in the middle of typing it that view, unless the user
expressed an intent for the excerpts to update (by e.g. saving the
buffer). This was the behavior we originally implemented, but has
changed a few times since then, in attempts to fix other issues. I've
restored that invariant.
Times when the excerpts will update:
* diagnostics are updated while the diagnostics view is not focused
* the user changes focus away from the diagnostics view
* the language server sends a `work done progress end` message for its
disk-based diagnostics token (i.e. cargo check finishes)
* the user saves a buffer associated with a language server, and then a
debounce timer expires
* [x] The project diagnostics view indicates when its diagnostics are
stale. States:
* when diagnostics have been updated while the diagnostics view was
focused:
* the indicator shows a 'refresh' icon
* clicking the indicator updates the excerpts
* when diagnostics have been updated, but a file has been saved, so that
the diagnostics will soon update, the indicator is disabled
With these UX changes, the only 'complex' part of the our diagnostics
presentation is the Project Diagnostics view's excerpt management,
because it needs to implement the deferred updates in order to avoid
disrupting the user while they may be typing. I want to take some steps
to reduce the potential for bugs in this view.
* [x] Reduce the amount of state that the view uses, and simplify its
implementation
* [x] Add a randomized test that checks the invariant that a mutated
diagnostics view matches a freshly computed diagnostics view
## Release Notes
- Reworked the project diagnostics view:
- Fixed an issue where the project diagnostics view could update its
excerpts while you were typing in it.
- Fixed bugs where the project diagnostics view could show the wrong
excerpts.
- Changed the diagnostics view to always update its excerpts eagerly
when not focused.
- Added an indicator to the project diagnostics view's toolbar, showing
when diagnostics have been changed.
---------
Co-authored-by: Richard Feldman <oss@rtfeldman.com>
1009 lines
35 KiB
Rust
1009 lines
35 KiB
Rust
use super::*;
|
|
use collections::HashMap;
|
|
use editor::{
|
|
display_map::{BlockContext, TransformBlock},
|
|
DisplayPoint, GutterDimensions,
|
|
};
|
|
use gpui::{px, AvailableSpace, Stateful, TestAppContext, VisualTestContext};
|
|
use language::{
|
|
Diagnostic, DiagnosticEntry, DiagnosticSeverity, OffsetRangeExt, PointUtf16, Rope, Unclipped,
|
|
};
|
|
use pretty_assertions::assert_eq;
|
|
use project::FakeFs;
|
|
use rand::{rngs::StdRng, seq::IteratorRandom as _, Rng};
|
|
use serde_json::json;
|
|
use settings::SettingsStore;
|
|
use std::{env, path::Path};
|
|
use unindent::Unindent as _;
|
|
use util::{post_inc, RandomCharIter};
|
|
|
|
#[ctor::ctor]
|
|
fn init_logger() {
|
|
if env::var("RUST_LOG").is_ok() {
|
|
env_logger::init();
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_diagnostics(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/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(), ["/test".as_ref()], cx).await;
|
|
let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
|
|
let cx = &mut VisualTestContext::from_window(*window, cx);
|
|
let workspace = window.root(cx).unwrap();
|
|
|
|
// Create some diagnostics
|
|
project.update(cx, |project, cx| {
|
|
project
|
|
.update_diagnostic_entries(
|
|
language_server_id,
|
|
PathBuf::from("/test/main.rs"),
|
|
None,
|
|
vec![
|
|
DiagnosticEntry {
|
|
range: Unclipped(PointUtf16::new(1, 8))..Unclipped(PointUtf16::new(1, 9)),
|
|
diagnostic: Diagnostic {
|
|
message:
|
|
"move occurs because `x` has type `Vec<char>`, which does not implement the `Copy` trait"
|
|
.to_string(),
|
|
severity: DiagnosticSeverity::INFORMATION,
|
|
is_primary: false,
|
|
is_disk_based: true,
|
|
group_id: 1,
|
|
..Default::default()
|
|
},
|
|
},
|
|
DiagnosticEntry {
|
|
range: Unclipped(PointUtf16::new(2, 8))..Unclipped(PointUtf16::new(2, 9)),
|
|
diagnostic: Diagnostic {
|
|
message:
|
|
"move occurs because `y` has type `Vec<char>`, which does not implement the `Copy` trait"
|
|
.to_string(),
|
|
severity: DiagnosticSeverity::INFORMATION,
|
|
is_primary: false,
|
|
is_disk_based: true,
|
|
group_id: 0,
|
|
..Default::default()
|
|
},
|
|
},
|
|
DiagnosticEntry {
|
|
range: Unclipped(PointUtf16::new(3, 6))..Unclipped(PointUtf16::new(3, 7)),
|
|
diagnostic: Diagnostic {
|
|
message: "value moved here".to_string(),
|
|
severity: DiagnosticSeverity::INFORMATION,
|
|
is_primary: false,
|
|
is_disk_based: true,
|
|
group_id: 1,
|
|
..Default::default()
|
|
},
|
|
},
|
|
DiagnosticEntry {
|
|
range: Unclipped(PointUtf16::new(4, 6))..Unclipped(PointUtf16::new(4, 7)),
|
|
diagnostic: Diagnostic {
|
|
message: "value moved here".to_string(),
|
|
severity: DiagnosticSeverity::INFORMATION,
|
|
is_primary: false,
|
|
is_disk_based: true,
|
|
group_id: 0,
|
|
..Default::default()
|
|
},
|
|
},
|
|
DiagnosticEntry {
|
|
range: Unclipped(PointUtf16::new(7, 6))..Unclipped(PointUtf16::new(7, 7)),
|
|
diagnostic: Diagnostic {
|
|
message: "use of moved value\nvalue used here after move".to_string(),
|
|
severity: DiagnosticSeverity::ERROR,
|
|
is_primary: true,
|
|
is_disk_based: true,
|
|
group_id: 0,
|
|
..Default::default()
|
|
},
|
|
},
|
|
DiagnosticEntry {
|
|
range: Unclipped(PointUtf16::new(8, 6))..Unclipped(PointUtf16::new(8, 7)),
|
|
diagnostic: Diagnostic {
|
|
message: "use of moved value\nvalue used here after move".to_string(),
|
|
severity: DiagnosticSeverity::ERROR,
|
|
is_primary: true,
|
|
is_disk_based: true,
|
|
group_id: 1,
|
|
..Default::default()
|
|
},
|
|
},
|
|
],
|
|
cx,
|
|
)
|
|
.unwrap();
|
|
});
|
|
|
|
// Open the project diagnostics view while there are already diagnostics.
|
|
let view = window.build_view(cx, |cx| {
|
|
ProjectDiagnosticsEditor::new_with_context(1, project.clone(), workspace.downgrade(), cx)
|
|
});
|
|
let editor = view.update(cx, |view, _| view.editor.clone());
|
|
|
|
view.next_notification(cx).await;
|
|
assert_eq!(
|
|
editor_blocks(&editor, cx),
|
|
[
|
|
(0, "path header block".into()),
|
|
(2, "diagnostic header".into()),
|
|
(15, "collapsed context".into()),
|
|
(16, "diagnostic header".into()),
|
|
(25, "collapsed context".into()),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
editor.update(cx, |editor, cx| editor.display_text(cx)),
|
|
concat!(
|
|
//
|
|
// main.rs
|
|
//
|
|
"\n", // filename
|
|
"\n", // padding
|
|
// diagnostic group 1
|
|
"\n", // primary message
|
|
"\n", // padding
|
|
" let x = vec![];\n",
|
|
" let y = vec![];\n",
|
|
"\n", // supporting diagnostic
|
|
" a(x);\n",
|
|
" b(y);\n",
|
|
"\n", // supporting diagnostic
|
|
" // comment 1\n",
|
|
" // comment 2\n",
|
|
" c(y);\n",
|
|
"\n", // supporting diagnostic
|
|
" d(x);\n",
|
|
"\n", // context ellipsis
|
|
// diagnostic group 2
|
|
"\n", // primary message
|
|
"\n", // padding
|
|
"fn main() {\n",
|
|
" let x = vec![];\n",
|
|
"\n", // supporting diagnostic
|
|
" let y = vec![];\n",
|
|
" a(x);\n",
|
|
"\n", // supporting diagnostic
|
|
" b(y);\n",
|
|
"\n", // context ellipsis
|
|
" c(y);\n",
|
|
" d(x);\n",
|
|
"\n", // supporting diagnostic
|
|
"}"
|
|
)
|
|
);
|
|
|
|
// Cursor is at the first diagnostic
|
|
editor.update(cx, |editor, cx| {
|
|
assert_eq!(
|
|
editor.selections.display_ranges(cx),
|
|
[DisplayPoint::new(12, 6)..DisplayPoint::new(12, 6)]
|
|
);
|
|
});
|
|
|
|
// Diagnostics are added for another earlier path.
|
|
project.update(cx, |project, cx| {
|
|
project.disk_based_diagnostics_started(language_server_id, cx);
|
|
project
|
|
.update_diagnostic_entries(
|
|
language_server_id,
|
|
PathBuf::from("/test/consts.rs"),
|
|
None,
|
|
vec![DiagnosticEntry {
|
|
range: Unclipped(PointUtf16::new(0, 15))..Unclipped(PointUtf16::new(0, 15)),
|
|
diagnostic: Diagnostic {
|
|
message: "mismatched types\nexpected `usize`, found `char`".to_string(),
|
|
severity: DiagnosticSeverity::ERROR,
|
|
is_primary: true,
|
|
is_disk_based: true,
|
|
group_id: 0,
|
|
..Default::default()
|
|
},
|
|
}],
|
|
cx,
|
|
)
|
|
.unwrap();
|
|
project.disk_based_diagnostics_finished(language_server_id, cx);
|
|
});
|
|
|
|
view.next_notification(cx).await;
|
|
assert_eq!(
|
|
editor_blocks(&editor, cx),
|
|
[
|
|
(0, "path header block".into()),
|
|
(2, "diagnostic header".into()),
|
|
(7, "path header block".into()),
|
|
(9, "diagnostic header".into()),
|
|
(22, "collapsed context".into()),
|
|
(23, "diagnostic header".into()),
|
|
(32, "collapsed context".into()),
|
|
]
|
|
);
|
|
|
|
assert_eq!(
|
|
editor.update(cx, |editor, cx| editor.display_text(cx)),
|
|
concat!(
|
|
//
|
|
// consts.rs
|
|
//
|
|
"\n", // filename
|
|
"\n", // padding
|
|
// diagnostic group 1
|
|
"\n", // primary message
|
|
"\n", // padding
|
|
"const a: i32 = 'a';\n",
|
|
"\n", // supporting diagnostic
|
|
"const b: i32 = c;\n",
|
|
//
|
|
// main.rs
|
|
//
|
|
"\n", // filename
|
|
"\n", // padding
|
|
// diagnostic group 1
|
|
"\n", // primary message
|
|
"\n", // padding
|
|
" let x = vec![];\n",
|
|
" let y = vec![];\n",
|
|
"\n", // supporting diagnostic
|
|
" a(x);\n",
|
|
" b(y);\n",
|
|
"\n", // supporting diagnostic
|
|
" // comment 1\n",
|
|
" // comment 2\n",
|
|
" c(y);\n",
|
|
"\n", // supporting diagnostic
|
|
" d(x);\n",
|
|
"\n", // collapsed context
|
|
// diagnostic group 2
|
|
"\n", // primary message
|
|
"\n", // filename
|
|
"fn main() {\n",
|
|
" let x = vec![];\n",
|
|
"\n", // supporting diagnostic
|
|
" let y = vec![];\n",
|
|
" a(x);\n",
|
|
"\n", // supporting diagnostic
|
|
" b(y);\n",
|
|
"\n", // context ellipsis
|
|
" c(y);\n",
|
|
" d(x);\n",
|
|
"\n", // supporting diagnostic
|
|
"}"
|
|
)
|
|
);
|
|
|
|
// Cursor keeps its position.
|
|
editor.update(cx, |editor, cx| {
|
|
assert_eq!(
|
|
editor.selections.display_ranges(cx),
|
|
[DisplayPoint::new(19, 6)..DisplayPoint::new(19, 6)]
|
|
);
|
|
});
|
|
|
|
// Diagnostics are added to the first path
|
|
project.update(cx, |project, cx| {
|
|
project.disk_based_diagnostics_started(language_server_id, cx);
|
|
project
|
|
.update_diagnostic_entries(
|
|
language_server_id,
|
|
PathBuf::from("/test/consts.rs"),
|
|
None,
|
|
vec![
|
|
DiagnosticEntry {
|
|
range: Unclipped(PointUtf16::new(0, 15))..Unclipped(PointUtf16::new(0, 15)),
|
|
diagnostic: Diagnostic {
|
|
message: "mismatched types\nexpected `usize`, found `char`".to_string(),
|
|
severity: DiagnosticSeverity::ERROR,
|
|
is_primary: true,
|
|
is_disk_based: true,
|
|
group_id: 0,
|
|
..Default::default()
|
|
},
|
|
},
|
|
DiagnosticEntry {
|
|
range: Unclipped(PointUtf16::new(1, 15))..Unclipped(PointUtf16::new(1, 15)),
|
|
diagnostic: Diagnostic {
|
|
message: "unresolved name `c`".to_string(),
|
|
severity: DiagnosticSeverity::ERROR,
|
|
is_primary: true,
|
|
is_disk_based: true,
|
|
group_id: 1,
|
|
..Default::default()
|
|
},
|
|
},
|
|
],
|
|
cx,
|
|
)
|
|
.unwrap();
|
|
project.disk_based_diagnostics_finished(language_server_id, cx);
|
|
});
|
|
|
|
view.next_notification(cx).await;
|
|
assert_eq!(
|
|
editor_blocks(&editor, cx),
|
|
[
|
|
(0, "path header block".into()),
|
|
(2, "diagnostic header".into()),
|
|
(7, "collapsed context".into()),
|
|
(8, "diagnostic header".into()),
|
|
(13, "path header block".into()),
|
|
(15, "diagnostic header".into()),
|
|
(28, "collapsed context".into()),
|
|
(29, "diagnostic header".into()),
|
|
(38, "collapsed context".into()),
|
|
]
|
|
);
|
|
|
|
assert_eq!(
|
|
editor.update(cx, |editor, cx| editor.display_text(cx)),
|
|
concat!(
|
|
//
|
|
// consts.rs
|
|
//
|
|
"\n", // filename
|
|
"\n", // padding
|
|
// diagnostic group 1
|
|
"\n", // primary message
|
|
"\n", // padding
|
|
"const a: i32 = 'a';\n",
|
|
"\n", // supporting diagnostic
|
|
"const b: i32 = c;\n",
|
|
"\n", // context ellipsis
|
|
// diagnostic group 2
|
|
"\n", // primary message
|
|
"\n", // padding
|
|
"const a: i32 = 'a';\n",
|
|
"const b: i32 = c;\n",
|
|
"\n", // supporting diagnostic
|
|
//
|
|
// main.rs
|
|
//
|
|
"\n", // filename
|
|
"\n", // padding
|
|
// diagnostic group 1
|
|
"\n", // primary message
|
|
"\n", // padding
|
|
" let x = vec![];\n",
|
|
" let y = vec![];\n",
|
|
"\n", // supporting diagnostic
|
|
" a(x);\n",
|
|
" b(y);\n",
|
|
"\n", // supporting diagnostic
|
|
" // comment 1\n",
|
|
" // comment 2\n",
|
|
" c(y);\n",
|
|
"\n", // supporting diagnostic
|
|
" d(x);\n",
|
|
"\n", // context ellipsis
|
|
// diagnostic group 2
|
|
"\n", // primary message
|
|
"\n", // filename
|
|
"fn main() {\n",
|
|
" let x = vec![];\n",
|
|
"\n", // supporting diagnostic
|
|
" let y = vec![];\n",
|
|
" a(x);\n",
|
|
"\n", // supporting diagnostic
|
|
" b(y);\n",
|
|
"\n", // context ellipsis
|
|
" c(y);\n",
|
|
" d(x);\n",
|
|
"\n", // supporting diagnostic
|
|
"}"
|
|
)
|
|
);
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/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(), ["/test".as_ref()], cx).await;
|
|
let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
|
|
let cx = &mut VisualTestContext::from_window(*window, cx);
|
|
let workspace = window.root(cx).unwrap();
|
|
|
|
let view = window.build_view(cx, |cx| {
|
|
ProjectDiagnosticsEditor::new_with_context(1, project.clone(), workspace.downgrade(), cx)
|
|
});
|
|
let editor = view.update(cx, |view, _| view.editor.clone());
|
|
|
|
// Two language servers start updating diagnostics
|
|
project.update(cx, |project, cx| {
|
|
project.disk_based_diagnostics_started(server_id_1, cx);
|
|
project.disk_based_diagnostics_started(server_id_2, cx);
|
|
project
|
|
.update_diagnostic_entries(
|
|
server_id_1,
|
|
PathBuf::from("/test/main.js"),
|
|
None,
|
|
vec![DiagnosticEntry {
|
|
range: Unclipped(PointUtf16::new(0, 0))..Unclipped(PointUtf16::new(0, 1)),
|
|
diagnostic: Diagnostic {
|
|
message: "error 1".to_string(),
|
|
severity: DiagnosticSeverity::WARNING,
|
|
is_primary: true,
|
|
is_disk_based: true,
|
|
group_id: 1,
|
|
..Default::default()
|
|
},
|
|
}],
|
|
cx,
|
|
)
|
|
.unwrap();
|
|
});
|
|
|
|
// The first language server finishes
|
|
project.update(cx, |project, cx| {
|
|
project.disk_based_diagnostics_finished(server_id_1, cx);
|
|
});
|
|
|
|
// Only the first language server's diagnostics are shown.
|
|
cx.executor().run_until_parked();
|
|
assert_eq!(
|
|
editor_blocks(&editor, cx),
|
|
[
|
|
(0, "path header block".into()),
|
|
(2, "diagnostic header".into()),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
editor.update(cx, |editor, cx| editor.display_text(cx)),
|
|
concat!(
|
|
"\n", // filename
|
|
"\n", // padding
|
|
// diagnostic group 1
|
|
"\n", // primary message
|
|
"\n", // padding
|
|
"a();\n", //
|
|
"b();",
|
|
)
|
|
);
|
|
|
|
// The second language server finishes
|
|
project.update(cx, |project, cx| {
|
|
project
|
|
.update_diagnostic_entries(
|
|
server_id_2,
|
|
PathBuf::from("/test/main.js"),
|
|
None,
|
|
vec![DiagnosticEntry {
|
|
range: Unclipped(PointUtf16::new(1, 0))..Unclipped(PointUtf16::new(1, 1)),
|
|
diagnostic: Diagnostic {
|
|
message: "warning 1".to_string(),
|
|
severity: DiagnosticSeverity::ERROR,
|
|
is_primary: true,
|
|
is_disk_based: true,
|
|
group_id: 2,
|
|
..Default::default()
|
|
},
|
|
}],
|
|
cx,
|
|
)
|
|
.unwrap();
|
|
project.disk_based_diagnostics_finished(server_id_2, cx);
|
|
});
|
|
|
|
// Both language server's diagnostics are shown.
|
|
cx.executor().run_until_parked();
|
|
assert_eq!(
|
|
editor_blocks(&editor, cx),
|
|
[
|
|
(0, "path header block".into()),
|
|
(2, "diagnostic header".into()),
|
|
(6, "collapsed context".into()),
|
|
(7, "diagnostic header".into()),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
editor.update(cx, |editor, cx| editor.display_text(cx)),
|
|
concat!(
|
|
"\n", // filename
|
|
"\n", // padding
|
|
// diagnostic group 1
|
|
"\n", // primary message
|
|
"\n", // padding
|
|
"a();\n", // location
|
|
"b();\n", //
|
|
"\n", // collapsed context
|
|
// diagnostic group 2
|
|
"\n", // primary message
|
|
"\n", // padding
|
|
"a();\n", // context
|
|
"b();\n", //
|
|
"c();", // context
|
|
)
|
|
);
|
|
|
|
// Both language servers start updating diagnostics, and the first server finishes.
|
|
project.update(cx, |project, cx| {
|
|
project.disk_based_diagnostics_started(server_id_1, cx);
|
|
project.disk_based_diagnostics_started(server_id_2, cx);
|
|
project
|
|
.update_diagnostic_entries(
|
|
server_id_1,
|
|
PathBuf::from("/test/main.js"),
|
|
None,
|
|
vec![DiagnosticEntry {
|
|
range: Unclipped(PointUtf16::new(2, 0))..Unclipped(PointUtf16::new(2, 1)),
|
|
diagnostic: Diagnostic {
|
|
message: "warning 2".to_string(),
|
|
severity: DiagnosticSeverity::WARNING,
|
|
is_primary: true,
|
|
is_disk_based: true,
|
|
group_id: 1,
|
|
..Default::default()
|
|
},
|
|
}],
|
|
cx,
|
|
)
|
|
.unwrap();
|
|
project
|
|
.update_diagnostic_entries(
|
|
server_id_2,
|
|
PathBuf::from("/test/main.rs"),
|
|
None,
|
|
vec![],
|
|
cx,
|
|
)
|
|
.unwrap();
|
|
project.disk_based_diagnostics_finished(server_id_1, cx);
|
|
});
|
|
|
|
// Only the first language server's diagnostics are updated.
|
|
cx.executor().run_until_parked();
|
|
assert_eq!(
|
|
editor_blocks(&editor, cx),
|
|
[
|
|
(0, "path header block".into()),
|
|
(2, "diagnostic header".into()),
|
|
(7, "collapsed context".into()),
|
|
(8, "diagnostic header".into()),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
editor.update(cx, |editor, cx| editor.display_text(cx)),
|
|
concat!(
|
|
"\n", // filename
|
|
"\n", // padding
|
|
// diagnostic group 1
|
|
"\n", // primary message
|
|
"\n", // padding
|
|
"a();\n", // location
|
|
"b();\n", //
|
|
"c();\n", // context
|
|
"\n", // collapsed context
|
|
// diagnostic group 2
|
|
"\n", // primary message
|
|
"\n", // padding
|
|
"b();\n", // context
|
|
"c();\n", //
|
|
"d();", // context
|
|
)
|
|
);
|
|
|
|
// The second language server finishes.
|
|
project.update(cx, |project, cx| {
|
|
project
|
|
.update_diagnostic_entries(
|
|
server_id_2,
|
|
PathBuf::from("/test/main.js"),
|
|
None,
|
|
vec![DiagnosticEntry {
|
|
range: Unclipped(PointUtf16::new(3, 0))..Unclipped(PointUtf16::new(3, 1)),
|
|
diagnostic: Diagnostic {
|
|
message: "warning 2".to_string(),
|
|
severity: DiagnosticSeverity::WARNING,
|
|
is_primary: true,
|
|
is_disk_based: true,
|
|
group_id: 1,
|
|
..Default::default()
|
|
},
|
|
}],
|
|
cx,
|
|
)
|
|
.unwrap();
|
|
project.disk_based_diagnostics_finished(server_id_2, cx);
|
|
});
|
|
|
|
// Both language servers' diagnostics are updated.
|
|
cx.executor().run_until_parked();
|
|
assert_eq!(
|
|
editor_blocks(&editor, cx),
|
|
[
|
|
(0, "path header block".into()),
|
|
(2, "diagnostic header".into()),
|
|
(7, "collapsed context".into()),
|
|
(8, "diagnostic header".into()),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
editor.update(cx, |editor, cx| editor.display_text(cx)),
|
|
concat!(
|
|
"\n", // filename
|
|
"\n", // padding
|
|
// diagnostic group 1
|
|
"\n", // primary message
|
|
"\n", // padding
|
|
"b();\n", // location
|
|
"c();\n", //
|
|
"d();\n", // context
|
|
"\n", // collapsed context
|
|
// diagnostic group 2
|
|
"\n", // primary message
|
|
"\n", // padding
|
|
"c();\n", // context
|
|
"d();\n", //
|
|
"e();", // context
|
|
)
|
|
);
|
|
}
|
|
|
|
#[gpui::test(iterations = 20)]
|
|
async fn test_random_diagnostics(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("/test", json!({})).await;
|
|
|
|
let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await;
|
|
let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
|
|
let cx = &mut VisualTestContext::from_window(*window, cx);
|
|
let workspace = window.root(cx).unwrap();
|
|
|
|
let mutated_view = window.build_view(cx, |cx| {
|
|
ProjectDiagnosticsEditor::new_with_context(1, project.clone(), workspace.downgrade(), cx)
|
|
});
|
|
|
|
workspace.update(cx, |workspace, cx| {
|
|
workspace.add_item_to_center(Box::new(mutated_view.clone()), cx);
|
|
});
|
|
mutated_view.update(cx, |view, cx| {
|
|
assert!(view.focus_handle.is_focused(cx));
|
|
});
|
|
|
|
let mut next_group_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<DiagnosticEntry<Unclipped<PointUtf16>>>,
|
|
> = Default::default();
|
|
|
|
for _ in 0..operations {
|
|
match rng.gen_range(0..100) {
|
|
// language server completes its diagnostic check
|
|
0..=20 if !updated_language_servers.is_empty() => {
|
|
let server_id = *updated_language_servers.iter().choose(&mut rng).unwrap();
|
|
log::info!("finishing diagnostic check for language server {server_id}");
|
|
project.update(cx, |project, cx| {
|
|
project.disk_based_diagnostics_finished(server_id, cx)
|
|
});
|
|
|
|
if rng.gen_bool(0.5) {
|
|
cx.run_until_parked();
|
|
}
|
|
}
|
|
|
|
// language server updates diagnostics
|
|
_ => {
|
|
let (path, server_id, diagnostics) =
|
|
match current_diagnostics.iter_mut().choose(&mut rng) {
|
|
// update existing set of diagnostics
|
|
Some(((path, server_id), diagnostics)) if rng.gen_bool(0.5) => {
|
|
(path.clone(), *server_id, diagnostics)
|
|
}
|
|
|
|
// insert a set of diagnostics for a new path
|
|
_ => {
|
|
let path: PathBuf =
|
|
format!("/test/{}.rs", post_inc(&mut next_filename)).into();
|
|
let len = rng.gen_range(128..256);
|
|
let content =
|
|
RandomCharIter::new(&mut rng).take(len).collect::<String>();
|
|
fs.insert_file(&path, content.into_bytes()).await;
|
|
|
|
let server_id = match language_server_ids.iter().choose(&mut rng) {
|
|
Some(server_id) if rng.gen_bool(0.5) => *server_id,
|
|
_ => {
|
|
let id = LanguageServerId(language_server_ids.len());
|
|
language_server_ids.push(id);
|
|
id
|
|
}
|
|
};
|
|
|
|
(
|
|
path.clone(),
|
|
server_id,
|
|
current_diagnostics
|
|
.entry((path, server_id))
|
|
.or_insert(vec![]),
|
|
)
|
|
}
|
|
};
|
|
|
|
updated_language_servers.insert(server_id);
|
|
|
|
project.update(cx, |project, cx| {
|
|
log::info!("updating diagnostics. language server {server_id} path {path:?}");
|
|
randomly_update_diagnostics_for_path(
|
|
&fs,
|
|
&path,
|
|
diagnostics,
|
|
&mut next_group_id,
|
|
&mut rng,
|
|
);
|
|
project
|
|
.update_diagnostic_entries(server_id, path, None, diagnostics.clone(), cx)
|
|
.unwrap()
|
|
});
|
|
|
|
cx.run_until_parked();
|
|
}
|
|
}
|
|
}
|
|
|
|
log::info!("updating mutated diagnostics view");
|
|
mutated_view.update(cx, |view, _| view.enqueue_update_stale_excerpts(None));
|
|
cx.run_until_parked();
|
|
|
|
log::info!("constructing reference diagnostics view");
|
|
let reference_view = window.build_view(cx, |cx| {
|
|
ProjectDiagnosticsEditor::new_with_context(1, project.clone(), workspace.downgrade(), cx)
|
|
});
|
|
cx.run_until_parked();
|
|
|
|
let mutated_excerpts = get_diagnostics_excerpts(&mutated_view, cx);
|
|
let reference_excerpts = get_diagnostics_excerpts(&reference_view, cx);
|
|
assert_eq!(mutated_excerpts, reference_excerpts);
|
|
}
|
|
|
|
fn init_test(cx: &mut TestAppContext) {
|
|
cx.update(|cx| {
|
|
let settings = SettingsStore::test(cx);
|
|
cx.set_global(settings);
|
|
theme::init(theme::LoadThemes::JustBase, cx);
|
|
language::init(cx);
|
|
client::init_settings(cx);
|
|
workspace::init_settings(cx);
|
|
Project::init_settings(cx);
|
|
crate::init(cx);
|
|
editor::init(cx);
|
|
});
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
struct ExcerptInfo {
|
|
path: PathBuf,
|
|
range: ExcerptRange<Point>,
|
|
group_id: usize,
|
|
primary: bool,
|
|
language_server: LanguageServerId,
|
|
}
|
|
|
|
fn get_diagnostics_excerpts(
|
|
view: &View<ProjectDiagnosticsEditor>,
|
|
cx: &mut VisualTestContext,
|
|
) -> Vec<ExcerptInfo> {
|
|
view.update(cx, |view, cx| {
|
|
let mut result = vec![];
|
|
let mut excerpt_indices_by_id = HashMap::default();
|
|
view.excerpts.update(cx, |multibuffer, cx| {
|
|
let snapshot = multibuffer.snapshot(cx);
|
|
for (id, buffer, range) in snapshot.excerpts() {
|
|
excerpt_indices_by_id.insert(id, result.len());
|
|
result.push(ExcerptInfo {
|
|
path: buffer.file().unwrap().path().to_path_buf(),
|
|
range: ExcerptRange {
|
|
context: range.context.to_point(&buffer),
|
|
primary: range.primary.map(|range| range.to_point(&buffer)),
|
|
},
|
|
group_id: usize::MAX,
|
|
primary: false,
|
|
language_server: LanguageServerId(0),
|
|
});
|
|
}
|
|
});
|
|
|
|
for state in &view.path_states {
|
|
for group in &state.diagnostic_groups {
|
|
for (ix, excerpt_id) in group.excerpts.iter().enumerate() {
|
|
let excerpt_ix = excerpt_indices_by_id[excerpt_id];
|
|
let excerpt = &mut result[excerpt_ix];
|
|
excerpt.group_id = group.primary_diagnostic.diagnostic.group_id;
|
|
excerpt.language_server = group.language_server_id;
|
|
excerpt.primary = ix == group.primary_excerpt_ix;
|
|
}
|
|
}
|
|
}
|
|
|
|
result
|
|
})
|
|
}
|
|
|
|
fn randomly_update_diagnostics_for_path(
|
|
fs: &FakeFs,
|
|
path: &Path,
|
|
diagnostics: &mut Vec<DiagnosticEntry<Unclipped<PointUtf16>>>,
|
|
next_group_id: &mut usize,
|
|
rng: &mut impl Rng,
|
|
) {
|
|
let file_content = fs.read_file_sync(path).unwrap();
|
|
let file_text = Rope::from(String::from_utf8_lossy(&file_content).as_ref());
|
|
|
|
let mut group_ids = diagnostics
|
|
.iter()
|
|
.map(|d| d.diagnostic.group_id)
|
|
.collect::<HashSet<_>>();
|
|
|
|
let mutation_count = rng.gen_range(1..=3);
|
|
for _ in 0..mutation_count {
|
|
if rng.gen_bool(0.5) && !group_ids.is_empty() {
|
|
let group_id = *group_ids.iter().choose(rng).unwrap();
|
|
log::info!(" removing diagnostic group {group_id}");
|
|
diagnostics.retain(|d| d.diagnostic.group_id != group_id);
|
|
group_ids.remove(&group_id);
|
|
} else {
|
|
let group_id = *next_group_id;
|
|
*next_group_id += 1;
|
|
|
|
let mut new_diagnostics = vec![random_diagnostic(rng, &file_text, group_id, true)];
|
|
for _ in 0..rng.gen_range(0..=1) {
|
|
new_diagnostics.push(random_diagnostic(rng, &file_text, group_id, false));
|
|
}
|
|
|
|
let ix = rng.gen_range(0..=diagnostics.len());
|
|
log::info!(
|
|
" inserting diagnostic group {group_id} at index {ix}. ranges: {:?}",
|
|
new_diagnostics
|
|
.iter()
|
|
.map(|d| (d.range.start.0, d.range.end.0))
|
|
.collect::<Vec<_>>()
|
|
);
|
|
diagnostics.splice(ix..ix, new_diagnostics);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn random_diagnostic(
|
|
rng: &mut impl Rng,
|
|
file_text: &Rope,
|
|
group_id: usize,
|
|
is_primary: bool,
|
|
) -> DiagnosticEntry<Unclipped<PointUtf16>> {
|
|
// 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 start = rng.gen_range(0..file_text.len().saturating_add(ERROR_MARGIN));
|
|
let end = rng.gen_range(start..file_text.len().saturating_add(ERROR_MARGIN));
|
|
let range = Range {
|
|
start: Unclipped(file_text.offset_to_point_utf16(start)),
|
|
end: Unclipped(file_text.offset_to_point_utf16(end)),
|
|
};
|
|
let severity = if rng.gen_bool(0.5) {
|
|
DiagnosticSeverity::WARNING
|
|
} else {
|
|
DiagnosticSeverity::ERROR
|
|
};
|
|
let message = format!("diagnostic group {group_id}");
|
|
|
|
DiagnosticEntry {
|
|
range,
|
|
diagnostic: Diagnostic {
|
|
source: None, // (optional) service that created the diagnostic
|
|
code: None, // (optional) machine-readable code that identifies the diagnostic
|
|
severity,
|
|
message,
|
|
group_id,
|
|
is_primary,
|
|
is_disk_based: false,
|
|
is_unnecessary: false,
|
|
},
|
|
}
|
|
}
|
|
|
|
fn editor_blocks(editor: &View<Editor>, cx: &mut VisualTestContext) -> Vec<(u32, SharedString)> {
|
|
let mut blocks = Vec::new();
|
|
cx.draw(gpui::Point::default(), AvailableSpace::min_size(), |cx| {
|
|
editor.update(cx, |editor, cx| {
|
|
let snapshot = editor.snapshot(cx);
|
|
blocks.extend(
|
|
snapshot
|
|
.blocks_in_range(0..snapshot.max_point().row())
|
|
.enumerate()
|
|
.filter_map(|(ix, (row, block))| {
|
|
let name: SharedString = match block {
|
|
TransformBlock::Custom(block) => {
|
|
let mut element = block.render(&mut BlockContext {
|
|
context: cx,
|
|
anchor_x: px(0.),
|
|
gutter_dimensions: &GutterDimensions::default(),
|
|
line_height: px(0.),
|
|
em_width: px(0.),
|
|
max_width: px(0.),
|
|
block_id: ix,
|
|
editor_style: &editor::EditorStyle::default(),
|
|
});
|
|
let element = element.downcast_mut::<Stateful<Div>>().unwrap();
|
|
element
|
|
.interactivity()
|
|
.element_id
|
|
.clone()?
|
|
.try_into()
|
|
.ok()?
|
|
}
|
|
|
|
TransformBlock::ExcerptHeader {
|
|
starts_new_buffer, ..
|
|
} => {
|
|
if *starts_new_buffer {
|
|
"path header block".into()
|
|
} else {
|
|
"collapsed context".into()
|
|
}
|
|
}
|
|
};
|
|
|
|
Some((row, name))
|
|
}),
|
|
)
|
|
});
|
|
|
|
div().into_any()
|
|
});
|
|
blocks
|
|
}
|