diagnostics.rs

  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}