diagnostics_command.rs

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