bacon_ls/
lib.rs

1//! Bacon Language Server
2use std::borrow::Cow;
3use std::collections::HashSet;
4use std::env;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::time::Duration;
8
9use argh::FromArgs;
10use bacon::Bacon;
11use ls_types::{ProgressToken, Uri, WorkspaceFolder};
12use native::Cargo;
13use rand::Rng;
14use tokio::sync::RwLock;
15use tokio::task::JoinHandle;
16use tokio::time::Instant;
17use tokio_util::sync::CancellationToken;
18use tower_lsp_server::{Client, LspService, Server};
19use tracing_subscriber::fmt::format::FmtSpan;
20
21mod bacon;
22mod lsp;
23mod native;
24
25const PKG_NAME: &str = env!("CARGO_PKG_NAME");
26pub const PKG_VERSION: &str = env!("CARGO_PKG_VERSION");
27const LOCATIONS_FILE: &str = ".bacon-locations";
28const BACON_BACKGROUND_COMMAND: &str = "bacon";
29const BACON_BACKGROUND_COMMAND_ARGS: &str = "--headless -j bacon-ls";
30const CARGO_COMMAND_ARGS: &str =
31    "clippy --tests --all-features --all-targets --message-format json-diagnostic-rendered-ansi";
32
33/// bacon-ls - https://github.com/crisidev/bacon-ls
34#[derive(Debug, FromArgs)]
35pub struct Args {
36    /// display version information
37    #[argh(switch, short = 'v')]
38    pub version: bool,
39}
40
41#[derive(Debug, Clone, Copy)]
42enum Backend {
43    Bacon,
44    Cargo,
45}
46
47#[derive(Debug)]
48struct State {
49    project_root: Option<PathBuf>,
50    workspace_folders: Option<Vec<WorkspaceFolder>>,
51    locations_file: String,
52    update_on_save: bool,
53    update_on_save_wait_millis: Duration,
54    update_on_change: bool,
55    update_on_change_cooldown_millis: Duration,
56    validate_bacon_preferences: bool,
57    run_bacon_in_background: bool,
58    run_bacon_in_background_command: String,
59    run_bacon_in_background_command_args: String,
60    create_bacon_preferences_file: bool,
61    bacon_command_handle: Option<JoinHandle<()>>,
62    syncronize_all_open_files_wait_millis: Duration,
63    diagnostics_data_supported: bool,
64    open_files: HashSet<Uri>,
65    cancel_token: CancellationToken,
66    sync_files_handle: Option<JoinHandle<()>>,
67    backend: Backend,
68    diagnostics_version: i32,
69    cargo_command_args: String,
70    cargo_env: Vec<String>,
71    build_folder: PathBuf,
72    last_change: Instant,
73}
74
75impl Default for State {
76    fn default() -> Self {
77        Self {
78            project_root: None,
79            workspace_folders: None,
80            locations_file: LOCATIONS_FILE.to_string(),
81            update_on_save: true,
82            update_on_save_wait_millis: Duration::from_millis(1000),
83            update_on_change: false,
84            update_on_change_cooldown_millis: Duration::from_millis(5000),
85            validate_bacon_preferences: true,
86            run_bacon_in_background: true,
87            run_bacon_in_background_command: BACON_BACKGROUND_COMMAND.to_string(),
88            run_bacon_in_background_command_args: BACON_BACKGROUND_COMMAND_ARGS.to_string(),
89            create_bacon_preferences_file: true,
90            bacon_command_handle: None,
91            syncronize_all_open_files_wait_millis: Duration::from_millis(2000),
92            diagnostics_data_supported: false,
93            open_files: HashSet::new(),
94            cancel_token: CancellationToken::new(),
95            sync_files_handle: None,
96            backend: Backend::Cargo,
97            diagnostics_version: 0,
98            cargo_command_args: CARGO_COMMAND_ARGS.to_string(),
99            cargo_env: vec![],
100            build_folder: tempfile::tempdir().unwrap().path().into(),
101            last_change: Instant::now(),
102        }
103    }
104}
105
106#[derive(Debug, serde::Serialize, serde::Deserialize)]
107struct DiagnosticData<'c> {
108    corrections: Vec<Cow<'c, str>>,
109}
110
111#[derive(Debug, Default)]
112pub struct BaconLs {
113    client: Option<Arc<Client>>,
114    state: Arc<RwLock<State>>,
115}
116
117impl BaconLs {
118    fn new(client: Client) -> Self {
119        Self {
120            client: Some(Arc::new(client)),
121            state: Arc::new(RwLock::new(State::default())),
122        }
123    }
124
125    fn configure_tracing(log_level: Option<String>) {
126        // Configure logging to file.
127        let level = log_level.unwrap_or_else(|| env::var("RUST_LOG").unwrap_or("off".to_string()));
128        if level != "off" {
129            tracing_subscriber::fmt()
130                .with_env_filter(level)
131                .with_writer(
132                    std::fs::OpenOptions::new()
133                        .create(true)
134                        .write(true)
135                        .truncate(true)
136                        .open(format!("{PKG_NAME}.log"))
137                        .unwrap(),
138                )
139                .with_thread_names(true)
140                .with_span_events(FmtSpan::CLOSE)
141                .with_target(true)
142                .with_file(true)
143                .with_line_number(true)
144                .init();
145        }
146    }
147
148    /// Run the LSP server.
149    pub async fn serve() {
150        Self::configure_tracing(None);
151        // Lock stdin / stdout.
152        let stdin = tokio::io::stdin();
153        let stdout = tokio::io::stdout();
154        // Start the service.
155        let (service, socket) = LspService::new(Self::new);
156        Server::new(stdin, stdout, socket).serve(service).await;
157    }
158
159    async fn find_git_root_directory(path: &Path) -> Option<PathBuf> {
160        let output = tokio::process::Command::new("git")
161            .arg("-C")
162            .arg(path)
163            .arg("rev-parse")
164            .arg("--show-toplevel")
165            .output()
166            .await
167            .ok()?;
168
169        if output.status.success() {
170            String::from_utf8(output.stdout).ok().map(|v| PathBuf::from(v.trim()))
171        } else {
172            None
173        }
174    }
175
176    async fn publish_diagnostics(&self, uri: &Uri) {
177        let mut guard = self.state.write().await;
178        let locations_file_name = guard.locations_file.clone();
179        let workspace_folders = guard.workspace_folders.clone();
180        let open_files = guard.open_files.clone();
181        let backend = guard.backend;
182        let command_args = guard.cargo_command_args.clone();
183        let cargo_env = guard.cargo_env.clone();
184        let project_root = guard.project_root.clone();
185        let build_folder = guard.build_folder.clone();
186        guard.diagnostics_version += 1;
187        let version = guard.diagnostics_version;
188        drop(guard);
189
190        tracing::info!(uri = uri.to_string(), "publish diagnostics");
191        match backend {
192            Backend::Bacon => {
193                Bacon::publish_diagnostics(
194                    self.client.as_ref(),
195                    uri,
196                    &locations_file_name,
197                    workspace_folders.as_deref(),
198                )
199                .await;
200            }
201            Backend::Cargo => {
202                if let Some(client) = self.client.as_ref() {
203                    let token = ProgressToken::Number(rand::rng().random::<i32>());
204                    let first_arg = command_args.split_whitespace().next().unwrap_or("check");
205                    let progress = client
206                        .progress(token, "running:")
207                        .with_message(format!("cargo {first_arg}"))
208                        .with_percentage(0)
209                        .begin()
210                        .await;
211                    let diagnostics =
212                        Cargo::cargo_diagnostics(&command_args, &cargo_env, project_root.as_ref(), &build_folder)
213                            .await
214                            .inspect_err(|err| tracing::error!(?err, "error building diagnostics"))
215                            .unwrap_or_default();
216                    progress.report(90).await;
217                    if !diagnostics.contains_key(uri) {
218                        tracing::info!(
219                            uri = uri.to_string(),
220                            "cleaned up cargo diagnostics. does not contain key."
221                        );
222                        client.publish_diagnostics(uri.clone(), vec![], Some(version)).await;
223                    }
224                    for (uri, diagnostics) in diagnostics.into_iter() {
225                        if diagnostics.is_empty() {
226                            tracing::info!(uri = uri.to_string(), "cleaned up cargo diagnostics. empty.");
227                            client.publish_diagnostics(uri, vec![], Some(version)).await;
228                        } else if open_files.contains(&uri) {
229                            tracing::info!(uri = uri.to_string(), "sent {} cargo diagnostics", diagnostics.len());
230                            client.publish_diagnostics(uri, diagnostics, Some(version)).await;
231                        }
232                    }
233
234                    progress.report(100).await;
235                    progress.finish().await;
236                }
237            }
238        }
239    }
240
241    async fn syncronize_diagnostics(state: Arc<RwLock<State>>, client: Option<Arc<Client>>) {
242        let backend = state.read().await.backend;
243        if let Backend::Bacon = backend {
244            Bacon::syncronize_diagnostics(state, client).await;
245        }
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_can_configure_tracing() {
255        BaconLs::configure_tracing(Some("info".to_string()));
256    }
257}