diff --git a/Cargo.lock b/Cargo.lock index 57f834cac70..125c966f366 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -657,6 +657,7 @@ dependencies = [ "cairo-lang-test-utils", "cairo-lang-utils", "futures", + "indent", "indoc", "itertools 0.12.1", "pathdiff", diff --git a/crates/cairo-lang-language-server/Cargo.toml b/crates/cairo-lang-language-server/Cargo.toml index e03a94ca708..7d80ef8e443 100644 --- a/crates/cairo-lang-language-server/Cargo.toml +++ b/crates/cairo-lang-language-server/Cargo.toml @@ -25,6 +25,8 @@ cairo-lang-starknet = { path = "../cairo-lang-starknet", version = "~2.6.4" } cairo-lang-syntax = { path = "../cairo-lang-syntax", version = "~2.6.4" } cairo-lang-test-plugin = { path = "../cairo-lang-test-plugin", version = "~2.6.4" } cairo-lang-utils = { path = "../cairo-lang-utils", version = "~2.6.4" } +indent.workspace = true +indoc.workspace = true itertools.workspace = true salsa.workspace = true scarb-metadata = "1.12" @@ -42,7 +44,6 @@ cairo-lang-language-server = { path = ".", features = ["testing"] } assert_fs = "1.1" cairo-lang-test-utils = { path = "../cairo-lang-test-utils", features = ["testing"] } futures = "0.3" -indoc.workspace = true pathdiff = "0.2" test-log.workspace = true tower-service = "0.3" diff --git a/crates/cairo-lang-language-server/src/lang/inspect/crates.rs b/crates/cairo-lang-language-server/src/lang/inspect/crates.rs new file mode 100644 index 00000000000..9a5d54a5c50 --- /dev/null +++ b/crates/cairo-lang-language-server/src/lang/inspect/crates.rs @@ -0,0 +1,40 @@ +use cairo_lang_filesystem::db::FilesGroup; +use indent::indent_by; +use indoc::formatdoc; +use itertools::Itertools; + +use crate::lang::db::AnalysisDatabase; +use crate::project::Crate; + +/// Generates a Markdown text describing all crates in the database. +pub fn inspect_analyzed_crates(db: &AnalysisDatabase) -> String { + let list = db + .crates() + .into_iter() + .flat_map(|crate_id| Crate::reconstruct(db, crate_id)) + .sorted_by_key(|cr| cr.name.clone()) + .map(inspect_crate) + .collect::>() + .join(""); + formatdoc! {r#" + # Analyzed Crates + + {list} + + "#} +} + +/// Generates a Markdown fragment describing a single crate. +fn inspect_crate(cr: Crate) -> String { + formatdoc! { + r#" + - `{name}`: `{source_path}` + ```rust + {settings} + ``` + "#, + name = cr.name, + source_path = cr.source_path().display(), + settings = indent_by(4, format!("{:#?}", cr.settings)), + } +} diff --git a/crates/cairo-lang-language-server/src/lang/inspect/mod.rs b/crates/cairo-lang-language-server/src/lang/inspect/mod.rs index 2f686d07af8..df5671e4f5d 100644 --- a/crates/cairo-lang-language-server/src/lang/inspect/mod.rs +++ b/crates/cairo-lang-language-server/src/lang/inspect/mod.rs @@ -1,3 +1,4 @@ -//! High-level constructs for inspecting language elements from the Salsa database. +//! High-level constructs for inspecting language elements from the analysis database. +pub mod crates; pub mod defs; diff --git a/crates/cairo-lang-language-server/src/lib.rs b/crates/cairo-lang-language-server/src/lib.rs index 3ed4dc29644..cc5c75765b2 100644 --- a/crates/cairo-lang-language-server/src/lib.rs +++ b/crates/cairo-lang-language-server/src/lib.rs @@ -77,6 +77,7 @@ use salsa::ParallelDatabase; use serde_json::Value; use tokio::task::spawn_blocking; use tower_lsp::jsonrpc::{Error as LSPError, Result as LSPResult}; +use tower_lsp::lsp_types::request::Request; use tower_lsp::lsp_types::*; use tower_lsp::{Client, ClientSocket, LanguageServer, LspService, Server}; use tracing::{debug, error, info, trace_span, warn, Instrument}; @@ -98,7 +99,7 @@ mod config; mod env_config; mod ide; mod lang; -mod lsp; +pub mod lsp; mod markdown; mod project; mod server; @@ -272,6 +273,7 @@ impl Backend { fn build_service(tricks: Tricks) -> (LspService, ClientSocket) { LspService::build(|client| Self::new(client, tricks)) .custom_method("vfs/provide", Self::vfs_provide) + .custom_method(lsp::ext::ViewAnalyzedCrates::METHOD, Self::view_analyzed_crates) .finish() } @@ -508,6 +510,11 @@ impl Backend { } } + #[tracing::instrument(level = "trace", skip_all)] + async fn view_analyzed_crates(&self) -> LSPResult { + self.with_db(lang::inspect::crates::inspect_analyzed_crates).await + } + #[tracing::instrument(level = "trace", skip_all)] async fn vfs_provide( &self, diff --git a/crates/cairo-lang-language-server/src/lsp/ext.rs b/crates/cairo-lang-language-server/src/lsp/ext.rs new file mode 100644 index 00000000000..571e3c5e54d --- /dev/null +++ b/crates/cairo-lang-language-server/src/lsp/ext.rs @@ -0,0 +1,13 @@ +//! CairoLS extensions to the Language Server Protocol. + +use tower_lsp::lsp_types::request::Request; + +// TODO(mkaput): Provide this as a command in VSCode. +/// Collect information about all Cairo crates that are currently being analyzed. +pub struct ViewAnalyzedCrates; + +impl Request for ViewAnalyzedCrates { + type Params = (); + type Result = String; + const METHOD: &'static str = "cairo/viewAnalyzedCrates"; +} diff --git a/crates/cairo-lang-language-server/src/lsp/mod.rs b/crates/cairo-lang-language-server/src/lsp/mod.rs index 0a8fd608d36..a19089fd498 100644 --- a/crates/cairo-lang-language-server/src/lsp/mod.rs +++ b/crates/cairo-lang-language-server/src/lsp/mod.rs @@ -1 +1,2 @@ -pub mod client_capabilities; +pub(crate) mod client_capabilities; +pub mod ext; diff --git a/crates/cairo-lang-language-server/src/project/crate_data.rs b/crates/cairo-lang-language-server/src/project/crate_data.rs index 8a9342f83c0..cab541f3e56 100644 --- a/crates/cairo-lang-language-server/src/project/crate_data.rs +++ b/crates/cairo-lang-language-server/src/project/crate_data.rs @@ -4,10 +4,11 @@ use std::sync::Arc; use cairo_lang_defs::db::DefsGroup; use cairo_lang_defs::ids::ModuleId; use cairo_lang_filesystem::db::{ - AsFilesGroupMut, CrateConfiguration, CrateSettings, FilesGroupEx, CORELIB_CRATE_NAME, + AsFilesGroupMut, CrateConfiguration, CrateSettings, FilesGroup, FilesGroupEx, + CORELIB_CRATE_NAME, }; use cairo_lang_filesystem::ids::{CrateId, CrateLongId, Directory}; -use cairo_lang_utils::Intern; +use cairo_lang_utils::{Intern, LookupIntern}; use smol_str::SmolStr; use crate::lang::db::AnalysisDatabase; @@ -49,10 +50,37 @@ impl Crate { } } + /// Construct a [`Crate`] from data already applied to the [`AnalysisDatabase`]. + /// + /// Returns `None` if the crate is virtual or the crate configuration is missing. + pub fn reconstruct(db: &AnalysisDatabase, crate_id: CrateId) -> Option { + let CrateLongId::Real(name) = crate_id.lookup_intern(db) else { + return None; + }; + + let Some(CrateConfiguration { root: Directory::Real(root), settings }) = + db.crate_config(crate_id) + else { + return None; + }; + + let custom_main_file_stem = extract_custom_file_stem(db, crate_id); + + Some(Self { name, root, custom_main_file_stem, settings }) + } + /// States whether this is the `core` crate. pub fn is_core(&self) -> bool { self.name == CORELIB_CRATE_NAME } + + /// Returns the path to the main file of this crate. + pub fn source_path(&self) -> PathBuf { + self.root.join(match &self.custom_main_file_stem { + Some(stem) => format!("{stem}.cairo"), + None => "lib.cairo".into(), + }) + } } /// Generate a wrapper lib file for a compilation unit without a root `lib.cairo`. @@ -67,3 +95,22 @@ fn inject_virtual_wrapper_lib(db: &mut AnalysisDatabase, crate_id: CrateId, file db.as_files_group_mut() .override_file_content(file_id, Some(Arc::new(format!("mod {file_stem};")))); } + +/// The inverse of [`inject_virtual_wrapper_lib`], +/// tries to infer root module name from crate if it does not have real `lib.cairo`. +fn extract_custom_file_stem(db: &AnalysisDatabase, crate_id: CrateId) -> Option { + let CrateConfiguration { root: Directory::Real(root), .. } = db.crate_config(crate_id)? else { + return None; + }; + + if root.join("lib.cairo").exists() { + return None; + } + + let module_id = ModuleId::CrateRoot(crate_id); + let file_id = db.module_main_file(module_id).ok()?; + let content = db.file_content(file_id)?; + + let name = content.strip_prefix("mod ")?.strip_suffix(';')?; + Some(name.into()) +} diff --git a/crates/cairo-lang-language-server/src/project/mod.rs b/crates/cairo-lang-language-server/src/project/mod.rs index 2477cef4b70..5b6916bc189 100644 --- a/crates/cairo-lang-language-server/src/project/mod.rs +++ b/crates/cairo-lang-language-server/src/project/mod.rs @@ -1,3 +1,4 @@ +pub use self::crate_data::Crate; pub use self::project_manifest_path::*; mod crate_data; diff --git a/crates/cairo-lang-language-server/tests/e2e/analysis.rs b/crates/cairo-lang-language-server/tests/e2e/analysis.rs new file mode 100644 index 00000000000..7f0a0a798b1 --- /dev/null +++ b/crates/cairo-lang-language-server/tests/e2e/analysis.rs @@ -0,0 +1,38 @@ +use cairo_lang_language_server::lsp; +use cairo_lang_test_utils::parse_test_file::TestRunnerResult; +use cairo_lang_utils::ordered_hash_map::OrderedHashMap; + +use crate::support::normalize::normalize; +use crate::support::sandbox; + +cairo_lang_test_utils::test_file_test!( + project, + "tests/test_data/analysis/crates", + { + cairo_projects: "cairo_projects.txt", + }, + test_analyzed_crates +); + +fn test_analyzed_crates( + inputs: &OrderedHashMap, + _args: &OrderedHashMap, +) -> TestRunnerResult { + let dyn_files = inputs.iter().flat_map(|(p, c)| Some((p.strip_prefix("file: ")?, c))); + + let mut ls = sandbox! { + dyn_files(dyn_files) + }; + + for path_to_open in inputs["open files in order"].lines() { + ls.open_and_wait_for_diagnostics(path_to_open); + } + + let output = ls.send_request::(()); + let output = normalize(&ls, output); + + TestRunnerResult::success(OrderedHashMap::from([( + "expected analyzed crates".to_owned(), + output, + )])) +} diff --git a/crates/cairo-lang-language-server/tests/e2e/main.rs b/crates/cairo-lang-language-server/tests/e2e/main.rs index 7bfc4fb344e..de2051ed2c4 100644 --- a/crates/cairo-lang-language-server/tests/e2e/main.rs +++ b/crates/cairo-lang-language-server/tests/e2e/main.rs @@ -1,3 +1,4 @@ +mod analysis; mod hover; mod semantic_tokens; mod support; diff --git a/crates/cairo-lang-language-server/tests/e2e/support/fixture.rs b/crates/cairo-lang-language-server/tests/e2e/support/fixture.rs index 07ea84805fb..022dc6666af 100644 --- a/crates/cairo-lang-language-server/tests/e2e/support/fixture.rs +++ b/crates/cairo-lang-language-server/tests/e2e/support/fixture.rs @@ -30,6 +30,10 @@ impl Fixture { /// Introspection methods. impl Fixture { + pub fn root_path(&self) -> &Path { + self.t.path() + } + pub fn root_url(&self) -> Url { Url::from_directory_path(self.t.path()).unwrap() } diff --git a/crates/cairo-lang-language-server/tests/e2e/support/mock_client.rs b/crates/cairo-lang-language-server/tests/e2e/support/mock_client.rs index d563cf923aa..49af92f64d6 100644 --- a/crates/cairo-lang-language-server/tests/e2e/support/mock_client.rs +++ b/crates/cairo-lang-language-server/tests/e2e/support/mock_client.rs @@ -418,3 +418,9 @@ impl MockClient { .collect() } } + +impl AsRef for MockClient { + fn as_ref(&self) -> &Fixture { + &self.fixture + } +} diff --git a/crates/cairo-lang-language-server/tests/e2e/support/mod.rs b/crates/cairo-lang-language-server/tests/e2e/support/mod.rs index e756c9d04b5..faf1a8d6aa3 100644 --- a/crates/cairo-lang-language-server/tests/e2e/support/mod.rs +++ b/crates/cairo-lang-language-server/tests/e2e/support/mod.rs @@ -3,6 +3,7 @@ pub mod cursor; pub mod fixture; pub mod jsonrpc; mod mock_client; +pub mod normalize; mod runtime; pub use self::cursor::cursors; @@ -17,6 +18,7 @@ pub use self::mock_client::MockClient; macro_rules! sandbox { ( $(files { $($file:expr => $content:expr),* $(,)? })? + $(dyn_files($dyn_file_iter:expr))? $(client_capabilities = $client_capabilities:expr;)? $(workspace_configuration = $workspace_configuration:expr;)? ) => {{ @@ -30,6 +32,12 @@ macro_rules! sandbox { $($(fixture.add_file($file, $content);)*)? + $( + for (file, content) in $dyn_file_iter { + fixture.add_file(file, content); + } + )? + #[allow(unused_mut)] let mut client_capabilities = client_capabilities::base(); diff --git a/crates/cairo-lang-language-server/tests/e2e/support/normalize.rs b/crates/cairo-lang-language-server/tests/e2e/support/normalize.rs new file mode 100644 index 00000000000..16c45120309 --- /dev/null +++ b/crates/cairo-lang-language-server/tests/e2e/support/normalize.rs @@ -0,0 +1,36 @@ +use std::path::Path; + +use crate::support::fixture::Fixture; + +/// Performs various normalization steps of the input data, to remove any runtime-specific artifacts +/// and make comparisons in test assertions deterministic. +pub fn normalize(fixture: impl AsRef, data: impl ToString) -> String { + let fixture = fixture.as_ref(); + normalize_well_known_paths(fixture, normalize_paths(data.to_string())) +} + +/// Replace all well-known paths/urls for a fixture with placeholders. +fn normalize_well_known_paths(fixture: &Fixture, data: String) -> String { + let mut data = data + .replace(&fixture.root_url().to_string(), "[ROOT_URL]") + .replace(&normalize_path(fixture.root_path()), "[ROOT]"); + + if let Ok(pwd) = std::env::current_dir() { + data = data.replace(&normalize_path(&pwd), "[PWD]"); + } + + let cairo_source = Path::new(env!("CARGO_MANIFEST_DIR")).parent().unwrap().parent().unwrap(); + data = data.replace(&normalize_path(cairo_source), "[CAIRO_SOURCE]"); + + data +} + +/// Normalizes path separators. +fn normalize_paths(data: String) -> String { + data.replace('\\', "/") +} + +/// Normalize a path to a consistent format. +fn normalize_path(path: &Path) -> String { + normalize_paths(path.to_string_lossy().to_string()) +} diff --git a/crates/cairo-lang-language-server/tests/test_data/analysis/crates/cairo_projects.txt b/crates/cairo-lang-language-server/tests/test_data/analysis/crates/cairo_projects.txt new file mode 100644 index 00000000000..6adf6d0d0cf --- /dev/null +++ b/crates/cairo-lang-language-server/tests/test_data/analysis/crates/cairo_projects.txt @@ -0,0 +1,78 @@ +//! > Project model for cairo_project.toml files + +//! > test_runner_name +test_analyzed_crates + +//! > file: project1/cairo_project.toml +[crate_roots] +project1 = "src" + +//! > file: project1/src/lib.cairo +fn main() {} + +//! > file: project2/cairo_project.toml +[crate_roots] +project2 = "src" + +//! > file: project2/src/lib.cairo +fn main() {} + +//! > file: project2/subproject/cairo_project.toml +[crate_roots] +subproject = "src" + +//! > file: project2/subproject/src/lib.cairo +fn main() {} + +//! > open files in order +project1/src/lib.cairo +project2/src/lib.cairo +project2/subproject/src/lib.cairo + +//! > expected analyzed crates +# Analyzed Crates + +- `core`: `[CAIRO_SOURCE]/corelib/src/lib.cairo` + ```rust + CrateSettings { + edition: V2023_01, + cfg_set: None, + experimental_features: ExperimentalFeaturesConfig { + negative_impls: true, + coupons: true, + }, + } + ``` +- `project1`: `[ROOT]/project1/src/lib.cairo` + ```rust + CrateSettings { + edition: V2023_01, + cfg_set: None, + experimental_features: ExperimentalFeaturesConfig { + negative_impls: false, + coupons: false, + }, + } + ``` +- `project2`: `[ROOT]/project2/src/lib.cairo` + ```rust + CrateSettings { + edition: V2023_01, + cfg_set: None, + experimental_features: ExperimentalFeaturesConfig { + negative_impls: false, + coupons: false, + }, + } + ``` +- `subproject`: `[ROOT]/project2/subproject/src/lib.cairo` + ```rust + CrateSettings { + edition: V2023_01, + cfg_set: None, + experimental_features: ExperimentalFeaturesConfig { + negative_impls: false, + coupons: false, + }, + } + ```