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