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