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