use std::collections::{HashMap, HashSet};
use std::env;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use argh::FromArgs;
use bacon::Bacon;
use flume::RecvError;
use ls_types::{Diagnostic, DiagnosticSeverity, MessageType, ProgressToken, Range, Uri, WorkspaceFolder};
use native::Cargo;
use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};
use serde_json::{Map, Value};
use tokio::sync::{RwLock, RwLockWriteGuard};
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use tower_lsp_server::{Client, LspService, Server, jsonrpc};
use tracing_subscriber::fmt::format::FmtSpan;
mod bacon;
mod lsp;
mod native;
const PKG_NAME: &str = env!("CARGO_PKG_NAME");
pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
const LOCATIONS_FILE: &str = ".bacon-locations";
const BACON_BACKGROUND_COMMAND: &str = "bacon";
const BACON_BACKGROUND_COMMAND_ARGS: &str = "--headless -j bacon-ls";
const PATH_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'<')
.add(b'>')
.add(b'?')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}')
.add(b'%');
pub(crate) fn path_to_file_uri(path: &str) -> String {
format!("file://{}", utf8_percent_encode(path, PATH_ENCODE_SET))
}
pub(crate) type DiagKey = (Range, i32, String);
pub(crate) fn diag_key(d: &Diagnostic) -> DiagKey {
(d.range, severity_tag(d.severity), d.message.clone())
}
fn severity_tag(s: Option<DiagnosticSeverity>) -> i32 {
match s {
None => 0,
Some(s) if s == DiagnosticSeverity::ERROR => 1,
Some(s) if s == DiagnosticSeverity::WARNING => 2,
Some(s) if s == DiagnosticSeverity::INFORMATION => 3,
Some(s) if s == DiagnosticSeverity::HINT => 4,
Some(_) => -1,
}
}
#[derive(Debug, FromArgs)]
pub struct Args {
#[argh(switch, short = 'v')]
pub version: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BackendChoice {
Cargo,
Bacon,
}
#[derive(Debug)]
enum BackendRuntime {
Bacon {
config: BaconOptions,
runtime: BaconRuntime,
},
Cargo {
config: CargoOptions,
runtime: CargoRuntime,
},
}
impl BackendRuntime {
fn backend_choice(&self) -> BackendChoice {
match self {
Self::Bacon { .. } => BackendChoice::Bacon,
Self::Cargo { .. } => BackendChoice::Cargo,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CargoRunState {
Idle,
Running,
RunningPending,
}
#[derive(Debug, Copy, Clone)]
pub(crate) enum PublishMode {
CancelRunning,
QueueIfRunning,
}
#[derive(Debug)]
pub(crate) struct CargoOptions {
pub(crate) command: String,
pub(crate) features: Vec<String>,
pub(crate) package: Option<String>,
pub(crate) extra_command_args: Vec<String>,
pub(crate) env: Vec<(String, String)>,
pub(crate) publish_mode: PublishMode,
pub(crate) refresh_interval_seconds: Option<Duration>,
pub(crate) separate_child_diagnostics: Option<bool>,
pub(crate) check_on_save: bool,
pub(crate) clear_diagnostics_on_check: bool,
}
impl CargoOptions {
pub(crate) fn build_command_args(&self) -> Vec<String> {
let mut args = vec![self.command.clone()];
args.push("--message-format=json-diagnostic-rendered-ansi".to_string());
if !self.features.is_empty() {
args.push("--features".to_string());
let mut features = String::new();
for feature in &self.features[..self.features.len() - 1] {
features += feature;
features += ",";
}
features += &self.features[self.features.len() - 1];
args.push(features);
}
if let Some(pkg) = self.package.clone() {
args.push("-p".to_string());
args.push(pkg);
}
for arg in self.extra_command_args.iter().cloned() {
args.push(arg);
}
args
}
pub(crate) fn update_from_json_obj(&mut self, cargo_obj: &Map<String, Value>) -> jsonrpc::Result<()> {
if let Some(value) = cargo_obj.get("command") {
self.command = value
.as_str()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
.to_string();
}
if let Some(value) = cargo_obj.get("features") {
self.features = value
.as_array()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
.iter()
.map(|item| {
item.as_str()
.map(|s| s.to_string())
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))
})
.collect::<jsonrpc::Result<Vec<_>>>()?;
}
if let Some(value) = cargo_obj.get("package") {
self.package = Some(
value
.as_str()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
.to_string(),
);
}
if let Some(value) = cargo_obj.get("extraArgs") {
self.extra_command_args = value
.as_array()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
.iter()
.map(|item| {
item.as_str()
.map(|s| s.to_string())
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))
})
.collect::<jsonrpc::Result<Vec<_>>>()?;
}
if let Some(value) = cargo_obj.get("env") {
self.env = value
.as_object()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
.iter()
.map(|(k, v)| {
let val = v
.as_str()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
Ok((k.clone(), val.to_string()))
})
.collect::<jsonrpc::Result<Vec<_>>>()?;
}
if let Some(value) = cargo_obj.get("cancelRunning") {
let cancel = value
.as_bool()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
self.publish_mode = if cancel {
PublishMode::CancelRunning
} else {
PublishMode::QueueIfRunning
};
}
if let Some(value) = cargo_obj.get("refreshIntervalSeconds") {
if value.is_null() {
self.refresh_interval_seconds = None;
} else {
let seconds = value
.as_i64()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
if seconds < 0 {
self.refresh_interval_seconds = None;
} else {
self.refresh_interval_seconds = Some(Duration::from_secs(seconds as u64));
}
}
}
if let Some(value) = cargo_obj.get("separateChildDiagnostics") {
self.separate_child_diagnostics = value.as_bool();
}
if let Some(value) = cargo_obj.get("checkOnSave") {
self.check_on_save = value
.as_bool()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
}
if let Some(value) = cargo_obj.get("clearDiagnosticsOnCheck") {
self.clear_diagnostics_on_check = value
.as_bool()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
}
Ok(())
}
pub(crate) fn reset(&mut self) {
*self = Self::default();
}
}
impl Default for CargoOptions {
fn default() -> Self {
Self {
env: Vec::new(),
publish_mode: PublishMode::CancelRunning,
command: "check".to_string(),
features: vec![],
extra_command_args: vec![],
package: None,
refresh_interval_seconds: Some(Duration::from_secs(5)),
separate_child_diagnostics: None,
check_on_save: true,
clear_diagnostics_on_check: false,
}
}
}
#[derive(Debug)]
pub(crate) struct BaconOptions {
pub(crate) locations_file: String,
pub(crate) run_in_background: bool,
pub(crate) run_in_background_command: String,
pub(crate) run_in_background_command_args: String,
pub(crate) validate_preferences: bool,
pub(crate) create_preferences_file: bool,
pub(crate) synchronize_all_open_files_wait: Duration,
pub(crate) update_on_save: bool,
pub(crate) update_on_save_wait: Duration,
}
impl BaconOptions {
pub(crate) fn update_from_json_obj(&mut self, bacon_obj: &Map<String, Value>) -> jsonrpc::Result<()> {
if let Some(value) = bacon_obj.get("locationsFile") {
self.locations_file = value
.as_str()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
.to_string();
}
if let Some(value) = bacon_obj.get("runInBackground") {
self.run_in_background = value
.as_bool()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
}
if let Some(value) = bacon_obj.get("runInBackgroundCommand") {
self.run_in_background_command = value
.as_str()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
.to_string();
}
if let Some(value) = bacon_obj.get("runInBackgroundCommandArguments") {
self.run_in_background_command_args = value
.as_str()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?
.to_string();
}
if let Some(value) = bacon_obj.get("validatePreferences") {
self.validate_preferences = value
.as_bool()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
}
if let Some(value) = bacon_obj.get("createPreferencesFile") {
self.create_preferences_file = value
.as_bool()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
}
if let Some(value) = bacon_obj.get("synchronizeAllOpenFilesWaitMillis") {
self.synchronize_all_open_files_wait = Duration::from_millis(
value
.as_u64()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?,
);
}
if let Some(value) = bacon_obj.get("updateOnSave") {
self.update_on_save = value
.as_bool()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?;
}
if let Some(value) = bacon_obj.get("updateOnSaveWaitMillis") {
self.update_on_save_wait = Duration::from_millis(
value
.as_u64()
.ok_or(jsonrpc::Error::new(jsonrpc::ErrorCode::InvalidParams))?,
);
}
Ok(())
}
pub fn reset(&mut self) {
*self = Self::default();
}
}
impl Default for BaconOptions {
fn default() -> Self {
Self {
locations_file: LOCATIONS_FILE.to_string(),
run_in_background: true,
run_in_background_command: BACON_BACKGROUND_COMMAND.to_string(),
run_in_background_command_args: BACON_BACKGROUND_COMMAND_ARGS.to_string(),
validate_preferences: true,
create_preferences_file: true,
synchronize_all_open_files_wait: Duration::from_millis(2000),
update_on_save: true,
update_on_save_wait: Duration::from_millis(1000),
}
}
}
#[derive(Debug)]
pub(crate) struct CargoRuntime {
cancel_token: CancellationToken,
run_state: CargoRunState,
files_with_diags: HashSet<Uri>,
diagnostics_version: i32,
build_folder: PathBuf,
last_run_started: Option<Instant>,
}
impl Default for CargoRuntime {
fn default() -> Self {
Self {
cancel_token: CancellationToken::new(),
run_state: CargoRunState::Idle,
files_with_diags: HashSet::new(),
diagnostics_version: 0,
build_folder: PathBuf::new(),
last_run_started: None,
}
}
}
#[derive(Debug)]
pub(crate) struct BaconRuntime {
pub(crate) shutdown_token: CancellationToken,
pub(crate) open_files: HashSet<Uri>,
pub(crate) command_handle: Option<JoinHandle<()>>,
pub(crate) sync_files_handle: JoinHandle<()>,
pub(crate) diagnostics_version: i32,
}
#[derive(Debug, Default)]
struct State {
project_root: Option<PathBuf>,
workspace_folders: Option<Vec<WorkspaceFolder>>,
diagnostics_data_supported: bool,
related_information_supported: bool,
backend: Option<BackendRuntime>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct CorrectionEdit {
pub(crate) range: Range,
pub(crate) new_text: String,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct Correction {
pub(crate) label: String,
pub(crate) edits: Vec<CorrectionEdit>,
}
impl Correction {
pub(crate) fn from_single(range: Range, new_text: &str) -> Self {
let label = if new_text.is_empty() {
"Remove".to_string()
} else {
format!("Replace with: {new_text}")
};
Self {
label,
edits: vec![CorrectionEdit {
range,
new_text: new_text.to_string(),
}],
}
}
pub(crate) fn from_multi(edits: Vec<CorrectionEdit>) -> Self {
let label = match edits.iter().find(|e| !e.new_text.is_empty()) {
None => "Remove".to_string(),
Some(e) => format!("Replace with: {}", e.new_text),
};
Self { label, edits }
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
struct DiagnosticData {
corrections: Vec<Correction>,
}
#[derive(Debug)]
pub struct BaconLs {
client: Arc<Client>,
state: Arc<RwLock<State>>,
}
impl BaconLs {
fn new(client: Client) -> Self {
Self {
client: Arc::new(client),
state: Arc::new(RwLock::new(State::default())),
}
}
fn configure_tracing(log_level: Option<String>) {
let level = log_level.unwrap_or_else(|| env::var("RUST_LOG").unwrap_or("off".to_string()));
if level == "off" {
return;
}
let log_path = format!("{PKG_NAME}.log");
let file = match std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&log_path)
{
Ok(file) => file,
Err(e) => {
eprintln!("{PKG_NAME}: could not open log file {log_path}: {e} (tracing disabled)");
return;
}
};
tracing_subscriber::fmt()
.with_env_filter(level)
.with_writer(file)
.with_thread_names(true)
.with_span_events(FmtSpan::CLOSE)
.with_target(true)
.with_file(true)
.with_line_number(true)
.init();
}
pub async fn serve() {
Self::configure_tracing(None);
let stdin = tokio::io::stdin();
let stdout = tokio::io::stdout();
let (service, socket) = LspService::new(Self::new);
Server::new(stdin, stdout, socket).serve(service).await;
std::process::exit(0);
}
async fn find_git_root_directory(path: &Path) -> Option<PathBuf> {
let output = tokio::process::Command::new("git")
.arg("-C")
.arg(path)
.arg("rev-parse")
.arg("--show-toplevel")
.output()
.await
.ok()?;
if output.status.success() {
String::from_utf8(output.stdout).ok().map(|v| PathBuf::from(v.trim()))
} else {
None
}
}
fn detect_backend(values: &Map<String, Value>) -> Result<BackendChoice, String> {
if let Some(value) = values.get("backend") {
let backend = value.as_str().ok_or("'backend' must be a string")?;
match backend {
"cargo" => Ok(BackendChoice::Cargo),
"bacon" => Ok(BackendChoice::Bacon),
other => Err(format!("Invalid backend value '{other}'. Must be 'cargo' or 'bacon'.")),
}
} else {
let has_cargo = values.get("cargo").and_then(|v| v.as_object()).is_some();
let has_bacon = values.get("bacon").and_then(|v| v.as_object()).is_some();
match (has_cargo, has_bacon) {
(true, true) => Err(
"Both 'cargo' and 'bacon' config sections present without a 'backend' key. \
Set 'backend' to 'cargo' or 'bacon'."
.to_string(),
),
(_, true) => Ok(BackendChoice::Bacon),
_ => Ok(BackendChoice::Cargo),
}
}
}
async fn pull_configuration(&self) {
tracing::debug!("pull_configuration");
let configuration_fut = self.client.configuration(vec![ls_types::ConfigurationItem {
scope_uri: None,
section: Some("bacon_ls".to_string()),
}]);
let response = match tokio::time::timeout(std::time::Duration::from_secs(5), configuration_fut).await {
Ok(Ok(response)) => response,
Ok(Err(e)) => {
tracing::error!("failed to pull configuration: {e}");
return;
}
Err(_) => {
tracing::warn!("workspace/configuration request timed out; proceeding with defaults");
return;
}
};
let Some(settings) = response.into_iter().next() else {
tracing::warn!("empty configuration response from client");
return;
};
tracing::trace!("pulled configuration: {settings:#?}");
self.adapt_to_settings(&settings).await;
}
async fn adapt_to_settings(&self, settings: &Value) {
let mut state = self.state.write().await;
let Some(values) = settings.as_object() else {
tracing::warn!("configuration is not a JSON object");
return;
};
if state.backend.is_none() {
let backend_choice = match Self::detect_backend(values) {
Ok(choice) => {
tracing::info!(backend = ?choice, "backend detected");
choice
}
Err(msg) => {
tracing::error!("{msg}");
self.client.show_message(MessageType::ERROR, &msg).await;
return;
}
};
match backend_choice {
BackendChoice::Bacon => {
let mut config = BaconOptions::default();
if let Some(bacon_obj) = values.get("bacon").and_then(|v| v.as_object())
&& let Err(e) = config.update_from_json_obj(bacon_obj)
{
tracing::error!("invalid bacon configuration: {e}");
self.client
.show_message(MessageType::ERROR, format!("Error in \"bacon\" section: {e}"))
.await;
}
if config.validate_preferences {
if let Err(e) = Bacon::validate_preferences(
&config.run_in_background_command,
config.create_preferences_file,
)
.await
{
tracing::error!("{e}");
self.client.show_message(MessageType::ERROR, e).await;
}
} else {
tracing::warn!("skipping validation of bacon preferences, validateBaconPreferences is false");
}
let proj_root = state.project_root.clone();
let shutdown_token = CancellationToken::new();
let command_handle = if config.run_in_background {
let mut current_dir = None;
if let Ok(cwd) = env::current_dir() {
current_dir = Self::find_git_root_directory(&cwd).await;
if let Some(dir) = ¤t_dir {
if !dir.join("Cargo.toml").exists() {
current_dir = proj_root;
}
} else {
current_dir = proj_root;
}
}
match Bacon::run_in_background(
&config.run_in_background_command,
&config.run_in_background_command_args,
current_dir.as_ref(),
shutdown_token.clone(),
)
.await
{
Ok(command) => {
tracing::info!("bacon was started successfully and is running in the background");
Some(command)
}
Err(e) => {
tracing::error!("{e}");
self.client.show_message(MessageType::ERROR, e).await;
None
}
}
} else {
tracing::warn!("skipping background bacon startup, runBaconInBackground is false");
None
};
let task_state = self.state.clone();
let task_client = self.client.clone();
state.backend = Some(BackendRuntime::Bacon {
config,
runtime: BaconRuntime {
shutdown_token,
open_files: HashSet::new(),
command_handle,
sync_files_handle: tokio::task::spawn(Self::synchronize_diagnostics(
task_state,
task_client,
)),
diagnostics_version: 0,
},
});
tracing::info!("bacon backend initialized");
}
BackendChoice::Cargo => {
let mut config = CargoOptions::default();
if let Some(cargo_obj) = values.get("cargo").and_then(|v| v.as_object())
&& let Err(e) = config.update_from_json_obj(cargo_obj)
{
tracing::error!("invalid cargo configuration: {e}");
self.client
.show_message(MessageType::ERROR, format!("Error in \"cargo\" section: {e}"))
.await;
}
if let Err(e) = Self::init_cargo_backend(&mut state, config) {
tracing::error!("{e}");
drop(state);
self.client.show_message(MessageType::ERROR, e).await;
return;
}
drop(state);
}
}
} else {
let current_choice = match &state.backend {
Some(BackendRuntime::Bacon { .. }) => BackendChoice::Bacon,
Some(BackendRuntime::Cargo { .. }) => BackendChoice::Cargo,
None => unreachable!("backend is Some in this branch"),
};
let desired = match Self::detect_backend(values) {
Ok(choice) => choice,
Err(err) => {
tracing::error!("invalid backend configuration on reload: {err}");
self.client.show_message(MessageType::ERROR, &err).await;
return;
}
};
if desired != current_choice {
let msg = "Backend cannot be changed while the server is running. \
Restart the server to switch backends.";
tracing::error!("{msg}");
self.client.show_message(MessageType::ERROR, msg).await;
return;
}
let project_root = state.project_root.clone();
match &mut state.backend {
Some(BackendRuntime::Cargo { config, runtime }) => {
config.reset();
if let Some(cargo_obj) = values.get("cargo").and_then(|v| v.as_object())
&& let Err(e) = config.update_from_json_obj(cargo_obj)
{
tracing::error!("invalid cargo configuration: {e}");
self.client
.show_message(MessageType::ERROR, format!("Error in \"cargo\" section: {e}"))
.await;
}
if let Some(root) = project_root {
runtime.build_folder = root;
}
tracing::debug!("cargo configuration updated");
}
Some(BackendRuntime::Bacon { config, .. }) => {
config.reset();
if let Some(bacon_obj) = values.get("bacon").and_then(|v| v.as_object())
&& let Err(e) = config.update_from_json_obj(bacon_obj)
{
tracing::error!("invalid bacon configuration: {e}");
self.client
.show_message(MessageType::ERROR, format!("Error in \"bacon\" section: {e}"))
.await;
}
tracing::debug!("bacon configuration updated");
}
None => unreachable!("backend is Some in this branch"),
}
}
}
fn init_cargo_backend(state: &mut RwLockWriteGuard<'_, State>, config: CargoOptions) -> Result<(), String> {
let build_folder = match &state.project_root {
Some(root) => root.clone(),
None => match env::current_dir() {
Ok(cwd) => {
tracing::warn!(
"no Cargo project root detected; falling back to current working directory: {}",
cwd.display()
);
cwd
}
Err(e) => {
return Err(format!(
"cargo backend cannot start: no project root detected and current working \
directory is unavailable ({e}). Open a folder containing a Cargo.toml and \
restart the server."
));
}
},
};
let runtime = CargoRuntime {
build_folder,
..CargoRuntime::default()
};
tracing::info!(build_folder = ?runtime.build_folder, "cargo backend initialized");
state.backend = Some(BackendRuntime::Cargo { config, runtime });
Ok(())
}
async fn publish_cargo_diagnostics(&self) {
tracing::info!("starting cargo diagnostics run");
let mut guard = self.state.write().await;
let project_root = guard.project_root.clone();
let related_information_supported = guard.related_information_supported;
let Some(BackendRuntime::Cargo { config, runtime }) = &mut guard.backend else {
return;
};
let use_related_information = !config
.separate_child_diagnostics
.unwrap_or(!related_information_supported);
let cargo_command = config.command.clone();
let cargo_env = config.env.clone();
let cmd_args = config.build_command_args();
let publish_mode = config.publish_mode;
let clear_diagnostics_on_check = config.clear_diagnostics_on_check;
let build_folder = runtime.build_folder.clone();
runtime.diagnostics_version += 1;
runtime.last_run_started = Some(Instant::now());
let version = runtime.diagnostics_version;
let refresh_interval = config.refresh_interval_seconds;
let cancel_token = match publish_mode {
PublishMode::CancelRunning => {
runtime.cancel_token.cancel();
runtime.cancel_token = CancellationToken::new();
runtime.cancel_token.clone()
}
PublishMode::QueueIfRunning => match runtime.run_state {
CargoRunState::Running | CargoRunState::RunningPending => {
runtime.run_state = CargoRunState::RunningPending;
tracing::debug!("cargo already running, marking pending");
drop(guard);
return;
}
CargoRunState::Idle => {
runtime.run_state = CargoRunState::Running;
runtime.cancel_token.clone()
}
},
};
if clear_diagnostics_on_check {
for file in &runtime.files_with_diags {
self.client
.publish_diagnostics(file.clone(), vec![], Some(version))
.await;
}
runtime.files_with_diags.clear();
}
drop(guard);
let token = ProgressToken::Number(version);
let progress = self
.client
.progress(token, "checking")
.with_message(format!("cargo {cargo_command}"))
.with_percentage(0)
.begin()
.await;
let (tx, rx) = flume::unbounded();
let cargo_future = Cargo::cargo_diagnostics(
cmd_args,
&cargo_env,
project_root.as_ref(),
&build_folder,
use_related_information,
&progress,
tx,
);
let consumer_client = self.client.clone();
let diagnostic_consumer = async move {
let mut diagnostics_map = HashMap::<Uri, (Vec<Diagnostic>, HashSet<DiagKey>, bool)>::new();
fn accumulate_diagnostics(
recv_result: Result<(Uri, Diagnostic), RecvError>,
diagnostics_map: &mut HashMap<Uri, (Vec<Diagnostic>, HashSet<DiagKey>, bool)>,
) -> bool {
let Ok((url, diagnostic)) = recv_result else {
return true;
};
let (diagnostics, seen, dirty) = diagnostics_map.entry(url).or_default();
if seen.insert(diag_key(&diagnostic)) {
diagnostics.push(diagnostic);
*dirty = true;
}
false
}
if let Some(refresh_interval) = refresh_interval {
let mut t = std::time::Instant::now();
loop {
tokio::select! {
result = rx.recv_async() => {
if accumulate_diagnostics(result, &mut diagnostics_map) {
break;
}
}
_ = tokio::time::sleep_until(tokio::time::Instant::from_std(t + refresh_interval)) => {}
}
if t.elapsed() >= refresh_interval {
for (url, (diagnostics, _seen, dirty)) in diagnostics_map.iter_mut() {
if *dirty {
consumer_client
.publish_diagnostics(url.clone(), diagnostics.clone(), Some(version))
.await;
*dirty = false;
}
}
t = std::time::Instant::now();
}
}
} else {
loop {
if accumulate_diagnostics(rx.recv_async().await, &mut diagnostics_map) {
break;
}
}
}
diagnostics_map
};
let consumer_handle = tokio::spawn(diagnostic_consumer);
let result = tokio::select! {
result = cargo_future => {
result.map(|_| false)
},
() = cancel_token.cancelled() => {
tracing::info!("cargo run cancelled by newer request");
Ok(true)
}
};
let was_cancelled = match result {
Ok(t) => t,
Err(error) => {
tracing::error!(?error, "error building diagnostics");
progress.finish().await;
let _ = consumer_handle.await;
self.client.log_message(MessageType::ERROR, format!("{error}")).await;
self.client.show_message(MessageType::ERROR, format!("{error}")).await;
return;
}
};
if was_cancelled {
let _ = consumer_handle.await;
progress.finish_with_message("cancelled by user").await;
return;
}
tracing::info!("cargo run finished, collecting diagnostics");
let mut diagnostics = match consumer_handle.await {
Ok(d) => d,
Err(error) => {
tracing::error!(?error, "diagnostics fetching task panicked");
progress.finish().await;
self.client.log_message(MessageType::ERROR, format!("{error}")).await;
self.client.show_message(MessageType::ERROR, format!("{error}")).await;
return;
}
};
let mut state = self.state.write().await;
let Some(BackendRuntime::Cargo {
config,
runtime: cargo_rt,
}) = &mut state.backend
else {
tracing::error!("backend changed during cargo run");
return;
};
let publish_mode = config.publish_mode;
if let PublishMode::CancelRunning = publish_mode
&& version != cargo_rt.diagnostics_version
{
tracing::info!(
version,
current = cargo_rt.diagnostics_version,
"skipping stale publish"
);
progress.finish_with_message("superseded by newer run").await;
return;
}
for file in cargo_rt.files_with_diags.drain() {
let _ = diagnostics.entry(file).or_insert((vec![], HashSet::new(), true));
}
let mut num_warnings = 0;
let mut num_errors = 0;
for (uri, (diagnostics, _seen, is_dirty)) in diagnostics.into_iter() {
tracing::debug!(uri = uri.to_string(), "sent {} cargo diagnostics", diagnostics.len());
for diagnostic in &diagnostics {
match diagnostic.severity {
Some(DiagnosticSeverity::ERROR) => num_errors += 1,
Some(DiagnosticSeverity::WARNING) => num_warnings += 1,
Some(_) | None => {}
}
}
if !diagnostics.is_empty() {
let _ = cargo_rt.files_with_diags.insert(uri.clone());
}
if is_dirty {
self.client.publish_diagnostics(uri, diagnostics, Some(version)).await;
}
}
let message = format!("done, errors: {num_errors}, warnings: {num_warnings}");
progress.finish_with_message(message).await;
if let PublishMode::QueueIfRunning = publish_mode {
match cargo_rt.run_state {
CargoRunState::RunningPending => {
cargo_rt.run_state = CargoRunState::Idle;
drop(state);
tracing::info!("re-running cargo after queued request");
Box::pin(self.publish_cargo_diagnostics()).await;
}
_ => {
cargo_rt.run_state = CargoRunState::Idle;
drop(state);
}
}
}
}
async fn publish_bacon_diagnostics(&self, uri: &Uri) {
let mut guard = self.state.write().await;
let workspace_folders = guard.workspace_folders.clone();
let Some(BackendRuntime::Bacon { config, runtime }) = &mut guard.backend else {
return;
};
tracing::info!(uri = uri.to_string(), "publish bacon diagnostics");
let locations_file_name = config.locations_file.clone();
runtime.diagnostics_version = runtime.diagnostics_version.wrapping_add(1);
let version = runtime.diagnostics_version;
drop(guard);
Bacon::publish_diagnostics(
&self.client,
uri,
&locations_file_name,
workspace_folders.as_deref(),
version,
)
.await;
}
async fn synchronize_diagnostics(state: Arc<RwLock<State>>, client: Arc<Client>) {
Bacon::synchronize_diagnostics(state, client).await;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_can_configure_tracing() {
BaconLs::configure_tracing(Some("info".to_string()));
}
#[test]
fn test_path_to_file_uri_plain_ascii() {
let uri = path_to_file_uri("/home/me/src/lib.rs");
assert_eq!(uri, "file:///home/me/src/lib.rs");
let parsed = uri.parse::<Uri>().expect("must parse as Uri");
assert_eq!(parsed.path().as_str(), "/home/me/src/lib.rs");
}
#[test]
fn test_path_to_file_uri_escapes_space_and_hash_and_percent() {
let uri = path_to_file_uri("/home/me/My Projects/tests#1/file%.rs");
assert_eq!(uri, "file:///home/me/My%20Projects/tests%231/file%25.rs");
let parsed = uri.parse::<Uri>().expect("must parse as Uri");
assert_eq!(parsed.path().as_str(), "/home/me/My%20Projects/tests%231/file%25.rs");
}
#[test]
fn test_path_to_file_uri_preserves_path_separators() {
let uri = path_to_file_uri("/a/b/c");
assert_eq!(uri, "file:///a/b/c");
}
#[test]
fn test_path_to_file_uri_relative_path_preserves_segments() {
let uri = path_to_file_uri("src/lib.rs");
assert_eq!(uri, "file://src/lib.rs");
let parsed = uri.parse::<Uri>().expect("must parse as Uri");
assert_eq!(
parsed.authority().map(|a| a.host().to_string()),
Some("src".to_string())
);
assert_eq!(parsed.path().as_str(), "/lib.rs");
}
#[test]
fn test_cancel_mode_replaces_token() {
let original = CancellationToken::new();
let token = original.clone();
token.cancel();
assert!(original.is_cancelled());
let new_token = CancellationToken::new();
assert!(!new_token.is_cancelled());
}
#[test]
fn test_detect_backend_explicit_cargo() {
let values: Map<String, Value> = serde_json::from_str(r#"{"backend": "cargo"}"#).unwrap();
assert_eq!(BaconLs::detect_backend(&values).unwrap(), BackendChoice::Cargo);
}
#[test]
fn test_detect_backend_explicit_bacon() {
let values: Map<String, Value> = serde_json::from_str(r#"{"backend": "bacon"}"#).unwrap();
assert_eq!(BaconLs::detect_backend(&values).unwrap(), BackendChoice::Bacon);
}
#[test]
fn test_detect_backend_invalid_value() {
let values: Map<String, Value> = serde_json::from_str(r#"{"backend": "invalid"}"#).unwrap();
assert!(BaconLs::detect_backend(&values).is_err());
}
#[test]
fn test_detect_backend_infer_from_cargo_key() {
let values: Map<String, Value> = serde_json::from_str(r#"{"cargo": {"command": "check"}}"#).unwrap();
assert_eq!(BaconLs::detect_backend(&values).unwrap(), BackendChoice::Cargo);
}
#[test]
fn test_detect_backend_infer_from_bacon_key() {
let values: Map<String, Value> =
serde_json::from_str(r#"{"bacon": {"locationsFile": ".bacon-locations"}}"#).unwrap();
assert_eq!(BaconLs::detect_backend(&values).unwrap(), BackendChoice::Bacon);
}
#[test]
fn test_detect_backend_both_keys_error() {
let values: Map<String, Value> = serde_json::from_str(r#"{"cargo": {}, "bacon": {}}"#).unwrap();
assert!(BaconLs::detect_backend(&values).is_err());
}
#[test]
fn test_detect_backend_no_keys_defaults_to_cargo() {
let values: Map<String, Value> = serde_json::from_str(r#"{}"#).unwrap();
assert_eq!(BaconLs::detect_backend(&values).unwrap(), BackendChoice::Cargo);
}
#[test]
fn test_detect_backend_explicit_overrides_keys() {
let values: Map<String, Value> = serde_json::from_str(r#"{"backend": "cargo", "bacon": {}}"#).unwrap();
assert_eq!(BaconLs::detect_backend(&values).unwrap(), BackendChoice::Cargo);
}
#[test]
fn test_cargo_options_build_args_default() {
let args = CargoOptions::default().build_command_args();
assert_eq!(args, vec!["check", "--message-format=json-diagnostic-rendered-ansi"]);
}
#[test]
fn test_cargo_options_build_args_with_features() {
let opts = CargoOptions {
features: vec!["a".into(), "b".into(), "c".into()],
..CargoOptions::default()
};
let args = opts.build_command_args();
assert_eq!(
args,
vec![
"check",
"--message-format=json-diagnostic-rendered-ansi",
"--features",
"a,b,c"
]
);
}
#[test]
fn test_cargo_options_build_args_single_feature() {
let opts = CargoOptions {
features: vec!["only".into()],
..CargoOptions::default()
};
let args = opts.build_command_args();
assert_eq!(
args,
vec![
"check",
"--message-format=json-diagnostic-rendered-ansi",
"--features",
"only"
]
);
}
#[test]
fn test_cargo_options_build_args_with_package_and_extras() {
let opts = CargoOptions {
command: "clippy".into(),
package: Some("my-crate".into()),
extra_command_args: vec!["--workspace".into(), "--all-targets".into()],
..CargoOptions::default()
};
let args = opts.build_command_args();
assert_eq!(
args,
vec![
"clippy",
"--message-format=json-diagnostic-rendered-ansi",
"-p",
"my-crate",
"--workspace",
"--all-targets",
]
);
}
#[test]
fn test_cargo_options_update_from_json_full_roundtrip() {
let mut opts = CargoOptions::default();
let json = serde_json::json!({
"command": "clippy",
"features": ["a", "b"],
"package": "pkg",
"extraArgs": ["--workspace"],
"env": {"RUST_LOG": "trace"},
"cancelRunning": false,
"refreshIntervalSeconds": 10,
"separateChildDiagnostics": true,
"checkOnSave": false,
"clearDiagnosticsOnCheck": true,
});
let obj = json.as_object().unwrap();
opts.update_from_json_obj(obj).expect("should parse");
assert_eq!(opts.command, "clippy");
assert_eq!(opts.features, vec!["a".to_string(), "b".to_string()]);
assert_eq!(opts.package.as_deref(), Some("pkg"));
assert_eq!(opts.extra_command_args, vec!["--workspace".to_string()]);
assert_eq!(opts.env, vec![("RUST_LOG".into(), "trace".into())]);
assert!(matches!(opts.publish_mode, PublishMode::QueueIfRunning));
assert_eq!(opts.refresh_interval_seconds, Some(Duration::from_secs(10)));
assert_eq!(opts.separate_child_diagnostics, Some(true));
assert!(!opts.check_on_save);
assert!(opts.clear_diagnostics_on_check);
}
#[test]
fn test_cargo_options_update_from_json_refresh_null_means_no_partial() {
let mut opts = CargoOptions::default();
let json = serde_json::json!({"refreshIntervalSeconds": null});
opts.update_from_json_obj(json.as_object().unwrap()).unwrap();
assert_eq!(opts.refresh_interval_seconds, None);
}
#[test]
fn test_cargo_options_update_from_json_refresh_negative_means_no_partial() {
let mut opts = CargoOptions::default();
let json = serde_json::json!({"refreshIntervalSeconds": -1});
opts.update_from_json_obj(json.as_object().unwrap()).unwrap();
assert_eq!(opts.refresh_interval_seconds, None);
}
#[test]
fn test_cargo_options_update_from_json_rejects_wrong_type() {
let mut opts = CargoOptions::default();
let json = serde_json::json!({"command": 42});
assert!(opts.update_from_json_obj(json.as_object().unwrap()).is_err());
}
#[test]
fn test_cargo_options_update_from_json_partial_leaves_others_unchanged() {
let mut opts = CargoOptions {
command: "clippy".into(),
..CargoOptions::default()
};
let json = serde_json::json!({"checkOnSave": false});
opts.update_from_json_obj(json.as_object().unwrap()).unwrap();
assert_eq!(opts.command, "clippy");
assert!(!opts.check_on_save);
}
#[test]
fn test_cargo_options_reset_restores_defaults() {
let mut opts = CargoOptions {
command: "clippy".into(),
features: vec!["foo".into()],
check_on_save: false,
..CargoOptions::default()
};
opts.reset();
let defaults = CargoOptions::default();
assert_eq!(opts.command, defaults.command);
assert_eq!(opts.features, defaults.features);
assert_eq!(opts.check_on_save, defaults.check_on_save);
}
#[test]
fn test_bacon_options_update_from_json_full_roundtrip() {
let mut opts = BaconOptions::default();
let json = serde_json::json!({
"locationsFile": "custom.locations",
"runInBackground": false,
"runInBackgroundCommand": "/usr/local/bin/bacon",
"runInBackgroundCommandArguments": "--headless -j custom",
"validatePreferences": false,
"createPreferencesFile": false,
"synchronizeAllOpenFilesWaitMillis": 500,
"updateOnSave": false,
"updateOnSaveWaitMillis": 250,
});
opts.update_from_json_obj(json.as_object().unwrap()).unwrap();
assert_eq!(opts.locations_file, "custom.locations");
assert!(!opts.run_in_background);
assert_eq!(opts.run_in_background_command, "/usr/local/bin/bacon");
assert_eq!(opts.run_in_background_command_args, "--headless -j custom");
assert!(!opts.validate_preferences);
assert!(!opts.create_preferences_file);
assert_eq!(opts.synchronize_all_open_files_wait, Duration::from_millis(500));
assert!(!opts.update_on_save);
assert_eq!(opts.update_on_save_wait, Duration::from_millis(250));
}
#[test]
fn test_bacon_options_update_from_json_rejects_wrong_type() {
let mut opts = BaconOptions::default();
let json = serde_json::json!({"runInBackground": "yes"});
assert!(opts.update_from_json_obj(json.as_object().unwrap()).is_err());
}
#[test]
fn test_bacon_options_reset_restores_defaults() {
let mut opts = BaconOptions {
run_in_background: false,
locations_file: "foo".into(),
..BaconOptions::default()
};
opts.reset();
let defaults = BaconOptions::default();
assert_eq!(opts.run_in_background, defaults.run_in_background);
assert_eq!(opts.locations_file, defaults.locations_file);
}
#[test]
fn test_correction_from_single_empty_is_remove() {
let range = Range::default();
let c = Correction::from_single(range, "");
assert_eq!(c.label, "Remove");
assert_eq!(c.edits.len(), 1);
assert_eq!(c.edits[0].new_text, "");
}
#[test]
fn test_correction_from_single_nonempty_is_replace() {
let range = Range::default();
let c = Correction::from_single(range, "foo");
assert_eq!(c.label, "Replace with: foo");
assert_eq!(c.edits.len(), 1);
}
#[test]
fn test_correction_from_multi_all_empty_is_remove() {
let edits = vec![
CorrectionEdit {
range: Range::default(),
new_text: "".into(),
},
CorrectionEdit {
range: Range::default(),
new_text: "".into(),
},
];
let c = Correction::from_multi(edits);
assert_eq!(c.label, "Remove");
assert_eq!(c.edits.len(), 2);
}
#[test]
fn test_correction_from_multi_labels_by_first_nonempty() {
let edits = vec![
CorrectionEdit {
range: Range::default(),
new_text: "".into(),
},
CorrectionEdit {
range: Range::default(),
new_text: "new".into(),
},
];
let c = Correction::from_multi(edits);
assert_eq!(c.label, "Replace with: new");
}
#[test]
fn test_severity_tag_distinguishes_levels() {
assert_eq!(severity_tag(None), 0);
assert_eq!(severity_tag(Some(DiagnosticSeverity::ERROR)), 1);
assert_eq!(severity_tag(Some(DiagnosticSeverity::WARNING)), 2);
assert_eq!(severity_tag(Some(DiagnosticSeverity::INFORMATION)), 3);
assert_eq!(severity_tag(Some(DiagnosticSeverity::HINT)), 4);
let tags = [
severity_tag(Some(DiagnosticSeverity::ERROR)),
severity_tag(Some(DiagnosticSeverity::WARNING)),
severity_tag(Some(DiagnosticSeverity::INFORMATION)),
severity_tag(Some(DiagnosticSeverity::HINT)),
];
let unique: HashSet<_> = tags.iter().collect();
assert_eq!(unique.len(), tags.len());
}
#[test]
fn test_diag_key_collides_for_equal_diagnostics() {
let a = Diagnostic {
range: Range::default(),
severity: Some(DiagnosticSeverity::ERROR),
message: "hi".into(),
..Diagnostic::default()
};
let b = a.clone();
assert_eq!(diag_key(&a), diag_key(&b));
}
#[test]
fn test_diag_key_differs_when_message_differs() {
let mut a = Diagnostic {
range: Range::default(),
severity: Some(DiagnosticSeverity::ERROR),
message: "first".into(),
..Diagnostic::default()
};
let b = a.clone();
a.message = "second".into();
assert_ne!(diag_key(&a), diag_key(&b));
}
#[test]
fn test_path_to_file_uri_empty_path() {
assert_eq!(path_to_file_uri(""), "file://");
}
#[test]
fn test_correction_from_single_label_replaces_with_text() {
let c = Correction::from_single(Range::default(), "x");
assert_eq!(c.label, "Replace with: x");
assert_eq!(c.edits.len(), 1);
assert_eq!(c.edits[0].new_text, "x");
}
#[test]
fn test_correction_from_multi_empty_edits_is_remove() {
let c = Correction::from_multi(vec![]);
assert_eq!(c.label, "Remove");
assert!(c.edits.is_empty());
}
#[test]
fn test_cargo_options_env_roundtrip_preserves_order_in_serde_iteration() {
let mut opts = CargoOptions::default();
let json = serde_json::json!({
"env": {"A": "1", "B": "2", "C": "3"}
});
opts.update_from_json_obj(json.as_object().unwrap()).unwrap();
assert_eq!(opts.env.len(), 3);
let keys: Vec<_> = opts.env.iter().map(|(k, _)| k.as_str()).collect();
assert_eq!(keys, vec!["A", "B", "C"]);
}
#[test]
fn test_cargo_options_update_rejects_non_object_env() {
let mut opts = CargoOptions::default();
let json = serde_json::json!({"env": ["A=1"]});
assert!(opts.update_from_json_obj(json.as_object().unwrap()).is_err());
}
#[test]
fn test_cargo_options_update_rejects_non_string_env_value() {
let mut opts = CargoOptions::default();
let json = serde_json::json!({"env": {"A": 1}});
assert!(opts.update_from_json_obj(json.as_object().unwrap()).is_err());
}
#[test]
fn test_cargo_options_update_rejects_non_string_feature_item() {
let mut opts = CargoOptions::default();
let json = serde_json::json!({"features": ["a", 2, "c"]});
assert!(opts.update_from_json_obj(json.as_object().unwrap()).is_err());
}
#[test]
fn test_cargo_options_publish_mode_toggle_via_cancel_running() {
let mut opts = CargoOptions::default();
assert!(matches!(opts.publish_mode, PublishMode::CancelRunning));
opts.update_from_json_obj(serde_json::json!({"cancelRunning": false}).as_object().unwrap())
.unwrap();
assert!(matches!(opts.publish_mode, PublishMode::QueueIfRunning));
opts.update_from_json_obj(serde_json::json!({"cancelRunning": true}).as_object().unwrap())
.unwrap();
assert!(matches!(opts.publish_mode, PublishMode::CancelRunning));
}
#[test]
fn test_cargo_options_separate_child_diagnostics_can_unset() {
let mut opts = CargoOptions {
separate_child_diagnostics: Some(true),
..CargoOptions::default()
};
opts.update_from_json_obj(
serde_json::json!({"separateChildDiagnostics": null})
.as_object()
.unwrap(),
)
.unwrap();
assert_eq!(opts.separate_child_diagnostics, None);
}
#[tokio::test]
async fn test_find_git_root_directory_returns_none_outside_git() {
let tmp = tempfile::TempDir::new().unwrap();
let root = BaconLs::find_git_root_directory(tmp.path()).await;
assert_eq!(root, None);
}
#[tokio::test]
async fn test_find_git_root_directory_finds_top_of_repo() {
let crate_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let src = crate_root.join("src");
let from_subdir = BaconLs::find_git_root_directory(&src).await;
assert!(from_subdir.is_some(), "src/ is inside a git repo");
let from_root = BaconLs::find_git_root_directory(crate_root).await.unwrap();
assert_eq!(from_subdir.unwrap(), from_root);
}
#[test]
fn test_init_cargo_backend_uses_existing_project_root() {
let tmp = tempfile::TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let mut state = State {
project_root: Some(root.clone()),
..State::default()
};
let lock = RwLock::new(std::mem::take(&mut state));
let mut guard = lock.try_write().unwrap();
BaconLs::init_cargo_backend(&mut guard, CargoOptions::default())
.expect("init should succeed with explicit project root");
match &guard.backend {
Some(BackendRuntime::Cargo { runtime, .. }) => {
assert_eq!(runtime.build_folder, root);
assert_eq!(runtime.run_state, CargoRunState::Idle);
assert_eq!(runtime.diagnostics_version, 0);
}
other => panic!("expected Cargo backend, got {other:?}"),
}
}
#[test]
fn test_init_cargo_backend_falls_back_to_cwd_when_no_project_root() {
let mut state = State::default();
let lock = RwLock::new(std::mem::take(&mut state));
let mut guard = lock.try_write().unwrap();
BaconLs::init_cargo_backend(&mut guard, CargoOptions::default())
.expect("init should fall back to CWD when project root is unset");
match &guard.backend {
Some(BackendRuntime::Cargo { runtime, .. }) => {
let cwd = std::env::current_dir().unwrap();
assert_eq!(runtime.build_folder, cwd, "should fall back to CWD");
}
other => panic!("expected Cargo backend, got {other:?}"),
}
}
#[test]
fn test_cargo_options_build_args_with_env_does_not_leak_into_args() {
let opts = CargoOptions {
env: vec![("A".into(), "1".into())],
..CargoOptions::default()
};
let args = opts.build_command_args();
assert!(args.iter().all(|a| !a.contains("A=1") && !a.contains("=1")));
}
}