use std::{
env,
path::{Path, PathBuf},
process::Stdio,
};
use anyhow::Context;
use ls_types::{
Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, DiagnosticTag, InitializeParams, Location, Position,
Range, Uri,
};
use serde::{Deserialize, Deserializer};
use tokio::io::{AsyncBufRead, AsyncBufReadExt};
use tokio::process::Command;
use tower_lsp_server::{Bounded, NotCancellable, OngoingProgress};
use crate::{BaconLs, Correction, CorrectionEdit, DiagnosticData, PKG_NAME, path_to_file_uri};
async fn read_until_cr_or_lf<R: AsyncBufRead + Unpin>(reader: &mut R, buf: &mut Vec<u8>) -> std::io::Result<usize> {
let mut total = 0;
loop {
let available = reader.fill_buf().await?;
if available.is_empty() {
return Ok(total);
}
if let Some(pos) = available.iter().position(|&b| b == b'\r' || b == b'\n') {
buf.extend_from_slice(&available[..pos]);
let consumed = pos + 1;
total += consumed;
reader.consume(consumed);
return Ok(total);
}
buf.extend_from_slice(available);
let len = available.len();
total += len;
reader.consume(len);
}
}
#[derive(Debug, Deserialize)]
struct CargoExpansion {
span: CargoSpan,
}
#[derive(Debug, Deserialize)]
struct CargoSpan {
#[serde(deserialize_with = "deserialize_url")]
file_name: Uri,
line_start: u32,
line_end: u32,
column_start: u32,
column_end: u32,
suggested_replacement: Option<String>,
suggestion_applicability: Option<String>,
#[serde(default)]
is_primary: bool,
#[serde(default)]
expansion: Option<Box<CargoExpansion>>,
}
impl CargoSpan {
fn is_machine_applicable(&self) -> bool {
self.suggestion_applicability.as_deref() == Some("MachineApplicable")
}
fn project_span(&self) -> &Self {
let is_relative = self
.file_name
.authority()
.map(|a| !a.host().is_empty())
.unwrap_or(false);
if is_relative {
return self;
}
if let Some(exp) = &self.expansion {
return exp.span.project_span();
}
self
}
}
fn deserialize_url<'de, D>(deserializer: D) -> Result<Uri, D::Error>
where
D: Deserializer<'de>,
{
let url_str: &str = Deserialize::deserialize(deserializer)?;
str::parse::<Uri>(&path_to_file_uri(url_str)).map_err(serde::de::Error::custom)
}
#[derive(Debug, Deserialize)]
struct CargoChildren {
message: String,
level: String,
spans: Vec<CargoSpan>,
}
#[derive(Debug, Deserialize)]
struct CargoCode {
code: String,
}
#[derive(Debug, Deserialize)]
struct CargoMessage {
#[serde(rename(deserialize = "$message_type"))]
message_type: String,
rendered: String,
level: String,
spans: Vec<CargoSpan>,
children: Vec<CargoChildren>,
#[serde(default)]
code: Option<CargoCode>,
}
fn tags_from_code(code: &str) -> Option<Vec<DiagnosticTag>> {
let mut tags = Vec::new();
if code.starts_with("unused_")
|| matches!(
code,
"dead_code" | "unreachable_code" | "unreachable_patterns" | "redundant_imports" | "redundant_semicolons"
)
{
tags.push(DiagnosticTag::UNNECESSARY);
}
if code == "deprecated" {
tags.push(DiagnosticTag::DEPRECATED);
}
if tags.is_empty() { None } else { Some(tags) }
}
#[derive(Debug, Deserialize)]
struct CargoLine {
message: Option<CargoMessage>,
}
#[derive(Debug, Default)]
pub(crate) struct Cargo;
fn parse_building_line(line: &str) -> Option<(String, u32)> {
if !line.starts_with("Building") {
return None;
}
let after_bracket = line.split("] ").nth(1)?;
let clean: String = after_bracket.chars().filter(|c| !c.is_control()).collect();
let (fraction, _crates) = clean.split_once(": ")?;
let (n_str, total_str) = fraction.split_once('/')?;
let n: u32 = n_str.parse().ok()?;
let total: u32 = total_str.parse().ok()?;
if total == 0 {
return None;
}
let pct = (n * 100 / total).min(99);
Some((clean, pct))
}
impl Cargo {
fn parse_severity(severity_str: &str) -> DiagnosticSeverity {
match severity_str {
"error" => DiagnosticSeverity::ERROR,
"warning" | "failure-note" => DiagnosticSeverity::WARNING,
"info" | "information" | "note" => DiagnosticSeverity::INFORMATION,
"hint" | "help" => DiagnosticSeverity::HINT,
other => {
tracing::warn!("unknown cargo severity level {other:?}, defaulting to INFORMATION");
DiagnosticSeverity::INFORMATION
}
}
}
fn span_to_uri(project_root: Option<&PathBuf>, span: &CargoSpan) -> anyhow::Result<Option<Uri>> {
let Some(host) = span.file_name.authority().map(|auth| auth.host()) else {
return Ok(None);
};
let root_dir = match project_root {
Some(r) => r.clone(),
None => env::current_dir().context("getting current dir")?,
};
tracing::trace!(?root_dir, ?host, file_name = ?span.file_name, "building uri");
let path = if host.is_empty() {
PathBuf::from(span.file_name.path().as_str())
} else {
let tmp = root_dir.join(host);
if span.file_name.path().as_str().is_empty() {
tmp
} else {
tmp.join(span.file_name.path().as_str().replacen("/", "", 1))
}
};
let canonical = match path.canonicalize() {
Ok(c) => c,
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"skipping diagnostic span: cannot canonicalize path"
);
return Ok(None);
}
};
let file_name = canonical
.into_os_string()
.into_string()
.map_err(|_orig| std::io::Error::other("cannot convert file name to string"))?;
Ok(Some(
str::parse::<Uri>(&path_to_file_uri(&file_name)).context("parsing filename")?,
))
}
async fn maybe_add_diagnostic(
project_root: Option<&PathBuf>,
message: &CargoMessage,
use_related_information: bool,
tx: &flume::Sender<(Uri, Diagnostic)>,
) -> anyhow::Result<bool> {
let rendered = ansi_regex::ansi_regex().replace_all(&message.rendered, "").into_owned();
let rendered = rendered.trim_end_matches('\n');
let tags = message.code.as_ref().and_then(|c| tags_from_code(&c.code));
let related_information = if use_related_information {
let info: Vec<DiagnosticRelatedInformation> = message
.children
.iter()
.flat_map(|child| {
child
.spans
.iter()
.filter(|s| s.is_primary)
.filter_map(|s| {
Self::span_to_uri(project_root, s.project_span())
.ok()
.flatten()
.map(|uri| DiagnosticRelatedInformation {
location: Location {
uri,
range: Range::new(
Position::new(
s.line_start.saturating_sub(1),
s.column_start.saturating_sub(1),
),
Position::new(s.line_end.saturating_sub(1), s.column_end.saturating_sub(1)),
),
},
message: child.message.clone(),
})
})
.collect::<Vec<_>>()
})
.collect();
if info.is_empty() { None } else { Some(info) }
} else {
None
};
let mut at_least_one = false;
for span in message.spans.iter().filter(|s| s.is_primary) {
let resolved = span.project_span();
let Some(url) = Self::span_to_uri(project_root, resolved)? else {
continue;
};
tracing::trace!(uri = url.to_string(), "adding diagnostic");
let range = Range::new(
Position::new(
resolved.line_start.saturating_sub(1),
resolved.column_start.saturating_sub(1),
),
Position::new(
resolved.line_end.saturating_sub(1),
resolved.column_end.saturating_sub(1),
),
);
let corrections: Vec<Correction> = resolved
.is_machine_applicable()
.then_some(resolved.suggested_replacement.as_deref())
.flatten()
.map(|text| Correction::from_single(range, text))
.into_iter()
.collect();
let data = if corrections.is_empty() {
None
} else {
Some(serde_json::json!(DiagnosticData { corrections }))
};
let diagnostic = Diagnostic {
range,
severity: Some(Self::parse_severity(&message.level)),
source: Some(PKG_NAME.to_string()),
message: rendered.to_string(),
related_information: related_information.clone(),
data,
tags: tags.clone(),
..Diagnostic::default()
};
tracing::trace!(uri = url.to_string(), ?diagnostic, "sending diagnostic");
tx.send_async((url, diagnostic)).await?;
at_least_one = true;
}
if !use_related_information {
for child in &message.children {
let Some(first_primary) = child.spans.iter().find(|s| s.is_primary) else {
continue;
};
let resolved = first_primary.project_span();
let Some(url) = Self::span_to_uri(project_root, resolved)? else {
continue;
};
let range = Range::new(
Position::new(
resolved.line_start.saturating_sub(1),
resolved.column_start.saturating_sub(1),
),
Position::new(
resolved.line_end.saturating_sub(1),
resolved.column_end.saturating_sub(1),
),
);
let edits: Vec<CorrectionEdit> = child
.spans
.iter()
.filter(|s| s.is_primary && s.is_machine_applicable())
.filter_map(|s| {
let new_text = s.suggested_replacement.as_deref()?;
let r = s.project_span();
Some(CorrectionEdit {
range: Range::new(
Position::new(r.line_start.saturating_sub(1), r.column_start.saturating_sub(1)),
Position::new(r.line_end.saturating_sub(1), r.column_end.saturating_sub(1)),
),
new_text: new_text.to_string(),
})
})
.collect();
let corrections = if edits.is_empty() {
vec![]
} else {
vec![Correction::from_multi(edits)]
};
let data = if corrections.is_empty() {
None
} else {
Some(serde_json::json!(DiagnosticData { corrections }))
};
let diagnostic = Diagnostic {
range,
severity: Some(Self::parse_severity(&child.level)),
source: Some(PKG_NAME.to_string()),
message: child.message.clone(),
data,
tags: tags.clone(),
..Diagnostic::default()
};
tracing::trace!(uri = url.to_string(), ?diagnostic, "sending child diagnostic");
tx.send_async((url, diagnostic)).await?;
at_least_one = true;
}
}
Ok(at_least_one)
}
pub(crate) async fn cargo_diagnostics(
command_args: Vec<String>,
cargo_env: &[(String, String)],
project_root: Option<&PathBuf>,
destination_folder: &Path,
use_related_information: bool,
progress: &OngoingProgress<Bounded, NotCancellable>,
tx: flume::Sender<(Uri, Diagnostic)>,
) -> anyhow::Result<()> {
tracing::info!(cwd = ?destination_folder, "running cargo {}", command_args.join(" "));
let mut cmd = Command::new("cargo");
cmd.env("CARGO_TERM_PROGRESS_WHEN", "always");
cmd.env("CARGO_TERM_PROGRESS_WIDTH", "80");
for (key, val) in cargo_env {
cmd.env(key, val);
}
let mut child = cmd
.args(command_args)
.current_dir(destination_folder)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()?;
let stdout = child.stdout.take().context("taking stdout")?;
let stderr = child.stderr.take().context("taking stderr")?;
let log_cargo = env::var("BACON_LS_LOG_CARGO").unwrap_or("off".to_string());
let stdout_future = async {
let mut at_least_one_diag = false;
let mut reader = tokio::io::BufReader::new(stdout).lines();
while let Some(line) = reader.next_line().await? {
match serde_json::from_str::<CargoLine>(&line) {
Ok(message) => {
if let Some(message) = message.message
&& message.message_type == "diagnostic"
{
at_least_one_diag |=
Self::maybe_add_diagnostic(project_root, &message, use_related_information, &tx)
.await
.context("adding diagnostic")?;
}
}
Err(e) => tracing::error!("error deserializing cargo line:\n{line}\n{e}"),
}
}
anyhow::Ok(at_least_one_diag)
};
let stderr_future = async {
let mut errors = String::new();
let mut reader = tokio::io::BufReader::new(stderr);
let mut buf = Vec::new();
loop {
buf.clear();
let bytes_read = read_until_cr_or_lf(&mut reader, &mut buf).await?;
if bytes_read == 0 {
break;
}
let segment = String::from_utf8_lossy(&buf);
let segment = segment.trim();
if segment.is_empty() {
continue;
}
if log_cargo != "off" {
tracing::trace!("[cargo stderr]{segment}");
}
let trimmed = segment.trim_start();
if let Some((message, pct)) = parse_building_line(trimmed) {
tracing::trace!(msg = message, pct = pct, "reported Building");
progress.report_with_message(message, pct).await;
} else if let Some(msg) = trimmed.strip_prefix("Blocking") {
tracing::trace!(msg = msg, "reported Blocking");
progress.report_with_message(msg.trim_start(), 0).await;
} else if let Some(msg) = trimmed.strip_prefix("error:") {
errors.push_str(msg);
}
}
anyhow::Ok(errors)
};
let (stdout_result, stderr_result) = tokio::join!(stdout_future, stderr_future);
let at_least_one_diag = stdout_result?;
let logged_errors = match stderr_result {
Ok(logged_errors) => logged_errors,
Err(e) => {
tracing::warn!("error reading cargo stderr: {e}");
String::new()
}
};
let status = child.wait().await?;
tracing::info!("cargo finished with status {status}");
if !logged_errors.is_empty() && !at_least_one_diag {
anyhow::bail!("cargo exited with {status}:{logged_errors}");
}
Ok(())
}
pub(crate) async fn find_project_root(params: &InitializeParams) -> Option<PathBuf> {
let mut candidates: Vec<PathBuf> = Vec::new();
if let Some(workspace_folders) = ¶ms.workspace_folders {
for folder in workspace_folders {
candidates.push(PathBuf::from(folder.uri.path().as_str()));
}
}
#[allow(deprecated)]
if let Some(root_uri) = ¶ms.root_uri {
candidates.push(PathBuf::from(root_uri.path().as_str()));
}
#[allow(deprecated)]
if let Some(root_path) = ¶ms.root_path {
candidates.push(PathBuf::from(root_path));
}
if let Ok(cwd) = std::env::current_dir() {
candidates.push(cwd);
}
for candidate in &candidates {
if let Some(git_root) = BaconLs::find_git_root_directory(candidate).await
&& git_root.join("Cargo.toml").exists()
{
return Some(git_root);
}
if candidate.join("Cargo.toml").exists() {
return Some(candidate.clone());
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
const TOKIO_SELECT_EXPANSION: &str = include_str!("testdata/expansion-needed.json");
const UNUSED_VARIABLE: &str = include_str!("testdata/unused-variable.json");
const UNUSED_IMPORT_LINE: &str = include_str!("testdata/unused-import-line.json");
const UNUSED_IMPORT_GROUPED: &str = include_str!("testdata/unused-import-compact.json");
#[test]
fn test_project_span_follows_macro_expansion_chain() {
let line: CargoLine = serde_json::from_str(TOKIO_SELECT_EXPANSION).unwrap();
let message = line.message.unwrap();
assert_eq!(message.message_type, "diagnostic");
for span in &message.spans {
let resolved = span.project_span();
let host = resolved.file_name.authority().map(|a| a.host().to_string());
assert_eq!(
host,
Some("src".to_string()),
"expected project-local span, got {:?}",
resolved.file_name
);
assert_eq!(resolved.line_start, 708);
assert_eq!(resolved.line_end, 715);
}
}
#[test]
fn test_project_span_returns_self_when_already_project_local() {
let json = r#"{
"file_name": "src/main.rs",
"byte_start": 0, "byte_end": 10,
"line_start": 1, "line_end": 1,
"column_start": 1, "column_end": 10,
"is_primary": true,
"text": [],
"label": null,
"suggested_replacement": null,
"suggestion_applicability": null,
"expansion": null
}"#;
let span: CargoSpan = serde_json::from_str(json).unwrap();
let resolved = span.project_span();
assert!(std::ptr::eq(resolved, &span));
}
#[test]
fn test_children_structure_for_separate_diagnostics() {
let line: CargoLine = serde_json::from_str(UNUSED_VARIABLE).unwrap();
let message = line.message.unwrap();
assert_eq!(message.message_type, "diagnostic");
assert_eq!(message.level, "warning");
let primary_spans: Vec<_> = message.spans.iter().filter(|s| s.is_primary).collect();
assert_eq!(primary_spans.len(), 1);
let span = primary_spans[0];
assert_eq!(span.project_span().line_start, 719);
assert!(
span.file_name
.authority()
.map(|a| !a.host().is_empty())
.unwrap_or(false),
"primary span should already be project-local"
);
assert_eq!(message.children.len(), 2);
assert!(message.children[0].spans.is_empty());
assert_eq!(message.children[0].level, "note");
let help_child = &message.children[1];
assert_eq!(help_child.level, "help");
assert_eq!(
help_child.message,
"if this is intentional, prefix it with an underscore"
);
let help_spans: Vec<_> = help_child.spans.iter().filter(|s| s.is_primary).collect();
assert_eq!(help_spans.len(), 1);
assert_eq!(help_spans[0].suggested_replacement.as_deref(), Some("_lol"));
let help_child_spans: Vec<_> = help_child
.spans
.iter()
.filter(|s| s.is_primary && s.is_machine_applicable())
.collect();
assert_eq!(help_child_spans.len(), 1);
assert_eq!(help_child_spans[0].suggested_replacement.as_deref(), Some("_lol"));
}
#[test]
fn test_unused_import_whole_line_produces_remove_correction() {
let line: CargoLine = serde_json::from_str(UNUSED_IMPORT_LINE).unwrap();
let message = line.message.unwrap();
assert_eq!(message.level, "warning");
let primary_spans: Vec<_> = message.spans.iter().filter(|s| s.is_primary).collect();
assert_eq!(primary_spans.len(), 1);
assert!(primary_spans[0].suggested_replacement.is_none());
assert_eq!(message.children.len(), 2);
let help_child = &message.children[1];
let primary_spans: Vec<_> = help_child.spans.iter().filter(|s| s.is_primary).collect();
assert_eq!(primary_spans.len(), 1);
assert_eq!(primary_spans[0].suggested_replacement.as_deref(), Some(""));
let resolved = primary_spans[0].project_span();
let range = Range::new(
Position::new(
resolved.line_start.saturating_sub(1),
resolved.column_start.saturating_sub(1),
),
Position::new(
resolved.line_end.saturating_sub(1),
resolved.column_end.saturating_sub(1),
),
);
let correction = Correction::from_single(range, "");
assert_eq!(correction.label, "Remove");
assert_eq!(correction.edits.len(), 1);
}
#[test]
fn test_unused_import_grouped_produces_three_remove_corrections() {
let line: CargoLine = serde_json::from_str(UNUSED_IMPORT_GROUPED).unwrap();
let message = line.message.unwrap();
assert_eq!(message.level, "warning");
assert_eq!(message.children.len(), 2);
let help_child = &message.children[1];
let primary_spans: Vec<_> = help_child
.spans
.iter()
.filter(|s| s.is_primary && s.is_machine_applicable())
.collect();
assert_eq!(primary_spans.len(), 3, "three spans for grouped import removal");
let edits: Vec<CorrectionEdit> = primary_spans
.iter()
.filter_map(|s| {
let new_text = s.suggested_replacement.as_deref()?;
let resolved = s.project_span();
Some(CorrectionEdit {
range: Range::new(
Position::new(
resolved.line_start.saturating_sub(1),
resolved.column_start.saturating_sub(1),
),
Position::new(
resolved.line_end.saturating_sub(1),
resolved.column_end.saturating_sub(1),
),
),
new_text: new_text.to_string(),
})
})
.collect();
assert_eq!(edits.len(), 3);
let correction = Correction::from_multi(edits);
assert_eq!(correction.label, "Remove");
assert_eq!(correction.edits.len(), 3);
}
#[test]
fn test_project_span_falls_back_to_self_when_no_project_span_in_chain() {
let json = r#"{
"file_name": "/home/user/.cargo/registry/src/foo/lib.rs",
"byte_start": 0, "byte_end": 10,
"line_start": 5, "line_end": 5,
"column_start": 1, "column_end": 10,
"is_primary": true,
"text": [],
"label": null,
"suggested_replacement": null,
"suggestion_applicability": null,
"expansion": null
}"#;
let span: CargoSpan = serde_json::from_str(json).unwrap();
let resolved = span.project_span();
assert!(std::ptr::eq(resolved, &span));
}
#[test]
fn test_parse_building_line_basic() {
let line = "Building [==== ] 7/10: foo, bar";
let (msg, pct) = parse_building_line(line).unwrap();
assert_eq!(msg, "7/10: foo, bar");
assert_eq!(pct, 70);
}
#[test]
fn test_parse_building_line_caps_percentage_at_99() {
let line = "Building [========] 10/10: last";
let (_, pct) = parse_building_line(line).unwrap();
assert_eq!(
pct, 99,
"100% should cap at 99 to keep the bar indeterminate until Finished"
);
}
#[test]
fn test_parse_building_line_rejects_non_building_prefix() {
assert!(parse_building_line("Finished `dev` profile").is_none());
assert!(parse_building_line("Compiling foo v0.1.0").is_none());
}
#[test]
fn test_parse_building_line_zero_total_returns_none() {
assert!(parse_building_line("Building [] 0/0: nothing").is_none());
}
#[test]
fn test_parse_building_line_malformed_returns_none() {
assert!(parse_building_line("Building [====] no-fraction-here").is_none());
assert!(parse_building_line("Building [====] abc/def: foo").is_none());
}
#[test]
fn test_parse_severity_known_levels() {
assert_eq!(Cargo::parse_severity("error"), DiagnosticSeverity::ERROR);
assert_eq!(Cargo::parse_severity("warning"), DiagnosticSeverity::WARNING);
assert_eq!(Cargo::parse_severity("failure-note"), DiagnosticSeverity::WARNING);
assert_eq!(Cargo::parse_severity("note"), DiagnosticSeverity::INFORMATION);
assert_eq!(Cargo::parse_severity("info"), DiagnosticSeverity::INFORMATION);
assert_eq!(Cargo::parse_severity("help"), DiagnosticSeverity::HINT);
assert_eq!(Cargo::parse_severity("hint"), DiagnosticSeverity::HINT);
}
#[test]
fn test_parse_severity_unknown_level_defaults_to_information() {
assert_eq!(
Cargo::parse_severity("something-brand-new"),
DiagnosticSeverity::INFORMATION
);
}
#[test]
fn test_tags_from_code_unused_lints_get_unnecessary() {
for code in [
"unused_variables",
"unused_imports",
"unused_mut",
"unused_assignments",
"unused_must_use",
] {
assert_eq!(
tags_from_code(code),
Some(vec![DiagnosticTag::UNNECESSARY]),
"{code} should be tagged UNNECESSARY"
);
}
}
#[test]
fn test_tags_from_code_dead_and_unreachable_get_unnecessary() {
for code in [
"dead_code",
"unreachable_code",
"unreachable_patterns",
"redundant_imports",
"redundant_semicolons",
] {
assert_eq!(
tags_from_code(code),
Some(vec![DiagnosticTag::UNNECESSARY]),
"{code} should be tagged UNNECESSARY"
);
}
}
#[test]
fn test_tags_from_code_deprecated_gets_deprecated_tag() {
assert_eq!(tags_from_code("deprecated"), Some(vec![DiagnosticTag::DEPRECATED]));
}
#[test]
fn test_tags_from_code_unrelated_returns_none() {
assert_eq!(tags_from_code("E0425"), None);
assert_eq!(tags_from_code("clippy::needless_pass_by_value"), None);
assert_eq!(tags_from_code("non_snake_case"), None);
assert_eq!(tags_from_code(""), None);
}
#[tokio::test]
async fn test_read_until_lf_consumes_terminator() {
let mut reader = tokio::io::BufReader::new(&b"hello\nworld"[..]);
let mut buf = Vec::new();
let n = read_until_cr_or_lf(&mut reader, &mut buf).await.unwrap();
assert_eq!(n, 6);
assert_eq!(buf, b"hello");
}
#[tokio::test]
async fn test_read_until_cr_consumes_terminator() {
let mut reader = tokio::io::BufReader::new(&b"progress\rnext"[..]);
let mut buf = Vec::new();
let n = read_until_cr_or_lf(&mut reader, &mut buf).await.unwrap();
assert_eq!(n, 9);
assert_eq!(buf, b"progress");
}
#[tokio::test]
async fn test_read_until_cr_or_lf_returns_zero_at_eof() {
let mut reader = tokio::io::BufReader::new(&b""[..]);
let mut buf = Vec::new();
let n = read_until_cr_or_lf(&mut reader, &mut buf).await.unwrap();
assert_eq!(n, 0);
assert!(buf.is_empty());
}
#[tokio::test]
async fn test_read_until_cr_or_lf_no_terminator_reads_to_eof() {
let mut reader = tokio::io::BufReader::new(&b"trailing"[..]);
let mut buf = Vec::new();
let n = read_until_cr_or_lf(&mut reader, &mut buf).await.unwrap();
assert_eq!(n, 8);
assert_eq!(buf, b"trailing");
}
#[tokio::test]
async fn test_read_until_cr_or_lf_spans_multiple_internal_buffers() {
let mut reader = tokio::io::BufReader::with_capacity(4, &b"abcdefghi\nrest"[..]);
let mut buf = Vec::new();
let n = read_until_cr_or_lf(&mut reader, &mut buf).await.unwrap();
assert_eq!(n, 10);
assert_eq!(buf, b"abcdefghi");
}
fn make_relative_span(file_name_str: &str, is_primary: bool) -> CargoSpan {
let json = format!(
r#"{{
"file_name": "{file_name_str}",
"byte_start": 0, "byte_end": 0,
"line_start": 1, "line_end": 1,
"column_start": 1, "column_end": 1,
"is_primary": {is_primary},
"text": [],
"label": null,
"suggested_replacement": null,
"suggestion_applicability": null,
"expansion": null
}}"#
);
serde_json::from_str(&json).unwrap()
}
#[test]
fn test_span_to_uri_returns_none_when_no_authority() {
let span = make_relative_span("/absolute/path/that/will/not/exist", true);
let result = Cargo::span_to_uri(None, &span).unwrap();
assert_eq!(result, None);
}
#[tokio::test]
async fn test_span_to_uri_returns_none_when_canonicalize_fails() {
let tmp = tempfile::TempDir::new().unwrap();
let span = make_relative_span("does/not/exist.rs", true);
let result = Cargo::span_to_uri(Some(&tmp.path().to_path_buf()), &span).unwrap();
assert_eq!(result, None, "missing file should be skipped, not error");
}
#[tokio::test]
#[cfg(not(target_os = "windows"))]
async fn test_span_to_uri_resolves_existing_relative_path() {
let tmp = tempfile::TempDir::new().unwrap();
let src_dir = tmp.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
let lib_rs = src_dir.join("lib.rs");
std::fs::write(&lib_rs, "// content").unwrap();
let span = make_relative_span("src/lib.rs", true);
let uri = Cargo::span_to_uri(Some(&tmp.path().to_path_buf()), &span)
.unwrap()
.expect("existing path should resolve");
let canonical = lib_rs.canonicalize().unwrap();
let expected = format!("file://{}", canonical.display());
assert_eq!(uri.to_string(), expected);
}
#[tokio::test]
#[cfg(not(target_os = "windows"))]
async fn test_maybe_add_diagnostic_emits_per_primary_span() {
let tmp = tempfile::TempDir::new().unwrap();
let src_dir = tmp.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::write(src_dir.join("lib.rs"), "// content").unwrap();
let project_root = tmp.path().to_path_buf();
let line: CargoLine = serde_json::from_str(UNUSED_VARIABLE).unwrap();
let message = line.message.unwrap();
let (tx, rx) = flume::unbounded();
let any = Cargo::maybe_add_diagnostic(Some(&project_root), &message, true, &tx)
.await
.unwrap();
drop(tx);
assert!(any, "should emit at least one diagnostic for the unused variable");
let received: Vec<_> = rx.drain().collect();
assert_eq!(received.len(), 1, "one primary span → one diagnostic");
let (_uri, diag) = &received[0];
assert_eq!(diag.severity, Some(DiagnosticSeverity::WARNING));
assert_eq!(diag.source, Some(PKG_NAME.to_string()));
assert!(
diag.related_information.as_ref().is_some_and(|r| !r.is_empty()),
"help child should attach as related information when supported"
);
assert_eq!(
diag.tags.as_deref(),
Some(&[DiagnosticTag::UNNECESSARY][..]),
"unused_variables warning must be tagged UNNECESSARY"
);
}
#[tokio::test]
#[cfg(not(target_os = "windows"))]
async fn test_maybe_add_diagnostic_separate_children_when_unsupported() {
let tmp = tempfile::TempDir::new().unwrap();
let src_dir = tmp.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::write(src_dir.join("lib.rs"), "// content").unwrap();
let project_root = tmp.path().to_path_buf();
let line: CargoLine = serde_json::from_str(UNUSED_VARIABLE).unwrap();
let message = line.message.unwrap();
let (tx, rx) = flume::unbounded();
let any = Cargo::maybe_add_diagnostic(Some(&project_root), &message, false, &tx)
.await
.unwrap();
drop(tx);
assert!(any);
let received: Vec<_> = rx.drain().collect();
assert_eq!(received.len(), 2, "parent + help child as separate diagnostics");
let levels: Vec<_> = received.iter().map(|(_, d)| d.severity).collect();
assert!(levels.contains(&Some(DiagnosticSeverity::WARNING)));
assert!(levels.contains(&Some(DiagnosticSeverity::HINT)));
let help_diag = received
.iter()
.find(|(_, d)| d.severity == Some(DiagnosticSeverity::HINT))
.unwrap();
assert!(help_diag.1.data.is_some(), "help child should carry quick-fix data");
for (_, d) in &received {
assert_eq!(
d.tags.as_deref(),
Some(&[DiagnosticTag::UNNECESSARY][..]),
"unused_variables tag must propagate to the help child"
);
}
}
#[tokio::test]
#[cfg(not(target_os = "windows"))]
async fn test_find_project_root_picks_workspace_folder_with_cargo_toml() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"").unwrap();
let folder_uri = format!("file://{}", tmp.path().display());
let params: InitializeParams = serde_json::from_value(serde_json::json!({
"processId": null,
"rootUri": null,
"capabilities": {},
"workspaceFolders": [{
"uri": folder_uri,
"name": "test"
}]
}))
.unwrap();
let root = Cargo::find_project_root(¶ms).await;
let canonical = tmp.path().canonicalize().unwrap();
assert_eq!(
root.map(|p| p.canonicalize().unwrap()),
Some(canonical),
"workspace folder containing Cargo.toml should win"
);
}
#[tokio::test]
#[cfg(not(target_os = "windows"))]
async fn test_find_project_root_returns_none_when_no_cargo_toml_anywhere() {
let tmp = tempfile::TempDir::new().unwrap();
let folder_uri = format!("file://{}", tmp.path().display());
let params: InitializeParams = serde_json::from_value(serde_json::json!({
"processId": null,
"rootUri": folder_uri,
"capabilities": {},
}))
.unwrap();
let root = Cargo::find_project_root(¶ms).await;
if let Some(p) = root {
assert_ne!(
p.canonicalize().ok(),
Some(tmp.path().canonicalize().unwrap()),
"tempdir without Cargo.toml must not be picked"
);
}
}
}