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