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, DiagnosticEntry, DiagnosticSeverity, LspAdapterDelegate,
10 OffsetRangeExt, ToOffset,
11};
12use project::{DiagnosticSummary, PathMatchCandidateSet, Project};
13use rope::Point;
14use std::{
15 fmt::Write,
16 path::{Path, PathBuf},
17 sync::{Arc, atomic::AtomicBool},
18};
19use ui::prelude::*;
20use util::ResultExt;
21use util::paths::PathMatcher;
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<str> = Arc::default();
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 query = arguments.last().cloned().unwrap_or_default();
129
130 let paths = self.search_paths(query.clone(), cancellation_flag.clone(), &workspace, cx);
131 let executor = cx.background_executor().clone();
132 cx.background_spawn(async move {
133 let mut matches: Vec<String> = paths
134 .await
135 .into_iter()
136 .map(|path_match| {
137 format!(
138 "{}{}",
139 path_match.path_prefix,
140 path_match.path.to_string_lossy()
141 )
142 })
143 .collect();
144
145 matches.extend(
146 fuzzy::match_strings(
147 &Options::match_candidates_for_args(),
148 &query,
149 false,
150 true,
151 10,
152 &cancellation_flag,
153 executor,
154 )
155 .await
156 .into_iter()
157 .map(|candidate| candidate.string),
158 );
159
160 Ok(matches
161 .into_iter()
162 .map(|completion| ArgumentCompletion {
163 label: completion.clone().into(),
164 new_text: completion,
165 after_completion: assistant_slash_command::AfterCompletion::Run,
166 replace_previous_arguments: false,
167 })
168 .collect())
169 })
170 }
171
172 fn run(
173 self: Arc<Self>,
174 arguments: &[String],
175 _context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
176 _context_buffer: BufferSnapshot,
177 workspace: WeakEntity<Workspace>,
178 _delegate: Option<Arc<dyn LspAdapterDelegate>>,
179 window: &mut Window,
180 cx: &mut App,
181 ) -> Task<SlashCommandResult> {
182 let Some(workspace) = workspace.upgrade() else {
183 return Task::ready(Err(anyhow!("workspace was dropped")));
184 };
185
186 let options = Options::parse(arguments);
187
188 let task = collect_diagnostics(workspace.read(cx).project().clone(), options, cx);
189
190 window.spawn(cx, async move |_| {
191 task.await?
192 .map(|output| output.into_event_stream())
193 .context("No diagnostics found")
194 })
195 }
196}
197
198#[derive(Default)]
199struct Options {
200 include_warnings: bool,
201 path_matcher: Option<PathMatcher>,
202}
203
204const INCLUDE_WARNINGS_ARGUMENT: &str = "--include-warnings";
205
206impl Options {
207 fn parse(arguments: &[String]) -> Self {
208 let mut include_warnings = false;
209 let mut path_matcher = None;
210 for arg in arguments {
211 if arg == INCLUDE_WARNINGS_ARGUMENT {
212 include_warnings = true;
213 } else {
214 path_matcher = PathMatcher::new(&[arg.to_owned()]).log_err();
215 }
216 }
217 Self {
218 include_warnings,
219 path_matcher,
220 }
221 }
222
223 fn match_candidates_for_args() -> [StringMatchCandidate; 1] {
224 [StringMatchCandidate::new(0, INCLUDE_WARNINGS_ARGUMENT)]
225 }
226}
227
228fn collect_diagnostics(
229 project: Entity<Project>,
230 options: Options,
231 cx: &mut App,
232) -> Task<Result<Option<SlashCommandOutput>>> {
233 let error_source = if let Some(path_matcher) = &options.path_matcher {
234 debug_assert_eq!(path_matcher.sources().len(), 1);
235 Some(path_matcher.sources().first().cloned().unwrap_or_default())
236 } else {
237 None
238 };
239
240 let glob_is_exact_file_match = if let Some(path) = options
241 .path_matcher
242 .as_ref()
243 .and_then(|pm| pm.sources().first())
244 {
245 PathBuf::try_from(path)
246 .ok()
247 .and_then(|path| {
248 project.read(cx).worktrees(cx).find_map(|worktree| {
249 let worktree = worktree.read(cx);
250 let worktree_root_path = Path::new(worktree.root_name());
251 let relative_path = path.strip_prefix(worktree_root_path).ok()?;
252 worktree.absolutize(relative_path).ok()
253 })
254 })
255 .is_some()
256 } else {
257 false
258 };
259
260 let project_handle = project.downgrade();
261 let diagnostic_summaries: Vec<_> = project
262 .read(cx)
263 .diagnostic_summaries(false, cx)
264 .flat_map(|(path, _, summary)| {
265 let worktree = project.read(cx).worktree_for_id(path.worktree_id, cx)?;
266 let mut path_buf = PathBuf::from(worktree.read(cx).root_name());
267 path_buf.push(&path.path);
268 Some((path, path_buf, summary))
269 })
270 .collect();
271
272 cx.spawn(async move |cx| {
273 let mut output = SlashCommandOutput::default();
274
275 if let Some(error_source) = error_source.as_ref() {
276 writeln!(output.text, "diagnostics: {}", error_source).unwrap();
277 } else {
278 writeln!(output.text, "diagnostics").unwrap();
279 }
280
281 let mut project_summary = DiagnosticSummary::default();
282 for (project_path, path, summary) in diagnostic_summaries {
283 if let Some(path_matcher) = &options.path_matcher
284 && !path_matcher.is_match(&path)
285 {
286 continue;
287 }
288
289 project_summary.error_count += summary.error_count;
290 if options.include_warnings {
291 project_summary.warning_count += summary.warning_count;
292 } else if summary.error_count == 0 {
293 continue;
294 }
295
296 let last_end = output.text.len();
297 let file_path = path.to_string_lossy().to_string();
298 if !glob_is_exact_file_match {
299 writeln!(&mut output.text, "{file_path}").unwrap();
300 }
301
302 if let Some(buffer) = project_handle
303 .update(cx, |project, cx| project.open_buffer(project_path, cx))?
304 .await
305 .log_err()
306 {
307 let snapshot = cx.read_entity(&buffer, |buffer, _| buffer.snapshot())?;
308 collect_buffer_diagnostics(&mut output, &snapshot, options.include_warnings);
309 }
310
311 if !glob_is_exact_file_match {
312 output.sections.push(SlashCommandOutputSection {
313 range: last_end..output.text.len().saturating_sub(1),
314 icon: IconName::File,
315 label: file_path.into(),
316 metadata: None,
317 });
318 }
319 }
320
321 // No diagnostics found
322 if output.sections.is_empty() {
323 return Ok(None);
324 }
325
326 let mut label = String::new();
327 label.push_str("Diagnostics");
328 if let Some(source) = error_source {
329 write!(label, " ({})", source).unwrap();
330 }
331
332 if project_summary.error_count > 0 || project_summary.warning_count > 0 {
333 label.push(':');
334
335 if project_summary.error_count > 0 {
336 write!(label, " {} errors", project_summary.error_count).unwrap();
337 if project_summary.warning_count > 0 {
338 label.push_str(",");
339 }
340 }
341
342 if project_summary.warning_count > 0 {
343 write!(label, " {} warnings", project_summary.warning_count).unwrap();
344 }
345 }
346
347 output.sections.insert(
348 0,
349 SlashCommandOutputSection {
350 range: 0..output.text.len(),
351 icon: IconName::Warning,
352 label: label.into(),
353 metadata: None,
354 },
355 );
356
357 Ok(Some(output))
358 })
359}
360
361pub fn collect_buffer_diagnostics(
362 output: &mut SlashCommandOutput,
363 snapshot: &BufferSnapshot,
364 include_warnings: bool,
365) {
366 for (_, group) in snapshot.diagnostic_groups(None) {
367 let entry = &group.entries[group.primary_ix];
368 collect_diagnostic(output, entry, snapshot, include_warnings)
369 }
370}
371
372fn collect_diagnostic(
373 output: &mut SlashCommandOutput,
374 entry: &DiagnosticEntry<Anchor>,
375 snapshot: &BufferSnapshot,
376 include_warnings: bool,
377) {
378 const EXCERPT_EXPANSION_SIZE: u32 = 2;
379 const MAX_MESSAGE_LENGTH: usize = 2000;
380
381 let (ty, icon) = match entry.diagnostic.severity {
382 DiagnosticSeverity::WARNING => {
383 if !include_warnings {
384 return;
385 }
386 ("warning", IconName::Warning)
387 }
388 DiagnosticSeverity::ERROR => ("error", IconName::XCircle),
389 _ => return,
390 };
391 let prev_len = output.text.len();
392
393 let range = entry.range.to_point(snapshot);
394 let diagnostic_row_number = range.start.row + 1;
395
396 let start_row = range.start.row.saturating_sub(EXCERPT_EXPANSION_SIZE);
397 let end_row = (range.end.row + EXCERPT_EXPANSION_SIZE).min(snapshot.max_point().row) + 1;
398 let excerpt_range =
399 Point::new(start_row, 0).to_offset(snapshot)..Point::new(end_row, 0).to_offset(snapshot);
400
401 output.text.push_str("```");
402 if let Some(language_name) = snapshot.language().map(|l| l.code_fence_block_name()) {
403 output.text.push_str(&language_name);
404 }
405 output.text.push('\n');
406
407 let mut buffer_text = String::new();
408 for chunk in snapshot.text_for_range(excerpt_range) {
409 buffer_text.push_str(chunk);
410 }
411
412 for (i, line) in buffer_text.lines().enumerate() {
413 let line_number = start_row + i as u32 + 1;
414 writeln!(output.text, "{}", line).unwrap();
415
416 if line_number == diagnostic_row_number {
417 output.text.push_str("//");
418 let prev_len = output.text.len();
419 write!(output.text, " {}: ", ty).unwrap();
420 let padding = output.text.len() - prev_len;
421
422 let message = util::truncate(&entry.diagnostic.message, MAX_MESSAGE_LENGTH)
423 .replace('\n', format!("\n//{:padding$}", "").as_str());
424
425 writeln!(output.text, "{message}").unwrap();
426 }
427 }
428
429 writeln!(output.text, "```").unwrap();
430 output.sections.push(SlashCommandOutputSection {
431 range: prev_len..output.text.len().saturating_sub(1),
432 icon,
433 label: entry.diagnostic.message.clone().into(),
434 metadata: None,
435 });
436}