cairo_language_server/
lib.rs

1//! # CairoLS
2//!
3//! Implements the LSP protocol over stdin/out.
4//!
5//! ## Running vanilla
6//!
7//! This is basically the source code of the `cairo-language-server` and
8//! `scarb cairo-language-server` binaries.
9//!
10//! ```no_run
11//! # #![allow(clippy::needless_doctest_main)]
12//! fn main() {
13//!     cairo_language_server::start();
14//! }
15//! ```
16//!
17//! ## Running with customizations
18//!
19//! Due to the immaturity of various Cairo compiler parts (especially around potentially
20//! dynamically-loadable things), for some projects it might be necessary to provide a custom build
21//! of CairoLS that includes custom modifications to the compiler.
22//! The [`start_with_tricks`] function allows building a customized build of CairoLS that supports
23//! project-specific features.
24//! See the [`Tricks`] struct documentation for available customizations.
25//!
26//! ```no_run
27//! # #![allow(clippy::needless_doctest_main)]
28//! use cairo_language_server::Tricks;
29//!
30//! # fn dojo_plugin_suite() -> cairo_lang_semantic::plugin::PluginSuite {
31//! #    // Returning something realistic, to make sure restrictive trait bounds do compile.
32//! #    cairo_lang_starknet::starknet_plugin_suite()
33//! # }
34//! fn main() {
35//!     let mut tricks = Tricks::default();
36//!     tricks.extra_plugin_suites = Some(&|| vec![dojo_plugin_suite()]);
37//!     cairo_language_server::start_with_tricks(tricks);
38//! }
39//! ```
40
41use std::num::NonZeroU32;
42use std::panic::RefUnwindSafe;
43use std::path::PathBuf;
44use std::process::ExitCode;
45use std::time::{Duration, SystemTime};
46use std::{io, panic};
47
48use anyhow::Result;
49use cairo_lang_filesystem::db::FilesGroup;
50use cairo_lang_filesystem::ids::FileLongId;
51use cairo_lang_semantic::plugin::PluginSuite;
52use crossbeam::channel::{Receiver, select_biased};
53use governor::{Quota, RateLimiter};
54use lsp_server::Message;
55use lsp_types::RegistrationParams;
56use tracing::{debug, error, info};
57
58use crate::lang::lsp::LsProtoGroup;
59use crate::lang::proc_macros::controller::ProcMacroChannels;
60use crate::lsp::capabilities::server::{
61    collect_dynamic_registrations, collect_server_capabilities,
62};
63use crate::lsp::result::LSPResult;
64use crate::project::{ProjectController, ProjectUpdate};
65use crate::server::client::{Notifier, Requester, Responder};
66use crate::server::connection::{Connection, ConnectionInitializer};
67use crate::server::panic::is_cancelled;
68use crate::server::schedule::thread::JoinHandle;
69use crate::server::schedule::{Scheduler, Task, event_loop_thread};
70use crate::state::State;
71
72mod config;
73mod env_config;
74mod ide;
75mod lang;
76pub mod lsp;
77mod project;
78mod server;
79mod state;
80mod toolchain;
81
82/// Carries various customizations that can be applied to CairoLS.
83///
84/// See [the top-level documentation][lib] documentation for usage examples.
85///
86/// [lib]: crate#running-with-customizations
87#[non_exhaustive]
88#[derive(Default, Clone)]
89pub struct Tricks {
90    /// A function that returns a list of additional compiler plugin suites to be loaded in the
91    /// language server database.
92    pub extra_plugin_suites:
93        Option<&'static (dyn Fn() -> Vec<PluginSuite> + Send + Sync + RefUnwindSafe)>,
94}
95
96/// Starts the language server.
97///
98/// See [the top-level documentation][lib] documentation for usage examples.
99///
100/// [lib]: crate#running-vanilla
101pub fn start() -> ExitCode {
102    start_with_tricks(Tricks::default())
103}
104
105/// Starts the language server with customizations.
106///
107/// See [the top-level documentation][lib] documentation for usage examples.
108///
109/// [lib]: crate#running-with-customizations
110pub fn start_with_tricks(tricks: Tricks) -> ExitCode {
111    let _log_guard = init_logging();
112    set_panic_hook();
113
114    info!("language server starting");
115    env_config::report_to_logs();
116
117    let exit_code = match Backend::new(tricks) {
118        Ok(backend) => {
119            if let Err(err) = backend.run().map(|handle| handle.join()) {
120                error!("language server encountered an unrecoverable error: {err}");
121                ExitCode::from(1)
122            } else {
123                ExitCode::from(0)
124            }
125        }
126        Err(err) => {
127            error!("language server failed during initialization: {err}");
128            ExitCode::from(1)
129        }
130    };
131
132    info!("language server stopped");
133    exit_code
134}
135
136/// Special function to run the language server in end-to-end tests.
137#[cfg(feature = "testing")]
138pub fn build_service_for_e2e_tests()
139-> (Box<dyn FnOnce() -> BackendForTesting + Send>, lsp_server::Connection) {
140    BackendForTesting::new_for_testing(Default::default())
141}
142
143/// Initialize logging infrastructure for the language server.
144///
145/// Returns a guard that should be dropped when the LS ends, to flush log files.
146fn init_logging() -> Option<impl Drop> {
147    use std::fs;
148    use std::io::IsTerminal;
149
150    use tracing_chrome::ChromeLayerBuilder;
151    use tracing_subscriber::filter::{EnvFilter, LevelFilter, Targets};
152    use tracing_subscriber::fmt::Layer;
153    use tracing_subscriber::fmt::time::Uptime;
154    use tracing_subscriber::prelude::*;
155
156    let mut guard = None;
157
158    let fmt_layer = Layer::new()
159        .with_writer(io::stderr)
160        .with_timer(Uptime::default())
161        .with_ansi(io::stderr().is_terminal())
162        .with_filter(
163            EnvFilter::builder()
164                .with_default_directive(LevelFilter::WARN.into())
165                .with_env_var(env_config::CAIRO_LS_LOG)
166                .from_env_lossy(),
167        );
168
169    let profile_layer = if env_config::tracing_profile() {
170        let mut path = PathBuf::from(format!(
171            "./cairols-profile-{}.json",
172            SystemTime::UNIX_EPOCH.elapsed().unwrap().as_micros()
173        ));
174
175        // Create the file now, so that we early panic, and `fs::canonicalize` will work.
176        let profile_file = fs::File::create(&path).expect("Failed to create profile file.");
177
178        // Try to canonicalize the path, so that it's easier to find the file from logs.
179        if let Ok(canonical) = fs::canonicalize(&path) {
180            path = canonical;
181        }
182
183        eprintln!("this LS run will output tracing profile to: {}", path.display());
184        eprintln!(
185            "open that file with https://ui.perfetto.dev (or chrome://tracing) to analyze it"
186        );
187
188        let (profile_layer, profile_layer_guard) =
189            ChromeLayerBuilder::new().writer(profile_file).include_args(true).build();
190
191        // Filter out less important Salsa logs because they are too verbose,
192        // and with them the profile file quickly grows to several GBs of data.
193        let profile_layer = profile_layer.with_filter(
194            Targets::new().with_default(LevelFilter::TRACE).with_target("salsa", LevelFilter::WARN),
195        );
196
197        guard = Some(profile_layer_guard);
198        Some(profile_layer)
199    } else {
200        None
201    };
202
203    tracing::subscriber::set_global_default(
204        tracing_subscriber::registry().with(fmt_layer).with(profile_layer),
205    )
206    .expect("Could not set up global logger.");
207
208    guard
209}
210
211/// Sets a special panic hook that skips execution for Salsa cancellation panics.
212fn set_panic_hook() {
213    let previous_hook = panic::take_hook();
214    panic::set_hook(Box::new(move |info| {
215        if !is_cancelled(info.payload()) {
216            previous_hook(info);
217        }
218    }))
219}
220
221struct Backend {
222    connection: Connection,
223    state: State,
224}
225
226#[cfg(feature = "testing")]
227pub struct BackendForTesting(Backend);
228
229#[cfg(feature = "testing")]
230impl BackendForTesting {
231    fn new_for_testing(
232        tricks: Tricks,
233    ) -> (Box<dyn FnOnce() -> BackendForTesting + Send>, lsp_server::Connection) {
234        let (connection_initializer, client) = ConnectionInitializer::memory();
235
236        let init = Box::new(|| {
237            BackendForTesting(Backend::initialize(tricks, connection_initializer).unwrap())
238        });
239
240        (init, client)
241    }
242
243    pub fn run_for_tests(self) -> Result<JoinHandle<Result<()>>> {
244        self.0.run()
245    }
246}
247
248impl Backend {
249    fn new(tricks: Tricks) -> Result<Self> {
250        let connection_initializer = ConnectionInitializer::stdio();
251
252        Self::initialize(tricks, connection_initializer)
253    }
254
255    /// Initializes the connection and crate a ready to run [`Backend`] instance.
256    ///
257    /// As part of the initialization flow, this function exchanges client and server capabilities.
258    fn initialize(tricks: Tricks, connection_initializer: ConnectionInitializer) -> Result<Self> {
259        let (id, init_params) = connection_initializer.initialize_start()?;
260
261        let client_capabilities = init_params.capabilities;
262        let server_capabilities = collect_server_capabilities(&client_capabilities);
263
264        let connection = connection_initializer.initialize_finish(id, server_capabilities)?;
265        let state = State::new(connection.make_sender(), client_capabilities, tricks);
266
267        Ok(Self { connection, state })
268    }
269
270    /// Runs the main event loop thread and wait for its completion.
271    fn run(self) -> Result<JoinHandle<Result<()>>> {
272        event_loop_thread(move || {
273            let Self { mut state, connection } = self;
274            let proc_macro_channels = state.proc_macro_controller.channels();
275            let project_updates_receiver = state.project_controller.response_receiver();
276
277            let mut scheduler = Scheduler::new(&mut state, connection.make_sender());
278
279            Self::dispatch_setup_tasks(&mut scheduler);
280
281            // Attempt to swap the database to reduce memory use.
282            // Because diagnostics are always refreshed afterwards, the fresh database state will
283            // be quickly repopulated.
284            scheduler.on_sync_task(Self::maybe_swap_database);
285
286            // Refresh diagnostics each time state changes.
287            // Although it is possible to mutate state without affecting the analysis database,
288            // we basically never hit such a case in CairoLS in happy paths.
289            scheduler.on_sync_task(Self::refresh_diagnostics);
290
291            let result = Self::event_loop(
292                &connection,
293                proc_macro_channels,
294                project_updates_receiver,
295                scheduler,
296            );
297
298            state.db.cancel_all();
299
300            if let Err(err) = connection.close() {
301                error!("failed to close connection to the language server: {err:?}");
302            }
303
304            result
305        })
306    }
307
308    /// Runs various setup tasks before entering the main event loop.
309    fn dispatch_setup_tasks(scheduler: &mut Scheduler<'_>) {
310        scheduler.local(Self::register_dynamic_capabilities);
311
312        scheduler.local(|state, _notifier, requester, _responder| {
313            let _ = state.config.reload(requester, &state.client_capabilities);
314        });
315    }
316
317    fn register_dynamic_capabilities(
318        state: &mut State,
319        _notifier: Notifier,
320        requester: &mut Requester<'_>,
321        _responder: Responder,
322    ) {
323        let registrations = collect_dynamic_registrations(&state.client_capabilities);
324
325        let _ = requester
326            .request::<lsp_types::request::RegisterCapability>(
327                RegistrationParams { registrations },
328                |()| {
329                    debug!("configuration file watcher successfully registered");
330                    Task::nothing()
331                },
332            )
333            .inspect_err(|e| {
334                error!(
335                    "failed to register dynamic capabilities, some features may not work \
336                     properly: {e:?}"
337                )
338            });
339    }
340
341    // +--------------------------------------------------+
342    // | Function code adopted from:                      |
343    // | Repository: https://github.com/astral-sh/ruff    |
344    // | File: `crates/ruff_server/src/server.rs`         |
345    // | Commit: 46a457318d8d259376a2b458b3f814b9b795fe69 |
346    // +--------------------------------------------------+
347    fn event_loop(
348        connection: &Connection,
349        proc_macro_channels: ProcMacroChannels,
350        project_updates_receiver: Receiver<ProjectUpdate>,
351        mut scheduler: Scheduler<'_>,
352    ) -> Result<()> {
353        let incoming = connection.incoming();
354
355        let response_resolving_limiter =
356            RateLimiter::direct(Quota::with_period(Duration::from_secs(3)).unwrap().allow_burst(
357                // Don't allow any burst.
358                NonZeroU32::new(1).unwrap(),
359            ));
360
361        loop {
362            select_biased! {
363                // Project updates may significantly change the state, therefore
364                // they should be handled first in case of multiple operations being ready at once.
365                // To ensure it, keep project updates channel in the first arm of `select_biased!`.
366                recv(project_updates_receiver) -> project_update => {
367                    let Ok(project_update) = project_update else { break };
368
369                    scheduler.local(move |state, notifier, _, _| ProjectController::handle_update(state, notifier, project_update));
370                }
371                recv(incoming) -> msg => {
372                    let Ok(msg) = msg else { break };
373
374                    if connection.handle_shutdown(&msg)? {
375                        break;
376                    }
377                    let task = match msg {
378                        Message::Request(req) => server::request(req),
379                        Message::Notification(notification) => server::notification(notification),
380                        Message::Response(response) => scheduler.response(response),
381                    };
382                    scheduler.dispatch(task);
383                }
384                recv(proc_macro_channels.response_receiver) -> response => {
385                    let Ok(()) = response else { break };
386
387                    if response_resolving_limiter.check().is_ok() {
388                        scheduler.local(Self::on_proc_macro_response);
389                    } else {
390                        let _ = proc_macro_channels.response_sender.try_send(());
391                    }
392                }
393                recv(proc_macro_channels.error_receiver) -> error => {
394                    let Ok(()) = error else { break };
395
396                    scheduler.local(Self::on_proc_macro_error);
397                }
398            }
399        }
400
401        Ok(())
402    }
403
404    /// Calls [`lang::proc_macros::controller::ProcMacroClientController::handle_error`] to do its
405    /// work.
406    fn on_proc_macro_error(state: &mut State, _: Notifier, _: &mut Requester<'_>, _: Responder) {
407        state.proc_macro_controller.handle_error(&mut state.db, &state.config);
408    }
409
410    /// Calls [`lang::proc_macros::controller::ProcMacroClientController::on_response`] to do its
411    /// work.
412    fn on_proc_macro_response(state: &mut State, _: Notifier, _: &mut Requester<'_>, _: Responder) {
413        state.proc_macro_controller.on_response(&mut state.db, &state.config);
414    }
415
416    /// Calls [`lang::db::AnalysisDatabaseSwapper::maybe_swap`] to do its work.
417    fn maybe_swap_database(state: &mut State, _notifier: Notifier) {
418        state.db_swapper.maybe_swap(
419            &mut state.db,
420            &state.open_files,
421            &state.tricks,
422            &mut state.project_controller,
423        );
424    }
425
426    /// Calls [`lang::diagnostics::DiagnosticsController::refresh`] to do its work.
427    fn refresh_diagnostics(state: &mut State, _notifier: Notifier) {
428        state.diagnostics_controller.refresh(state);
429    }
430
431    /// Reload config and update project model for all open files.
432    fn reload(state: &mut State, requester: &mut Requester<'_>) -> LSPResult<()> {
433        state.project_controller.clear_loaded_workspaces();
434        state.config.reload(requester, &state.client_capabilities)?;
435
436        for uri in state.open_files.iter() {
437            let Some(file_id) = state.db.file_for_url(uri) else { continue };
438            if let FileLongId::OnDisk(file_path) = state.db.lookup_intern_file(file_id) {
439                state.project_controller.request_updating_project_for_file(file_path);
440            }
441        }
442
443        Ok(())
444    }
445}