diagnostics_command.rs

  1use anyhow::{Context as _, Result, anyhow};
  2use assistant_slash_command::{
  3    ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
  4    SlashCommandResult,
  5};
  6use fuzzy::{PathMatch, StringMatchCandidate};
  7use gpui::{App, Entity, Task, WeakEntity};
  8use language::{
  9    Anchor, BufferSnapshot, DiagnosticEntry, DiagnosticSeverity, LspAdapterDelegate,
 10    OffsetRangeExt, ToOffset,
 11};
 12use project::{DiagnosticSummary, PathMatchCandidateSet, Project};
 13use rope::Point;
 14use std::{
 15    fmt::Write,
 16    path::{Path, PathBuf},
 17    sync::{Arc, atomic::AtomicBool},
 18};
 19use ui::prelude::*;
 20use util::ResultExt;
 21use util::paths::PathMatcher;
 22use workspace::Workspace;
 23
 24use crate::create_label_for_command;
 25
 26pub struct DiagnosticsSlashCommand;
 27
 28impl DiagnosticsSlashCommand {
 29    fn search_paths(
 30        &self,
 31        query: String,
 32        cancellation_flag: Arc<AtomicBool>,
 33        workspace: &Entity<Workspace>,
 34        cx: &mut App,
 35    ) -> Task<Vec<PathMatch>> {
 36        if query.is_empty() {
 37            let workspace = workspace.read(cx);
 38            let entries = workspace.recent_navigation_history(Some(10), cx);
 39            let path_prefix: Arc<str> = Arc::default();
 40            Task::ready(
 41                entries
 42                    .into_iter()
 43                    .map(|(entry, _)| PathMatch {
 44                        score: 0.,
 45                        positions: Vec::new(),
 46                        worktree_id: entry.worktree_id.to_usize(),
 47                        path: entry.path.clone(),
 48                        path_prefix: path_prefix.clone(),
 49                        is_dir: false, // Diagnostics can't be produced for directories
 50                        distance_to_relative_ancestor: 0,
 51                    })
 52                    .collect(),
 53            )
 54        } else {
 55            let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
 56            let candidate_sets = worktrees
 57                .into_iter()
 58                .map(|worktree| {
 59                    let worktree = worktree.read(cx);
 60                    PathMatchCandidateSet {
 61                        snapshot: worktree.snapshot(),
 62                        include_ignored: worktree
 63                            .root_entry()
 64                            .map_or(false, |entry| entry.is_ignored),
 65                        include_root_name: true,
 66                        candidates: project::Candidates::Entries,
 67                    }
 68                })
 69                .collect::<Vec<_>>();
 70
 71            let executor = cx.background_executor().clone();
 72            cx.foreground_executor().spawn(async move {
 73                fuzzy::match_path_sets(
 74                    candidate_sets.as_slice(),
 75                    query.as_str(),
 76                    None,
 77                    false,
 78                    100,
 79                    &cancellation_flag,
 80                    executor,
 81                )
 82                .await
 83            })
 84        }
 85    }
 86}
 87
 88impl SlashCommand for DiagnosticsSlashCommand {
 89    fn name(&self) -> String {
 90        "diagnostics".into()
 91    }
 92
 93    fn label(&self, cx: &App) -> language::CodeLabel {
 94        create_label_for_command("diagnostics", &[INCLUDE_WARNINGS_ARGUMENT], cx)
 95    }
 96
 97    fn description(&self) -> String {
 98        "Insert diagnostics".into()
 99    }
100
101    fn icon(&self) -> IconName {
102        IconName::XCircle
103    }
104
105    fn menu_text(&self) -> String {
106        self.description()
107    }
108
109    fn requires_argument(&self) -> bool {
110        false
111    }
112
113    fn accepts_arguments(&self) -> bool {
114        true
115    }
116
117    fn complete_argument(
118        self: Arc<Self>,
119        arguments: &[String],
120        cancellation_flag: Arc<AtomicBool>,
121        workspace: Option<WeakEntity<Workspace>>,
122        _: &mut Window,
123        cx: &mut App,
124    ) -> Task<Result<Vec<ArgumentCompletion>>> {
125        let Some(workspace) = workspace.and_then(|workspace| workspace.upgrade()) else {
126            return Task::ready(Err(anyhow!("workspace was dropped")));
127        };
128        let query = arguments.last().cloned().unwrap_or_default();
129
130        let paths = self.search_paths(query.clone(), cancellation_flag.clone(), &workspace, cx);
131        let executor = cx.background_executor().clone();
132        cx.background_spawn(async move {
133            let mut matches: Vec<String> = paths
134                .await
135                .into_iter()
136                .map(|path_match| {
137                    format!(
138                        "{}{}",
139                        path_match.path_prefix,
140                        path_match.path.to_string_lossy()
141                    )
142                })
143                .collect();
144
145            matches.extend(
146                fuzzy::match_strings(
147                    &Options::match_candidates_for_args(),
148                    &query,
149                    false,
150                    10,
151                    &cancellation_flag,
152                    executor,
153                )
154                .await
155                .into_iter()
156                .map(|candidate| candidate.string),
157            );
158
159            Ok(matches
160                .into_iter()
161                .map(|completion| ArgumentCompletion {
162                    label: completion.clone().into(),
163                    new_text: completion,
164                    after_completion: assistant_slash_command::AfterCompletion::Run,
165                    replace_previous_arguments: false,
166                })
167                .collect())
168        })
169    }
170
171    fn run(
172        self: Arc<Self>,
173        arguments: &[String],
174        _context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
175        _context_buffer: BufferSnapshot,
176        workspace: WeakEntity<Workspace>,
177        _delegate: Option<Arc<dyn LspAdapterDelegate>>,
178        window: &mut Window,
179        cx: &mut App,
180    ) -> Task<SlashCommandResult> {
181        let Some(workspace) = workspace.upgrade() else {
182            return Task::ready(Err(anyhow!("workspace was dropped")));
183        };
184
185        let options = Options::parse(arguments);
186
187        let task = collect_diagnostics(workspace.read(cx).project().clone(), options, cx);
188
189        window.spawn(cx, async move |_| {
190            task.await?
191                .map(|output| output.to_event_stream())
192                .context("No diagnostics found")
193        })
194    }
195}
196
197#[derive(Default)]
198struct Options {
199    include_warnings: bool,
200    path_matcher: Option<PathMatcher>,
201}
202
203const INCLUDE_WARNINGS_ARGUMENT: &str = "--include-warnings";
204
205impl Options {
206    fn parse(arguments: &[String]) -> Self {
207        let mut include_warnings = false;
208        let mut path_matcher = None;
209        for arg in arguments {
210            if arg == INCLUDE_WARNINGS_ARGUMENT {
211                include_warnings = true;
212            } else {
213                path_matcher = PathMatcher::new(&[arg.to_owned()]).log_err();
214            }
215        }
216        Self {
217            include_warnings,
218            path_matcher,
219        }
220    }
221
222    fn match_candidates_for_args() -> [StringMatchCandidate; 1] {
223        [StringMatchCandidate::new(0, INCLUDE_WARNINGS_ARGUMENT)]
224    }
225}
226
227fn collect_diagnostics(
228    project: Entity<Project>,
229    options: Options,
230    cx: &mut App,
231) -> Task<Result<Option<SlashCommandOutput>>> {
232    let error_source = if let Some(path_matcher) = &options.path_matcher {
233        debug_assert_eq!(path_matcher.sources().len(), 1);
234        Some(path_matcher.sources().first().cloned().unwrap_or_default())
235    } else {
236        None
237    };
238
239    let glob_is_exact_file_match = if let Some(path) = options
240        .path_matcher
241        .as_ref()
242        .and_then(|pm| pm.sources().first())
243    {
244        PathBuf::try_from(path)
245            .ok()
246            .and_then(|path| {
247                project.read(cx).worktrees(cx).find_map(|worktree| {
248                    let worktree = worktree.read(cx);
249                    let worktree_root_path = Path::new(worktree.root_name());
250                    let relative_path = path.strip_prefix(worktree_root_path).ok()?;
251                    worktree.absolutize(&relative_path).ok()
252                })
253            })
254            .is_some()
255    } else {
256        false
257    };
258
259    let project_handle = project.downgrade();
260    let diagnostic_summaries: Vec<_> = project
261        .read(cx)
262        .diagnostic_summaries(false, cx)
263        .flat_map(|(path, _, summary)| {
264            let worktree = project.read(cx).worktree_for_id(path.worktree_id, cx)?;
265            let mut path_buf = PathBuf::from(worktree.read(cx).root_name());
266            path_buf.push(&path.path);
267            Some((path, path_buf, summary))
268        })
269        .collect();
270
271    cx.spawn(async move |cx| {
272        let mut output = SlashCommandOutput::default();
273
274        if let Some(error_source) = error_source.as_ref() {
275            writeln!(output.text, "diagnostics: {}", error_source).unwrap();
276        } else {
277            writeln!(output.text, "diagnostics").unwrap();
278        }
279
280        let mut project_summary = DiagnosticSummary::default();
281        for (project_path, path, summary) in diagnostic_summaries {
282            if let Some(path_matcher) = &options.path_matcher {
283                if !path_matcher.is_match(&path) {
284                    continue;
285                }
286            }
287
288            project_summary.error_count += summary.error_count;
289            if options.include_warnings {
290                project_summary.warning_count += summary.warning_count;
291            } else if summary.error_count == 0 {
292                continue;
293            }
294
295            let last_end = output.text.len();
296            let file_path = path.to_string_lossy().to_string();
297            if !glob_is_exact_file_match {
298                writeln!(&mut output.text, "{file_path}").unwrap();
299            }
300
301            if let Some(buffer) = project_handle
302                .update(cx, |project, cx| project.open_buffer(project_path, cx))?
303                .await
304                .log_err()
305            {
306                let snapshot = cx.read_entity(&buffer, |buffer, _| buffer.snapshot())?;
307                collect_buffer_diagnostics(&mut output, &snapshot, options.include_warnings);
308            }
309
310            if !glob_is_exact_file_match {
311                output.sections.push(SlashCommandOutputSection {
312                    range: last_end..output.text.len().saturating_sub(1),
313                    icon: IconName::File,
314                    label: file_path.into(),
315                    metadata: None,
316                });
317            }
318        }
319
320        // No diagnostics found
321        if output.sections.is_empty() {
322            return Ok(None);
323        }
324
325        let mut label = String::new();
326        label.push_str("Diagnostics");
327        if let Some(source) = error_source {
328            write!(label, " ({})", source).unwrap();
329        }
330
331        if project_summary.error_count > 0 || project_summary.warning_count > 0 {
332            label.push(':');
333
334            if project_summary.error_count > 0 {
335                write!(label, " {} errors", project_summary.error_count).unwrap();
336                if project_summary.warning_count > 0 {
337                    label.push_str(",");
338                }
339            }
340
341            if project_summary.warning_count > 0 {
342                write!(label, " {} warnings", project_summary.warning_count).unwrap();
343            }
344        }
345
346        output.sections.insert(
347            0,
348            SlashCommandOutputSection {
349                range: 0..output.text.len(),
350                icon: IconName::Warning,
351                label: label.into(),
352                metadata: None,
353            },
354        );
355
356        Ok(Some(output))
357    })
358}
359
360pub fn collect_buffer_diagnostics(
361    output: &mut SlashCommandOutput,
362    snapshot: &BufferSnapshot,
363    include_warnings: bool,
364) {
365    for (_, group) in snapshot.diagnostic_groups(None) {
366        let entry = &group.entries[group.primary_ix];
367        collect_diagnostic(output, entry, &snapshot, include_warnings)
368    }
369}
370
371fn collect_diagnostic(
372    output: &mut SlashCommandOutput,
373    entry: &DiagnosticEntry<Anchor>,
374    snapshot: &BufferSnapshot,
375    include_warnings: bool,
376) {
377    const EXCERPT_EXPANSION_SIZE: u32 = 2;
378    const MAX_MESSAGE_LENGTH: usize = 2000;
379
380    let (ty, icon) = match entry.diagnostic.severity {
381        DiagnosticSeverity::WARNING => {
382            if !include_warnings {
383                return;
384            }
385            ("warning", IconName::Warning)
386        }
387        DiagnosticSeverity::ERROR => ("error", IconName::XCircle),
388        _ => return,
389    };
390    let prev_len = output.text.len();
391
392    let range = entry.range.to_point(snapshot);
393    let diagnostic_row_number = range.start.row + 1;
394
395    let start_row = range.start.row.saturating_sub(EXCERPT_EXPANSION_SIZE);
396    let end_row = (range.end.row + EXCERPT_EXPANSION_SIZE).min(snapshot.max_point().row) + 1;
397    let excerpt_range =
398        Point::new(start_row, 0).to_offset(&snapshot)..Point::new(end_row, 0).to_offset(&snapshot);
399
400    output.text.push_str("```");
401    if let Some(language_name) = snapshot.language().map(|l| l.code_fence_block_name()) {
402        output.text.push_str(&language_name);
403    }
404    output.text.push('\n');
405
406    let mut buffer_text = String::new();
407    for chunk in snapshot.text_for_range(excerpt_range) {
408        buffer_text.push_str(chunk);
409    }
410
411    for (i, line) in buffer_text.lines().enumerate() {
412        let line_number = start_row + i as u32 + 1;
413        writeln!(output.text, "{}", line).unwrap();
414
415        if line_number == diagnostic_row_number {
416            output.text.push_str("//");
417            let prev_len = output.text.len();
418            write!(output.text, " {}: ", ty).unwrap();
419            let padding = output.text.len() - prev_len;
420
421            let message = util::truncate(&entry.diagnostic.message, MAX_MESSAGE_LENGTH)
422                .replace('\n', format!("\n//{:padding$}", "").as_str());
423
424            writeln!(output.text, "{message}").unwrap();
425        }
426    }
427
428    writeln!(output.text, "```").unwrap();
429    output.sections.push(SlashCommandOutputSection {
430        range: prev_len..output.text.len().saturating_sub(1),
431        icon,
432        label: entry.diagnostic.message.clone().into(),
433        metadata: None,
434    });
435}