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