1use anyhow::Result;
2use gpui::{App, AppContext as _, Entity, Task};
3use language::{Anchor, BufferSnapshot, DiagnosticEntryRef, DiagnosticSeverity, ToOffset};
4use project::{DiagnosticSummary, Project};
5use rope::Point;
6use std::{fmt::Write, ops::RangeInclusive, path::Path};
7use text::OffsetRangeExt;
8use util::ResultExt;
9use util::paths::PathMatcher;
10
11pub fn codeblock_fence_for_path(
12 path: Option<&str>,
13 row_range: Option<RangeInclusive<u32>>,
14) -> String {
15 let mut text = String::new();
16 write!(text, "```").unwrap();
17
18 if let Some(path) = path {
19 if let Some(extension) = Path::new(path).extension().and_then(|ext| ext.to_str()) {
20 write!(text, "{} ", extension).unwrap();
21 }
22
23 write!(text, "{path}").unwrap();
24 } else {
25 write!(text, "untitled").unwrap();
26 }
27
28 if let Some(row_range) = row_range {
29 write!(text, ":{}-{}", row_range.start() + 1, row_range.end() + 1).unwrap();
30 }
31
32 text.push('\n');
33 text
34}
35
36pub struct DiagnosticsOptions {
37 pub include_errors: bool,
38 pub include_warnings: bool,
39 pub path_matcher: Option<PathMatcher>,
40}
41
42/// Collects project diagnostics into a formatted string.
43///
44/// Returns `None` if no matching diagnostics were found.
45pub fn collect_diagnostics(
46 project: Entity<Project>,
47 options: DiagnosticsOptions,
48 cx: &mut App,
49) -> Task<Result<Option<String>>> {
50 let path_style = project.read(cx).path_style(cx);
51 let glob_is_exact_file_match = if let Some(path) = options
52 .path_matcher
53 .as_ref()
54 .and_then(|pm| pm.sources().next())
55 {
56 project
57 .read(cx)
58 .find_project_path(Path::new(path), cx)
59 .is_some()
60 } else {
61 false
62 };
63
64 let project_handle = project.downgrade();
65 let diagnostic_summaries: Vec<_> = project
66 .read(cx)
67 .diagnostic_summaries(false, cx)
68 .flat_map(|(path, _, summary)| {
69 let worktree = project.read(cx).worktree_for_id(path.worktree_id, cx)?;
70 let full_path = worktree.read(cx).root_name().join(&path.path);
71 Some((path, full_path, summary))
72 })
73 .collect();
74
75 cx.spawn(async move |cx| {
76 let error_source = if let Some(path_matcher) = &options.path_matcher {
77 debug_assert_eq!(path_matcher.sources().count(), 1);
78 Some(path_matcher.sources().next().unwrap_or_default())
79 } else {
80 None
81 };
82
83 let mut text = String::new();
84 if let Some(error_source) = error_source.as_ref() {
85 writeln!(text, "diagnostics: {}", error_source).unwrap();
86 } else {
87 writeln!(text, "diagnostics").unwrap();
88 }
89
90 let mut found_any_diagnostics = false;
91 let mut project_summary = DiagnosticSummary::default();
92 for (project_path, path, summary) in diagnostic_summaries {
93 if let Some(path_matcher) = &options.path_matcher
94 && !path_matcher.is_match(&path)
95 {
96 continue;
97 }
98
99 let has_errors = options.include_errors && summary.error_count > 0;
100 let has_warnings = options.include_warnings && summary.warning_count > 0;
101 if !has_errors && !has_warnings {
102 continue;
103 }
104
105 if options.include_errors {
106 project_summary.error_count += summary.error_count;
107 }
108 if options.include_warnings {
109 project_summary.warning_count += summary.warning_count;
110 }
111
112 let file_path = path.display(path_style).to_string();
113 if !glob_is_exact_file_match {
114 writeln!(&mut text, "{file_path}").unwrap();
115 }
116
117 if let Some(buffer) = project_handle
118 .update(cx, |project, cx| project.open_buffer(project_path, cx))?
119 .await
120 .log_err()
121 {
122 let snapshot = cx.read_entity(&buffer, |buffer, _| buffer.snapshot());
123 if collect_buffer_diagnostics(
124 &mut text,
125 &snapshot,
126 options.include_warnings,
127 options.include_errors,
128 ) {
129 found_any_diagnostics = true;
130 }
131 }
132 }
133
134 if !found_any_diagnostics {
135 return Ok(None);
136 }
137
138 let mut label = String::new();
139 label.push_str("Diagnostics");
140 if let Some(source) = error_source {
141 write!(label, " ({})", source).unwrap();
142 }
143
144 if project_summary.error_count > 0 || project_summary.warning_count > 0 {
145 label.push(':');
146
147 if project_summary.error_count > 0 {
148 write!(label, " {} errors", project_summary.error_count).unwrap();
149 if project_summary.warning_count > 0 {
150 label.push(',');
151 }
152 }
153
154 if project_summary.warning_count > 0 {
155 write!(label, " {} warnings", project_summary.warning_count).unwrap();
156 }
157 }
158
159 // Prepend the summary label to the output.
160 text.insert_str(0, &format!("{label}\n"));
161
162 Ok(Some(text))
163 })
164}
165
166/// Collects diagnostics from a buffer snapshot into the text output.
167///
168/// Returns `true` if any diagnostics were written.
169fn collect_buffer_diagnostics(
170 text: &mut String,
171 snapshot: &BufferSnapshot,
172 include_warnings: bool,
173 include_errors: bool,
174) -> bool {
175 let mut found_any = false;
176 for (_, group) in snapshot.diagnostic_groups(None) {
177 let entry = &group.entries[group.primary_ix];
178 if collect_diagnostic(text, entry, snapshot, include_warnings, include_errors) {
179 found_any = true;
180 }
181 }
182 found_any
183}
184
185/// Formats a single diagnostic entry as a code excerpt with the diagnostic message.
186///
187/// Returns `true` if the diagnostic was written (i.e. it matched severity filters).
188fn collect_diagnostic(
189 text: &mut String,
190 entry: &DiagnosticEntryRef<'_, Anchor>,
191 snapshot: &BufferSnapshot,
192 include_warnings: bool,
193 include_errors: bool,
194) -> bool {
195 const EXCERPT_EXPANSION_SIZE: u32 = 2;
196 const MAX_MESSAGE_LENGTH: usize = 2000;
197
198 let ty = match entry.diagnostic.severity {
199 DiagnosticSeverity::WARNING => {
200 if !include_warnings {
201 return false;
202 }
203 "warning"
204 }
205 DiagnosticSeverity::ERROR => {
206 if !include_errors {
207 return false;
208 }
209 "error"
210 }
211 _ => return false,
212 };
213
214 let range = entry.range.to_point(snapshot);
215 let diagnostic_row_number = range.start.row + 1;
216
217 let start_row = range.start.row.saturating_sub(EXCERPT_EXPANSION_SIZE);
218 let end_row = (range.end.row + EXCERPT_EXPANSION_SIZE).min(snapshot.max_point().row) + 1;
219 let excerpt_range =
220 Point::new(start_row, 0).to_offset(snapshot)..Point::new(end_row, 0).to_offset(snapshot);
221
222 text.push_str("```");
223 if let Some(language_name) = snapshot.language().map(|l| l.code_fence_block_name()) {
224 text.push_str(&language_name);
225 }
226 text.push('\n');
227
228 let mut buffer_text = String::new();
229 for chunk in snapshot.text_for_range(excerpt_range) {
230 buffer_text.push_str(chunk);
231 }
232
233 for (i, line) in buffer_text.lines().enumerate() {
234 let line_number = start_row + i as u32 + 1;
235 writeln!(text, "{}", line).unwrap();
236
237 if line_number == diagnostic_row_number {
238 text.push_str("//");
239 let marker_start = text.len();
240 write!(text, " {}: ", ty).unwrap();
241 let padding = text.len() - marker_start;
242
243 let message = util::truncate(&entry.diagnostic.message, MAX_MESSAGE_LENGTH)
244 .replace('\n', format!("\n//{:padding$}", "").as_str());
245
246 writeln!(text, "{message}").unwrap();
247 }
248 }
249
250 writeln!(text, "```").unwrap();
251 true
252}