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