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