diagnostics_command.rs

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