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,
 17    sync::{Arc, atomic::AtomicBool},
 18};
 19use ui::prelude::*;
 20use util::paths::{PathMatcher, PathStyle};
 21use util::{ResultExt, rel_path::RelPath};
 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<RelPath> = RelPath::empty().into();
 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,
 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                            .is_some_and(|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 path_style = workspace.read(cx).project().read(cx).path_style(cx);
129        let query = arguments.last().cloned().unwrap_or_default();
130
131        let paths = self.search_paths(query.clone(), cancellation_flag.clone(), &workspace, cx);
132        let executor = cx.background_executor().clone();
133        cx.background_spawn(async move {
134            let mut matches: Vec<String> = paths
135                .await
136                .into_iter()
137                .map(|path_match| {
138                    path_match
139                        .path_prefix
140                        .join(&path_match.path)
141                        .display(path_style)
142                        .to_string()
143                })
144                .collect();
145
146            matches.extend(
147                fuzzy::match_strings(
148                    &Options::match_candidates_for_args(),
149                    &query,
150                    false,
151                    true,
152                    10,
153                    &cancellation_flag,
154                    executor,
155                )
156                .await
157                .into_iter()
158                .map(|candidate| candidate.string),
159            );
160
161            Ok(matches
162                .into_iter()
163                .map(|completion| ArgumentCompletion {
164                    label: completion.clone().into(),
165                    new_text: completion,
166                    after_completion: assistant_slash_command::AfterCompletion::Run,
167                    replace_previous_arguments: false,
168                })
169                .collect())
170        })
171    }
172
173    fn run(
174        self: Arc<Self>,
175        arguments: &[String],
176        _context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
177        _context_buffer: BufferSnapshot,
178        workspace: WeakEntity<Workspace>,
179        _delegate: Option<Arc<dyn LspAdapterDelegate>>,
180        window: &mut Window,
181        cx: &mut App,
182    ) -> Task<SlashCommandResult> {
183        let Some(workspace) = workspace.upgrade() else {
184            return Task::ready(Err(anyhow!("workspace was dropped")));
185        };
186
187        let project = workspace.read(cx).project();
188        let path_style = project.read(cx).path_style(cx);
189        let options = Options::parse(arguments, path_style);
190
191        let task = collect_diagnostics(project.clone(), options, cx);
192
193        window.spawn(cx, async move |_| {
194            task.await?
195                .map(|output| output.into_event_stream())
196                .context("No diagnostics found")
197        })
198    }
199}
200
201#[derive(Default)]
202struct Options {
203    include_warnings: bool,
204    path_matcher: Option<PathMatcher>,
205}
206
207const INCLUDE_WARNINGS_ARGUMENT: &str = "--include-warnings";
208
209impl Options {
210    fn parse(arguments: &[String], path_style: PathStyle) -> Self {
211        let mut include_warnings = false;
212        let mut path_matcher = None;
213        for arg in arguments {
214            if arg == INCLUDE_WARNINGS_ARGUMENT {
215                include_warnings = true;
216            } else {
217                path_matcher = PathMatcher::new(&[arg.to_owned()], path_style).log_err();
218            }
219        }
220        Self {
221            include_warnings,
222            path_matcher,
223        }
224    }
225
226    fn match_candidates_for_args() -> [StringMatchCandidate; 1] {
227        [StringMatchCandidate::new(0, INCLUDE_WARNINGS_ARGUMENT)]
228    }
229}
230
231fn collect_diagnostics(
232    project: Entity<Project>,
233    options: Options,
234    cx: &mut App,
235) -> Task<Result<Option<SlashCommandOutput>>> {
236    let error_source = if let Some(path_matcher) = &options.path_matcher {
237        debug_assert_eq!(path_matcher.sources().len(), 1);
238        Some(path_matcher.sources().first().cloned().unwrap_or_default())
239    } else {
240        None
241    };
242
243    let path_style = project.read(cx).path_style(cx);
244    let glob_is_exact_file_match = if let Some(path) = options
245        .path_matcher
246        .as_ref()
247        .and_then(|pm| pm.sources().first())
248    {
249        project
250            .read(cx)
251            .find_project_path(Path::new(path), cx)
252            .is_some()
253    } else {
254        false
255    };
256
257    let project_handle = project.downgrade();
258    let diagnostic_summaries: Vec<_> = project
259        .read(cx)
260        .diagnostic_summaries(false, cx)
261        .flat_map(|(path, _, summary)| {
262            let worktree = project.read(cx).worktree_for_id(path.worktree_id, cx)?;
263            let full_path = worktree.read(cx).root_name().join(&path.path);
264            Some((path, full_path, summary))
265        })
266        .collect();
267
268    cx.spawn(async move |cx| {
269        let mut output = SlashCommandOutput::default();
270
271        if let Some(error_source) = error_source.as_ref() {
272            writeln!(output.text, "diagnostics: {}", error_source).unwrap();
273        } else {
274            writeln!(output.text, "diagnostics").unwrap();
275        }
276
277        let mut project_summary = DiagnosticSummary::default();
278        for (project_path, path, summary) in diagnostic_summaries {
279            if let Some(path_matcher) = &options.path_matcher
280                && !path_matcher.is_match(&path.as_std_path())
281            {
282                continue;
283            }
284
285            project_summary.error_count += summary.error_count;
286            if options.include_warnings {
287                project_summary.warning_count += summary.warning_count;
288            } else if summary.error_count == 0 {
289                continue;
290            }
291
292            let last_end = output.text.len();
293            let file_path = path.display(path_style).to_string();
294            if !glob_is_exact_file_match {
295                writeln!(&mut output.text, "{file_path}").unwrap();
296            }
297
298            if let Some(buffer) = project_handle
299                .update(cx, |project, cx| project.open_buffer(project_path, cx))?
300                .await
301                .log_err()
302            {
303                let snapshot = cx.read_entity(&buffer, |buffer, _| buffer.snapshot())?;
304                collect_buffer_diagnostics(&mut output, &snapshot, options.include_warnings);
305            }
306
307            if !glob_is_exact_file_match {
308                output.sections.push(SlashCommandOutputSection {
309                    range: last_end..output.text.len().saturating_sub(1),
310                    icon: IconName::File,
311                    label: file_path.into(),
312                    metadata: None,
313                });
314            }
315        }
316
317        // No diagnostics found
318        if output.sections.is_empty() {
319            return Ok(None);
320        }
321
322        let mut label = String::new();
323        label.push_str("Diagnostics");
324        if let Some(source) = error_source {
325            write!(label, " ({})", source).unwrap();
326        }
327
328        if project_summary.error_count > 0 || project_summary.warning_count > 0 {
329            label.push(':');
330
331            if project_summary.error_count > 0 {
332                write!(label, " {} errors", project_summary.error_count).unwrap();
333                if project_summary.warning_count > 0 {
334                    label.push_str(",");
335                }
336            }
337
338            if project_summary.warning_count > 0 {
339                write!(label, " {} warnings", project_summary.warning_count).unwrap();
340            }
341        }
342
343        output.sections.insert(
344            0,
345            SlashCommandOutputSection {
346                range: 0..output.text.len(),
347                icon: IconName::Warning,
348                label: label.into(),
349                metadata: None,
350            },
351        );
352
353        Ok(Some(output))
354    })
355}
356
357pub fn collect_buffer_diagnostics(
358    output: &mut SlashCommandOutput,
359    snapshot: &BufferSnapshot,
360    include_warnings: bool,
361) {
362    for (_, group) in snapshot.diagnostic_groups(None) {
363        let entry = &group.entries[group.primary_ix];
364        collect_diagnostic(output, entry, snapshot, include_warnings)
365    }
366}
367
368fn collect_diagnostic(
369    output: &mut SlashCommandOutput,
370    entry: &DiagnosticEntry<Anchor>,
371    snapshot: &BufferSnapshot,
372    include_warnings: bool,
373) {
374    const EXCERPT_EXPANSION_SIZE: u32 = 2;
375    const MAX_MESSAGE_LENGTH: usize = 2000;
376
377    let (ty, icon) = match entry.diagnostic.severity {
378        DiagnosticSeverity::WARNING => {
379            if !include_warnings {
380                return;
381            }
382            ("warning", IconName::Warning)
383        }
384        DiagnosticSeverity::ERROR => ("error", IconName::XCircle),
385        _ => return,
386    };
387    let prev_len = output.text.len();
388
389    let range = entry.range.to_point(snapshot);
390    let diagnostic_row_number = range.start.row + 1;
391
392    let start_row = range.start.row.saturating_sub(EXCERPT_EXPANSION_SIZE);
393    let end_row = (range.end.row + EXCERPT_EXPANSION_SIZE).min(snapshot.max_point().row) + 1;
394    let excerpt_range =
395        Point::new(start_row, 0).to_offset(snapshot)..Point::new(end_row, 0).to_offset(snapshot);
396
397    output.text.push_str("```");
398    if let Some(language_name) = snapshot.language().map(|l| l.code_fence_block_name()) {
399        output.text.push_str(&language_name);
400    }
401    output.text.push('\n');
402
403    let mut buffer_text = String::new();
404    for chunk in snapshot.text_for_range(excerpt_range) {
405        buffer_text.push_str(chunk);
406    }
407
408    for (i, line) in buffer_text.lines().enumerate() {
409        let line_number = start_row + i as u32 + 1;
410        writeln!(output.text, "{}", line).unwrap();
411
412        if line_number == diagnostic_row_number {
413            output.text.push_str("//");
414            let prev_len = output.text.len();
415            write!(output.text, " {}: ", ty).unwrap();
416            let padding = output.text.len() - prev_len;
417
418            let message = util::truncate(&entry.diagnostic.message, MAX_MESSAGE_LENGTH)
419                .replace('\n', format!("\n//{:padding$}", "").as_str());
420
421            writeln!(output.text, "{message}").unwrap();
422        }
423    }
424
425    writeln!(output.text, "```").unwrap();
426    output.sections.push(SlashCommandOutputSection {
427        range: prev_len..output.text.len().saturating_sub(1),
428        icon,
429        label: entry.diagnostic.message.clone().into(),
430        metadata: None,
431    });
432}