diagnostics_command.rs

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