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 pub 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).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 Some(path_matcher.source().to_string())
259 } else {
260 None
261 };
262
263 let project_handle = project.downgrade();
264 let diagnostic_summaries: Vec<_> = project.read(cx).diagnostic_summaries(false, cx).collect();
265
266 cx.spawn(|mut cx| async move {
267 let mut text = String::new();
268 if let Some(error_source) = error_source.as_ref() {
269 writeln!(text, "diagnostics: {}", error_source).unwrap();
270 } else {
271 writeln!(text, "diagnostics").unwrap();
272 }
273 let mut sections: Vec<(Range<usize>, PlaceholderType)> = Vec::new();
274
275 let mut project_summary = DiagnosticSummary::default();
276 for (project_path, _, summary) in diagnostic_summaries {
277 if let Some(path_matcher) = &options.path_matcher {
278 if !path_matcher.is_match(&project_path.path) {
279 continue;
280 }
281 }
282
283 project_summary.error_count += summary.error_count;
284 if options.include_warnings {
285 project_summary.warning_count += summary.warning_count;
286 } else if summary.error_count == 0 {
287 continue;
288 }
289
290 let last_end = text.len();
291 let file_path = project_path.path.to_string_lossy().to_string();
292 writeln!(&mut text, "{file_path}").unwrap();
293
294 if let Some(buffer) = project_handle
295 .update(&mut cx, |project, cx| project.open_buffer(project_path, cx))?
296 .await
297 .log_err()
298 {
299 collect_buffer_diagnostics(
300 &mut text,
301 &mut sections,
302 cx.read_model(&buffer, |buffer, _| buffer.snapshot())?,
303 options.include_warnings,
304 );
305 }
306
307 sections.push((
308 last_end..text.len().saturating_sub(1),
309 PlaceholderType::File(file_path),
310 ))
311 }
312 sections.push((
313 0..text.len(),
314 PlaceholderType::Root(project_summary, error_source),
315 ));
316
317 Ok((text, sections))
318 })
319}
320
321fn collect_buffer_diagnostics(
322 text: &mut String,
323 sections: &mut Vec<(Range<usize>, PlaceholderType)>,
324 snapshot: BufferSnapshot,
325 include_warnings: bool,
326) {
327 for (_, group) in snapshot.diagnostic_groups(None) {
328 let entry = &group.entries[group.primary_ix];
329 collect_diagnostic(text, sections, entry, &snapshot, include_warnings)
330 }
331}
332
333fn collect_diagnostic(
334 text: &mut String,
335 sections: &mut Vec<(Range<usize>, PlaceholderType)>,
336 entry: &DiagnosticEntry<Anchor>,
337 snapshot: &BufferSnapshot,
338 include_warnings: bool,
339) {
340 const EXCERPT_EXPANSION_SIZE: u32 = 2;
341 const MAX_MESSAGE_LENGTH: usize = 2000;
342
343 let ty = match entry.diagnostic.severity {
344 DiagnosticSeverity::WARNING => {
345 if !include_warnings {
346 return;
347 }
348 DiagnosticType::Warning
349 }
350 DiagnosticSeverity::ERROR => DiagnosticType::Error,
351 _ => return,
352 };
353 let prev_len = text.len();
354
355 let range = entry.range.to_point(snapshot);
356 let diagnostic_row_number = range.start.row + 1;
357
358 let start_row = range.start.row.saturating_sub(EXCERPT_EXPANSION_SIZE);
359 let end_row = (range.end.row + EXCERPT_EXPANSION_SIZE).min(snapshot.max_point().row) + 1;
360 let excerpt_range =
361 Point::new(start_row, 0).to_offset(&snapshot)..Point::new(end_row, 0).to_offset(&snapshot);
362
363 text.push_str("```");
364 if let Some(language_name) = snapshot.language().map(|l| l.code_fence_block_name()) {
365 text.push_str(&language_name);
366 }
367 text.push('\n');
368
369 let mut buffer_text = String::new();
370 for chunk in snapshot.text_for_range(excerpt_range) {
371 buffer_text.push_str(chunk);
372 }
373
374 for (i, line) in buffer_text.lines().enumerate() {
375 let line_number = start_row + i as u32 + 1;
376 writeln!(text, "{}", line).unwrap();
377
378 if line_number == diagnostic_row_number {
379 text.push_str("//");
380 let prev_len = text.len();
381 write!(text, " {}: ", ty.as_str()).unwrap();
382 let padding = text.len() - prev_len;
383
384 let message = util::truncate(&entry.diagnostic.message, MAX_MESSAGE_LENGTH)
385 .replace('\n', format!("\n//{:padding$}", "").as_str());
386
387 writeln!(text, "{message}").unwrap();
388 }
389 }
390
391 writeln!(text, "```").unwrap();
392 sections.push((
393 prev_len..text.len().saturating_sub(1),
394 PlaceholderType::Diagnostic(ty, entry.diagnostic.message.clone()),
395 ))
396}
397
398#[derive(Clone)]
399pub enum PlaceholderType {
400 Root(DiagnosticSummary, Option<String>),
401 File(String),
402 Diagnostic(DiagnosticType, String),
403}
404
405#[derive(Copy, Clone)]
406pub enum DiagnosticType {
407 Warning,
408 Error,
409}
410
411impl DiagnosticType {
412 pub fn as_str(&self) -> &'static str {
413 match self {
414 DiagnosticType::Warning => "warning",
415 DiagnosticType::Error => "error",
416 }
417 }
418}