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                    replace_previous_arguments: false,
154                })
155                .collect())
156        })
157    }
158
159    fn run(
160        self: Arc<Self>,
161        arguments: &[String],
162        workspace: WeakView<Workspace>,
163        _delegate: Option<Arc<dyn LspAdapterDelegate>>,
164        cx: &mut WindowContext,
165    ) -> Task<Result<SlashCommandOutput>> {
166        let Some(workspace) = workspace.upgrade() else {
167            return Task::ready(Err(anyhow!("workspace was dropped")));
168        };
169
170        let options = Options::parse(arguments);
171
172        let task = collect_diagnostics(workspace.read(cx).project().clone(), options, cx);
173
174        cx.spawn(move |_| async move {
175            let Some((text, sections)) = task.await? else {
176                return Ok(SlashCommandOutput {
177                    sections: vec![SlashCommandOutputSection {
178                        range: 0..1,
179                        icon: IconName::Library,
180                        label: "No Diagnostics".into(),
181                    }],
182                    text: "\n".to_string(),
183                    run_commands_in_text: true,
184                });
185            };
186
187            let sections = sections
188                .into_iter()
189                .map(|(range, placeholder_type)| SlashCommandOutputSection {
190                    range,
191                    icon: match placeholder_type {
192                        PlaceholderType::Root(_, _) => IconName::ExclamationTriangle,
193                        PlaceholderType::File(_) => IconName::File,
194                        PlaceholderType::Diagnostic(DiagnosticType::Error, _) => IconName::XCircle,
195                        PlaceholderType::Diagnostic(DiagnosticType::Warning, _) => {
196                            IconName::ExclamationTriangle
197                        }
198                    },
199                    label: match placeholder_type {
200                        PlaceholderType::Root(summary, source) => {
201                            let mut label = String::new();
202                            label.push_str("Diagnostics");
203                            if let Some(source) = source {
204                                write!(label, " ({})", source).unwrap();
205                            }
206
207                            if summary.error_count > 0 || summary.warning_count > 0 {
208                                label.push(':');
209
210                                if summary.error_count > 0 {
211                                    write!(label, " {} errors", summary.error_count).unwrap();
212                                    if summary.warning_count > 0 {
213                                        label.push_str(",");
214                                    }
215                                }
216
217                                if summary.warning_count > 0 {
218                                    write!(label, " {} warnings", summary.warning_count).unwrap();
219                                }
220                            }
221
222                            label.into()
223                        }
224                        PlaceholderType::File(file_path) => file_path.into(),
225                        PlaceholderType::Diagnostic(_, message) => message.into(),
226                    },
227                })
228                .collect();
229
230            Ok(SlashCommandOutput {
231                text,
232                sections,
233                run_commands_in_text: false,
234            })
235        })
236    }
237}
238
239#[derive(Default)]
240struct Options {
241    include_warnings: bool,
242    path_matcher: Option<PathMatcher>,
243}
244
245const INCLUDE_WARNINGS_ARGUMENT: &str = "--include-warnings";
246
247impl Options {
248    fn parse(arguments: &[String]) -> Self {
249        let mut include_warnings = false;
250        let mut path_matcher = None;
251        for arg in arguments {
252            if arg == INCLUDE_WARNINGS_ARGUMENT {
253                include_warnings = true;
254            } else {
255                path_matcher = PathMatcher::new(&[arg.to_owned()]).log_err();
256            }
257        }
258        Self {
259            include_warnings,
260            path_matcher,
261        }
262    }
263
264    fn match_candidates_for_args() -> [StringMatchCandidate; 1] {
265        [StringMatchCandidate::new(
266            0,
267            INCLUDE_WARNINGS_ARGUMENT.to_string(),
268        )]
269    }
270}
271
272fn collect_diagnostics(
273    project: Model<Project>,
274    options: Options,
275    cx: &mut AppContext,
276) -> Task<Result<Option<(String, Vec<(Range<usize>, PlaceholderType)>)>>> {
277    let error_source = if let Some(path_matcher) = &options.path_matcher {
278        debug_assert_eq!(path_matcher.sources().len(), 1);
279        Some(path_matcher.sources().first().cloned().unwrap_or_default())
280    } else {
281        None
282    };
283
284    let glob_is_exact_file_match = if let Some(path) = options
285        .path_matcher
286        .as_ref()
287        .and_then(|pm| pm.sources().first())
288    {
289        PathBuf::try_from(path)
290            .ok()
291            .and_then(|path| {
292                project.read(cx).worktrees(cx).find_map(|worktree| {
293                    let worktree = worktree.read(cx);
294                    let worktree_root_path = Path::new(worktree.root_name());
295                    let relative_path = path.strip_prefix(worktree_root_path).ok()?;
296                    worktree.absolutize(&relative_path).ok()
297                })
298            })
299            .is_some()
300    } else {
301        false
302    };
303
304    let project_handle = project.downgrade();
305    let diagnostic_summaries: Vec<_> = project
306        .read(cx)
307        .diagnostic_summaries(false, cx)
308        .flat_map(|(path, _, summary)| {
309            let worktree = project.read(cx).worktree_for_id(path.worktree_id, cx)?;
310            let mut path_buf = PathBuf::from(worktree.read(cx).root_name());
311            path_buf.push(&path.path);
312            Some((path, path_buf, summary))
313        })
314        .collect();
315
316    cx.spawn(|mut cx| async move {
317        let mut text = String::new();
318        if let Some(error_source) = error_source.as_ref() {
319            writeln!(text, "diagnostics: {}", error_source).unwrap();
320        } else {
321            writeln!(text, "diagnostics").unwrap();
322        }
323        let mut sections: Vec<(Range<usize>, PlaceholderType)> = Vec::new();
324
325        let mut project_summary = DiagnosticSummary::default();
326        for (project_path, path, summary) in diagnostic_summaries {
327            if let Some(path_matcher) = &options.path_matcher {
328                if !path_matcher.is_match(&path) {
329                    continue;
330                }
331            }
332
333            project_summary.error_count += summary.error_count;
334            if options.include_warnings {
335                project_summary.warning_count += summary.warning_count;
336            } else if summary.error_count == 0 {
337                continue;
338            }
339
340            let last_end = text.len();
341            let file_path = path.to_string_lossy().to_string();
342            if !glob_is_exact_file_match {
343                writeln!(&mut text, "{file_path}").unwrap();
344            }
345
346            if let Some(buffer) = project_handle
347                .update(&mut cx, |project, cx| project.open_buffer(project_path, cx))?
348                .await
349                .log_err()
350            {
351                collect_buffer_diagnostics(
352                    &mut text,
353                    &mut sections,
354                    cx.read_model(&buffer, |buffer, _| buffer.snapshot())?,
355                    options.include_warnings,
356                );
357            }
358
359            if !glob_is_exact_file_match {
360                sections.push((
361                    last_end..text.len().saturating_sub(1),
362                    PlaceholderType::File(file_path),
363                ))
364            }
365        }
366
367        // No diagnostics found
368        if sections.is_empty() {
369            return Ok(None);
370        }
371
372        sections.push((
373            0..text.len(),
374            PlaceholderType::Root(project_summary, error_source),
375        ));
376        Ok(Some((text, sections)))
377    })
378}
379
380pub fn buffer_has_error_diagnostics(snapshot: &BufferSnapshot) -> bool {
381    for (_, group) in snapshot.diagnostic_groups(None) {
382        let entry = &group.entries[group.primary_ix];
383        if entry.diagnostic.severity == DiagnosticSeverity::ERROR {
384            return true;
385        }
386    }
387    false
388}
389
390pub fn write_single_file_diagnostics(
391    output: &mut String,
392    path: Option<&Path>,
393    snapshot: &BufferSnapshot,
394) -> bool {
395    if let Some(path) = path {
396        if buffer_has_error_diagnostics(&snapshot) {
397            output.push_str("/diagnostics ");
398            output.push_str(&path.to_string_lossy());
399            return true;
400        }
401    }
402    false
403}
404
405fn collect_buffer_diagnostics(
406    text: &mut String,
407    sections: &mut Vec<(Range<usize>, PlaceholderType)>,
408    snapshot: BufferSnapshot,
409    include_warnings: bool,
410) {
411    for (_, group) in snapshot.diagnostic_groups(None) {
412        let entry = &group.entries[group.primary_ix];
413        collect_diagnostic(text, sections, entry, &snapshot, include_warnings)
414    }
415}
416
417fn collect_diagnostic(
418    text: &mut String,
419    sections: &mut Vec<(Range<usize>, PlaceholderType)>,
420    entry: &DiagnosticEntry<Anchor>,
421    snapshot: &BufferSnapshot,
422    include_warnings: bool,
423) {
424    const EXCERPT_EXPANSION_SIZE: u32 = 2;
425    const MAX_MESSAGE_LENGTH: usize = 2000;
426
427    let ty = match entry.diagnostic.severity {
428        DiagnosticSeverity::WARNING => {
429            if !include_warnings {
430                return;
431            }
432            DiagnosticType::Warning
433        }
434        DiagnosticSeverity::ERROR => DiagnosticType::Error,
435        _ => return,
436    };
437    let prev_len = text.len();
438
439    let range = entry.range.to_point(snapshot);
440    let diagnostic_row_number = range.start.row + 1;
441
442    let start_row = range.start.row.saturating_sub(EXCERPT_EXPANSION_SIZE);
443    let end_row = (range.end.row + EXCERPT_EXPANSION_SIZE).min(snapshot.max_point().row) + 1;
444    let excerpt_range =
445        Point::new(start_row, 0).to_offset(&snapshot)..Point::new(end_row, 0).to_offset(&snapshot);
446
447    text.push_str("```");
448    if let Some(language_name) = snapshot.language().map(|l| l.code_fence_block_name()) {
449        text.push_str(&language_name);
450    }
451    text.push('\n');
452
453    let mut buffer_text = String::new();
454    for chunk in snapshot.text_for_range(excerpt_range) {
455        buffer_text.push_str(chunk);
456    }
457
458    for (i, line) in buffer_text.lines().enumerate() {
459        let line_number = start_row + i as u32 + 1;
460        writeln!(text, "{}", line).unwrap();
461
462        if line_number == diagnostic_row_number {
463            text.push_str("//");
464            let prev_len = text.len();
465            write!(text, " {}: ", ty.as_str()).unwrap();
466            let padding = text.len() - prev_len;
467
468            let message = util::truncate(&entry.diagnostic.message, MAX_MESSAGE_LENGTH)
469                .replace('\n', format!("\n//{:padding$}", "").as_str());
470
471            writeln!(text, "{message}").unwrap();
472        }
473    }
474
475    writeln!(text, "```").unwrap();
476    sections.push((
477        prev_len..text.len().saturating_sub(1),
478        PlaceholderType::Diagnostic(ty, entry.diagnostic.message.clone()),
479    ))
480}
481
482#[derive(Clone)]
483pub enum PlaceholderType {
484    Root(DiagnosticSummary, Option<String>),
485    File(String),
486    Diagnostic(DiagnosticType, String),
487}
488
489#[derive(Copy, Clone)]
490pub enum DiagnosticType {
491    Warning,
492    Error,
493}
494
495impl DiagnosticType {
496    pub fn as_str(&self) -> &'static str {
497        match self {
498            DiagnosticType::Warning => "warning",
499            DiagnosticType::Error => "error",
500        }
501    }
502}