cairo-language-server 2.20.0

The Cairo Language Server
Documentation
use std::collections::HashSet;
use std::path::PathBuf;

use cairo_lang_compiler::db::validate_corelib;
use cairo_lang_compiler::project::{setup_project, update_crate_roots_from_project_config};
use cairo_lang_filesystem::db::{CrateIdentifier, FilesGroup};
use cairo_lang_filesystem::ids::CrateId;
use cairo_lang_filesystem::set_crate_config;
use cairo_lang_project::ProjectConfig;
use crossbeam::channel::{Receiver, Sender};
use lsp_types::DiagnosticSeverity;
use lsp_types::notification::ShowMessage;
use lsp_types::{MessageType, ShowMessageParams};
use tracing::{debug, error, warn};

pub use self::crate_data::{Crate, CrateInfo, extract_custom_file_stems};
pub use self::model::ConfigsRegistry;
pub use self::project_manifest_path::*;
pub use self::scarb_manifest_diagnostics::{
    ScarbMetadataMessage, scarb_metadata_messages_to_diagnostics,
};
use self::scarb_manifest_diagnostics::{
    collect_scarb_manifest_diagnostics, manifest_diagnostics_from_ndjson,
    scarb_metadata_messages_contain_only_errors,
};
use crate::ide::code_lens::FileChange;
use crate::lang::db::AnalysisDatabase;
use crate::lsp::ext::CorelibVersionMismatch;
use crate::project::model::ProjectModel;
use crate::project::scarb::extract_crates;
use crate::project::unmanaged_core_crate::try_to_init_unmanaged_core_if_not_present;
use crate::server::client::Notifier;
use crate::server::is_cairo_file_path;
use crate::server::schedule::thread;
use crate::server::schedule::thread::{JoinHandle, ThreadPriority};
use crate::state::{Snapshot, State};
use crate::toolchain::scarb::ScarbToolchain;

pub mod builtin_plugins;
mod crate_data;
mod model;
mod project_manifest_path;
mod scarb;
mod scarb_manifest_diagnostics;
mod unmanaged_core_crate;

pub struct ProjectController {
    model: ProjectModel,
    scarb_toolchain: ScarbToolchain,
    // NOTE: Member order matters here.
    //   The request sender MUST be dropped before controller's thread join handle.
    //   Otherwise, the controller thread will never stop, and the controller's
    //   JoinHandle drop will cause deadlock by waiting for the thread to join.
    requests_sender: Sender<ProjectUpdateRequest>,
    response_receiver: Receiver<ProjectUpdate>,
    _thread: JoinHandle,
}

impl ProjectController {
    /// Initializes [`ProjectController`] by spawning a background thread to handle extracting
    /// project updates for files and initializing channels needed for communication with the
    /// thread.
    ///
    /// The background thread is responsible for fetching changes to the project model: check
    /// [`ProjectControllerThread::send_project_update_for_file`] for more information.
    pub fn initialize(scarb_toolchain: ScarbToolchain, notifier: Notifier) -> Self {
        let (requests_sender, requests_receiver) = crossbeam::channel::unbounded();
        let (response_sender, response_receiver) = crossbeam::channel::unbounded();

        let thread = ProjectControllerThread::spawn(
            requests_receiver,
            response_sender,
            scarb_toolchain.clone(),
            notifier,
        );

        ProjectController {
            model: ProjectModel::new(),
            scarb_toolchain,
            requests_sender,
            response_receiver,
            _thread: thread,
        }
    }

    pub fn configs_registry(&self) -> Snapshot<ConfigsRegistry> {
        self.model.configs_registry()
    }

    pub fn response_receiver(&self) -> Receiver<ProjectUpdate> {
        self.response_receiver.clone()
    }

    pub fn request_updating_project_for_file(&self, file_path: PathBuf) {
        // Skip updating the project model for dependencies from Scarb cache.
        // It is extremely likely that this is not the project that a user wants to work on and
        // opening it was a result of `goto` to dependency.
        if self.scarb_toolchain.is_from_scarb_cache(&file_path) {
            return;
        }

        self.send_request(ProjectUpdateRequest {
            file_path,
            loaded_manifests: self.model.loaded_manifests(),
        })
    }

    pub fn clear_loaded_workspaces(&mut self) {
        self.model.clear_loaded_workspaces();
    }

    /// Handles project update by applying necessary changes to the database.
    ///
    /// The project update is sent from [`ProjectControllerThread::send_project_update_for_file`]
    /// and received in the main [`event loop`](crate::Backend::event_loop).
    #[tracing::instrument(skip_all, fields(project_update))]
    pub fn handle_update(state: &mut State, notifier: Notifier, project_update: ProjectUpdate) {
        let db = &mut state.db;
        match project_update {
            ProjectUpdate::Scarb {
                crates,
                workspace_dir,
                workspace_manifest_path,
                diagnostics,
            } => {
                state.diagnostics_controller.clear_scarb_manifest_diagnostics(&crates, &notifier);
                debug!("updating crate roots from scarb metadata: {crates:#?}");
                state
                    .proc_macro_controller
                    .request_defined_macros(db, workspace_manifest_path.clone());
                state.project_controller.model.load_workspace(
                    db,
                    crates,
                    workspace_dir,
                    &state.proc_macro_controller,
                );
                state.diagnostics_controller.publish_scarb_manifest_diagnostics(
                    &workspace_manifest_path,
                    diagnostics,
                    DiagnosticSeverity::WARNING,
                    db,
                    &notifier,
                );
                state.analysis_progress_controller.project_model_loaded();
            }
            ProjectUpdate::ScarbMetadataFailed { manifest_path, diagnostics } => {
                state.diagnostics_controller.publish_scarb_manifest_diagnostics(
                    &manifest_path,
                    diagnostics,
                    DiagnosticSeverity::ERROR,
                    db,
                    &notifier,
                );
                // Try to set up a corelib at least if it is not in the db already.
                try_to_init_unmanaged_core_if_not_present(
                    db,
                    &state.config,
                    &state.scarb_toolchain,
                );
            }
            ProjectUpdate::CairoProjectToml(maybe_project_config) => {
                if let Some(project_config) = *maybe_project_config {
                    update_crate_roots_from_project_config(db, &project_config);

                    // Make sure cfg(test) is not set if the core crate comes from the Scarb cache.
                    if contains_core_from_scarb_cache(&project_config, &state.scarb_toolchain) {
                        let core_id = CrateId::core(db);

                        let mut core_settings = db.crate_config(core_id).unwrap().clone();
                        core_settings.settings.cfg_set = Some(
                            core_settings
                                .settings
                                .cfg_set
                                .unwrap_or_default()
                                .union(&AnalysisDatabase::initial_cfg_set_for_deps()),
                        );
                        set_crate_config!(db, core_id, Some(core_settings));
                    }

                    state.analysis_progress_controller.project_model_loaded();
                }

                try_to_init_unmanaged_core_if_not_present(
                    db,
                    &state.config,
                    &state.scarb_toolchain,
                );
            }
            ProjectUpdate::NoConfig(file_path) => {
                try_to_init_unmanaged_core_if_not_present(
                    db,
                    &state.config,
                    &state.scarb_toolchain,
                );

                if let Err(err) = setup_project(&mut *db, &file_path) {
                    error!(
                        "error loading file {} as a single crate: {err}",
                        file_path.to_string_lossy()
                    );
                } else {
                    state.analysis_progress_controller.project_model_loaded();
                }
            }
        }

        // Drop mut ref so we can obtain snapshot.
        let _ = db;

        // Manifest may have changed, update for open files
        state.code_lens_controller.on_did_change(
            state.db.clone(),
            state.config.clone(),
            state
                .open_files
                .iter()
                .filter(|&url| is_cairo_file_path(url))
                .cloned()
                .map(|file| FileChange { url: file, was_deleted: false }),
        );

        if let Err(result) = validate_corelib(&state.db) {
            notifier.notify::<CorelibVersionMismatch>(result.to_string());
        }

        #[cfg(feature = "testing")]
        notifier.notify::<crate::lsp::ext::testing::ProjectUpdatingFinished>(());
    }

    /// Sends an action request to the background thread.
    fn send_request(&self, request: ProjectUpdateRequest) {
        self.requests_sender
            .send(request)
            .expect("project controller thread should not have panicked or dropped the receiver")
    }
}

/// Intermediate struct used to communicate what changes to the project model should be applied.
/// Associated with [`ProjectManifestPath`] (or its absence) that was detected for a given file.
pub enum ProjectUpdate {
    Scarb {
        crates: Vec<CrateInfo>,
        workspace_dir: PathBuf,
        workspace_manifest_path: PathBuf,
        diagnostics: Vec<ScarbMetadataMessage>,
    },
    ScarbMetadataFailed {
        manifest_path: PathBuf,
        diagnostics: Vec<ScarbMetadataMessage>,
    },
    CairoProjectToml(Box<Option<ProjectConfig>>),
    NoConfig(PathBuf),
}

/// Stores entire state of project controller thread.
struct ProjectControllerThread {
    requests_receiver: Receiver<ProjectUpdateRequest>,
    response_sender: Sender<ProjectUpdate>,
    scarb_toolchain: ScarbToolchain,
    notifier: Notifier,
}

impl ProjectControllerThread {
    /// Spawns a new project controller thread and returns a handle to it.
    fn spawn(
        requests_receiver: Receiver<ProjectUpdateRequest>,
        response_sender: Sender<ProjectUpdate>,
        scarb_toolchain: ScarbToolchain,
        notifier: Notifier,
    ) -> JoinHandle {
        let this = Self { requests_receiver, response_sender, scarb_toolchain, notifier };

        thread::Builder::new(ThreadPriority::Worker)
            .name("cairo-ls:project-controller".into())
            .spawn(move || this.event_loop())
            .expect("failed to spawn project controller thread")
    }

    /// Runs project controller's event loop.
    fn event_loop(mut self) {
        while let Ok(request) = self.requests_receiver.recv() {
            let project_update = self.fetch_project_update_for_file(request);
            if let Some(project_update) = project_update {
                self.send_project_update(project_update);
            }
        }
    }

    /// Tries to fetch changes to the project model that are necessary for the file analysis.
    ///
    /// NOTE: this function is potentially expensive as it may call `scarb metadata`.
    /// It is meant to be run only in the background thread.
    #[tracing::instrument(skip_all)]
    fn fetch_project_update_for_file(
        &mut self,
        project_update_request: ProjectUpdateRequest,
    ) -> Option<ProjectUpdate> {
        let project_update = match ProjectManifestPath::discover(
            &project_update_request.file_path,
            &self.notifier,
        ) {
            Some(ProjectManifestPath::Scarb(manifest_path)) => {
                if project_update_request.loaded_manifests.contains(&manifest_path) {
                    debug!("scarb project is already loaded: {}", manifest_path.display());
                    return None;
                }

                let scarb_path = self.scarb_toolchain.discover()?;
                let metadata = self.scarb_toolchain.metadata_raw(&manifest_path, scarb_path);

                metadata
                    .map(|output| ProjectUpdate::Scarb {
                        crates: extract_crates(&output.metadata),
                        workspace_dir: output.metadata.workspace.root.into_std_path_buf(),
                        workspace_manifest_path: output
                            .metadata
                            .workspace
                            .manifest_path
                            .into_std_path_buf(),
                        diagnostics: manifest_diagnostics_from_ndjson(&output.stdout),
                    })
                    .unwrap_or_else(|error| {
                        let metadata_messages = collect_scarb_manifest_diagnostics(error);

                        if scarb_metadata_messages_contain_only_errors(&metadata_messages) {
                            self.scarb_toolchain.notify_metadata_failed();
                        }

                        ProjectUpdate::ScarbMetadataFailed {
                            manifest_path,
                            diagnostics: metadata_messages,
                        }
                    })
            }

            Some(ProjectManifestPath::CairoProject(config_path)) => {
                // The base path of ProjectConfig must be absolute to ensure that all paths in Salsa
                // DB will also be absolute.
                assert!(config_path.is_absolute());

                let maybe_project_config = ProjectConfig::from_file(&config_path)
                    .inspect_err(|err| {
                        let config_path_lossy = config_path.to_string_lossy();
                        error!("parsing {config_path_lossy} failed: {err:?}");
                        self.notifier.notify::<ShowMessage>(ShowMessageParams {
                            typ: MessageType::ERROR,
                            message: format!(
                                "Failed to parse: {config_path_lossy}. Project analysis will not \
                                 be available.",
                            ),
                        });
                    })
                    .ok();
                ProjectUpdate::CairoProjectToml(Box::new(maybe_project_config))
            }

            None => ProjectUpdate::NoConfig(project_update_request.file_path),
        };

        Some(project_update)
    }

    ///  Sends [`ProjectUpdate`] to the main [`event loop`](crate::Backend::event_loop).
    fn send_project_update(&self, project_update: ProjectUpdate) {
        self.response_sender
            .send(project_update)
            .expect("the receiver was expected to exist in the main event loop");
    }
}

struct ProjectUpdateRequest {
    file_path: PathBuf,
    loaded_manifests: Snapshot<HashSet<PathBuf>>,
}

fn contains_core_from_scarb_cache(
    project_config: &ProjectConfig,
    scarb_toolchain: &ScarbToolchain,
) -> bool {
    project_config
        .content
        .crate_roots
        .get(&CrateIdentifier::from("core"))
        .map(|p| project_config.absolute_crate_root(p))
        .is_some_and(|core_root| scarb_toolchain.is_from_scarb_cache(&core_root))
}