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, DiagnosticEntryRef, 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_output(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
201pub struct Options {
202    pub include_errors: bool,
203    pub include_warnings: bool,
204    pub 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_errors: true,
222            include_warnings,
223            path_matcher,
224        }
225    }
226
227    fn match_candidates_for_args() -> [StringMatchCandidate; 1] {
228        [StringMatchCandidate::new(0, INCLUDE_WARNINGS_ARGUMENT)]
229    }
230}
231
232pub fn collect_diagnostics_output(
233    project: Entity<Project>,
234    options: Options,
235    cx: &mut App,
236) -> Task<Result<Option<SlashCommandOutput>>> {
237    let path_style = project.read(cx).path_style(cx);
238    let glob_is_exact_file_match = if let Some(path) = options
239        .path_matcher
240        .as_ref()
241        .and_then(|pm| pm.sources().next())
242    {
243        project
244            .read(cx)
245            .find_project_path(Path::new(path), cx)
246            .is_some()
247    } else {
248        false
249    };
250
251    let project_handle = project.downgrade();
252    let diagnostic_summaries: Vec<_> = project
253        .read(cx)
254        .diagnostic_summaries(false, cx)
255        .flat_map(|(path, _, summary)| {
256            let worktree = project.read(cx).worktree_for_id(path.worktree_id, cx)?;
257            let full_path = worktree.read(cx).root_name().join(&path.path);
258            Some((path, full_path, summary))
259        })
260        .collect();
261
262    cx.spawn(async move |cx| {
263        let error_source = if let Some(path_matcher) = &options.path_matcher {
264            debug_assert_eq!(path_matcher.sources().count(), 1);
265            Some(path_matcher.sources().next().unwrap_or_default())
266        } else {
267            None
268        };
269
270        let mut output = SlashCommandOutput::default();
271
272        if let Some(error_source) = error_source.as_ref() {
273            writeln!(output.text, "diagnostics: {}", error_source).unwrap();
274        } else {
275            writeln!(output.text, "diagnostics").unwrap();
276        }
277
278        let mut project_summary = DiagnosticSummary::default();
279        for (project_path, path, summary) in diagnostic_summaries {
280            if let Some(path_matcher) = &options.path_matcher
281                && !path_matcher.is_match(&path)
282            {
283                continue;
284            }
285
286            let has_errors = options.include_errors && summary.error_count > 0;
287            let has_warnings = options.include_warnings && summary.warning_count > 0;
288            if !has_errors && !has_warnings {
289                continue;
290            }
291
292            if options.include_errors {
293                project_summary.error_count += summary.error_count;
294            }
295            if options.include_warnings {
296                project_summary.warning_count += summary.warning_count;
297            }
298
299            let last_end = output.text.len();
300            let file_path = path.display(path_style).to_string();
301            if !glob_is_exact_file_match {
302                writeln!(&mut output.text, "{file_path}").unwrap();
303            }
304
305            if let Some(buffer) = project_handle
306                .update(cx, |project, cx| project.open_buffer(project_path, cx))?
307                .await
308                .log_err()
309            {
310                let snapshot = cx.read_entity(&buffer, |buffer, _| buffer.snapshot());
311                collect_buffer_diagnostics(
312                    &mut output,
313                    &snapshot,
314                    options.include_warnings,
315                    options.include_errors,
316                );
317            }
318
319            if !glob_is_exact_file_match {
320                output.sections.push(SlashCommandOutputSection {
321                    range: last_end..output.text.len().saturating_sub(1),
322                    icon: IconName::File,
323                    label: file_path.into(),
324                    metadata: None,
325                });
326            }
327        }
328
329        // No diagnostics found
330        if output.sections.is_empty() {
331            return Ok(None);
332        }
333
334        let mut label = String::new();
335        label.push_str("Diagnostics");
336        if let Some(source) = error_source {
337            write!(label, " ({})", source).unwrap();
338        }
339
340        if project_summary.error_count > 0 || project_summary.warning_count > 0 {
341            label.push(':');
342
343            if project_summary.error_count > 0 {
344                write!(label, " {} errors", project_summary.error_count).unwrap();
345                if project_summary.warning_count > 0 {
346                    label.push_str(",");
347                }
348            }
349
350            if project_summary.warning_count > 0 {
351                write!(label, " {} warnings", project_summary.warning_count).unwrap();
352            }
353        }
354
355        output.sections.insert(
356            0,
357            SlashCommandOutputSection {
358                range: 0..output.text.len(),
359                icon: IconName::Warning,
360                label: label.into(),
361                metadata: None,
362            },
363        );
364
365        Ok(Some(output))
366    })
367}
368
369pub fn collect_buffer_diagnostics(
370    output: &mut SlashCommandOutput,
371    snapshot: &BufferSnapshot,
372    include_warnings: bool,
373    include_errors: bool,
374) {
375    for (_, group) in snapshot.diagnostic_groups(None) {
376        let entry = &group.entries[group.primary_ix];
377        collect_diagnostic(output, entry, snapshot, include_warnings, include_errors)
378    }
379}
380
381fn collect_diagnostic(
382    output: &mut SlashCommandOutput,
383    entry: &DiagnosticEntryRef<'_, Anchor>,
384    snapshot: &BufferSnapshot,
385    include_warnings: bool,
386    include_errors: bool,
387) {
388    const EXCERPT_EXPANSION_SIZE: u32 = 2;
389    const MAX_MESSAGE_LENGTH: usize = 2000;
390
391    let (ty, icon) = match entry.diagnostic.severity {
392        DiagnosticSeverity::WARNING => {
393            if !include_warnings {
394                return;
395            }
396            ("warning", IconName::Warning)
397        }
398        DiagnosticSeverity::ERROR => {
399            if !include_errors {
400                return;
401            }
402            ("error", IconName::XCircle)
403        }
404        _ => return,
405    };
406    let prev_len = output.text.len();
407
408    let range = entry.range.to_point(snapshot);
409    let diagnostic_row_number = range.start.row + 1;
410
411    let start_row = range.start.row.saturating_sub(EXCERPT_EXPANSION_SIZE);
412    let end_row = (range.end.row + EXCERPT_EXPANSION_SIZE).min(snapshot.max_point().row) + 1;
413    let excerpt_range =
414        Point::new(start_row, 0).to_offset(snapshot)..Point::new(end_row, 0).to_offset(snapshot);
415
416    output.text.push_str("```");
417    if let Some(language_name) = snapshot.language().map(|l| l.code_fence_block_name()) {
418        output.text.push_str(&language_name);
419    }
420    output.text.push('\n');
421
422    let mut buffer_text = String::new();
423    for chunk in snapshot.text_for_range(excerpt_range) {
424        buffer_text.push_str(chunk);
425    }
426
427    for (i, line) in buffer_text.lines().enumerate() {
428        let line_number = start_row + i as u32 + 1;
429        writeln!(output.text, "{}", line).unwrap();
430
431        if line_number == diagnostic_row_number {
432            output.text.push_str("//");
433            let prev_len = output.text.len();
434            write!(output.text, " {}: ", ty).unwrap();
435            let padding = output.text.len() - prev_len;
436
437            let message = util::truncate(&entry.diagnostic.message, MAX_MESSAGE_LENGTH)
438                .replace('\n', format!("\n//{:padding$}", "").as_str());
439
440            writeln!(output.text, "{message}").unwrap();
441        }
442    }
443
444    writeln!(output.text, "```").unwrap();
445    output.sections.push(SlashCommandOutputSection {
446        range: prev_len..output.text.len().saturating_sub(1),
447        icon,
448        label: entry.diagnostic.message.clone().into(),
449        metadata: None,
450    });
451}