use std::borrow::Cow;
use std::env;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use ls_types::{Diagnostic, DiagnosticSeverity, Position, Range, Uri, WorkspaceFolder};
use notify_debouncer_full::{DebounceEventResult, new_debouncer};
use serde::{Deserialize, Serialize};
use tokio::fs::File;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::RwLock;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tower_lsp_server::Client;
use std::collections::HashSet;
use crate::{
BackendRuntime, BaconLs, Correction, DiagKey, DiagnosticData, LOCATIONS_FILE, PKG_NAME, State, diag_key,
path_to_file_uri,
};
#[derive(Debug, Deserialize, Serialize)]
struct BaconConfig {
jobs: Jobs,
exports: Exports,
}
#[derive(Debug, Deserialize, Serialize)]
struct Jobs {
#[serde(rename = "bacon-ls")]
bacon_ls: BaconLsJob,
}
#[derive(Debug, Deserialize, Serialize)]
struct BaconLsJob {
#[serde(skip_deserializing)]
command: Vec<String>,
analyzer: String,
need_stdout: bool,
}
#[derive(Debug, Deserialize, Serialize)]
struct Exports {
#[serde(rename = "cargo-json-spans")]
cargo_json_spans: CargoJsonSpans,
}
#[derive(Debug, Deserialize, Serialize)]
struct CargoJsonSpans {
auto: bool,
exporter: String,
line_format: String,
path: String,
}
const ERROR_MESSAGE: &str = "bacon configuration is not compatible with bacon-ls: please take a look to https://github.com/crisidev/bacon-ls?tab=readme-ov-file#configuration and adapt your bacon configuration";
const BACON_ANALYZER: &str = "cargo_json";
const BACON_EXPORTER: &str = "analyzer";
const BACON_COMMAND: [&str; 7] = [
"cargo",
"clippy",
"--tests",
"--all-targets",
"--all-features",
"--message-format",
"json-diagnostic-rendered-ansi",
];
const LINE_FORMAT: &str = "{diagnostic.level}|:|{span.file_name}|:|{span.line_start}|:|{span.line_end}|:|{span.column_start}|:|{span.column_end}|:|{diagnostic.message}|:|{diagnostic.rendered}|:|{span.suggested_replacement}";
pub(crate) struct Bacon;
impl Bacon {
async fn validate_preferences_file(path: &Path) -> Result<(), String> {
let toml_content = tokio::fs::read_to_string(path)
.await
.map_err(|e| format!("{ERROR_MESSAGE}: {e}"))?;
let config: BaconConfig = toml::from_str(&toml_content).map_err(|e| format!("{ERROR_MESSAGE}: {e}"))?;
tracing::debug!("bacon config is {config:#?}");
if config.jobs.bacon_ls.analyzer == BACON_ANALYZER
&& config.jobs.bacon_ls.need_stdout
&& config.exports.cargo_json_spans.auto
&& config.exports.cargo_json_spans.exporter == BACON_EXPORTER
&& config.exports.cargo_json_spans.line_format == LINE_FORMAT
&& config.exports.cargo_json_spans.path == LOCATIONS_FILE
{
tracing::info!("bacon configuration {} is valid", path.display());
Ok(())
} else {
Err(ERROR_MESSAGE.to_string())
}
}
async fn create_preferences_file(filename: &str) -> Result<(), String> {
let bacon_config = BaconConfig {
jobs: Jobs {
bacon_ls: BaconLsJob {
command: BACON_COMMAND.map(|c| c.to_string()).into_iter().collect(),
analyzer: BACON_ANALYZER.to_string(),
need_stdout: true,
},
},
exports: Exports {
cargo_json_spans: CargoJsonSpans {
auto: true,
exporter: BACON_EXPORTER.to_string(),
line_format: LINE_FORMAT.to_string(),
path: LOCATIONS_FILE.to_string(),
},
},
};
tracing::info!("creating new bacon preference file {filename}",);
let toml_string = toml::to_string_pretty(&bacon_config)
.map_err(|e| format!("error serializing bacon preferences {filename} content: {e}"))?;
// `tokio::fs::write` is open + write_all + flush + close in one shot,
// so the bytes are durable by the time the future resolves. The
// previous `File::create + write_all` form left flushing to drop and
// could race a subsequent read on busy CI runners (causing the
// freshly-created file to be observed empty during validation).
tokio::fs::write(filename, toml_string)
.await
.map_err(|e| format!("error creating bacon preferences {filename}: {e}"))?;
Ok(())
}
async fn validate_preferences_impl(bacon_prefs: &[u8], create_prefs_file: bool) -> Result<(), String> {
let bacon_prefs_files = String::from_utf8_lossy(bacon_prefs);
let bacon_prefs_files_split: Vec<&str> = bacon_prefs_files.split("\n").collect();
let mut preference_file_exists = false;
for prefs_file in bacon_prefs_files_split.iter() {
let prefs_file_path = Path::new(prefs_file);
if prefs_file_path.exists() {
preference_file_exists = true;
Self::validate_preferences_file(prefs_file_path).await?;
} else {
tracing::debug!("skipping non existing bacon preference file {prefs_file}");
}
}
if !preference_file_exists && create_prefs_file {
Self::create_preferences_file(bacon_prefs_files_split[0]).await?;
}
Ok(())
}
/// Walks `root` recursively for files named `locations_file_name`. Iterative
/// (stack-based) so it doesn't grow the async stack on deep trees, and uses
/// `tokio::fs` so the directory walk yields to the runtime instead of
/// blocking the executor on large workspaces.
pub(crate) async fn find_bacon_locations(root: &Path, locations_file_name: &str) -> std::io::Result<Vec<PathBuf>> {
let mut results = Vec::new();
let mut stack: Vec<PathBuf> = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let mut entries = tokio::fs::read_dir(&dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
let file_type = entry.file_type().await?;
if file_type.is_dir() {
stack.push(path);
} else if path.file_name().is_some_and(|name| name == locations_file_name) {
results.push(path);
}
}
}
Ok(results)
}
fn parse_severity(severity_str: &str) -> DiagnosticSeverity {
match severity_str {
"error" => DiagnosticSeverity::ERROR,
"warning" => DiagnosticSeverity::WARNING,
"info" | "information" | "note" | "failure-note" => DiagnosticSeverity::INFORMATION,
"hint" | "help" => DiagnosticSeverity::HINT,
other => {
tracing::warn!("unknown bacon severity level {other:?}, defaulting to INFORMATION");
DiagnosticSeverity::INFORMATION
}
}
}
fn parse_positions(fields: &[&str]) -> Option<(u32, u32, u32, u32)> {
let line_start = fields.first()?.parse().ok()?;
let line_end = fields.get(1)?.parse().ok()?;
let column_start = fields.get(2)?.parse().ok()?;
let column_end = fields.get(3)?.parse().ok()?;
Some((line_start, line_end, column_start, column_end))
}
fn parse_bacon_diagnostic_line(line: &str, folder_path: &Path) -> Option<(Uri, Diagnostic)> {
// Split line into parts; expect exactly 7 parts in the format specified.
let line_split: Vec<_> = line.splitn(9, "|:|").collect();
if line_split.len() != 9 {
tracing::error!(
"malformed line: expected 9 parts in the format of `severity|:|path|:|line_start|:|line_end|:|column_start|:|column_end|:|message|:|rendered_message|:|replacement` but found {}: {}",
line_split.len(),
line
);
return None;
}
// Parse elements from the split line
let severity = Self::parse_severity(line_split[0]);
let file_path = folder_path.join(line_split[1]);
// Handle potential parse errors
let (line_start, line_end, column_start, column_end) = match Self::parse_positions(&line_split[2..6]) {
Some(values) => values,
None => {
tracing::error!("error parsing diagnostic position {:?}", &line_split[2..6]);
return None;
}
};
let Some(file_path_str) = file_path.to_str() else {
tracing::error!("file path is not valid UTF-8: {}", file_path.display());
return None;
};
let path = match str::parse::<Uri>(&path_to_file_uri(file_path_str)) {
Ok(url) => url,
Err(e) => {
tracing::error!("error parsing file path {}: {}", file_path.display(), e);
return None;
}
};
let mut message = line_split[6].replace("\\n", "\n").trim_end_matches('\n').to_string();
let range = Range::new(
Position::new(line_start.saturating_sub(1), column_start.saturating_sub(1)),
Position::new(line_end.saturating_sub(1), column_end.saturating_sub(1)),
);
let replacement = line_split[8];
let data = if replacement != "none" {
tracing::debug!("storing potential quick fix code action to replace word with {replacement}");
Some(serde_json::json!(DiagnosticData {
corrections: vec![Correction::from_single(range, replacement)]
}))
} else {
None
};
tracing::debug!(
"new diagnostic: severity: {severity:?}, path: {path:?}, line_start: {line_start}, line_end: {line_end}, column_start: {column_start}, column_end: {column_end}, message: {message}",
);
// Create the Diagnostic object
let rendered_message = line_split[7];
if rendered_message != "none" {
message = ansi_regex::ansi_regex()
.replace_all(rendered_message, "")
.trim_end_matches('\n')
.to_string()
}
let diagnostic = Diagnostic {
range,
severity: Some(severity),
source: Some(PKG_NAME.to_string()),
message,
data,
..Diagnostic::default()
};
Some((path, diagnostic))
}
fn deduplicate_diagnostics(
path: Uri,
uri: &Uri,
diagnostic: Diagnostic,
diagnostics: &mut Vec<(Uri, Diagnostic)>,
seen: &mut HashSet<DiagKey>,
) {
if &path != uri {
return;
}
if seen.insert(diag_key(&diagnostic)) {
diagnostics.push((path, diagnostic));
}
}
pub(crate) async fn validate_preferences(bacon_command: &str, create_prefs_file: bool) -> Result<(), String> {
let bacon_prefs = Command::new(bacon_command)
.arg("--prefs")
.output()
.await
.map_err(|e| e.to_string())?;
Self::validate_preferences_impl(&bacon_prefs.stdout, create_prefs_file).await
}
pub(crate) async fn run_in_background(
bacon_command: &str,
bacon_command_args: &str,
current_dir: Option<&PathBuf>,
cancel_token: CancellationToken,
) -> Result<JoinHandle<()>, String> {
tracing::info!("starting bacon in background with arguments `{bacon_command_args}`");
let log_bacon = env::var("BACON_LS_LOG_BACON").unwrap_or("on".to_string());
let mut command = Command::new(bacon_command);
command
.args(bacon_command_args.split_whitespace().collect::<Vec<&str>>())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(current_dir) = current_dir {
command.current_dir(current_dir);
}
match command.spawn() {
Ok(mut child) => {
// Handle stdout
if log_bacon != "off"
&& let Some(stdout) = child.stdout.take()
{
let reader = BufReader::new(stdout).lines();
tokio::spawn(async move {
let mut reader = reader;
while let Ok(Some(line)) = reader.next_line().await {
tracing::info!("[bacon stdout]: {}", line);
}
});
}
// Handle stderr
if log_bacon != "off"
&& let Some(stderr) = child.stderr.take()
{
let reader = BufReader::new(stderr).lines();
tokio::spawn(async move {
let mut reader = reader;
while let Ok(Some(line)) = reader.next_line().await {
tracing::error!("[bacon stderr]: {}", line);
}
});
}
// Wait for the child process to finish
Ok(tokio::spawn(async move {
tracing::debug!("waiting for bacon to terminate");
tokio::select! {
_ = child.wait() => {},
_ = cancel_token.cancelled() => {},
};
}))
}
Err(e) => Err(format!("failed to start bacon: {e}")),
}
}
async fn diagnostics(
uri: &Uri,
locations_file_name: &str,
workspace_folders: Option<&[WorkspaceFolder]>,
) -> Vec<(Uri, Diagnostic)> {
let mut diagnostics: Vec<(Uri, Diagnostic)> = vec![];
let mut seen: HashSet<DiagKey> = HashSet::new();
if let Some(workspace_folders) = workspace_folders {
for folder in workspace_folders.iter() {
let Some(mut folder_path) = folder.uri.to_file_path() else {
tracing::warn!("skipping workspace folder with non-file URI: {}", folder.uri.as_str());
continue;
};
if let Some(git_root) = BaconLs::find_git_root_directory(&folder_path).await
&& git_root.join("Cargo.toml").exists()
{
tracing::debug!(
"found git root directory {}, using it for files base path",
git_root.display()
);
folder_path = Cow::Owned(git_root);
}
let bacon_locations = match Bacon::find_bacon_locations(&folder_path, locations_file_name).await {
Ok(v) => v,
Err(e) => {
tracing::warn!("unable to find valid bacon location files: {e}");
Vec::new()
}
};
for bacon_location in bacon_locations.iter() {
tracing::info!("found bacon locations file to parse {}", bacon_location.display());
match File::open(&bacon_location).await {
Ok(fd) => {
let reader = BufReader::new(fd);
let mut lines = reader.lines();
let mut buffer = String::new();
while let Some(line) = lines.next_line().await.unwrap_or_else(|e| {
tracing::error!("error reading line from file {}: {e}", bacon_location.display());
None
}) {
let trimmed = line.trim_end();
// Use the first word to determine the start of a new diagnostic
let is_new_diagnostic = trimmed.starts_with("warning")
|| trimmed.starts_with("error")
|| trimmed.starts_with("info")
|| trimmed.starts_with("note")
|| trimmed.starts_with("failure-note")
|| trimmed.starts_with("help");
if is_new_diagnostic {
// Process the collected buffer before starting a new entry
if !buffer.is_empty()
&& let Some((path, diagnostic)) =
Self::parse_bacon_diagnostic_line(&buffer, &folder_path)
{
tracing::debug!("found diagnostic for {}", path.as_str());
Self::deduplicate_diagnostics(
path.clone(),
uri,
diagnostic,
&mut diagnostics,
&mut seen,
);
}
// Reset buffer for new diagnostic entry
buffer.clear();
}
// Append current line to buffer
if !buffer.is_empty() {
buffer.push('\n'); // Preserve multiline structure
}
buffer.push_str(trimmed);
}
// Flush the remaining buffer after loop ends
if !buffer.is_empty()
&& let Some((path, diagnostic)) =
Self::parse_bacon_diagnostic_line(&buffer, &folder_path)
{
Self::deduplicate_diagnostics(
path.clone(),
uri,
diagnostic,
&mut diagnostics,
&mut seen,
);
}
}
Err(e) => {
tracing::error!("unable to read file {}: {e}", bacon_location.display())
}
}
}
}
}
diagnostics
}
async fn diagnostics_vec(
uri: &Uri,
locations_file_name: &str,
workspace_folders: Option<&[WorkspaceFolder]>,
) -> Vec<Diagnostic> {
Self::diagnostics(uri, locations_file_name, workspace_folders)
.await
.into_iter()
.map(|(_, y)| y)
.collect::<Vec<Diagnostic>>()
}
pub(crate) async fn synchronize_diagnostics(state: Arc<RwLock<State>>, client: Arc<Client>) {
tracing::info!("starting background task in charge of syncronizing diagnostics for all open files");
let (tx, rx) = flume::unbounded::<DebounceEventResult>();
let (locations_file, proj_root, wait_time, shutdown_token) = {
let state = state.read().await;
let Some(BackendRuntime::Bacon { config, runtime }) = &state.backend else {
tracing::error!("synchronize_diagnostics called without bacon backend");
return;
};
(
config.locations_file.clone(),
state.project_root.clone(),
config.synchronize_all_open_files_wait,
runtime.shutdown_token.clone(),
)
};
let mut watcher = match new_debouncer(wait_time, None, move |ev: DebounceEventResult| {
// Returns an error if all senders are dropped.
let _res = tx.send(ev);
}) {
Ok(watcher) => watcher,
Err(e) => {
let msg = format!(
"bacon-ls could not create a file watcher: {e}. \
Diagnostics will still update on save but open-file \
synchronization is disabled."
);
tracing::error!("{msg}");
client.show_message(ls_types::MessageType::WARNING, msg).await;
return;
}
};
let locations_file_path =
proj_root.map_or_else(|| PathBuf::from(&locations_file), |root| root.join(&locations_file));
loop {
match watcher.watch(PathBuf::from(&locations_file_path), notify::RecursiveMode::Recursive) {
Ok(_) => {
tracing::info!("watching '{}' for changes...", locations_file_path.display());
break;
}
Err(e) => {
tracing::warn!(
"unable to watch '{}', retrying in 1 second",
locations_file_path.display()
);
tracing::error!(".bacon_locations watcher error: {e}");
tokio::time::sleep(Duration::from_secs(1)).await;
}
}
}
while let Some(Ok(res)) = tokio::select! {
ev = rx.recv_async() => {
Some(ev)
}
_ = shutdown_token.cancelled() => {
None
}
} {
let events = match res {
Ok(events) => events,
Err(err) => {
tracing::error!(?err, "watch error");
continue;
}
};
// Only publish if the file was modified.
if !events.iter().any(|ev| ev.kind.is_modify()) {
continue;
}
let mut loop_state = state.write().await;
let Some(BackendRuntime::Bacon { runtime, .. }) = &mut loop_state.backend else {
tracing::error!("backend changed during sync loop");
return;
};
runtime.diagnostics_version = runtime.diagnostics_version.wrapping_add(1);
let version = runtime.diagnostics_version;
let open_files = runtime.open_files.clone();
let workspace_folders = loop_state.workspace_folders.clone();
drop(loop_state);
tracing::debug!(
"running periodic diagnostic publish for open files `{}`",
open_files.iter().map(|f| f.to_string()).collect::<Vec<_>>().join(",")
);
for uri in open_files.iter() {
Self::publish_diagnostics(&client, uri, &locations_file, workspace_folders.as_deref(), version).await;
}
}
}
pub(crate) async fn publish_diagnostics(
client: &Arc<Client>,
uri: &Uri,
locations_file_name: &str,
workspace_folders: Option<&[WorkspaceFolder]>,
version: i32,
) {
let diagnostics_vec = Self::diagnostics_vec(uri, locations_file_name, workspace_folders).await;
tracing::info!("sent {} bacon diagnostics for {uri:?}", diagnostics_vec.len());
client
.publish_diagnostics(uri.clone(), diagnostics_vec, Some(version))
.await;
}
}
#[cfg(test)]
mod tests {
use std::io::Write;
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[tokio::test]
async fn test_valid_bacon_preferences() {
let valid_toml = format!(
r#"
[jobs.bacon-ls]
analyzer = "{BACON_ANALYZER}"
need_stdout = true
[exports.cargo-json-spans]
auto = true
exporter = "{BACON_EXPORTER}"
line_format = "{LINE_FORMAT}"
path = "{LOCATIONS_FILE}"
"#
);
let tmp_dir = TempDir::new().unwrap();
let file_path = tmp_dir.path().join("prefs.toml");
let mut file = std::fs::File::create(&file_path).unwrap();
write!(file, "{}", valid_toml).unwrap();
assert!(Bacon::validate_preferences_file(&file_path).await.is_ok());
}
#[tokio::test]
async fn test_invalid_analyzer() {
let invalid_toml = format!(
r#"
[jobs.bacon-ls]
analyzer = "incorrect_analyzer"
need_stdout = true
[exports.cargo-json-spans]
auto = true
exporter = "{BACON_EXPORTER}"
line_format = "{LINE_FORMAT}"
path = "{LOCATIONS_FILE}"
"#
);
let tmp_dir = TempDir::new().unwrap();
let file_path = tmp_dir.path().join("prefs.toml");
let mut file = std::fs::File::create(&file_path).unwrap();
write!(file, "{}", invalid_toml).unwrap();
assert!(Bacon::validate_preferences_file(&file_path).await.is_err());
}
#[tokio::test]
async fn test_invalid_line_format() {
let invalid_toml = format!(
r#"
[jobs.bacon-ls]
analyzer = "{BACON_ANALYZER}"
need_stdout = true
[exports.cargo-json-spans]
auto = true
exporter = "{BACON_EXPORTER}"
line_format = "invalid_line_format"
path = "{LOCATIONS_FILE}"
"#
);
let tmp_dir = TempDir::new().unwrap();
let file_path = tmp_dir.path().join("prefs.toml");
let mut file = std::fs::File::create(&file_path).unwrap();
write!(file, "{}", invalid_toml).unwrap();
assert!(Bacon::validate_preferences_file(&file_path).await.is_err());
}
#[tokio::test]
async fn test_validate_preferences() {
let valid_toml = format!(
r#"
[jobs.bacon-ls]
analyzer = "{BACON_ANALYZER}"
need_stdout = true
[exports.cargo-json-spans]
auto = true
exporter = "{BACON_EXPORTER}"
line_format = "{LINE_FORMAT}"
path = "{LOCATIONS_FILE}"
"#
);
assert!(
Bacon::validate_preferences_impl(valid_toml.as_bytes(), false)
.await
.is_ok()
);
}
#[tokio::test]
async fn test_file_creation_failure() {
let invalid_path = "/invalid/path/to/file.toml";
let result = Bacon::create_preferences_file(invalid_path).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("error creating bacon preferences"));
}
#[tokio::test]
async fn test_file_write_failure() {
let tmp_dir = TempDir::new().unwrap();
let file_path = tmp_dir.path().join("prefs.toml");
// Simulate write failure by closing the file prematurely
let file = File::create(&file_path).await.unwrap();
drop(file); // Close the file to simulate failure
let result = Bacon::create_preferences_file(file_path.to_str().unwrap()).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_empty_bacon_preferences_file() {
let tmp_dir = TempDir::new().unwrap();
let file_path = tmp_dir.path().join("empty_prefs.toml");
std::fs::File::create(&file_path).unwrap();
assert!(Bacon::validate_preferences_file(&file_path).await.is_err());
}
#[tokio::test]
async fn test_run_in_background() {
let cancel_token = CancellationToken::new();
let handle = Bacon::run_in_background("cargo", "--version", None, cancel_token.clone()).await;
assert!(handle.is_ok());
cancel_token.cancel();
handle.unwrap().await.unwrap();
}
const ERROR_LINE: &str = "error|:|/app/github/bacon-ls/src/lib.rs|:|352|:|352|:|9|:|20|:|cannot find value `one` in this scope\n |\n352 | one\n | ^^^ help: a unit variant with a similar name exists: `None`\n |\n ::: /Users/matteobigoi/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/option.rs:576:5\n |\n576 | None,\n | ---- similarly named unit variant `None` defined here\n\nFor more information about this error, try `rustc --explain E0425`.\nerror: could not compile `bacon-ls` (lib) due to 1 previous error|:|none|:|none";
#[test]
fn test_parse_bacon_diagnostic_line_with_spans_ok() {
let result = Bacon::parse_bacon_diagnostic_line(ERROR_LINE, Path::new("/app/github/bacon-ls"));
let (url, diagnostic) = result.unwrap();
assert_eq!(url.to_string(), "file:///app/github/bacon-ls/src/lib.rs");
assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
assert_eq!(diagnostic.source, Some(PKG_NAME.to_string()));
assert_eq!(
diagnostic.message,
r#"cannot find value `one` in this scope
|
352 | one
| ^^^ help: a unit variant with a similar name exists: `None`
|
::: /Users/matteobigoi/.rustup/toolchains/stable-aarch64-apple-darwin/lib/rustlib/src/rust/library/core/src/option.rs:576:5
|
576 | None,
| ---- similarly named unit variant `None` defined here
For more information about this error, try `rustc --explain E0425`.
error: could not compile `bacon-ls` (lib) due to 1 previous error"#
);
let result = Bacon::parse_bacon_diagnostic_line(ERROR_LINE, Path::new("/app/github/bacon-ls"));
let (url, diagnostic) = result.unwrap();
assert_eq!(url.to_string(), "file:///app/github/bacon-ls/src/lib.rs");
assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::ERROR));
assert_eq!(diagnostic.source, Some(PKG_NAME.to_string()));
}
#[test]
fn test_parse_bacon_diagnostic_line_with_spans_ko() {
// Unparsable line
let result = Bacon::parse_bacon_diagnostic_line("warning:/file:1:1", Path::new("/app/github/bacon-ls"));
assert_eq!(result, None);
// Empty line
let result = Bacon::parse_bacon_diagnostic_line("", Path::new("/app/github/bacon-ls"));
assert_eq!(result, None);
}
#[tokio::test]
#[cfg(not(target_os = "windows"))]
async fn test_bacon_multiline_diagnostics_production() {
let tmp_dir = TempDir::new().unwrap();
let file_path = tmp_dir.path().join(".bacon-locations");
let mut tmp_file = std::fs::File::create(file_path).unwrap();
let error_path = format!("{}/src/lib.rs", tmp_dir.path().display());
let error_path_url = str::parse::<Uri>(&format!("file://{error_path}")).unwrap();
writeln!(
tmp_file,
"warning|:|src/lib.rs|:|130|:|142|:|33|:|34|:|this if statement can be collapsed|:|none|:|none"
)
.unwrap();
writeln!(
tmp_file,
r#"help|:|{error_path}|:|130|:|142|:|33|:|34|:|collapse nested if block|:|none|:|if Some(&the_path) == uri && !diagnostics.iter().any(
|(existing_path, existing_diagnostic)| {{
existing_path.path() == the_path.path()
&& diagnostic.range == existing_diagnostic.range
&& diagnostic.severity
== existing_diagnostic.severity
&& diagnostic.message == existing_diagnostic.message
}},
) {{
diagnostics.push((path, diagnostic));
}}"#
).unwrap();
writeln!(
tmp_file,
"warning|:|{error_path}|:|150|:|162|:|33|:|34|:|this if statement can be collapsed again|:|none|:|none"
)
.unwrap();
writeln!(
tmp_file,
r#"warning|:|{error_path}|:|150|:|162|:|33|:|34|:|collapse nested if block|:|if Some(&other_path) == uri && !diagnostics.iter().any(
|(existing_path, existing_diagnostic)| {{
existing_path.path() == other_path.path()
&& diagnostic.range == existing_diagnostic.range
&& diagnostic.severity
== existing_diagnostic.severity
&& diagnostic.message == existing_diagnostic.message
}},
) {{
diagnostics.push((path, diagnostic));
}}|:|none"#
).unwrap();
let workspace_folders = Some(vec![WorkspaceFolder {
name: tmp_dir.path().display().to_string(),
uri: str::parse::<Uri>(&format!("file://{}", tmp_dir.path().display())).unwrap(),
}]);
let diagnostics = Bacon::diagnostics(&error_path_url, LOCATIONS_FILE, workspace_folders.as_deref()).await;
assert_eq!(diagnostics.len(), 4);
assert!(diagnostics[0].1.data.is_none());
assert_eq!(diagnostics[0].1.message.len(), 34);
assert!(diagnostics[1].1.data.is_some());
assert_eq!(diagnostics[1].1.message.len(), 24);
assert!(diagnostics[2].1.data.is_none());
assert_eq!(diagnostics[2].1.message.len(), 40);
assert!(diagnostics[3].1.data.is_none());
assert_eq!(diagnostics[3].1.message.len(), 766);
}
#[tokio::test]
#[cfg(not(target_os = "windows"))]
async fn test_bacon_diagnostics_production_and_deduplication() {
let tmp_dir = TempDir::new().unwrap();
let file_path = tmp_dir.path().join(".bacon-locations");
let mut tmp_file = std::fs::File::create(file_path).unwrap();
let error_path = format!("{}/src/lib.rs", tmp_dir.path().display());
let error_path_url = str::parse::<Uri>(&format!("file://{error_path}")).unwrap();
writeln!(
tmp_file,
"error|:|{error_path}|:|352|:|352|:|9|:|20|:|cannot find value `one` in this scope|:|none|:|none"
)
.unwrap();
// duplicate the line
writeln!(
tmp_file,
"error|:|{error_path}|:|352|:|352|:|9|:|20|:|cannot find value `one` in this scope|:|none|:|none"
)
.unwrap();
writeln!(
tmp_file,
"warning|:|{error_path}|:|354|:|354|:|9|:|20|:|cannot find value `two` in this scope|:|some|:|none"
)
.unwrap();
writeln!(
tmp_file,
"help|:|{error_path}|:|356|:|356|:|9|:|20|:|cannot find value `three` in this scope|:|none|:|some other"
)
.unwrap();
let workspace_folders = Some(vec![WorkspaceFolder {
name: tmp_dir.path().display().to_string(),
uri: str::parse::<Uri>(&format!("file://{}", tmp_dir.path().display())).unwrap(),
}]);
let diagnostics = Bacon::diagnostics(&error_path_url, LOCATIONS_FILE, workspace_folders.as_deref()).await;
assert_eq!(diagnostics.len(), 3);
let diagnostics_vec =
Bacon::diagnostics_vec(&error_path_url, LOCATIONS_FILE, workspace_folders.as_deref()).await;
assert_eq!(diagnostics_vec.len(), 3);
}
#[test]
fn test_parse_severity_known_levels() {
assert_eq!(Bacon::parse_severity("error"), DiagnosticSeverity::ERROR);
assert_eq!(Bacon::parse_severity("warning"), DiagnosticSeverity::WARNING);
assert_eq!(Bacon::parse_severity("note"), DiagnosticSeverity::INFORMATION);
assert_eq!(Bacon::parse_severity("info"), DiagnosticSeverity::INFORMATION);
assert_eq!(Bacon::parse_severity("information"), DiagnosticSeverity::INFORMATION);
assert_eq!(Bacon::parse_severity("failure-note"), DiagnosticSeverity::INFORMATION);
assert_eq!(Bacon::parse_severity("help"), DiagnosticSeverity::HINT);
assert_eq!(Bacon::parse_severity("hint"), DiagnosticSeverity::HINT);
}
#[test]
fn test_parse_severity_unknown_level_defaults_to_information() {
assert_eq!(
Bacon::parse_severity("future-rustc-level"),
DiagnosticSeverity::INFORMATION
);
}
#[test]
fn test_parse_positions_valid() {
let parts = ["1", "2", "3", "4"];
assert_eq!(Bacon::parse_positions(&parts), Some((1, 2, 3, 4)));
}
#[test]
fn test_parse_positions_non_numeric_returns_none() {
let parts = ["1", "x", "3", "4"];
assert_eq!(Bacon::parse_positions(&parts), None);
}
#[test]
fn test_parse_positions_too_few_fields_returns_none() {
let parts = ["1", "2", "3"];
assert_eq!(Bacon::parse_positions(&parts), None);
}
#[test]
#[cfg(not(target_os = "windows"))]
fn test_parse_bacon_diagnostic_line_with_replacement_attaches_correction() {
// Skipped on Windows: this test asserts the produced URI as a unix-style
// string. On Windows `Path::new("/proj").join("src/lib.rs")` produces
// backslashes which percent-encode to `%5C` in the URI.
let line = "warning|:|src/lib.rs|:|10|:|10|:|5|:|8|:|unused import|:|none|:|use foo::bar;";
let (uri, diag) = Bacon::parse_bacon_diagnostic_line(line, Path::new("/proj")).expect("must parse");
assert_eq!(uri.to_string(), "file:///proj/src/lib.rs");
assert!(
diag.data.is_some(),
"non-`none` replacement should attach correction data"
);
// Position is converted from 1-based to 0-based.
assert_eq!(diag.range.start.line, 9);
assert_eq!(diag.range.start.character, 4);
}
#[test]
fn test_parse_bacon_diagnostic_line_zero_position_saturates() {
// Defensive: 0 in any position field should saturate to 0 rather than
// panic on `0 - 1`.
let line = "error|:|src/lib.rs|:|0|:|0|:|0|:|0|:|boom|:|none|:|none";
let (_, diag) = Bacon::parse_bacon_diagnostic_line(line, Path::new("/p")).unwrap();
assert_eq!(diag.range.start.line, 0);
assert_eq!(diag.range.start.character, 0);
assert_eq!(diag.range.end.line, 0);
assert_eq!(diag.range.end.character, 0);
}
#[test]
fn test_parse_bacon_diagnostic_line_strips_ansi_from_rendered() {
let line = "error|:|src/lib.rs|:|1|:|1|:|1|:|1|:|raw|:|\u{1b}[31mbright red\u{1b}[0m|:|none";
let (_, diag) = Bacon::parse_bacon_diagnostic_line(line, Path::new("/p")).unwrap();
// When the rendered slot is non-`none`, it replaces the message and
// ANSI codes are stripped.
assert_eq!(diag.message, "bright red");
}
#[test]
fn test_parse_bacon_diagnostic_line_too_few_fields_returns_none() {
let line = "error|:|/file|:|1|:|2|:|3|:|4|:|message";
assert_eq!(Bacon::parse_bacon_diagnostic_line(line, Path::new("/p")), None);
}
#[test]
fn test_deduplicate_diagnostics_skips_when_path_does_not_match() {
let other_uri = str::parse::<Uri>("file:///other.rs").unwrap();
let target_uri = str::parse::<Uri>("file:///target.rs").unwrap();
let mut diagnostics = Vec::new();
let mut seen = HashSet::new();
let diag = Diagnostic {
range: Range::default(),
severity: Some(DiagnosticSeverity::ERROR),
message: "msg".into(),
..Diagnostic::default()
};
Bacon::deduplicate_diagnostics(other_uri, &target_uri, diag, &mut diagnostics, &mut seen);
assert!(diagnostics.is_empty(), "diagnostic for a different URI must be dropped");
assert!(seen.is_empty(), "seen set must not record skipped entries");
}
#[test]
fn test_deduplicate_diagnostics_drops_exact_duplicate() {
let uri = str::parse::<Uri>("file:///x.rs").unwrap();
let mut diagnostics = Vec::new();
let mut seen = HashSet::new();
let make = || Diagnostic {
range: Range::default(),
severity: Some(DiagnosticSeverity::ERROR),
message: "same".into(),
..Diagnostic::default()
};
Bacon::deduplicate_diagnostics(uri.clone(), &uri, make(), &mut diagnostics, &mut seen);
Bacon::deduplicate_diagnostics(uri.clone(), &uri, make(), &mut diagnostics, &mut seen);
assert_eq!(diagnostics.len(), 1, "duplicate should not be re-added");
}
#[test]
fn test_deduplicate_diagnostics_keeps_distinct_severity() {
let uri = str::parse::<Uri>("file:///x.rs").unwrap();
let mut diagnostics = Vec::new();
let mut seen = HashSet::new();
let mut diag = Diagnostic {
range: Range::default(),
severity: Some(DiagnosticSeverity::ERROR),
message: "m".into(),
..Diagnostic::default()
};
Bacon::deduplicate_diagnostics(uri.clone(), &uri, diag.clone(), &mut diagnostics, &mut seen);
diag.severity = Some(DiagnosticSeverity::WARNING);
Bacon::deduplicate_diagnostics(uri.clone(), &uri, diag, &mut diagnostics, &mut seen);
assert_eq!(diagnostics.len(), 2, "different severity ⇒ different diagnostic");
}
#[tokio::test]
async fn test_find_bacon_locations_finds_nested_files() {
let tmp = TempDir::new().unwrap();
// Create root/.bacon-locations and root/sub/sub2/.bacon-locations,
// plus an unrelated file that must not match.
let root = tmp.path();
std::fs::write(root.join(".bacon-locations"), "").unwrap();
let nested = root.join("sub").join("sub2");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(nested.join(".bacon-locations"), "").unwrap();
std::fs::write(root.join("sub").join("other.txt"), "").unwrap();
let mut found = Bacon::find_bacon_locations(root, ".bacon-locations").await.unwrap();
found.sort();
assert_eq!(found.len(), 2, "should find both .bacon-locations files");
assert!(found.iter().all(|p| p.file_name().unwrap() == ".bacon-locations"));
}
#[tokio::test]
async fn test_find_bacon_locations_empty_dir_returns_empty() {
let tmp = TempDir::new().unwrap();
let found = Bacon::find_bacon_locations(tmp.path(), ".bacon-locations")
.await
.unwrap();
assert!(found.is_empty());
}
#[tokio::test]
async fn test_find_bacon_locations_missing_root_errors() {
let tmp = TempDir::new().unwrap();
let missing = tmp.path().join("does-not-exist");
let result = Bacon::find_bacon_locations(&missing, ".bacon-locations").await;
assert!(result.is_err(), "missing root must surface an io error");
}
#[tokio::test]
async fn test_validate_preferences_impl_creates_when_missing_and_requested() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("nested-prefs.toml");
// bacon prefs lookup returns one path, file does not exist, create_prefs_file=true.
let prefs_list = target.to_str().unwrap();
Bacon::validate_preferences_impl(prefs_list.as_bytes(), true)
.await
.expect("should create prefs file when missing");
assert!(target.exists(), "prefs file should be created");
// And the freshly-created file must validate cleanly.
Bacon::validate_preferences_file(&target)
.await
.expect("freshly created prefs file should validate");
}
#[tokio::test]
async fn test_validate_preferences_impl_no_create_when_disabled() {
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("absent-prefs.toml");
let prefs_list = target.to_str().unwrap();
Bacon::validate_preferences_impl(prefs_list.as_bytes(), false)
.await
.expect("missing prefs with create=false is not an error");
assert!(!target.exists());
}
}