diagnostics_command.rs

  1use super::{SlashCommand, SlashCommandOutput};
  2use anyhow::{anyhow, Result};
  3use assistant_slash_command::SlashCommandOutputSection;
  4use fuzzy::{PathMatch, StringMatchCandidate};
  5use gpui::{svg, AppContext, Model, RenderOnce, 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::*, ButtonLike, ElevationIndex};
 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 description(&self) -> String {
 89        "Insert diagnostics".into()
 90    }
 91
 92    fn menu_text(&self) -> String {
 93        "Insert Diagnostics".into()
 94    }
 95
 96    fn requires_argument(&self) -> bool {
 97        false
 98    }
 99
100    fn complete_argument(
101        &self,
102        query: String,
103        cancellation_flag: Arc<AtomicBool>,
104        workspace: Option<WeakView<Workspace>>,
105        cx: &mut AppContext,
106    ) -> Task<Result<Vec<String>>> {
107        let Some(workspace) = workspace.and_then(|workspace| workspace.upgrade()) else {
108            return Task::ready(Err(anyhow!("workspace was dropped")));
109        };
110        let query = query.split_whitespace().last().unwrap_or("").to_string();
111
112        let paths = self.search_paths(query.clone(), cancellation_flag.clone(), &workspace, cx);
113        let executor = cx.background_executor().clone();
114        cx.background_executor().spawn(async move {
115            let mut matches: Vec<String> = paths
116                .await
117                .into_iter()
118                .map(|path_match| {
119                    format!(
120                        "{}{}",
121                        path_match.path_prefix,
122                        path_match.path.to_string_lossy()
123                    )
124                })
125                .collect();
126
127            matches.extend(
128                fuzzy::match_strings(
129                    &Options::match_candidates_for_args(),
130                    &query,
131                    false,
132                    10,
133                    &cancellation_flag,
134                    executor,
135                )
136                .await
137                .into_iter()
138                .map(|candidate| candidate.string),
139            );
140
141            Ok(matches)
142        })
143    }
144
145    fn run(
146        self: Arc<Self>,
147        argument: Option<&str>,
148        workspace: WeakView<Workspace>,
149        _delegate: Arc<dyn LspAdapterDelegate>,
150        cx: &mut WindowContext,
151    ) -> Task<Result<SlashCommandOutput>> {
152        let Some(workspace) = workspace.upgrade() else {
153            return Task::ready(Err(anyhow!("workspace was dropped")));
154        };
155
156        let options = Options::parse(argument);
157
158        let task = collect_diagnostics(workspace.read(cx).project().clone(), options, cx);
159        cx.spawn(move |_| async move {
160            let (text, sections) = task.await?;
161            Ok(SlashCommandOutput {
162                text,
163                sections: sections
164                    .into_iter()
165                    .map(|(range, placeholder_type)| SlashCommandOutputSection {
166                        range,
167                        render_placeholder: Arc::new(move |id, unfold, _cx| {
168                            DiagnosticsPlaceholder {
169                                id,
170                                unfold,
171                                placeholder_type: placeholder_type.clone(),
172                            }
173                            .into_any_element()
174                        }),
175                    })
176                    .collect(),
177                run_commands_in_text: false,
178            })
179        })
180    }
181}
182
183#[derive(Default)]
184struct Options {
185    include_warnings: bool,
186    path_matcher: Option<PathMatcher>,
187}
188
189const INCLUDE_WARNINGS_ARGUMENT: &str = "--include-warnings";
190
191impl Options {
192    pub fn parse(arguments_line: Option<&str>) -> Self {
193        arguments_line
194            .map(|arguments_line| {
195                let args = arguments_line.split_whitespace().collect::<Vec<_>>();
196                let mut include_warnings = false;
197                let mut path_matcher = None;
198                for arg in args {
199                    if arg == INCLUDE_WARNINGS_ARGUMENT {
200                        include_warnings = true;
201                    } else {
202                        path_matcher = PathMatcher::new(arg).log_err();
203                    }
204                }
205                Self {
206                    include_warnings,
207                    path_matcher,
208                }
209            })
210            .unwrap_or_default()
211    }
212
213    fn match_candidates_for_args() -> [StringMatchCandidate; 1] {
214        [StringMatchCandidate::new(
215            0,
216            INCLUDE_WARNINGS_ARGUMENT.to_string(),
217        )]
218    }
219}
220
221fn collect_diagnostics(
222    project: Model<Project>,
223    options: Options,
224    cx: &mut AppContext,
225) -> Task<Result<(String, Vec<(Range<usize>, PlaceholderType)>)>> {
226    let header = if let Some(path_matcher) = &options.path_matcher {
227        format!("diagnostics: {}", path_matcher.source())
228    } else {
229        "diagnostics".to_string()
230    };
231
232    let project_handle = project.downgrade();
233    let diagnostic_summaries: Vec<_> = project.read(cx).diagnostic_summaries(false, cx).collect();
234
235    cx.spawn(|mut cx| async move {
236        let mut text = String::new();
237        writeln!(text, "{}", &header).unwrap();
238        let mut sections: Vec<(Range<usize>, PlaceholderType)> = Vec::new();
239
240        let mut project_summary = DiagnosticSummary::default();
241        for (project_path, _, summary) in diagnostic_summaries {
242            if let Some(path_matcher) = &options.path_matcher {
243                if !path_matcher.is_match(&project_path.path) {
244                    continue;
245                }
246            }
247
248            project_summary.error_count += summary.error_count;
249            if options.include_warnings {
250                project_summary.warning_count += summary.warning_count;
251            } else if summary.error_count == 0 {
252                continue;
253            }
254
255            let last_end = text.len();
256            let file_path = project_path.path.to_string_lossy().to_string();
257            writeln!(&mut text, "{file_path}").unwrap();
258
259            if let Some(buffer) = project_handle
260                .update(&mut cx, |project, cx| project.open_buffer(project_path, cx))?
261                .await
262                .log_err()
263            {
264                collect_buffer_diagnostics(
265                    &mut text,
266                    &mut sections,
267                    cx.read_model(&buffer, |buffer, _| buffer.snapshot())?,
268                    options.include_warnings,
269                );
270            }
271
272            sections.push((
273                last_end..text.len().saturating_sub(1),
274                PlaceholderType::File(file_path),
275            ))
276        }
277        sections.push((
278            0..text.len(),
279            PlaceholderType::Root(project_summary, header),
280        ));
281
282        Ok((text, sections))
283    })
284}
285
286fn collect_buffer_diagnostics(
287    text: &mut String,
288    sections: &mut Vec<(Range<usize>, PlaceholderType)>,
289    snapshot: BufferSnapshot,
290    include_warnings: bool,
291) {
292    for (_, group) in snapshot.diagnostic_groups(None) {
293        let entry = &group.entries[group.primary_ix];
294        collect_diagnostic(text, sections, entry, &snapshot, include_warnings)
295    }
296}
297
298fn collect_diagnostic(
299    text: &mut String,
300    sections: &mut Vec<(Range<usize>, PlaceholderType)>,
301    entry: &DiagnosticEntry<Anchor>,
302    snapshot: &BufferSnapshot,
303    include_warnings: bool,
304) {
305    const EXCERPT_EXPANSION_SIZE: u32 = 2;
306    const MAX_MESSAGE_LENGTH: usize = 2000;
307
308    let ty = match entry.diagnostic.severity {
309        DiagnosticSeverity::WARNING => {
310            if !include_warnings {
311                return;
312            }
313            DiagnosticType::Warning
314        }
315        DiagnosticSeverity::ERROR => DiagnosticType::Error,
316        _ => return,
317    };
318    let prev_len = text.len();
319
320    let range = entry.range.to_point(snapshot);
321    let diagnostic_row_number = range.start.row + 1;
322
323    let start_row = range.start.row.saturating_sub(EXCERPT_EXPANSION_SIZE);
324    let end_row = (range.end.row + EXCERPT_EXPANSION_SIZE).min(snapshot.max_point().row) + 1;
325    let excerpt_range =
326        Point::new(start_row, 0).to_offset(&snapshot)..Point::new(end_row, 0).to_offset(&snapshot);
327
328    text.push_str("```");
329    if let Some(language_name) = snapshot.language().map(|l| l.code_fence_block_name()) {
330        text.push_str(&language_name);
331    }
332    text.push('\n');
333
334    let mut buffer_text = String::new();
335    for chunk in snapshot.text_for_range(excerpt_range) {
336        buffer_text.push_str(chunk);
337    }
338
339    for (i, line) in buffer_text.lines().enumerate() {
340        let line_number = start_row + i as u32 + 1;
341        writeln!(text, "{}", line).unwrap();
342
343        if line_number == diagnostic_row_number {
344            text.push_str("//");
345            let prev_len = text.len();
346            write!(text, " {}: ", ty.as_str()).unwrap();
347            let padding = text.len() - prev_len;
348
349            let message = util::truncate(&entry.diagnostic.message, MAX_MESSAGE_LENGTH)
350                .replace('\n', format!("\n//{:padding$}", "").as_str());
351
352            writeln!(text, "{message}").unwrap();
353        }
354    }
355
356    writeln!(text, "```").unwrap();
357    sections.push((
358        prev_len..text.len().saturating_sub(1),
359        PlaceholderType::Diagnostic(ty, entry.diagnostic.message.clone()),
360    ))
361}
362
363#[derive(Clone)]
364pub enum PlaceholderType {
365    Root(DiagnosticSummary, String),
366    File(String),
367    Diagnostic(DiagnosticType, String),
368}
369
370#[derive(Copy, Clone, IntoElement)]
371pub enum DiagnosticType {
372    Warning,
373    Error,
374}
375
376impl DiagnosticType {
377    pub fn as_str(&self) -> &'static str {
378        match self {
379            DiagnosticType::Warning => "warning",
380            DiagnosticType::Error => "error",
381        }
382    }
383}
384
385#[derive(IntoElement)]
386pub struct DiagnosticsPlaceholder {
387    pub id: ElementId,
388    pub placeholder_type: PlaceholderType,
389    pub unfold: Arc<dyn Fn(&mut WindowContext)>,
390}
391
392impl RenderOnce for DiagnosticsPlaceholder {
393    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
394        let unfold = self.unfold;
395        let (icon, content) = match self.placeholder_type {
396            PlaceholderType::Root(summary, title) => (
397                h_flex()
398                    .w_full()
399                    .gap_0p5()
400                    .when(summary.error_count > 0, |this| {
401                        this.child(DiagnosticType::Error)
402                            .child(Label::new(summary.error_count.to_string()))
403                    })
404                    .when(summary.warning_count > 0, |this| {
405                        this.child(DiagnosticType::Warning)
406                            .child(Label::new(summary.warning_count.to_string()))
407                    })
408                    .into_any_element(),
409                Label::new(title),
410            ),
411            PlaceholderType::File(file) => (
412                Icon::new(IconName::File).into_any_element(),
413                Label::new(file),
414            ),
415            PlaceholderType::Diagnostic(diagnostic_type, message) => (
416                diagnostic_type.into_any_element(),
417                Label::new(message).single_line(),
418            ),
419        };
420
421        ButtonLike::new(self.id)
422            .style(ButtonStyle::Filled)
423            .layer(ElevationIndex::ElevatedSurface)
424            .child(icon)
425            .child(content)
426            .on_click(move |_, cx| unfold(cx))
427    }
428}
429
430impl RenderOnce for DiagnosticType {
431    fn render(self, cx: &mut WindowContext) -> impl IntoElement {
432        svg()
433            .size(cx.text_style().font_size)
434            .flex_none()
435            .map(|icon| match self {
436                DiagnosticType::Error => icon
437                    .path(IconName::XCircle.path())
438                    .text_color(Color::Error.color(cx)),
439                DiagnosticType::Warning => icon
440                    .path(IconName::ExclamationTriangle.path())
441                    .text_color(Color::Warning.color(cx)),
442            })
443    }
444}