1use anyhow::{Context as _, Result, anyhow};
2use assistant_slash_command::{
3 ArgumentCompletion, SlashCommand, SlashCommandOutput, SlashCommandOutputSection,
4 SlashCommandResult,
5};
6use fuzzy::{PathMatch, StringMatchCandidate};
7use gpui::{App, Entity, Task, WeakEntity};
8use language::{
9 Anchor, BufferSnapshot, DiagnosticEntryRef, DiagnosticSeverity, LspAdapterDelegate,
10 OffsetRangeExt, ToOffset,
11};
12use project::{DiagnosticSummary, PathMatchCandidateSet, Project};
13use rope::Point;
14use std::{
15 fmt::Write,
16 path::Path,
17 sync::{Arc, atomic::AtomicBool},
18};
19use ui::prelude::*;
20use util::paths::{PathMatcher, PathStyle};
21use util::{ResultExt, rel_path::RelPath};
22use workspace::Workspace;
23
24use crate::create_label_for_command;
25
26pub struct DiagnosticsSlashCommand;
27
28impl DiagnosticsSlashCommand {
29 fn search_paths(
30 &self,
31 query: String,
32 cancellation_flag: Arc<AtomicBool>,
33 workspace: &Entity<Workspace>,
34 cx: &mut App,
35 ) -> Task<Vec<PathMatch>> {
36 if query.is_empty() {
37 let workspace = workspace.read(cx);
38 let entries = workspace.recent_navigation_history(Some(10), cx);
39 let path_prefix: Arc<RelPath> = RelPath::empty().into();
40 Task::ready(
41 entries
42 .into_iter()
43 .map(|(entry, _)| PathMatch {
44 score: 0.,
45 positions: Vec::new(),
46 worktree_id: entry.worktree_id.to_usize(),
47 path: entry.path,
48 path_prefix: path_prefix.clone(),
49 is_dir: false, // Diagnostics can't be produced for directories
50 distance_to_relative_ancestor: 0,
51 })
52 .collect(),
53 )
54 } else {
55 let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
56 let candidate_sets = worktrees
57 .into_iter()
58 .map(|worktree| {
59 let worktree = worktree.read(cx);
60 PathMatchCandidateSet {
61 snapshot: worktree.snapshot(),
62 include_ignored: worktree
63 .root_entry()
64 .is_some_and(|entry| entry.is_ignored),
65 include_root_name: true,
66 candidates: project::Candidates::Entries,
67 }
68 })
69 .collect::<Vec<_>>();
70
71 let executor = cx.background_executor().clone();
72 cx.foreground_executor().spawn(async move {
73 fuzzy::match_path_sets(
74 candidate_sets.as_slice(),
75 query.as_str(),
76 &None,
77 false,
78 100,
79 &cancellation_flag,
80 executor,
81 )
82 .await
83 })
84 }
85 }
86}
87
88impl SlashCommand for DiagnosticsSlashCommand {
89 fn name(&self) -> String {
90 "diagnostics".into()
91 }
92
93 fn label(&self, cx: &App) -> language::CodeLabel {
94 create_label_for_command("diagnostics", &[INCLUDE_WARNINGS_ARGUMENT], cx)
95 }
96
97 fn description(&self) -> String {
98 "Insert diagnostics".into()
99 }
100
101 fn icon(&self) -> IconName {
102 IconName::XCircle
103 }
104
105 fn menu_text(&self) -> String {
106 self.description()
107 }
108
109 fn requires_argument(&self) -> bool {
110 false
111 }
112
113 fn accepts_arguments(&self) -> bool {
114 true
115 }
116
117 fn complete_argument(
118 self: Arc<Self>,
119 arguments: &[String],
120 cancellation_flag: Arc<AtomicBool>,
121 workspace: Option<WeakEntity<Workspace>>,
122 _: &mut Window,
123 cx: &mut App,
124 ) -> Task<Result<Vec<ArgumentCompletion>>> {
125 let Some(workspace) = workspace.and_then(|workspace| workspace.upgrade()) else {
126 return Task::ready(Err(anyhow!("workspace was dropped")));
127 };
128 let path_style = workspace.read(cx).project().read(cx).path_style(cx);
129 let query = arguments.last().cloned().unwrap_or_default();
130
131 let paths = self.search_paths(query.clone(), cancellation_flag.clone(), &workspace, cx);
132 let executor = cx.background_executor().clone();
133 cx.background_spawn(async move {
134 let mut matches: Vec<String> = paths
135 .await
136 .into_iter()
137 .map(|path_match| {
138 path_match
139 .path_prefix
140 .join(&path_match.path)
141 .display(path_style)
142 .to_string()
143 })
144 .collect();
145
146 matches.extend(
147 fuzzy::match_strings(
148 &Options::match_candidates_for_args(),
149 &query,
150 false,
151 true,
152 10,
153 &cancellation_flag,
154 executor,
155 )
156 .await
157 .into_iter()
158 .map(|candidate| candidate.string),
159 );
160
161 Ok(matches
162 .into_iter()
163 .map(|completion| ArgumentCompletion {
164 label: completion.clone().into(),
165 new_text: completion,
166 after_completion: assistant_slash_command::AfterCompletion::Run,
167 replace_previous_arguments: false,
168 })
169 .collect())
170 })
171 }
172
173 fn run(
174 self: Arc<Self>,
175 arguments: &[String],
176 _context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
177 _context_buffer: BufferSnapshot,
178 workspace: WeakEntity<Workspace>,
179 _delegate: Option<Arc<dyn LspAdapterDelegate>>,
180 window: &mut Window,
181 cx: &mut App,
182 ) -> Task<SlashCommandResult> {
183 let Some(workspace) = workspace.upgrade() else {
184 return Task::ready(Err(anyhow!("workspace was dropped")));
185 };
186
187 let project = workspace.read(cx).project();
188 let path_style = project.read(cx).path_style(cx);
189 let options = Options::parse(arguments, path_style);
190
191 let task = collect_diagnostics_output(project.clone(), options, cx);
192
193 window.spawn(cx, async move |_| {
194 task.await?
195 .map(|output| output.into_event_stream())
196 .context("No diagnostics found")
197 })
198 }
199}
200
201pub struct Options {
202 pub include_errors: bool,
203 pub include_warnings: bool,
204 pub path_matcher: Option<PathMatcher>,
205}
206
207const INCLUDE_WARNINGS_ARGUMENT: &str = "--include-warnings";
208
209impl Options {
210 fn parse(arguments: &[String], path_style: PathStyle) -> Self {
211 let mut include_warnings = false;
212 let mut path_matcher = None;
213 for arg in arguments {
214 if arg == INCLUDE_WARNINGS_ARGUMENT {
215 include_warnings = true;
216 } else {
217 path_matcher = PathMatcher::new(&[arg.to_owned()], path_style).log_err();
218 }
219 }
220 Self {
221 include_errors: true,
222 include_warnings,
223 path_matcher,
224 }
225 }
226
227 fn match_candidates_for_args() -> [StringMatchCandidate; 1] {
228 [StringMatchCandidate::new(0, INCLUDE_WARNINGS_ARGUMENT)]
229 }
230}
231
232pub fn collect_diagnostics_output(
233 project: Entity<Project>,
234 options: Options,
235 cx: &mut App,
236) -> Task<Result<Option<SlashCommandOutput>>> {
237 let path_style = project.read(cx).path_style(cx);
238 let glob_is_exact_file_match = if let Some(path) = options
239 .path_matcher
240 .as_ref()
241 .and_then(|pm| pm.sources().next())
242 {
243 project
244 .read(cx)
245 .find_project_path(Path::new(path), cx)
246 .is_some()
247 } else {
248 false
249 };
250
251 let project_handle = project.downgrade();
252 let diagnostic_summaries: Vec<_> = project
253 .read(cx)
254 .diagnostic_summaries(false, cx)
255 .flat_map(|(path, _, summary)| {
256 let worktree = project.read(cx).worktree_for_id(path.worktree_id, cx)?;
257 let full_path = worktree.read(cx).root_name().join(&path.path);
258 Some((path, full_path, summary))
259 })
260 .collect();
261
262 cx.spawn(async move |cx| {
263 let error_source = if let Some(path_matcher) = &options.path_matcher {
264 debug_assert_eq!(path_matcher.sources().count(), 1);
265 Some(path_matcher.sources().next().unwrap_or_default())
266 } else {
267 None
268 };
269
270 let mut output = SlashCommandOutput::default();
271
272 if let Some(error_source) = error_source.as_ref() {
273 writeln!(output.text, "diagnostics: {}", error_source).unwrap();
274 } else {
275 writeln!(output.text, "diagnostics").unwrap();
276 }
277
278 let mut project_summary = DiagnosticSummary::default();
279 for (project_path, path, summary) in diagnostic_summaries {
280 if let Some(path_matcher) = &options.path_matcher
281 && !path_matcher.is_match(&path)
282 {
283 continue;
284 }
285
286 let has_errors = options.include_errors && summary.error_count > 0;
287 let has_warnings = options.include_warnings && summary.warning_count > 0;
288 if !has_errors && !has_warnings {
289 continue;
290 }
291
292 if options.include_errors {
293 project_summary.error_count += summary.error_count;
294 }
295 if options.include_warnings {
296 project_summary.warning_count += summary.warning_count;
297 }
298
299 let last_end = output.text.len();
300 let file_path = path.display(path_style).to_string();
301 if !glob_is_exact_file_match {
302 writeln!(&mut output.text, "{file_path}").unwrap();
303 }
304
305 if let Some(buffer) = project_handle
306 .update(cx, |project, cx| project.open_buffer(project_path, cx))?
307 .await
308 .log_err()
309 {
310 let snapshot = cx.read_entity(&buffer, |buffer, _| buffer.snapshot());
311 collect_buffer_diagnostics(
312 &mut output,
313 &snapshot,
314 options.include_warnings,
315 options.include_errors,
316 );
317 }
318
319 if !glob_is_exact_file_match {
320 output.sections.push(SlashCommandOutputSection {
321 range: last_end..output.text.len().saturating_sub(1),
322 icon: IconName::File,
323 label: file_path.into(),
324 metadata: None,
325 });
326 }
327 }
328
329 // No diagnostics found
330 if output.sections.is_empty() {
331 return Ok(None);
332 }
333
334 let mut label = String::new();
335 label.push_str("Diagnostics");
336 if let Some(source) = error_source {
337 write!(label, " ({})", source).unwrap();
338 }
339
340 if project_summary.error_count > 0 || project_summary.warning_count > 0 {
341 label.push(':');
342
343 if project_summary.error_count > 0 {
344 write!(label, " {} errors", project_summary.error_count).unwrap();
345 if project_summary.warning_count > 0 {
346 label.push_str(",");
347 }
348 }
349
350 if project_summary.warning_count > 0 {
351 write!(label, " {} warnings", project_summary.warning_count).unwrap();
352 }
353 }
354
355 output.sections.insert(
356 0,
357 SlashCommandOutputSection {
358 range: 0..output.text.len(),
359 icon: IconName::Warning,
360 label: label.into(),
361 metadata: None,
362 },
363 );
364
365 Ok(Some(output))
366 })
367}
368
369pub fn collect_buffer_diagnostics(
370 output: &mut SlashCommandOutput,
371 snapshot: &BufferSnapshot,
372 include_warnings: bool,
373 include_errors: bool,
374) {
375 for (_, group) in snapshot.diagnostic_groups(None) {
376 let entry = &group.entries[group.primary_ix];
377 collect_diagnostic(output, entry, snapshot, include_warnings, include_errors)
378 }
379}
380
381fn collect_diagnostic(
382 output: &mut SlashCommandOutput,
383 entry: &DiagnosticEntryRef<'_, Anchor>,
384 snapshot: &BufferSnapshot,
385 include_warnings: bool,
386 include_errors: bool,
387) {
388 const EXCERPT_EXPANSION_SIZE: u32 = 2;
389 const MAX_MESSAGE_LENGTH: usize = 2000;
390
391 let (ty, icon) = match entry.diagnostic.severity {
392 DiagnosticSeverity::WARNING => {
393 if !include_warnings {
394 return;
395 }
396 ("warning", IconName::Warning)
397 }
398 DiagnosticSeverity::ERROR => {
399 if !include_errors {
400 return;
401 }
402 ("error", IconName::XCircle)
403 }
404 _ => return,
405 };
406 let prev_len = output.text.len();
407
408 let range = entry.range.to_point(snapshot);
409 let diagnostic_row_number = range.start.row + 1;
410
411 let start_row = range.start.row.saturating_sub(EXCERPT_EXPANSION_SIZE);
412 let end_row = (range.end.row + EXCERPT_EXPANSION_SIZE).min(snapshot.max_point().row) + 1;
413 let excerpt_range =
414 Point::new(start_row, 0).to_offset(snapshot)..Point::new(end_row, 0).to_offset(snapshot);
415
416 output.text.push_str("```");
417 if let Some(language_name) = snapshot.language().map(|l| l.code_fence_block_name()) {
418 output.text.push_str(&language_name);
419 }
420 output.text.push('\n');
421
422 let mut buffer_text = String::new();
423 for chunk in snapshot.text_for_range(excerpt_range) {
424 buffer_text.push_str(chunk);
425 }
426
427 for (i, line) in buffer_text.lines().enumerate() {
428 let line_number = start_row + i as u32 + 1;
429 writeln!(output.text, "{}", line).unwrap();
430
431 if line_number == diagnostic_row_number {
432 output.text.push_str("//");
433 let prev_len = output.text.len();
434 write!(output.text, " {}: ", ty).unwrap();
435 let padding = output.text.len() - prev_len;
436
437 let message = util::truncate(&entry.diagnostic.message, MAX_MESSAGE_LENGTH)
438 .replace('\n', format!("\n//{:padding$}", "").as_str());
439
440 writeln!(output.text, "{message}").unwrap();
441 }
442 }
443
444 writeln!(output.text, "```").unwrap();
445 output.sections.push(SlashCommandOutputSection {
446 range: prev_len..output.text.len().saturating_sub(1),
447 icon,
448 label: entry.diagnostic.message.clone().into(),
449 metadata: None,
450 });
451}