1use std::{
2 path::{Component, Path, Prefix},
3 process::Stdio,
4 sync::atomic::{self, AtomicUsize},
5};
6
7use cargo_metadata::{
8 Message,
9 diagnostic::{Applicability, Diagnostic as CargoDiagnostic, DiagnosticLevel, DiagnosticSpan},
10};
11use collections::HashMap;
12use gpui::{AppContext, Entity, Task};
13use itertools::Itertools as _;
14use language::Diagnostic;
15use project::{
16 Worktree, lsp_store::rust_analyzer_ext::CARGO_DIAGNOSTICS_SOURCE_NAME,
17 project_settings::ProjectSettings,
18};
19use serde::{Deserialize, Serialize};
20use settings::Settings;
21use smol::{
22 channel::Receiver,
23 io::{AsyncBufReadExt, BufReader},
24 process::Command,
25};
26use ui::App;
27use util::ResultExt;
28
29use crate::ProjectDiagnosticsEditor;
30
31#[derive(Debug, serde::Deserialize)]
32#[serde(untagged)]
33enum CargoMessage {
34 Cargo(Message),
35 Rustc(CargoDiagnostic),
36}
37
38/// Appends formatted string to a `String`.
39macro_rules! format_to {
40 ($buf:expr) => ();
41 ($buf:expr, $lit:literal $($arg:tt)*) => {
42 {
43 use ::std::fmt::Write as _;
44 // We can't do ::std::fmt::Write::write_fmt($buf, format_args!($lit $($arg)*))
45 // unfortunately, as that loses out on autoref behavior.
46 _ = $buf.write_fmt(format_args!($lit $($arg)*))
47 }
48 };
49}
50
51pub fn cargo_diagnostics_sources(
52 editor: &ProjectDiagnosticsEditor,
53 cx: &App,
54) -> Vec<Entity<Worktree>> {
55 let fetch_cargo_diagnostics = ProjectSettings::get_global(cx)
56 .diagnostics
57 .fetch_cargo_diagnostics();
58 if !fetch_cargo_diagnostics {
59 return Vec::new();
60 }
61 editor
62 .project
63 .read(cx)
64 .worktrees(cx)
65 .filter(|worktree| worktree.read(cx).entry_for_path("Cargo.toml").is_some())
66 .collect()
67}
68
69#[derive(Debug)]
70pub enum FetchUpdate {
71 Diagnostic(CargoDiagnostic),
72 Progress(String),
73}
74
75#[derive(Debug)]
76pub enum FetchStatus {
77 Started,
78 Progress { message: String },
79 Finished,
80}
81
82pub fn fetch_worktree_diagnostics(
83 worktree_root: &Path,
84 cx: &App,
85) -> Option<(Task<()>, Receiver<FetchUpdate>)> {
86 let diagnostics_settings = ProjectSettings::get_global(cx)
87 .diagnostics
88 .cargo
89 .as_ref()
90 .filter(|cargo_diagnostics| cargo_diagnostics.fetch_cargo_diagnostics)?;
91 let command_string = diagnostics_settings
92 .diagnostics_fetch_command
93 .iter()
94 .join(" ");
95 let mut command_parts = diagnostics_settings.diagnostics_fetch_command.iter();
96 let mut command = Command::new(command_parts.next()?)
97 .args(command_parts)
98 .envs(diagnostics_settings.env.clone())
99 .current_dir(worktree_root)
100 .stdout(Stdio::piped())
101 .stderr(Stdio::null())
102 .kill_on_drop(true)
103 .spawn()
104 .log_err()?;
105
106 let stdout = command.stdout.take()?;
107 let mut reader = BufReader::new(stdout);
108 let (tx, rx) = smol::channel::unbounded();
109 let error_threshold = 10;
110
111 let cargo_diagnostics_fetch_task = cx.background_spawn(async move {
112 let _command = command;
113 let mut errors = 0;
114 loop {
115 let mut line = String::new();
116 match reader.read_line(&mut line).await {
117 Ok(0) => {
118 return;
119 },
120 Ok(_) => {
121 errors = 0;
122 let mut deserializer = serde_json::Deserializer::from_str(&line);
123 deserializer.disable_recursion_limit();
124 let send_result = match CargoMessage::deserialize(&mut deserializer) {
125 Ok(CargoMessage::Cargo(Message::CompilerMessage(message))) => tx.send(FetchUpdate::Diagnostic(message.message)).await,
126 Ok(CargoMessage::Cargo(Message::CompilerArtifact(artifact))) => tx.send(FetchUpdate::Progress(format!("Compiled {:?}", artifact.manifest_path.parent().unwrap_or(&artifact.manifest_path)))).await,
127 Ok(CargoMessage::Cargo(_)) => Ok(()),
128 Ok(CargoMessage::Rustc(rustc_message)) => tx.send(FetchUpdate::Diagnostic(rustc_message)).await,
129 Err(_) => {
130 log::debug!("Failed to parse cargo diagnostics from line '{line}'");
131 Ok(())
132 },
133 };
134 if send_result.is_err() {
135 return;
136 }
137 },
138 Err(e) => {
139 log::error!("Failed to read line from {command_string} command output when fetching cargo diagnostics: {e}");
140 errors += 1;
141 if errors >= error_threshold {
142 log::error!("Failed {error_threshold} times, aborting the diagnostics fetch");
143 return;
144 }
145 },
146 }
147 }
148 });
149
150 Some((cargo_diagnostics_fetch_task, rx))
151}
152
153static CARGO_DIAGNOSTICS_FETCH_GENERATION: AtomicUsize = AtomicUsize::new(0);
154
155#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
156struct CargoFetchDiagnosticData {
157 generation: usize,
158}
159
160pub fn next_cargo_fetch_generation() {
161 CARGO_DIAGNOSTICS_FETCH_GENERATION.fetch_add(1, atomic::Ordering::Release);
162}
163
164pub fn is_outdated_cargo_fetch_diagnostic(diagnostic: &Diagnostic) -> bool {
165 if let Some(data) = diagnostic
166 .data
167 .clone()
168 .and_then(|data| serde_json::from_value::<CargoFetchDiagnosticData>(data).ok())
169 {
170 let current_generation = CARGO_DIAGNOSTICS_FETCH_GENERATION.load(atomic::Ordering::Acquire);
171 data.generation < current_generation
172 } else {
173 false
174 }
175}
176
177/// Converts a Rust root diagnostic to LSP form
178///
179/// This flattens the Rust diagnostic by:
180///
181/// 1. Creating a LSP diagnostic with the root message and primary span.
182/// 2. Adding any labelled secondary spans to `relatedInformation`
183/// 3. Categorising child diagnostics as either `SuggestedFix`es,
184/// `relatedInformation` or additional message lines.
185///
186/// If the diagnostic has no primary span this will return `None`
187///
188/// Taken from https://github.com/rust-lang/rust-analyzer/blob/fe7b4f2ad96f7c13cc571f45edc2c578b35dddb4/crates/rust-analyzer/src/diagnostics/to_proto.rs#L275-L285
189pub(crate) fn map_rust_diagnostic_to_lsp(
190 worktree_root: &Path,
191 cargo_diagnostic: &CargoDiagnostic,
192) -> Vec<(lsp::Url, lsp::Diagnostic)> {
193 let primary_spans: Vec<&DiagnosticSpan> = cargo_diagnostic
194 .spans
195 .iter()
196 .filter(|s| s.is_primary)
197 .collect();
198 if primary_spans.is_empty() {
199 return Vec::new();
200 }
201
202 let severity = diagnostic_severity(cargo_diagnostic.level);
203
204 let mut source = String::from(CARGO_DIAGNOSTICS_SOURCE_NAME);
205 let mut code = cargo_diagnostic.code.as_ref().map(|c| c.code.clone());
206
207 if let Some(code_val) = &code {
208 // See if this is an RFC #2103 scoped lint (e.g. from Clippy)
209 let scoped_code: Vec<&str> = code_val.split("::").collect();
210 if scoped_code.len() == 2 {
211 source = String::from(scoped_code[0]);
212 code = Some(String::from(scoped_code[1]));
213 }
214 }
215
216 let mut needs_primary_span_label = true;
217 let mut subdiagnostics = Vec::new();
218 let mut tags = Vec::new();
219
220 for secondary_span in cargo_diagnostic.spans.iter().filter(|s| !s.is_primary) {
221 if let Some(label) = secondary_span.label.clone() {
222 subdiagnostics.push(lsp::DiagnosticRelatedInformation {
223 location: location(worktree_root, secondary_span),
224 message: label,
225 });
226 }
227 }
228
229 let mut message = cargo_diagnostic.message.clone();
230 for child in &cargo_diagnostic.children {
231 let child = map_rust_child_diagnostic(worktree_root, child);
232 match child {
233 MappedRustChildDiagnostic::SubDiagnostic(sub) => {
234 subdiagnostics.push(sub);
235 }
236 MappedRustChildDiagnostic::MessageLine(message_line) => {
237 format_to!(message, "\n{message_line}");
238
239 // These secondary messages usually duplicate the content of the
240 // primary span label.
241 needs_primary_span_label = false;
242 }
243 }
244 }
245
246 if let Some(code) = &cargo_diagnostic.code {
247 let code = code.code.as_str();
248 if matches!(
249 code,
250 "dead_code"
251 | "unknown_lints"
252 | "unreachable_code"
253 | "unused_attributes"
254 | "unused_imports"
255 | "unused_macros"
256 | "unused_variables"
257 ) {
258 tags.push(lsp::DiagnosticTag::UNNECESSARY);
259 }
260
261 if matches!(code, "deprecated") {
262 tags.push(lsp::DiagnosticTag::DEPRECATED);
263 }
264 }
265
266 let code_description = match source.as_str() {
267 "rustc" => rustc_code_description(code.as_deref()),
268 "clippy" => clippy_code_description(code.as_deref()),
269 _ => None,
270 };
271
272 let generation = CARGO_DIAGNOSTICS_FETCH_GENERATION.load(atomic::Ordering::Acquire);
273 let data = Some(
274 serde_json::to_value(CargoFetchDiagnosticData { generation })
275 .expect("Serializing a regular Rust struct"),
276 );
277
278 primary_spans
279 .iter()
280 .flat_map(|primary_span| {
281 let primary_location = primary_location(worktree_root, primary_span);
282 let message = {
283 let mut message = message.clone();
284 if needs_primary_span_label {
285 if let Some(primary_span_label) = &primary_span.label {
286 format_to!(message, "\n{primary_span_label}");
287 }
288 }
289 message
290 };
291 // Each primary diagnostic span may result in multiple LSP diagnostics.
292 let mut diagnostics = Vec::new();
293
294 let mut related_info_macro_calls = vec![];
295
296 // If error occurs from macro expansion, add related info pointing to
297 // where the error originated
298 // Also, we would generate an additional diagnostic, so that exact place of macro
299 // will be highlighted in the error origin place.
300 let span_stack = std::iter::successors(Some(*primary_span), |span| {
301 Some(&span.expansion.as_ref()?.span)
302 });
303 for (i, span) in span_stack.enumerate() {
304 if is_dummy_macro_file(&span.file_name) {
305 continue;
306 }
307
308 // First span is the original diagnostic, others are macro call locations that
309 // generated that code.
310 let is_in_macro_call = i != 0;
311
312 let secondary_location = location(worktree_root, span);
313 if secondary_location == primary_location {
314 continue;
315 }
316 related_info_macro_calls.push(lsp::DiagnosticRelatedInformation {
317 location: secondary_location.clone(),
318 message: if is_in_macro_call {
319 "Error originated from macro call here".to_owned()
320 } else {
321 "Actual error occurred here".to_owned()
322 },
323 });
324 // For the additional in-macro diagnostic we add the inverse message pointing to the error location in code.
325 let information_for_additional_diagnostic =
326 vec![lsp::DiagnosticRelatedInformation {
327 location: primary_location.clone(),
328 message: "Exact error occurred here".to_owned(),
329 }];
330
331 let diagnostic = lsp::Diagnostic {
332 range: secondary_location.range,
333 // downgrade to hint if we're pointing at the macro
334 severity: Some(lsp::DiagnosticSeverity::HINT),
335 code: code.clone().map(lsp::NumberOrString::String),
336 code_description: code_description.clone(),
337 source: Some(source.clone()),
338 message: message.clone(),
339 related_information: Some(information_for_additional_diagnostic),
340 tags: if tags.is_empty() {
341 None
342 } else {
343 Some(tags.clone())
344 },
345 data: data.clone(),
346 };
347 diagnostics.push((secondary_location.uri, diagnostic));
348 }
349
350 // Emit the primary diagnostic.
351 diagnostics.push((
352 primary_location.uri.clone(),
353 lsp::Diagnostic {
354 range: primary_location.range,
355 severity,
356 code: code.clone().map(lsp::NumberOrString::String),
357 code_description: code_description.clone(),
358 source: Some(source.clone()),
359 message,
360 related_information: {
361 let info = related_info_macro_calls
362 .iter()
363 .cloned()
364 .chain(subdiagnostics.iter().cloned())
365 .collect::<Vec<_>>();
366 if info.is_empty() { None } else { Some(info) }
367 },
368 tags: if tags.is_empty() {
369 None
370 } else {
371 Some(tags.clone())
372 },
373 data: data.clone(),
374 },
375 ));
376
377 // Emit hint-level diagnostics for all `related_information` entries such as "help"s.
378 // This is useful because they will show up in the user's editor, unlike
379 // `related_information`, which just produces hard-to-read links, at least in VS Code.
380 let back_ref = lsp::DiagnosticRelatedInformation {
381 location: primary_location,
382 message: "original diagnostic".to_owned(),
383 };
384 for sub in &subdiagnostics {
385 diagnostics.push((
386 sub.location.uri.clone(),
387 lsp::Diagnostic {
388 range: sub.location.range,
389 severity: Some(lsp::DiagnosticSeverity::HINT),
390 code: code.clone().map(lsp::NumberOrString::String),
391 code_description: code_description.clone(),
392 source: Some(source.clone()),
393 message: sub.message.clone(),
394 related_information: Some(vec![back_ref.clone()]),
395 tags: None, // don't apply modifiers again
396 data: data.clone(),
397 },
398 ));
399 }
400
401 diagnostics
402 })
403 .collect()
404}
405
406fn rustc_code_description(code: Option<&str>) -> Option<lsp::CodeDescription> {
407 code.filter(|code| {
408 let mut chars = code.chars();
409 chars.next() == Some('E')
410 && chars.by_ref().take(4).all(|c| c.is_ascii_digit())
411 && chars.next().is_none()
412 })
413 .and_then(|code| {
414 lsp::Url::parse(&format!(
415 "https://doc.rust-lang.org/error-index.html#{code}"
416 ))
417 .ok()
418 .map(|href| lsp::CodeDescription { href })
419 })
420}
421
422fn clippy_code_description(code: Option<&str>) -> Option<lsp::CodeDescription> {
423 code.and_then(|code| {
424 lsp::Url::parse(&format!(
425 "https://rust-lang.github.io/rust-clippy/master/index.html#{code}"
426 ))
427 .ok()
428 .map(|href| lsp::CodeDescription { href })
429 })
430}
431
432/// Determines the LSP severity from a diagnostic
433fn diagnostic_severity(level: DiagnosticLevel) -> Option<lsp::DiagnosticSeverity> {
434 let res = match level {
435 DiagnosticLevel::Ice => lsp::DiagnosticSeverity::ERROR,
436 DiagnosticLevel::Error => lsp::DiagnosticSeverity::ERROR,
437 DiagnosticLevel::Warning => lsp::DiagnosticSeverity::WARNING,
438 DiagnosticLevel::Note => lsp::DiagnosticSeverity::INFORMATION,
439 DiagnosticLevel::Help => lsp::DiagnosticSeverity::HINT,
440 _ => return None,
441 };
442 Some(res)
443}
444
445enum MappedRustChildDiagnostic {
446 SubDiagnostic(lsp::DiagnosticRelatedInformation),
447 MessageLine(String),
448}
449
450fn map_rust_child_diagnostic(
451 worktree_root: &Path,
452 cargo_diagnostic: &CargoDiagnostic,
453) -> MappedRustChildDiagnostic {
454 let spans: Vec<&DiagnosticSpan> = cargo_diagnostic
455 .spans
456 .iter()
457 .filter(|s| s.is_primary)
458 .collect();
459 if spans.is_empty() {
460 // `rustc` uses these spanless children as a way to print multi-line
461 // messages
462 return MappedRustChildDiagnostic::MessageLine(cargo_diagnostic.message.clone());
463 }
464
465 let mut edit_map: HashMap<lsp::Url, Vec<lsp::TextEdit>> = HashMap::default();
466 let mut suggested_replacements = Vec::new();
467 for &span in &spans {
468 if let Some(suggested_replacement) = &span.suggested_replacement {
469 if !suggested_replacement.is_empty() {
470 suggested_replacements.push(suggested_replacement);
471 }
472 let location = location(worktree_root, span);
473 let edit = lsp::TextEdit::new(location.range, suggested_replacement.clone());
474
475 // Only actually emit a quickfix if the suggestion is "valid enough".
476 // We accept both "MaybeIncorrect" and "MachineApplicable". "MaybeIncorrect" means that
477 // the suggestion is *complete* (contains no placeholders where code needs to be
478 // inserted), but might not be what the user wants, or might need minor adjustments.
479 if matches!(
480 span.suggestion_applicability,
481 None | Some(Applicability::MaybeIncorrect | Applicability::MachineApplicable)
482 ) {
483 edit_map.entry(location.uri).or_default().push(edit);
484 }
485 }
486 }
487
488 // rustc renders suggestion diagnostics by appending the suggested replacement, so do the same
489 // here, otherwise the diagnostic text is missing useful information.
490 let mut message = cargo_diagnostic.message.clone();
491 if !suggested_replacements.is_empty() {
492 message.push_str(": ");
493 let suggestions = suggested_replacements
494 .iter()
495 .map(|suggestion| format!("`{suggestion}`"))
496 .join(", ");
497 message.push_str(&suggestions);
498 }
499
500 MappedRustChildDiagnostic::SubDiagnostic(lsp::DiagnosticRelatedInformation {
501 location: location(worktree_root, spans[0]),
502 message,
503 })
504}
505
506/// Converts a Rust span to a LSP location
507fn location(worktree_root: &Path, span: &DiagnosticSpan) -> lsp::Location {
508 let file_name = worktree_root.join(&span.file_name);
509 let uri = url_from_abs_path(&file_name);
510
511 let range = {
512 lsp::Range::new(
513 position(span, span.line_start, span.column_start.saturating_sub(1)),
514 position(span, span.line_end, span.column_end.saturating_sub(1)),
515 )
516 };
517 lsp::Location::new(uri, range)
518}
519
520/// Returns a `Url` object from a given path, will lowercase drive letters if present.
521/// This will only happen when processing windows paths.
522///
523/// When processing non-windows path, this is essentially the same as `Url::from_file_path`.
524pub(crate) fn url_from_abs_path(path: &Path) -> lsp::Url {
525 let url = lsp::Url::from_file_path(path).unwrap();
526 match path.components().next() {
527 Some(Component::Prefix(prefix))
528 if matches!(prefix.kind(), Prefix::Disk(_) | Prefix::VerbatimDisk(_)) =>
529 {
530 // Need to lowercase driver letter
531 }
532 _ => return url,
533 }
534
535 let driver_letter_range = {
536 let (scheme, drive_letter, _rest) = match url.as_str().splitn(3, ':').collect_tuple() {
537 Some(it) => it,
538 None => return url,
539 };
540 let start = scheme.len() + ':'.len_utf8();
541 start..(start + drive_letter.len())
542 };
543
544 // Note: lowercasing the `path` itself doesn't help, the `Url::parse`
545 // machinery *also* canonicalizes the drive letter. So, just massage the
546 // string in place.
547 let mut url: String = url.into();
548 url[driver_letter_range].make_ascii_lowercase();
549 lsp::Url::parse(&url).unwrap()
550}
551
552fn position(
553 span: &DiagnosticSpan,
554 line_number: usize,
555 column_offset_utf32: usize,
556) -> lsp::Position {
557 let line_index = line_number - span.line_start;
558
559 let column_offset_encoded = match span.text.get(line_index) {
560 // Fast path.
561 Some(line) if line.text.is_ascii() => column_offset_utf32,
562 Some(line) => {
563 let line_prefix_len = line
564 .text
565 .char_indices()
566 .take(column_offset_utf32)
567 .last()
568 .map(|(pos, c)| pos + c.len_utf8())
569 .unwrap_or(0);
570 let line_prefix = &line.text[..line_prefix_len];
571 line_prefix.len()
572 }
573 None => column_offset_utf32,
574 };
575
576 lsp::Position {
577 line: (line_number as u32).saturating_sub(1),
578 character: column_offset_encoded as u32,
579 }
580}
581
582/// Checks whether a file name is from macro invocation and does not refer to an actual file.
583fn is_dummy_macro_file(file_name: &str) -> bool {
584 file_name.starts_with('<') && file_name.ends_with('>')
585}
586
587/// Extracts a suitable "primary" location from a rustc diagnostic.
588///
589/// This takes locations pointing into the standard library, or generally outside the current
590/// workspace into account and tries to avoid those, in case macros are involved.
591fn primary_location(worktree_root: &Path, span: &DiagnosticSpan) -> lsp::Location {
592 let span_stack = std::iter::successors(Some(span), |span| Some(&span.expansion.as_ref()?.span));
593 for span in span_stack.clone() {
594 let abs_path = worktree_root.join(&span.file_name);
595 if !is_dummy_macro_file(&span.file_name) && abs_path.starts_with(worktree_root) {
596 return location(worktree_root, span);
597 }
598 }
599
600 // Fall back to the outermost macro invocation if no suitable span comes up.
601 let last_span = span_stack.last().unwrap();
602 location(worktree_root, last_span)
603}