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        query: 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 = query.split_whitespace().last().unwrap_or("").to_string();
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        argument: Option<&str>,
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(argument);
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_line: Option<&str>) -> Self {
248        arguments_line
249            .map(|arguments_line| {
250                let args = arguments_line.split_whitespace().collect::<Vec<_>>();
251                let mut include_warnings = false;
252                let mut path_matcher = None;
253                for arg in args {
254                    if arg == INCLUDE_WARNINGS_ARGUMENT {
255                        include_warnings = true;
256                    } else {
257                        path_matcher = PathMatcher::new(&[arg.to_owned()]).log_err();
258                    }
259                }
260                Self {
261                    include_warnings,
262                    path_matcher,
263                }
264            })
265            .unwrap_or_default()
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}