pub mod items;
mod project_diagnostics_settings;
mod toolbar_controls;
use anyhow::{Context as _, Result};
use collections::{HashMap, HashSet};
use editor::{
diagnostic_block_renderer,
display_map::{BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock},
highlight_diagnostic_message,
scroll::autoscroll::Autoscroll,
Editor, EditorEvent, ExcerptId, ExcerptRange, MultiBuffer, ToOffset,
};
use futures::future::try_join_all;
use gpui::{
actions, div, AnyElement, AnyView, AppContext, Context, Div, EventEmitter, FocusEvent,
FocusHandle, Focusable, FocusableElement, FocusableView, InteractiveElement, Model,
ParentElement, Render, RenderOnce, SharedString, Styled, Subscription, Task, View, ViewContext,
VisualContext, WeakView, WindowContext,
};
use language::{
Anchor, Bias, Buffer, Diagnostic, DiagnosticEntry, DiagnosticSeverity, Point, Selection,
SelectionGoal,
};
use lsp::LanguageServerId;
use project::{DiagnosticSummary, Project, ProjectPath};
use project_diagnostics_settings::ProjectDiagnosticsSettings;
use settings::Settings;
use std::{
any::{Any, TypeId},
cmp::Ordering,
mem,
ops::Range,
path::PathBuf,
sync::Arc,
};
use theme::ActiveTheme;
pub use toolbar_controls::ToolbarControls;
use ui::{h_stack, Color, HighlightedLabel, Icon, IconElement, Label};
use util::TryFutureExt;
use workspace::{
item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
ItemNavHistory, Pane, ToolbarItemLocation, Workspace,
};
actions!(Deploy, ToggleWarnings);
const CONTEXT_LINE_COUNT: u32 = 1;
pub fn init(cx: &mut AppContext) {
ProjectDiagnosticsSettings::register(cx);
cx.observe_new_views(ProjectDiagnosticsEditor::register)
.detach();
}
struct ProjectDiagnosticsEditor {
project: Model<Project>,
workspace: WeakView<Workspace>,
focus_handle: FocusHandle,
editor: View<Editor>,
summary: DiagnosticSummary,
excerpts: Model<MultiBuffer>,
path_states: Vec<PathState>,
paths_to_update: HashMap<LanguageServerId, HashSet<ProjectPath>>,
current_diagnostics: HashMap<LanguageServerId, HashSet<ProjectPath>>,
include_warnings: bool,
_subscriptions: Vec<Subscription>,
}
struct PathState {
path: ProjectPath,
diagnostic_groups: Vec<DiagnosticGroupState>,
}
#[derive(Clone, Debug, PartialEq)]
struct Jump {
path: ProjectPath,
position: Point,
anchor: Anchor,
}
struct DiagnosticGroupState {
language_server_id: LanguageServerId,
primary_diagnostic: DiagnosticEntry<language::Anchor>,
primary_excerpt_ix: usize,
excerpts: Vec<ExcerptId>,
blocks: HashSet<BlockId>,
block_count: usize,
}
impl EventEmitter<ItemEvent> for ProjectDiagnosticsEditor {}
impl Render for ProjectDiagnosticsEditor {
type Element = Focusable<Div>;
fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
let child = if self.path_states.is_empty() {
div()
.bg(cx.theme().colors().editor_background)
.flex()
.items_center()
.justify_center()
.size_full()
.child(Label::new("No problems in workspace"))
} else {
div().size_full().child(self.editor.clone())
};
div()
.track_focus(&self.focus_handle)
.size_full()
.on_focus_in(cx.listener(Self::focus_in))
.on_action(cx.listener(Self::toggle_warnings))
.child(child)
}
}
impl ProjectDiagnosticsEditor {
fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
workspace.register_action(Self::deploy);
}
fn new(
project_handle: Model<Project>,
workspace: WeakView<Workspace>,
cx: &mut ViewContext<Self>,
) -> Self {
let project_event_subscription =
cx.subscribe(&project_handle, |this, _, event, cx| match event {
project::Event::DiskBasedDiagnosticsFinished { language_server_id } => {
log::debug!("Disk based diagnostics finished for server {language_server_id}");
this.update_excerpts(Some(*language_server_id), cx);
}
project::Event::DiagnosticsUpdated {
language_server_id,
path,
} => {
log::debug!("Adding path {path:?} to update for server {language_server_id}");
this.paths_to_update
.entry(*language_server_id)
.or_default()
.insert(path.clone());
if this.editor.read(cx).selections.all::<usize>(cx).is_empty()
&& !this.is_dirty(cx)
{
this.update_excerpts(Some(*language_server_id), cx);
}
}
_ => {}
});
let excerpts = cx.build_model(|cx| MultiBuffer::new(project_handle.read(cx).replica_id()));
let editor = cx.build_view(|cx| {
let mut editor =
Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), cx);
editor.set_vertical_scroll_margin(5, cx);
editor
});
let editor_event_subscription =
cx.subscribe(&editor, |this, _editor, event: &EditorEvent, cx| {
Self::emit_item_event_for_editor_event(event, cx);
if event == &EditorEvent::Focused && this.path_states.is_empty() {
cx.focus(&this.focus_handle);
}
});
let project = project_handle.read(cx);
let summary = project.diagnostic_summary(cx);
let mut this = Self {
project: project_handle,
summary,
workspace,
excerpts,
focus_handle: cx.focus_handle(),
editor,
path_states: Default::default(),
paths_to_update: HashMap::default(),
include_warnings: ProjectDiagnosticsSettings::get_global(cx).include_warnings,
current_diagnostics: HashMap::default(),
_subscriptions: vec![project_event_subscription, editor_event_subscription],
};
this.update_excerpts(None, cx);
this
}
fn emit_item_event_for_editor_event(event: &EditorEvent, cx: &mut ViewContext<Self>) {
match event {
EditorEvent::Closed => cx.emit(ItemEvent::CloseItem),
EditorEvent::Saved | EditorEvent::TitleChanged => {
cx.emit(ItemEvent::UpdateTab);
cx.emit(ItemEvent::UpdateBreadcrumbs);
}
EditorEvent::Reparsed => {
cx.emit(ItemEvent::UpdateBreadcrumbs);
}
EditorEvent::SelectionsChanged { local } if *local => {
cx.emit(ItemEvent::UpdateBreadcrumbs);
}
EditorEvent::DirtyChanged => {
cx.emit(ItemEvent::UpdateTab);
}
EditorEvent::BufferEdited => {
cx.emit(ItemEvent::Edit);
cx.emit(ItemEvent::UpdateBreadcrumbs);
}
EditorEvent::ExcerptsAdded { .. } | EditorEvent::ExcerptsRemoved { .. } => {
cx.emit(ItemEvent::Edit);
}
_ => {}
}
}
fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext<Workspace>) {
if let Some(existing) = workspace.item_of_type::<ProjectDiagnosticsEditor>(cx) {
workspace.activate_item(&existing, cx);
} else {
let workspace_handle = cx.view().downgrade();
let diagnostics = cx.build_view(|cx| {
ProjectDiagnosticsEditor::new(workspace.project().clone(), workspace_handle, cx)
});
workspace.add_item(Box::new(diagnostics), cx);
}
}
fn toggle_warnings(&mut self, _: &ToggleWarnings, cx: &mut ViewContext<Self>) {
self.include_warnings = !self.include_warnings;
self.paths_to_update = self.current_diagnostics.clone();
self.update_excerpts(None, cx);
cx.notify();
}
fn focus_in(&mut self, _: &FocusEvent, cx: &mut ViewContext<Self>) {
if self.focus_handle.is_focused(cx) && !self.path_states.is_empty() {
self.editor.focus_handle(cx).focus(cx)
}
}
fn update_excerpts(
&mut self,
language_server_id: Option<LanguageServerId>,
cx: &mut ViewContext<Self>,
) {
log::debug!("Updating excerpts for server {language_server_id:?}");
let mut paths_to_recheck = HashSet::default();
let mut new_summaries: HashMap<LanguageServerId, HashSet<ProjectPath>> = self
.project
.read(cx)
.diagnostic_summaries(cx)
.fold(HashMap::default(), |mut summaries, (path, server_id, _)| {
summaries.entry(server_id).or_default().insert(path);
summaries
});
let mut old_diagnostics = if let Some(language_server_id) = language_server_id {
new_summaries.retain(|server_id, _| server_id == &language_server_id);
self.paths_to_update.retain(|server_id, paths| {
if server_id == &language_server_id {
paths_to_recheck.extend(paths.drain());
false
} else {
true
}
});
let mut old_diagnostics = HashMap::default();
if let Some(new_paths) = new_summaries.get(&language_server_id) {
if let Some(old_paths) = self
.current_diagnostics
.insert(language_server_id, new_paths.clone())
{
old_diagnostics.insert(language_server_id, old_paths);
}
} else {
if let Some(old_paths) = self.current_diagnostics.remove(&language_server_id) {
old_diagnostics.insert(language_server_id, old_paths);
}
}
old_diagnostics
} else {
paths_to_recheck.extend(self.paths_to_update.drain().flat_map(|(_, paths)| paths));
mem::replace(&mut self.current_diagnostics, new_summaries.clone())
};
for (server_id, new_paths) in new_summaries {
match old_diagnostics.remove(&server_id) {
Some(mut old_paths) => {
paths_to_recheck.extend(
new_paths
.into_iter()
.filter(|new_path| !old_paths.remove(new_path)),
);
paths_to_recheck.extend(old_paths);
}
None => paths_to_recheck.extend(new_paths),
}
}
paths_to_recheck.extend(old_diagnostics.into_iter().flat_map(|(_, paths)| paths));
if paths_to_recheck.is_empty() {
log::debug!("No paths to recheck for language server {language_server_id:?}");
return;
}
log::debug!(
"Rechecking {} paths for language server {:?}",
paths_to_recheck.len(),
language_server_id
);
let project = self.project.clone();
cx.spawn(|this, mut cx| {
async move {
let _: Vec<()> = try_join_all(paths_to_recheck.into_iter().map(|path| {
let mut cx = cx.clone();
let project = project.clone();
let this = this.clone();
async move {
let buffer = project
.update(&mut cx, |project, cx| project.open_buffer(path.clone(), cx))?
.await
.with_context(|| format!("opening buffer for path {path:?}"))?;
this.update(&mut cx, |this, cx| {
this.populate_excerpts(path, language_server_id, buffer, cx);
})
.context("missing project")?;
anyhow::Ok(())
}
}))
.await
.context("rechecking diagnostics for paths")?;
this.update(&mut cx, |this, cx| {
this.summary = this.project.read(cx).diagnostic_summary(cx);
cx.emit(ItemEvent::UpdateTab);
cx.emit(ItemEvent::UpdateBreadcrumbs);
})?;
anyhow::Ok(())
}
.log_err()
})
.detach();
}
fn populate_excerpts(
&mut self,
path: ProjectPath,
language_server_id: Option<LanguageServerId>,
buffer: Model<Buffer>,
cx: &mut ViewContext<Self>,
) {
let was_empty = self.path_states.is_empty();
let snapshot = buffer.read(cx).snapshot();
let path_ix = match self.path_states.binary_search_by_key(&&path, |e| &e.path) {
Ok(ix) => ix,
Err(ix) => {
self.path_states.insert(
ix,
PathState {
path: path.clone(),
diagnostic_groups: Default::default(),
},
);
ix
}
};
let mut prev_excerpt_id = if path_ix > 0 {
let prev_path_last_group = &self.path_states[path_ix - 1]
.diagnostic_groups
.last()
.unwrap();
prev_path_last_group.excerpts.last().unwrap().clone()
} else {
ExcerptId::min()
};
let path_state = &mut self.path_states[path_ix];
let mut groups_to_add = Vec::new();
let mut group_ixs_to_remove = Vec::new();
let mut blocks_to_add = Vec::new();
let mut blocks_to_remove = HashSet::default();
let mut first_excerpt_id = None;
let max_severity = if self.include_warnings {
DiagnosticSeverity::WARNING
} else {
DiagnosticSeverity::ERROR
};
let excerpts_snapshot = self.excerpts.update(cx, |excerpts, excerpts_cx| {
let mut old_groups = path_state.diagnostic_groups.iter().enumerate().peekable();
let mut new_groups = snapshot
.diagnostic_groups(language_server_id)
.into_iter()
.filter(|(_, group)| {
group.entries[group.primary_ix].diagnostic.severity <= max_severity
})
.peekable();
loop {
let mut to_insert = None;
let mut to_remove = None;
let mut to_keep = None;
match (old_groups.peek(), new_groups.peek()) {
(None, None) => break,
(None, Some(_)) => to_insert = new_groups.next(),
(Some((_, old_group)), None) => {
if language_server_id.map_or(true, |id| id == old_group.language_server_id)
{
to_remove = old_groups.next();
} else {
to_keep = old_groups.next();
}
}
(Some((_, old_group)), Some((_, new_group))) => {
let old_primary = &old_group.primary_diagnostic;
let new_primary = &new_group.entries[new_group.primary_ix];
match compare_diagnostics(old_primary, new_primary, &snapshot) {
Ordering::Less => {
if language_server_id
.map_or(true, |id| id == old_group.language_server_id)
{
to_remove = old_groups.next();
} else {
to_keep = old_groups.next();
}
}
Ordering::Equal => {
to_keep = old_groups.next();
new_groups.next();
}
Ordering::Greater => to_insert = new_groups.next(),
}
}
}
if let Some((language_server_id, group)) = to_insert {
let mut group_state = DiagnosticGroupState {
language_server_id,
primary_diagnostic: group.entries[group.primary_ix].clone(),
primary_excerpt_ix: 0,
excerpts: Default::default(),
blocks: Default::default(),
block_count: 0,
};
let mut pending_range: Option<(Range<Point>, usize)> = None;
let mut is_first_excerpt_for_group = true;
for (ix, entry) in group.entries.iter().map(Some).chain([None]).enumerate() {
let resolved_entry = entry.map(|e| e.resolve::<Point>(&snapshot));
if let Some((range, start_ix)) = &mut pending_range {
if let Some(entry) = resolved_entry.as_ref() {
if entry.range.start.row
<= range.end.row + 1 + CONTEXT_LINE_COUNT * 2
{
range.end = range.end.max(entry.range.end);
continue;
}
}
let excerpt_start =
Point::new(range.start.row.saturating_sub(CONTEXT_LINE_COUNT), 0);
let excerpt_end = snapshot.clip_point(
Point::new(range.end.row + CONTEXT_LINE_COUNT, u32::MAX),
Bias::Left,
);
let excerpt_id = excerpts
.insert_excerpts_after(
prev_excerpt_id,
buffer.clone(),
[ExcerptRange {
context: excerpt_start..excerpt_end,
primary: Some(range.clone()),
}],
excerpts_cx,
)
.pop()
.unwrap();
prev_excerpt_id = excerpt_id.clone();
first_excerpt_id.get_or_insert_with(|| prev_excerpt_id.clone());
group_state.excerpts.push(excerpt_id.clone());
let header_position = (excerpt_id.clone(), language::Anchor::MIN);
if is_first_excerpt_for_group {
is_first_excerpt_for_group = false;
let mut primary =
group.entries[group.primary_ix].diagnostic.clone();
primary.message =
primary.message.split('\n').next().unwrap().to_string();
group_state.block_count += 1;
blocks_to_add.push(BlockProperties {
position: header_position,
height: 2,
style: BlockStyle::Sticky,
render: diagnostic_header_renderer(primary),
disposition: BlockDisposition::Above,
});
}
for entry in &group.entries[*start_ix..ix] {
let mut diagnostic = entry.diagnostic.clone();
if diagnostic.is_primary {
group_state.primary_excerpt_ix = group_state.excerpts.len() - 1;
diagnostic.message =
entry.diagnostic.message.split('\n').skip(1).collect();
}
if !diagnostic.message.is_empty() {
group_state.block_count += 1;
blocks_to_add.push(BlockProperties {
position: (excerpt_id.clone(), entry.range.start),
height: diagnostic.message.matches('\n').count() as u8 + 1,
style: BlockStyle::Fixed,
render: diagnostic_block_renderer(diagnostic, true),
disposition: BlockDisposition::Below,
});
}
}
pending_range.take();
}
if let Some(entry) = resolved_entry {
pending_range = Some((entry.range.clone(), ix));
}
}
groups_to_add.push(group_state);
} else if let Some((group_ix, group_state)) = to_remove {
excerpts.remove_excerpts(group_state.excerpts.iter().copied(), excerpts_cx);
group_ixs_to_remove.push(group_ix);
blocks_to_remove.extend(group_state.blocks.iter().copied());
} else if let Some((_, group)) = to_keep {
prev_excerpt_id = group.excerpts.last().unwrap().clone();
first_excerpt_id.get_or_insert_with(|| prev_excerpt_id.clone());
}
}
excerpts.snapshot(excerpts_cx)
});
self.editor.update(cx, |editor, cx| {
editor.remove_blocks(blocks_to_remove, None, cx);
let block_ids = editor.insert_blocks(
blocks_to_add.into_iter().map(|block| {
let (excerpt_id, text_anchor) = block.position;
BlockProperties {
position: excerpts_snapshot.anchor_in_excerpt(excerpt_id, text_anchor),
height: block.height,
style: block.style,
render: block.render,
disposition: block.disposition,
}
}),
Some(Autoscroll::fit()),
cx,
);
let mut block_ids = block_ids.into_iter();
for group_state in &mut groups_to_add {
group_state.blocks = block_ids.by_ref().take(group_state.block_count).collect();
}
});
for ix in group_ixs_to_remove.into_iter().rev() {
path_state.diagnostic_groups.remove(ix);
}
path_state.diagnostic_groups.extend(groups_to_add);
path_state.diagnostic_groups.sort_unstable_by(|a, b| {
let range_a = &a.primary_diagnostic.range;
let range_b = &b.primary_diagnostic.range;
range_a
.start
.cmp(&range_b.start, &snapshot)
.then_with(|| range_a.end.cmp(&range_b.end, &snapshot))
});
if path_state.diagnostic_groups.is_empty() {
self.path_states.remove(path_ix);
}
self.editor.update(cx, |editor, cx| {
let groups;
let mut selections;
let new_excerpt_ids_by_selection_id;
if was_empty {
groups = self.path_states.first()?.diagnostic_groups.as_slice();
new_excerpt_ids_by_selection_id = [(0, ExcerptId::min())].into_iter().collect();
selections = vec![Selection {
id: 0,
start: 0,
end: 0,
reversed: false,
goal: SelectionGoal::None,
}];
} else {
groups = self.path_states.get(path_ix)?.diagnostic_groups.as_slice();
new_excerpt_ids_by_selection_id =
editor.change_selections(Some(Autoscroll::fit()), cx, |s| s.refresh());
selections = editor.selections.all::<usize>(cx);
}
let snapshot = editor.snapshot(cx);
for selection in &mut selections {
if let Some(new_excerpt_id) = new_excerpt_ids_by_selection_id.get(&selection.id) {
let group_ix = match groups.binary_search_by(|probe| {
probe
.excerpts
.last()
.unwrap()
.cmp(new_excerpt_id, &snapshot.buffer_snapshot)
}) {
Ok(ix) | Err(ix) => ix,
};
if let Some(group) = groups.get(group_ix) {
let offset = excerpts_snapshot
.anchor_in_excerpt(
group.excerpts[group.primary_excerpt_ix].clone(),
group.primary_diagnostic.range.start,
)
.to_offset(&excerpts_snapshot);
selection.start = offset;
selection.end = offset;
}
}
}
editor.change_selections(None, cx, |s| {
s.select(selections);
});
Some(())
});
if self.path_states.is_empty() {
if self.editor.focus_handle(cx).is_focused(cx) {
cx.focus(&self.focus_handle);
}
} else if self.focus_handle.is_focused(cx) {
let focus_handle = self.editor.focus_handle(cx);
cx.focus(&focus_handle);
}
cx.notify();
}
}
impl FocusableView for ProjectDiagnosticsEditor {
fn focus_handle(&self, _: &AppContext) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Item for ProjectDiagnosticsEditor {
fn deactivated(&mut self, cx: &mut ViewContext<Self>) {
self.editor.update(cx, |editor, cx| editor.deactivated(cx));
}
fn navigate(&mut self, data: Box<dyn Any>, cx: &mut ViewContext<Self>) -> bool {
self.editor
.update(cx, |editor, cx| editor.navigate(data, cx))
}
fn tab_tooltip_text(&self, _: &AppContext) -> Option<SharedString> {
Some("Project Diagnostics".into())
}
fn tab_content(&self, _detail: Option<usize>, _: &WindowContext) -> AnyElement {
render_summary(&self.summary)
}
fn for_each_project_item(
&self,
cx: &AppContext,
f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item),
) {
self.editor.for_each_project_item(cx, f)
}
fn is_singleton(&self, _: &AppContext) -> bool {
false
}
fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext<Self>) {
self.editor.update(cx, |editor, _| {
editor.set_nav_history(Some(nav_history));
});
}
fn clone_on_split(
&self,
_workspace_id: workspace::WorkspaceId,
cx: &mut ViewContext<Self>,
) -> Option<View<Self>>
where
Self: Sized,
{
Some(cx.build_view(|cx| {
ProjectDiagnosticsEditor::new(self.project.clone(), self.workspace.clone(), cx)
}))
}
fn is_dirty(&self, cx: &AppContext) -> bool {
self.excerpts.read(cx).is_dirty(cx)
}
fn has_conflict(&self, cx: &AppContext) -> bool {
self.excerpts.read(cx).has_conflict(cx)
}
fn can_save(&self, _: &AppContext) -> bool {
true
}
fn save(&mut self, project: Model<Project>, cx: &mut ViewContext<Self>) -> Task<Result<()>> {
self.editor.save(project, cx)
}
fn save_as(
&mut self,
_: Model<Project>,
_: PathBuf,
_: &mut ViewContext<Self>,
) -> Task<Result<()>> {
unreachable!()
}
fn reload(&mut self, project: Model<Project>, cx: &mut ViewContext<Self>) -> Task<Result<()>> {
self.editor.reload(project, cx)
}
fn act_as_type<'a>(
&'a self,
type_id: TypeId,
self_handle: &'a View<Self>,
_: &'a AppContext,
) -> Option<AnyView> {
if type_id == TypeId::of::<Self>() {
Some(self_handle.to_any())
} else if type_id == TypeId::of::<Editor>() {
Some(self.editor.to_any())
} else {
None
}
}
fn breadcrumb_location(&self) -> ToolbarItemLocation {
ToolbarItemLocation::PrimaryLeft
}
fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<BreadcrumbText>> {
self.editor.breadcrumbs(theme, cx)
}
fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
self.editor
.update(cx, |editor, cx| editor.added_to_workspace(workspace, cx));
}
fn serialized_item_kind() -> Option<&'static str> {
Some("diagnostics")
}
fn deserialize(
project: Model<Project>,
workspace: WeakView<Workspace>,
_workspace_id: workspace::WorkspaceId,
_item_id: workspace::ItemId,
cx: &mut ViewContext<Pane>,
) -> Task<Result<View<Self>>> {
Task::ready(Ok(cx.build_view(|cx| Self::new(project, workspace, cx))))
}
}
fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
let (message, highlights) = highlight_diagnostic_message(Vec::new(), &diagnostic.message);
Arc::new(move |_| {
h_stack()
.id("diagnostic header")
.gap_3()
.bg(gpui::red())
.map(|stack| {
let icon = if diagnostic.severity == DiagnosticSeverity::ERROR {
IconElement::new(Icon::XCircle).color(Color::Error)
} else {
IconElement::new(Icon::ExclamationTriangle).color(Color::Warning)
};
stack.child(div().pl_8().child(icon))
})
.when_some(diagnostic.source.as_ref(), |stack, source| {
stack.child(Label::new(format!("{source}:")).color(Color::Accent))
})
.child(HighlightedLabel::new(message.clone(), highlights.clone()))
.when_some(diagnostic.code.as_ref(), |stack, code| {
stack.child(Label::new(code.clone()))
})
.render_into_any()
})
}
pub(crate) fn render_summary(summary: &DiagnosticSummary) -> AnyElement {
if summary.error_count == 0 && summary.warning_count == 0 {
let label = Label::new("No problems");
label.render_into_any()
} else {
h_stack()
.bg(gpui::red())
.child(IconElement::new(Icon::XCircle))
.child(Label::new(summary.error_count.to_string()))
.child(IconElement::new(Icon::ExclamationTriangle))
.child(Label::new(summary.warning_count.to_string()))
.render_into_any()
}
}
fn compare_diagnostics<L: language::ToOffset, R: language::ToOffset>(
lhs: &DiagnosticEntry<L>,
rhs: &DiagnosticEntry<R>,
snapshot: &language::BufferSnapshot,
) -> Ordering {
lhs.range
.start
.to_offset(snapshot)
.cmp(&rhs.range.start.to_offset(snapshot))
.then_with(|| {
lhs.range
.end
.to_offset(snapshot)
.cmp(&rhs.range.end.to_offset(snapshot))
})
.then_with(|| lhs.diagnostic.message.cmp(&rhs.diagnostic.message))
}
#[cfg(test)]
mod tests {
use super::*;
use editor::{
display_map::{BlockContext, TransformBlock},
DisplayPoint,
};
use gpui::{px, TestAppContext, VisualTestContext, WindowContext};
use language::{Diagnostic, DiagnosticEntry, DiagnosticSeverity, PointUtf16, Unclipped};
use project::FakeFs;
use serde_json::json;
use settings::SettingsStore;
use unindent::Unindent as _;
#[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();
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();
});
let view = window.build_view(cx, |cx| {
ProjectDiagnosticsEditor::new(project.clone(), workspace.downgrade(), cx)
});
view.next_notification(cx).await;
view.update(cx, |view, cx| {
assert_eq!(
editor_blocks(&view.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!(
view.editor.update(cx, |editor, cx| editor.display_text(cx)),
concat!(
"\n", "\n", "\n", "\n", " let x = vec![];\n",
" let y = vec![];\n",
"\n", " a(x);\n",
" b(y);\n",
"\n", " // comment 1\n",
" // comment 2\n",
" c(y);\n",
"\n", " d(x);\n",
"\n", "\n", "\n", "fn main() {\n",
" let x = vec![];\n",
"\n", " let y = vec![];\n",
" a(x);\n",
"\n", " b(y);\n",
"\n", " c(y);\n",
" d(x);\n",
"\n", "}"
)
);
view.editor.update(cx, |editor, cx| {
assert_eq!(
editor.selections.display_ranges(cx),
[DisplayPoint::new(12, 6)..DisplayPoint::new(12, 6)]
);
});
});
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;
view.update(cx, |view, cx| {
assert_eq!(
editor_blocks(&view.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!(
view.editor.update(cx, |editor, cx| editor.display_text(cx)),
concat!(
"\n", "\n", "\n", "\n", "const a: i32 = 'a';\n",
"\n", "const b: i32 = c;\n",
"\n", "\n", "\n", "\n", " let x = vec![];\n",
" let y = vec![];\n",
"\n", " a(x);\n",
" b(y);\n",
"\n", " // comment 1\n",
" // comment 2\n",
" c(y);\n",
"\n", " d(x);\n",
"\n", "\n", "\n", "fn main() {\n",
" let x = vec![];\n",
"\n", " let y = vec![];\n",
" a(x);\n",
"\n", " b(y);\n",
"\n", " c(y);\n",
" d(x);\n",
"\n", "}"
)
);
view.editor.update(cx, |editor, cx| {
assert_eq!(
editor.selections.display_ranges(cx),
[DisplayPoint::new(19, 6)..DisplayPoint::new(19, 6)]
);
});
});
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;
view.update(cx, |view, cx| {
assert_eq!(
editor_blocks(&view.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!(
view.editor.update(cx, |editor, cx| editor.display_text(cx)),
concat!(
"\n", "\n", "\n", "\n", "const a: i32 = 'a';\n",
"\n", "const b: i32 = c;\n",
"\n", "\n", "\n", "const a: i32 = 'a';\n",
"const b: i32 = c;\n",
"\n", "\n", "\n", "\n", "\n", " let x = vec![];\n",
" let y = vec![];\n",
"\n", " a(x);\n",
" b(y);\n",
"\n", " // comment 1\n",
" // comment 2\n",
" c(y);\n",
"\n", " d(x);\n",
"\n", "\n", "\n", "fn main() {\n",
" let x = vec![];\n",
"\n", " let y = vec![];\n",
" a(x);\n",
"\n", " b(y);\n",
"\n", " c(y);\n",
" d(x);\n",
"\n", "}"
)
);
});
}
#[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(project.clone(), workspace.downgrade(), cx)
});
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();
});
project.update(cx, |project, cx| {
project.disk_based_diagnostics_finished(server_id_1, cx);
});
cx.executor().run_until_parked();
view.update(cx, |view, cx| {
assert_eq!(
editor_blocks(&view.editor, cx),
[
(0, "path header block".into()),
(2, "diagnostic header".into()),
]
);
assert_eq!(
view.editor.update(cx, |editor, cx| editor.display_text(cx)),
concat!(
"\n", "\n", "\n", "\n", "a();\n", "b();",
)
);
});
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);
});
cx.executor().run_until_parked();
view.update(cx, |view, cx| {
assert_eq!(
editor_blocks(&view.editor, cx),
[
(0, "path header block".into()),
(2, "diagnostic header".into()),
(6, "collapsed context".into()),
(7, "diagnostic header".into()),
]
);
assert_eq!(
view.editor.update(cx, |editor, cx| editor.display_text(cx)),
concat!(
"\n", "\n", "\n", "\n", "a();\n", "b();\n", "\n", "\n", "\n", "a();\n", "b();\n", "c();", )
);
});
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);
});
cx.executor().run_until_parked();
view.update(cx, |view, cx| {
assert_eq!(
editor_blocks(&view.editor, cx),
[
(0, "path header block".into()),
(2, "diagnostic header".into()),
(7, "collapsed context".into()),
(8, "diagnostic header".into()),
]
);
assert_eq!(
view.editor.update(cx, |editor, cx| editor.display_text(cx)),
concat!(
"\n", "\n", "\n", "\n", "a();\n", "b();\n", "c();\n", "\n", "\n", "\n", "b();\n", "c();\n", "d();", )
);
});
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);
});
cx.executor().run_until_parked();
view.update(cx, |view, cx| {
assert_eq!(
editor_blocks(&view.editor, cx),
[
(0, "path header block".into()),
(2, "diagnostic header".into()),
(7, "collapsed context".into()),
(8, "diagnostic header".into()),
]
);
assert_eq!(
view.editor.update(cx, |editor, cx| editor.display_text(cx)),
concat!(
"\n", "\n", "\n", "\n", "b();\n", "c();\n", "d();\n", "\n", "\n", "\n", "c();\n", "d();\n", "e();", )
);
});
}
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);
});
}
fn editor_blocks(editor: &View<Editor>, cx: &mut WindowContext) -> Vec<(u32, SharedString)> {
editor.update(cx, |editor, cx| {
let snapshot = editor.snapshot(cx);
snapshot
.blocks_in_range(0..snapshot.max_point().row())
.enumerate()
.filter_map(|(ix, (row, block))| {
let name = match block {
TransformBlock::Custom(block) => block
.render(&mut BlockContext {
view_context: cx,
anchor_x: px(0.),
gutter_padding: px(0.),
gutter_width: px(0.),
line_height: px(0.),
em_width: px(0.),
block_id: ix,
editor_style: &editor::EditorStyle::default(),
})
.element_id()?
.try_into()
.ok()?,
TransformBlock::ExcerptHeader {
starts_new_buffer, ..
} => {
if *starts_new_buffer {
"path header block".into()
} else {
"collapsed context".into()
}
}
};
Some((row, name))
})
.collect()
})
}
}