use anyhow::{Context as _, Result, anyhow}; use assistant_slash_command::{ ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection, SlashCommandResult, }; use fuzzy::{PathMatch, StringMatchCandidate}; use gpui::{App, Entity, Task, WeakEntity}; use language::{ Anchor, BufferSnapshot, DiagnosticEntry, DiagnosticSeverity, LspAdapterDelegate, OffsetRangeExt, ToOffset, }; use project::{DiagnosticSummary, PathMatchCandidateSet, Project}; use rope::Point; use std::{ fmt::Write, path::{Path, PathBuf}, sync::{Arc, atomic::AtomicBool}, }; use ui::prelude::*; use util::ResultExt; use util::paths::PathMatcher; use workspace::Workspace; use crate::create_label_for_command; pub struct DiagnosticsSlashCommand; impl DiagnosticsSlashCommand { fn search_paths( &self, query: String, cancellation_flag: Arc, workspace: &Entity, cx: &mut App, ) -> Task> { if query.is_empty() { let workspace = workspace.read(cx); let entries = workspace.recent_navigation_history(Some(10), cx); let path_prefix: Arc = Arc::default(); Task::ready( entries .into_iter() .map(|(entry, _)| PathMatch { score: 0., positions: Vec::new(), worktree_id: entry.worktree_id.to_usize(), path: entry.path.clone(), path_prefix: path_prefix.clone(), is_dir: false, // Diagnostics can't be produced for directories distance_to_relative_ancestor: 0, }) .collect(), ) } else { let worktrees = workspace.read(cx).visible_worktrees(cx).collect::>(); let candidate_sets = worktrees .into_iter() .map(|worktree| { let worktree = worktree.read(cx); PathMatchCandidateSet { snapshot: worktree.snapshot(), include_ignored: worktree .root_entry() .map_or(false, |entry| entry.is_ignored), include_root_name: true, candidates: project::Candidates::Entries, } }) .collect::>(); let executor = cx.background_executor().clone(); cx.foreground_executor().spawn(async move { fuzzy::match_path_sets( candidate_sets.as_slice(), query.as_str(), None, false, 100, &cancellation_flag, executor, ) .await }) } } } impl SlashCommand for DiagnosticsSlashCommand { fn name(&self) -> String { "diagnostics".into() } fn label(&self, cx: &App) -> language::CodeLabel { create_label_for_command("diagnostics", &[INCLUDE_WARNINGS_ARGUMENT], cx) } fn description(&self) -> String { "Insert diagnostics".into() } fn icon(&self) -> IconName { IconName::XCircle } fn menu_text(&self) -> String { self.description() } fn requires_argument(&self) -> bool { false } fn accepts_arguments(&self) -> bool { true } fn complete_argument( self: Arc, arguments: &[String], cancellation_flag: Arc, workspace: Option>, _: &mut Window, cx: &mut App, ) -> Task>> { let Some(workspace) = workspace.and_then(|workspace| workspace.upgrade()) else { return Task::ready(Err(anyhow!("workspace was dropped"))); }; let query = arguments.last().cloned().unwrap_or_default(); let paths = self.search_paths(query.clone(), cancellation_flag.clone(), &workspace, cx); let executor = cx.background_executor().clone(); cx.background_spawn(async move { let mut matches: Vec = paths .await .into_iter() .map(|path_match| { format!( "{}{}", path_match.path_prefix, path_match.path.to_string_lossy() ) }) .collect(); matches.extend( fuzzy::match_strings( &Options::match_candidates_for_args(), &query, false, true, 10, &cancellation_flag, executor, ) .await .into_iter() .map(|candidate| candidate.string), ); Ok(matches .into_iter() .map(|completion| ArgumentCompletion { label: completion.clone().into(), new_text: completion, after_completion: assistant_slash_command::AfterCompletion::Run, replace_previous_arguments: false, }) .collect()) }) } fn run( self: Arc, arguments: &[String], _context_slash_command_output_sections: &[SlashCommandOutputSection], _context_buffer: BufferSnapshot, workspace: WeakEntity, _delegate: Option>, window: &mut Window, cx: &mut App, ) -> Task { let Some(workspace) = workspace.upgrade() else { return Task::ready(Err(anyhow!("workspace was dropped"))); }; let options = Options::parse(arguments); let task = collect_diagnostics(workspace.read(cx).project().clone(), options, cx); window.spawn(cx, async move |_| { task.await? .map(|output| output.to_event_stream()) .context("No diagnostics found") }) } } #[derive(Default)] struct Options { include_warnings: bool, path_matcher: Option, } const INCLUDE_WARNINGS_ARGUMENT: &str = "--include-warnings"; impl Options { fn parse(arguments: &[String]) -> Self { let mut include_warnings = false; let mut path_matcher = None; for arg in arguments { if arg == INCLUDE_WARNINGS_ARGUMENT { include_warnings = true; } else { path_matcher = PathMatcher::new(&[arg.to_owned()]).log_err(); } } Self { include_warnings, path_matcher, } } fn match_candidates_for_args() -> [StringMatchCandidate; 1] { [StringMatchCandidate::new(0, INCLUDE_WARNINGS_ARGUMENT)] } } fn collect_diagnostics( project: Entity, options: Options, cx: &mut App, ) -> Task>> { let error_source = if let Some(path_matcher) = &options.path_matcher { debug_assert_eq!(path_matcher.sources().len(), 1); Some(path_matcher.sources().first().cloned().unwrap_or_default()) } else { None }; let glob_is_exact_file_match = if let Some(path) = options .path_matcher .as_ref() .and_then(|pm| pm.sources().first()) { PathBuf::try_from(path) .ok() .and_then(|path| { project.read(cx).worktrees(cx).find_map(|worktree| { let worktree = worktree.read(cx); let worktree_root_path = Path::new(worktree.root_name()); let relative_path = path.strip_prefix(worktree_root_path).ok()?; worktree.absolutize(&relative_path).ok() }) }) .is_some() } else { false }; let project_handle = project.downgrade(); let diagnostic_summaries: Vec<_> = project .read(cx) .diagnostic_summaries(false, cx) .flat_map(|(path, _, summary)| { let worktree = project.read(cx).worktree_for_id(path.worktree_id, cx)?; let mut path_buf = PathBuf::from(worktree.read(cx).root_name()); path_buf.push(&path.path); Some((path, path_buf, summary)) }) .collect(); cx.spawn(async move |cx| { let mut output = SlashCommandOutput::default(); if let Some(error_source) = error_source.as_ref() { writeln!(output.text, "diagnostics: {}", error_source).unwrap(); } else { writeln!(output.text, "diagnostics").unwrap(); } let mut project_summary = DiagnosticSummary::default(); for (project_path, path, summary) in diagnostic_summaries { if let Some(path_matcher) = &options.path_matcher { if !path_matcher.is_match(&path) { continue; } } project_summary.error_count += summary.error_count; if options.include_warnings { project_summary.warning_count += summary.warning_count; } else if summary.error_count == 0 { continue; } let last_end = output.text.len(); let file_path = path.to_string_lossy().to_string(); if !glob_is_exact_file_match { writeln!(&mut output.text, "{file_path}").unwrap(); } if let Some(buffer) = project_handle .update(cx, |project, cx| project.open_buffer(project_path, cx))? .await .log_err() { let snapshot = cx.read_entity(&buffer, |buffer, _| buffer.snapshot())?; collect_buffer_diagnostics(&mut output, &snapshot, options.include_warnings); } if !glob_is_exact_file_match { output.sections.push(SlashCommandOutputSection { range: last_end..output.text.len().saturating_sub(1), icon: IconName::File, label: file_path.into(), metadata: None, }); } } // No diagnostics found if output.sections.is_empty() { return Ok(None); } let mut label = String::new(); label.push_str("Diagnostics"); if let Some(source) = error_source { write!(label, " ({})", source).unwrap(); } if project_summary.error_count > 0 || project_summary.warning_count > 0 { label.push(':'); if project_summary.error_count > 0 { write!(label, " {} errors", project_summary.error_count).unwrap(); if project_summary.warning_count > 0 { label.push_str(","); } } if project_summary.warning_count > 0 { write!(label, " {} warnings", project_summary.warning_count).unwrap(); } } output.sections.insert( 0, SlashCommandOutputSection { range: 0..output.text.len(), icon: IconName::Warning, label: label.into(), metadata: None, }, ); Ok(Some(output)) }) } pub fn collect_buffer_diagnostics( output: &mut SlashCommandOutput, snapshot: &BufferSnapshot, include_warnings: bool, ) { for (_, group) in snapshot.diagnostic_groups(None) { let entry = &group.entries[group.primary_ix]; collect_diagnostic(output, entry, &snapshot, include_warnings) } } fn collect_diagnostic( output: &mut SlashCommandOutput, entry: &DiagnosticEntry, snapshot: &BufferSnapshot, include_warnings: bool, ) { const EXCERPT_EXPANSION_SIZE: u32 = 2; const MAX_MESSAGE_LENGTH: usize = 2000; let (ty, icon) = match entry.diagnostic.severity { DiagnosticSeverity::WARNING => { if !include_warnings { return; } ("warning", IconName::Warning) } DiagnosticSeverity::ERROR => ("error", IconName::XCircle), _ => return, }; let prev_len = output.text.len(); let range = entry.range.to_point(snapshot); let diagnostic_row_number = range.start.row + 1; let start_row = range.start.row.saturating_sub(EXCERPT_EXPANSION_SIZE); let end_row = (range.end.row + EXCERPT_EXPANSION_SIZE).min(snapshot.max_point().row) + 1; let excerpt_range = Point::new(start_row, 0).to_offset(&snapshot)..Point::new(end_row, 0).to_offset(&snapshot); output.text.push_str("```"); if let Some(language_name) = snapshot.language().map(|l| l.code_fence_block_name()) { output.text.push_str(&language_name); } output.text.push('\n'); let mut buffer_text = String::new(); for chunk in snapshot.text_for_range(excerpt_range) { buffer_text.push_str(chunk); } for (i, line) in buffer_text.lines().enumerate() { let line_number = start_row + i as u32 + 1; writeln!(output.text, "{}", line).unwrap(); if line_number == diagnostic_row_number { output.text.push_str("//"); let prev_len = output.text.len(); write!(output.text, " {}: ", ty).unwrap(); let padding = output.text.len() - prev_len; let message = util::truncate(&entry.diagnostic.message, MAX_MESSAGE_LENGTH) .replace('\n', format!("\n//{:padding$}", "").as_str()); writeln!(output.text, "{message}").unwrap(); } } writeln!(output.text, "```").unwrap(); output.sections.push(SlashCommandOutputSection { range: prev_len..output.text.len().saturating_sub(1), icon, label: entry.diagnostic.message.clone().into(), metadata: None, }); }