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