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