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