From ca91e0f6621084fe9a51477fcfa9daab6c663d45 Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Mon, 27 Jul 2026 14:10:49 +0300 Subject: [PATCH] feat: Simplex manifest discovery and client configuration --- Cargo.lock | 120 +++++++++- Cargo.toml | 6 + README.md | 62 ++++- src/backend.rs | 272 +++++++++++++++++++--- src/config.rs | 156 +++++++++++++ src/main.rs | 2 + src/project.rs | 600 +++++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1184 insertions(+), 34 deletions(-) create mode 100644 src/config.rs create mode 100644 src/project.rs diff --git a/Cargo.lock b/Cargo.lock index 0d4375d..65c7c8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -313,6 +313,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -423,6 +439,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + [[package]] name = "ghost-cell" version = "0.2.6" @@ -506,6 +533,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "lock_api" version = "0.4.14" @@ -642,6 +675,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.6" @@ -669,7 +708,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] [[package]] @@ -708,6 +747,19 @@ dependencies = [ "str_indices", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -816,6 +868,15 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "shlex" version = "1.3.0" @@ -832,7 +893,7 @@ dependencies = [ "bitcoin_hashes", "byteorder", "elements", - "getrandom", + "getrandom 0.2.17", "ghost-cell", "hex-conservative", "miniscript", @@ -859,7 +920,7 @@ dependencies = [ "chumsky", "clap", "either", - "getrandom", + "getrandom 0.2.17", "itertools", "miniscript", "serde", @@ -875,10 +936,13 @@ dependencies = [ "miniscript", "nom", "ropey", + "serde", "serde_json", "simplicityhl", + "tempfile", "thiserror", "tokio", + "toml", "tower-lsp-server", ] @@ -936,6 +1000,19 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "thiserror" version = "2.0.18" @@ -991,6 +1068,37 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + [[package]] name = "tower" version = "0.5.3" @@ -1153,6 +1261,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "zerocopy" version = "0.8.48" diff --git a/Cargo.toml b/Cargo.toml index ef7ea39..3f3fa35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,10 @@ tokio = { version = "1.47.1", features = [ "io-util", "sync", ] } +serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.143" +# Deserialize-only: the server reads Simplex.toml and never writes it. +toml = { version = "1.1", default-features = false, features = ["parse", "serde"] } tower-lsp-server = "0.22.1" env_logger = { version = "0.11.8", default-features = false } @@ -30,6 +33,9 @@ miniscript = "12" simplicityhl = {version = "0.6.0", features = ["docs"]} nom = "8.0.0" +[dev-dependencies] +tempfile = "3.23.0" + [profile.release] lto = "thin" strip = true diff --git a/README.md b/README.md index aedfca8..ef85248 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,57 @@ Language Server for [SimplicityHL language](https://simplicity-lang.org/). ![goto-definition](assets/goto-definition.gif) +## Configuration + +Editors send settings under the `simplicityhl` section. Every field is optional; the +defaults below are what the server uses when a client sends nothing. + +```json +{ + "simplicityhl": { + "experimentalFeatures": { + "imports": false + }, + "project": { + "simplex": { + "enabled": true, + "manifestPath": "" + }, + "sourceDirectory": "", + "dependencies": {} + } + } +} +``` + +- `experimentalFeatures.imports` enables the compiler's unstable `use` / `mod` / `pub` + syntax. It is off by default because the feature is unstable in the compiler itself. +- `project.simplex.enabled` looks for the nearest `Simplex.toml` (or `simplex.toml`) + in the file's ancestors and honours its `build.src_dir` and `[dependencies]`, resolving + path dependencies recursively and locating installed git dependencies under `deps/`. +- `project.simplex.manifestPath` pins an explicit manifest instead of searching. Relative + values resolve from the containing workspace folder. A path that does not exist is + reported as a diagnostic rather than silently ignored. +- `project.sourceDirectory` overrides the package root when there is no manifest, or when + the manifest's `src_dir` is not what the editor should use. +- `project.dependencies` adds import roots by hand. They supplement the manifest and win + on a colliding alias: + +```json +{ + "std": "../simplicityhl-std/simf", + "math": { "path": "../math/simf", "context": "simf/contracts" } +} +``` + + The shorthand form maps an alias for the whole package. The detailed form restricts the + mapping to files under `context`, matching how the compiler scopes dependencies per + package. + +Configuration changes take effect immediately: the server re-analyses open documents on +`workspace/didChangeConfiguration`, on workspace-folder changes, and when a watched +`.simf` file or Simplex manifest changes on disk. + ## Installation Install Language Server using `cargo`: @@ -47,7 +98,16 @@ vim.filetype.add({ }, }) -vim.lsp.config["simplicityhl-lsp"] = { cmd = { "simplicityhl-lsp" }, filetypes = { "simf" }, settings = {} } +vim.lsp.config["simplicityhl-lsp"] = { + cmd = { "simplicityhl-lsp" }, + filetypes = { "simf" }, + settings = { + simplicityhl = { + experimentalFeatures = { imports = true }, + project = { simplex = { enabled = true, manifestPath = "" } }, + }, + }, +} vim.lsp.enable("simplicityhl-lsp") ``` diff --git a/src/backend.rs b/src/backend.rs index 6671e22..b373ff3 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -5,7 +5,7 @@ use simplicityhl::parse::ParseFromStrWithErrors; use simplicityhl::TemplateProgram; use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; use tokio::sync::RwLock; @@ -18,13 +18,13 @@ use tower_lsp_server::lsp_types::{ DidSaveTextDocumentParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, ExecuteCommandParams, GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverParams, HoverProviderCapability, InitializeParams, InitializeResult, InitializedParams, Location, - MarkupContent, MarkupKind, OneOf, Range, ReferenceParams, SaveOptions, SemanticToken, - SemanticTokenModifier, SemanticTokenType, SemanticTokens, SemanticTokensFullOptions, - SemanticTokensLegend, SemanticTokensOptions, SemanticTokensParams, SemanticTokensResult, - SemanticTokensServerCapabilities, ServerCapabilities, SignatureHelp, SignatureHelpOptions, - SignatureHelpParams, SymbolKind, TextDocumentSyncCapability, TextDocumentSyncKind, - TextDocumentSyncOptions, TextDocumentSyncSaveOptions, Uri, WorkDoneProgressOptions, - WorkspaceFoldersServerCapabilities, WorkspaceServerCapabilities, + MarkupContent, MarkupKind, MessageType, OneOf, Range, ReferenceParams, SaveOptions, + SemanticToken, SemanticTokenModifier, SemanticTokenType, SemanticTokens, + SemanticTokensFullOptions, SemanticTokensLegend, SemanticTokensOptions, SemanticTokensParams, + SemanticTokensResult, SemanticTokensServerCapabilities, ServerCapabilities, SignatureHelp, + SignatureHelpOptions, SignatureHelpParams, SymbolKind, TextDocumentSyncCapability, + TextDocumentSyncKind, TextDocumentSyncOptions, TextDocumentSyncSaveOptions, Uri, + WorkDoneProgressOptions, WorkspaceFoldersServerCapabilities, WorkspaceServerCapabilities, }; use tower_lsp_server::{Client, LanguageServer, UriExt}; @@ -32,8 +32,10 @@ use miniscript::iter::TreeLike; use simplicityhl::{error::RichError, parse}; use crate::completion::{self, CompletionProvider}; +use crate::config::Settings; use crate::error::LspError; use crate::function::Functions; +use crate::project::{ProjectContext, SIMPLEX_MANIFEST}; use crate::utils::{ create_signature_info, find_builtin_signature, find_function_call_context, find_key_position, get_call_span, get_comments_from_lines, offset_to_position, position_to_span, span_contains, @@ -46,6 +48,30 @@ mod semantic_token_types { pub const NAMESPACE: u32 = 5; } +/// Collect the workspace folders the client opened with, falling back to the +/// deprecated `root_uri` for clients that do not send folders. +fn workspace_roots(params: &InitializeParams) -> Vec { + let mut roots = params + .workspace_folders + .as_ref() + .into_iter() + .flatten() + .filter_map(|folder| folder.uri.to_file_path().map(std::borrow::Cow::into_owned)) + .collect::>(); + #[allow(deprecated)] + if roots.is_empty() { + if let Some(path) = params + .root_uri + .as_ref() + .and_then(UriExt::to_file_path) + .map(std::borrow::Cow::into_owned) + { + roots.push(path); + } + } + roots +} + /// Get the semantic token legend for this server fn get_semantic_token_legend() -> SemanticTokensLegend { SemanticTokensLegend { @@ -88,12 +114,24 @@ pub struct Document { pub version: Option, } +/// Client-supplied configuration, kept separate from the document cache so a +/// settings change does not need the document lock. +#[derive(Debug, Default)] +struct ServerConfig { + settings: Settings, + + /// Workspace folders, used to resolve relative paths in [`Settings`]. + workspace_roots: Vec, +} + #[derive(Debug)] pub struct Backend { client: Client, document_map: Arc>>, + config: Arc>, + completion_provider: CompletionProvider, } @@ -104,7 +142,18 @@ struct TextDocumentItem<'a> { } impl LanguageServer for Backend { - async fn initialize(&self, _: InitializeParams) -> Result { + async fn initialize(&self, params: InitializeParams) -> Result { + let workspace_roots = workspace_roots(¶ms); + let settings = params + .initialization_options + .and_then(|value| Settings::from_json(value).ok()) + .unwrap_or_default(); + { + let mut config = self.config.write().await; + config.workspace_roots = workspace_roots; + config.settings = settings; + } + Ok(InitializeResult { server_info: None, capabilities: ServerCapabilities { @@ -162,11 +211,58 @@ impl LanguageServer for Backend { Ok(()) } - async fn did_change_workspace_folders(&self, _: DidChangeWorkspaceFoldersParams) {} + async fn did_change_workspace_folders(&self, params: DidChangeWorkspaceFoldersParams) { + { + let mut config = self.config.write().await; + for removed in params.event.removed { + if let Some(path) = removed.uri.to_file_path() { + config.workspace_roots.retain(|root| root != path.as_ref()); + } + } + for added in params.event.added { + if let Some(path) = added.uri.to_file_path() { + let path = path.into_owned(); + if !config.workspace_roots.contains(&path) { + config.workspace_roots.push(path); + } + } + } + } + self.reanalyze_open_documents().await; + } - async fn did_change_configuration(&self, _: DidChangeConfigurationParams) {} + async fn did_change_configuration(&self, params: DidChangeConfigurationParams) { + match Settings::from_json(params.settings) { + Ok(settings) => { + self.config.write().await.settings = settings; + self.reanalyze_open_documents().await; + } + Err(err) => { + self.client + .log_message( + MessageType::ERROR, + format!("Invalid SimplicityHL settings: {err}"), + ) + .await; + } + } + } - async fn did_change_watched_files(&self, _: DidChangeWatchedFilesParams) {} + async fn did_change_watched_files(&self, params: DidChangeWatchedFilesParams) { + // A manifest or a file elsewhere in the dependency graph changed, so results + // cached for the open documents may no longer be correct. + let relevant = params.changes.iter().any(|change| { + change.uri.to_file_path().is_some_and(|path| { + path.extension().is_some_and(|ext| ext == "simf") + || path + .file_name() + .is_some_and(|name| name.eq_ignore_ascii_case(SIMPLEX_MANIFEST)) + }) + }); + if relevant { + self.reanalyze_open_documents().await; + } + } async fn execute_command(&self, _: ExecuteCommandParams) -> Result> { Ok(None) @@ -694,10 +790,31 @@ impl Backend { Self { client, document_map: Arc::new(RwLock::new(HashMap::new())), + config: Arc::new(RwLock::new(ServerConfig::default())), completion_provider: CompletionProvider::new(), } } + /// Re-run analysis for every open document, after configuration that affects + /// dependency resolution has changed. + async fn reanalyze_open_documents(&self) { + let documents = { + let documents = self.document_map.read().await; + documents + .iter() + .map(|(uri, doc)| (uri.clone(), doc.text.to_string(), doc.version)) + .collect::>() + }; + for (uri, text, version) in documents { + self.on_change(TextDocumentItem { + uri, + text: &text, + version, + }) + .await; + } + } + /// Function which executed on change of file (`did_save`, `did_open` or `did_change` methods) async fn on_change(&self, params: TextDocumentItem<'_>) { let Some(path_buf) = params.uri.to_file_path() else { @@ -714,7 +831,11 @@ impl Backend { return; } - let (err, document) = parse_program(params.text, path); + let (settings, workspace_roots) = { + let config = self.config.read().await; + (config.settings.clone(), config.workspace_roots.clone()) + }; + let (err, document) = parse_program(params.text, path, &settings, &workspace_roots); let rope = Rope::from_str(params.text); let mut documents = self.document_map.write().await; @@ -813,13 +934,19 @@ fn create_document(program: &simplicityhl::parse::Program, text: &str) -> Docume /// Parse and analyze program using [`simplicityhl`] compiler and return an list of [`RichError`] /// to use in diagnostics. Also creates a [`Document`] if parsing is successful. -fn parse_program(text: &str, path: &Path) -> (Vec, Option) { +fn parse_program( + text: &str, + path: &Path, + settings: &Settings, + workspace_roots: &[PathBuf], +) -> (Vec, Option) { + let unstable_features = settings.unstable_features(); let mut error_collector = simplicityhl::error::ErrorCollector::new(); let text: Arc = Arc::from(text); let source_file = simplicityhl::source::SourceFile::new(path, Arc::clone(&text)); let Some(program) = parse::Program::parse_from_str_with_errors( source_file.clone(), - &simplicityhl::UnstableFeatures::all(), + &unstable_features, &mut error_collector, ) else { return (error_collector.get().to_vec(), None); @@ -827,18 +954,14 @@ fn parse_program(text: &str, path: &Path) -> (Vec, Option) let mut document = create_document(&program, text.as_ref()); - let Some(canon_root) = path - .parent() - .and_then(|p| simplicityhl::source::CanonPath::canonicalize(p).ok()) - else { - return (error_collector.get().to_vec(), Some(document)); - }; - - let dependencies = match simplicityhl::resolution::DependencyMapBuilder::new().build(canon_root) + // Import roots come from the Simplex manifest and the client's settings rather than + // from the containing directory, so dependencies resolve the way `simplex` builds them. + let dependencies = match ProjectContext::discover(path, &settings.project, workspace_roots) + .and_then(|project| project.dependency_map(path)) { - Ok(deps) => deps, + Ok(dependencies) => dependencies, Err(err) => { - error_collector.push(RichError::new(err, (0..0).into())); + error_collector.push(RichError::parsing_error(&err.to_string())); return (error_collector.get().to_vec(), Some(document)); } @@ -846,7 +969,7 @@ fn parse_program(text: &str, path: &Path) -> (Vec, Option) if let Ok(template_program) = TemplateProgram::new_with_dep( source_file.try_into().expect("name was defined above"), &dependencies, - &simplicityhl::UnstableFeatures::all(), + &unstable_features, Box::new(ElementsJetHinter::new()), ) .map_err(|e| error_collector = e) @@ -925,9 +1048,21 @@ fn validate_witness_file(text: &str) -> Vec { #[cfg(test)] mod tests { use simplicityhl::error::Error; + use tempfile::TempDir; use super::*; + /// `parse_program` resolves imports from the project the file lives in, so tests + /// need a real path on disk rather than a placeholder. + fn in_temp_project(source: &str) -> (TempDir, PathBuf) { + let temp = TempDir::new().expect("temp dir"); + std::fs::write(temp.path().join("Simplex.toml"), "").expect("write manifest"); + std::fs::create_dir(temp.path().join("simf")).expect("create source dir"); + let path = temp.path().join("simf/main.simf"); + std::fs::write(&path, source).expect("write source"); + (temp, path) + } + fn sample_program() -> &'static str { "fn add(a: u32, b: u32) -> u32 { let (_, res): (bool, u32) = jet::add_32(a, b); res } fn main() {}" @@ -943,8 +1078,14 @@ mod tests { #[test] fn test_parse_program_valid() { - let (err, doc) = parse_program(sample_program(), Path::new("")); - assert!(err.is_empty(), "Expected no parsing error"); + let (temp, path) = in_temp_project(sample_program()); + let (err, doc) = parse_program( + sample_program(), + &path, + &Settings::default(), + &[temp.path().to_path_buf()], + ); + assert!(err.is_empty(), "Expected no parsing error, got {err:?}"); let doc = doc.expect("Expected Some(Document)"); assert_eq!(doc.functions.map.len(), 2); } @@ -952,7 +1093,13 @@ mod tests { #[test] #[ignore = "TODO we need to also create a file with a path so that could work"] fn test_parse_program_invalid_ast() { - let (err, doc) = parse_program(invalid_program_on_ast(), Path::new("")); + let (temp, path) = in_temp_project(invalid_program_on_ast()); + let (err, doc) = parse_program( + invalid_program_on_ast(), + &path, + &Settings::default(), + &[temp.path().to_path_buf()], + ); assert!( err.first() .expect("program should produce an error") @@ -963,9 +1110,74 @@ mod tests { assert!(doc.is_some(), "Expected problem in AST build, not parse"); } + #[test] + fn parse_program_resolves_a_manifest_dependency() { + // End-to-end check that the manifest drives import resolution: `math` is only + // reachable because Simplex.toml declares it, and `src_dir` points the package + // root at `contracts` rather than the default `simf`. + let temp = TempDir::new().expect("temp dir"); + let root = temp.path(); + let write = |path: PathBuf, source: &str| { + std::fs::create_dir_all(path.parent().expect("has parent")).expect("create dir"); + std::fs::write(path, source).expect("write file"); + }; + write( + root.join("Simplex.toml"), + "[build]\nsrc_dir = 'contracts'\n[dependencies]\nmath = { path = 'vendor/math' }\n", + ); + write(root.join("vendor/math/Simplex.toml"), ""); + write( + root.join("vendor/math/simf/ops.simf"), + "pub fn double(a: u32) -> u32 {\n let (_, n): (bool, u32) = jet::add_32(a, a);\n n\n}\n", + ); + + let source = "use math::ops::double;\nfn main() {\n let _: u32 = double(2);\n}\n"; + let path = root.join("contracts/main.simf"); + write(path.clone(), source); + + let settings = Settings::from_json(serde_json::json!({ + "experimentalFeatures": { "imports": true } + })) + .expect("valid settings"); + + let (err, doc) = parse_program(source, &path, &settings, &[root.to_path_buf()]); + + assert!( + err.is_empty(), + "expected the import to resolve, got {err:?}" + ); + assert!(doc.is_some(), "expected a document"); + } + + #[test] + fn parse_program_reports_a_missing_configured_manifest() { + let (temp, path) = in_temp_project(sample_program()); + let mut settings = Settings::default(); + settings.project.simplex.manifest_path = "nowhere/Simplex.toml".to_string(); + + let (err, _) = parse_program( + sample_program(), + &path, + &settings, + &[temp.path().to_path_buf()], + ); + + assert!( + err.iter() + .any(|e| e.to_string().contains("Simplex manifest was not found")), + "a misconfigured manifest path should surface as a diagnostic, got {err:?}" + ); + } + #[test] fn test_parse_program_invalid_parse() { - let (err, doc) = parse_program(invalid_program_on_parsing(), Path::new("")); + let (temp, path) = in_temp_project(invalid_program_on_parsing()); + let (err, doc) = parse_program( + invalid_program_on_parsing(), + &path, + &Settings::default(), + &[temp.path().to_path_buf()], + ); match err .first() .expect("program should produce an error") diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..6a88854 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,156 @@ +use std::collections::BTreeMap; + +use serde::Deserialize; +use serde_json::Value; +use simplicityhl::{UnstableFeature, UnstableFeatures}; + +/// Runtime configuration shared by every editor client. +/// +/// VS Code sends this object below the `simplicityhl` key, while other clients +/// commonly send the section itself. [`Settings::from_json`] accepts both forms. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +pub struct Settings { + pub experimental_features: ExperimentalFeatures, + pub project: ProjectSettings, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct ExperimentalFeatures { + /// Enables `use`, `mod`, `pub`, aliases, and multi-file dependency resolution. + pub imports: bool, + // TODO: `enums` is absent: `UnstableFeature::Enums` does not exist in the + // released compiler, so the setting would silently do nothing. +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +pub struct ProjectSettings { + pub simplex: SimplexSettings, + /// Optional source root used when no Simplex manifest is available. + pub source_directory: String, + /// Manual import-root mappings. These supplement, and on collision override, + /// mappings discovered from `Simplex.toml`. + pub dependencies: BTreeMap, +} + +// TODO: maybe have a simplex as a dep to avoid code/logic duplication/invalidation +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +pub struct SimplexSettings { + /// Automatically discover and load the nearest Simplex manifest. + pub enabled: bool, + /// Explicit manifest path. Relative values resolve from the containing workspace folder. + pub manifest_path: String, +} + +impl Default for SimplexSettings { + fn default() -> Self { + Self { + enabled: true, + manifest_path: String::new(), + } + } +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(untagged)] +pub enum ManualDependency { + /// Shorthand: `"std": "../simplicityhl-std/simf"`. + Path(String), + /// Full form for context-specific compiler remappings. + Detailed(ManualDependencyDetails), +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct ManualDependencyDetails { + pub path: String, + pub context: String, +} + +impl ManualDependency { + pub fn path(&self) -> &str { + match self { + Self::Path(path) => path, + Self::Detailed(details) => &details.path, + } + } + + pub fn context(&self) -> Option<&str> { + match self { + Self::Path(_) => None, + Self::Detailed(details) if details.context.trim().is_empty() => None, + Self::Detailed(details) => Some(&details.context), + } + } +} + +impl Settings { + pub fn from_json(value: Value) -> Result { + let value = value.get("simplicityhl").cloned().unwrap_or(value); + serde_json::from_value(value) + } + + pub fn unstable_features(&self) -> UnstableFeatures { + let mut enabled = Vec::new(); + if self.experimental_features.imports { + enabled.push(UnstableFeature::Imports); + } + UnstableFeatures::new(enabled) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_keep_experimental_syntax_disabled_and_simplex_detection_enabled() { + let settings = Settings::default(); + + assert!(!settings.experimental_features.imports); + assert!(settings.project.simplex.enabled); + } + + #[test] + fn accepts_vscode_wrapped_configuration() { + let settings = Settings::from_json(serde_json::json!({ + "simplicityhl": { + "experimentalFeatures": { "imports": true, "enums": false }, + "project": { + "simplex": { "enabled": true, "manifestPath": "config/Simplex.toml" }, + "sourceDirectory": "contracts", + "dependencies": { + "std": "../std/simf", + "math": { "path": "../math/simf", "context": "contracts/lib" } + } + } + } + })) + .expect("valid settings"); + + assert!(settings.experimental_features.imports); + assert_eq!( + settings.project.simplex.manifest_path, + "config/Simplex.toml" + ); + assert_eq!(settings.project.dependencies["std"].path(), "../std/simf"); + assert_eq!( + settings.project.dependencies["math"].context(), + Some("contracts/lib") + ); + } + + #[test] + fn accepts_an_unwrapped_configuration_section() { + // `enums` is not a field yet; an editor that still sends it must not break parsing. + let settings = Settings::from_json(serde_json::json!({ + "experimentalFeatures": { "imports": true, "enums": true } + })) + .expect("valid settings"); + + assert!(settings.experimental_features.imports); + } +} diff --git a/src/main.rs b/src/main.rs index d73285d..1163561 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,8 +2,10 @@ mod backend; mod completion; +mod config; mod error; mod function; +mod project; mod utils; use backend::Backend; diff --git a/src/project.rs b/src/project.rs new file mode 100644 index 0000000..8245f41 --- /dev/null +++ b/src/project.rs @@ -0,0 +1,600 @@ +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::hash::{DefaultHasher, Hash as _, Hasher as _}; +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use simplicityhl::resolution::{DependencyMap, DependencyMapBuilder}; +use simplicityhl::source::CanonPath; +use thiserror::Error; + +use crate::config::{ManualDependency, ProjectSettings}; + +pub const SIMPLEX_MANIFEST: &str = "Simplex.toml"; +const SIMPLEX_MANIFEST_LOWERCASE: &str = "simplex.toml"; +const DEFAULT_SOURCE_DIRECTORY: &str = "simf"; +const DEFAULT_DEPENDENCY_DIRECTORY: &str = "deps"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DependencyMapping { + pub context: PathBuf, + pub alias: String, + pub target: PathBuf, +} + +#[derive(Clone, Debug)] +pub struct ProjectContext { + pub source_root: PathBuf, + pub dependencies: Vec, + pub package_roots: Vec, +} + +#[derive(Debug, Error)] +pub enum ProjectError { + #[error("Unable to read `{path}`: {source}")] + Read { + path: PathBuf, + source: std::io::Error, + }, + #[error("Unable to parse `{path}`: {source}")] + Parse { + path: PathBuf, + source: toml::de::Error, + }, + #[error("Unable to resolve `{path}`: {source}")] + Canonicalize { + path: PathBuf, + source: std::io::Error, + }, + #[error("Configured Simplex manifest was not found at `{0}`")] + MissingConfiguredManifest(PathBuf), + #[error("Dependency `{name}` in `{manifest}` must set exactly one of `path` or `git`")] + InvalidDependency { name: String, manifest: PathBuf }, + #[error("Git dependency `{name}` from `{url}` is not installed at `{expected}`")] + MissingGitDependency { + name: String, + url: String, + expected: PathBuf, + }, + #[error("Dependency `{name}` is missing a Simplex manifest in `{root}`")] + MissingDependencyManifest { name: String, root: PathBuf }, + #[error("Invalid git dependency URL `{0}`")] + InvalidGitUrl(String), + #[error("Unable to build compiler dependency mappings: {0}")] + Compiler(String), +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default)] +struct SimplexConfig { + build: BuildConfig, + dependencies: BTreeMap, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(default)] +struct BuildConfig { + src_dir: String, +} + +impl Default for BuildConfig { + fn default() -> Self { + Self { + src_dir: DEFAULT_SOURCE_DIRECTORY.to_string(), + } + } +} + +#[derive(Clone, Debug, Default, Deserialize)] +#[serde(default)] +struct DependencyConfig { + path: Option, + git: Option, +} + +struct ProjectCollector { + install_root: PathBuf, + visited: HashSet, + mappings: BTreeMap<(PathBuf, String), PathBuf>, + package_roots: HashSet, +} + +impl ProjectContext { + pub fn discover( + document_path: &Path, + settings: &ProjectSettings, + workspace_roots: &[PathBuf], + ) -> Result { + let workspace_root = containing_workspace(document_path, workspace_roots) + .or_else(|| document_path.parent().map(Path::to_path_buf)) + .unwrap_or_else(|| PathBuf::from(".")); + + let manifest_path = if settings.simplex.enabled { + if settings.simplex.manifest_path.trim().is_empty() { + find_manifest(document_path) + } else { + let configured = PathBuf::from(settings.simplex.manifest_path.trim()); + let configured = if configured.is_absolute() { + configured + } else { + workspace_root.join(configured) + }; + Some( + manifest_at(&configured) + .ok_or(ProjectError::MissingConfiguredManifest(configured))?, + ) + } + } else { + None + }; + + let (project_root, root_config) = if let Some(manifest_path) = &manifest_path { + let project_root = canonicalize( + manifest_path + .parent() + .expect("a manifest path always has a parent"), + )?; + (project_root, Some(load_manifest(manifest_path)?)) + } else { + (canonicalize_existing_ancestor(&workspace_root)?, None) + }; + let source_root = if !settings.source_directory.trim().is_empty() { + resolve_existing(&project_root, &settings.source_directory)? + } else if let Some(config) = &root_config { + canonicalize(&project_root.join(&config.build.src_dir))? + } else { + canonicalize_existing_ancestor( + document_path.parent().unwrap_or(project_root.as_path()), + )? + }; + let mut collector = ProjectCollector::new(project_root.clone()); + collector.package_roots.insert(source_root.clone()); + if let Some(config) = &root_config { + collector.collect(config, &project_root, &source_root)?; + } + + for (alias, dependency) in &settings.dependencies { + add_manual_dependency( + &mut collector, + &project_root, + &source_root, + alias, + dependency, + )?; + } + + let mut dependencies = collector + .mappings + .into_iter() + .map(|((context, alias), target)| DependencyMapping { + context, + alias, + target, + }) + .collect::>(); + dependencies.sort_by(|a, b| { + b.context + .as_os_str() + .len() + .cmp(&a.context.as_os_str().len()) + .then_with(|| a.alias.cmp(&b.alias)) + }); + + let mut package_roots = collector.package_roots.into_iter().collect::>(); + package_roots.sort_by_key(|path| std::cmp::Reverse(path.as_os_str().len())); + + Ok(Self { + source_root, + dependencies, + package_roots, + }) + } + + pub fn dependency_map(&self, document_path: &Path) -> Result { + let mut builder = DependencyMapBuilder::new(); + for mapping in &self.dependencies { + builder.add_dependency( + to_canon(&mapping.context)?, + mapping.alias.clone(), + to_canon(&mapping.target)?, + ); + } + + let package_root = self.package_root_for(document_path); + builder + .build(to_canon(package_root)?) + .map_err(|error| ProjectError::Compiler(error.to_string())) + } + + pub fn package_root_for(&self, document_path: &Path) -> &Path { + let canonical_document = + fs::canonicalize(document_path).unwrap_or_else(|_| document_path.to_path_buf()); + self.package_roots + .iter() + .filter(|root| canonical_document.starts_with(root)) + .max_by_key(|root| root.as_os_str().len()) + .map_or(self.source_root.as_path(), PathBuf::as_path) + } + + /// Resolve the directory an import alias points at, from the perspective of + /// `document_path`. + /// + /// The server itself hands whole mappings to the compiler via [`Self::dependency_map`] + /// and never resolves a single alias, so this is currently exercised only by the tests + /// below; import-path completion is its first real caller. + #[allow(dead_code)] + pub fn import_root(&self, document_path: &Path, alias: &str) -> Option<&Path> { + if alias == "crate" { + return Some(self.package_root_for(document_path)); + } + + let canonical_document = + fs::canonicalize(document_path).unwrap_or_else(|_| document_path.to_path_buf()); + self.dependencies + .iter() + .filter(|mapping| { + mapping.alias == alias && canonical_document.starts_with(&mapping.context) + }) + .max_by_key(|mapping| mapping.context.as_os_str().len()) + .map(|mapping| mapping.target.as_path()) + } +} + +impl ProjectCollector { + fn new(install_root: PathBuf) -> Self { + Self { + install_root, + visited: HashSet::new(), + mappings: BTreeMap::new(), + package_roots: HashSet::new(), + } + } + + fn collect( + &mut self, + config: &SimplexConfig, + package_root: &Path, + source_root: &Path, + ) -> Result<(), ProjectError> { + self.visited.insert(package_root.to_path_buf()); + + for (name, dependency) in &config.dependencies { + let dependency_root = self.resolve_dependency(name, dependency, package_root)?; + let manifest_path = manifest_in(&dependency_root).ok_or_else(|| { + ProjectError::MissingDependencyManifest { + name: name.clone(), + root: dependency_root.clone(), + } + })?; + let dependency_config = load_manifest(&manifest_path)?; + let dependency_source = + canonicalize(&dependency_root.join(&dependency_config.build.src_dir))?; + + self.mappings.insert( + (source_root.to_path_buf(), name.clone()), + dependency_source.clone(), + ); + self.package_roots.insert(dependency_source.clone()); + + if self.visited.insert(dependency_root.clone()) { + self.collect(&dependency_config, &dependency_root, &dependency_source)?; + } + } + + Ok(()) + } + + fn resolve_dependency( + &self, + name: &str, + dependency: &DependencyConfig, + package_root: &Path, + ) -> Result { + match (&dependency.path, &dependency.git) { + (Some(path), None) => canonicalize(&package_root.join(path)), + (None, Some(url)) => { + let relative = hashed_repository_path(url)?; + let expected = self + .install_root + .join(DEFAULT_DEPENDENCY_DIRECTORY) + .join(relative); + canonicalize(&expected).map_err(|_| ProjectError::MissingGitDependency { + name: name.to_string(), + url: url.clone(), + expected, + }) + } + (Some(_), Some(_)) | (None, None) => Err(ProjectError::InvalidDependency { + name: name.to_string(), + manifest: package_root.join(SIMPLEX_MANIFEST), + }), + } + } +} + +fn add_manual_dependency( + collector: &mut ProjectCollector, + project_root: &Path, + source_root: &Path, + alias: &str, + dependency: &ManualDependency, +) -> Result<(), ProjectError> { + let target = resolve_existing(project_root, dependency.path())?; + let context = dependency.context().map_or_else( + || Ok(source_root.to_path_buf()), + |path| resolve_existing(project_root, path), + )?; + collector + .mappings + .insert((context, alias.to_string()), target.clone()); + collector.package_roots.insert(target); + Ok(()) +} + +fn load_manifest(path: &Path) -> Result { + let source = read_to_string(path)?; + toml::from_str(&source).map_err(|source| ProjectError::Parse { + path: path.to_path_buf(), + source, + }) +} + +fn read_to_string(path: &Path) -> Result { + fs::read_to_string(path).map_err(|source| ProjectError::Read { + path: path.to_path_buf(), + source, + }) +} + +fn resolve_existing(base: &Path, path: &str) -> Result { + let path = PathBuf::from(path); + let resolved = if path.is_absolute() { + path + } else { + base.join(path) + }; + canonicalize(&resolved) +} + +fn canonicalize(path: &Path) -> Result { + fs::canonicalize(path).map_err(|source| ProjectError::Canonicalize { + path: path.to_path_buf(), + source, + }) +} + +fn canonicalize_existing_ancestor(path: &Path) -> Result { + let mut current = path; + loop { + match canonicalize(current) { + Ok(path) => return Ok(path), + Err(_error) if current.parent().is_some() => { + current = current.parent().expect("checked above"); + } + Err(error) => return Err(error), + } + } +} + +fn to_canon(path: &Path) -> Result { + CanonPath::canonicalize(path).map_err(ProjectError::Compiler) +} + +fn containing_workspace(path: &Path, roots: &[PathBuf]) -> Option { + roots + .iter() + .filter(|root| path.starts_with(root)) + .max_by_key(|root| root.as_os_str().len()) + .cloned() +} + +fn manifest_at(path: &Path) -> Option { + if path.is_file() { + return Some(path.to_path_buf()); + } + manifest_in(path) +} + +fn manifest_in(directory: &Path) -> Option { + [SIMPLEX_MANIFEST, SIMPLEX_MANIFEST_LOWERCASE] + .into_iter() + .map(|name| directory.join(name)) + .find(|path| path.is_file()) +} + +pub fn find_manifest(path: &Path) -> Option { + let start = if path.is_dir() { path } else { path.parent()? }; + start.ancestors().find_map(manifest_in) +} + +fn hashed_repository_path(url: &str) -> Result { + let clean_url = url.strip_suffix(".git").unwrap_or(url); + let repository_name = clean_url + .split('/') + .next_back() + .filter(|name| !name.is_empty()) + .ok_or_else(|| ProjectError::InvalidGitUrl(url.to_string()))?; + + let mut hasher = DefaultHasher::new(); + url.hash(&mut hasher); + Ok(PathBuf::from(format!( + "{repository_name}-{:016x}", + hasher.finish() + ))) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::*; + use crate::config::{ManualDependencyDetails, SimplexSettings}; + + fn write(path: &Path, source: &str) { + fs::create_dir_all(path.parent().expect("test file has a parent")).unwrap(); + fs::write(path, source).unwrap(); + } + + #[test] + fn discovers_manifest_and_recursive_path_dependencies() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + write( + &root.join(SIMPLEX_MANIFEST), + "[build]\nsrc_dir = 'contracts'\n[dependencies]\nmerkle = { path = 'vendor/merkle' }\n", + ); + write(&root.join("contracts/main.simf"), "fn main() {}\n"); + write( + &root.join("vendor/merkle/Simplex.toml"), + "[dependencies]\nmath = { path = '../math' }\n", + ); + write( + &root.join("vendor/merkle/simf/root.simf"), + "use math::ops::add;\npub fn root() { add(); }\n", + ); + write(&root.join("vendor/math/Simplex.toml"), ""); + write(&root.join("vendor/math/simf/ops.simf"), "pub fn add() {}\n"); + + let context = ProjectContext::discover( + &root.join("contracts/main.simf"), + &ProjectSettings::default(), + &[root.to_path_buf()], + ) + .unwrap(); + + assert_eq!( + context.source_root, + fs::canonicalize(root.join("contracts")).unwrap() + ); + assert_eq!(context.dependencies.len(), 2); + assert_eq!( + context.import_root(&root.join("contracts/main.simf"), "merkle"), + Some( + fs::canonicalize(root.join("vendor/merkle/simf")) + .unwrap() + .as_path() + ) + ); + assert_eq!( + context.import_root(&root.join("vendor/merkle/simf/root.simf"), "math"), + Some( + fs::canonicalize(root.join("vendor/math/simf")) + .unwrap() + .as_path() + ) + ); + } + + #[test] + fn resolves_simplex_git_install_directory_exactly() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + let url = "https://github.com/BlockstreamResearch/simplicityhl-std"; + let installed = root.join("deps").join(hashed_repository_path(url).unwrap()); + write( + &root.join(SIMPLEX_MANIFEST), + &format!("[dependencies]\nstd = {{ git = '{url}' }}\n"), + ); + write(&root.join("simf/main.simf"), "fn main() {}\n"); + write(&installed.join(SIMPLEX_MANIFEST), ""); + write(&installed.join("simf/lib.simf"), "pub fn helper() {}\n"); + + let context = ProjectContext::discover( + &root.join("simf/main.simf"), + &ProjectSettings::default(), + &[root.to_path_buf()], + ) + .unwrap(); + + assert_eq!( + context.import_root(&root.join("simf/main.simf"), "std"), + Some(fs::canonicalize(installed.join("simf")).unwrap().as_path()) + ); + } + + #[test] + fn manual_mapping_overrides_manifest_mapping() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + write( + &root.join(SIMPLEX_MANIFEST), + "[dependencies]\nmath = { path = 'old_math' }\n", + ); + write(&root.join("simf/main.simf"), "fn main() {}\n"); + write(&root.join("old_math/Simplex.toml"), ""); + write(&root.join("old_math/simf/old.simf"), "pub fn old() {}\n"); + write(&root.join("new_math/new.simf"), "pub fn new() {}\n"); + + let mut settings = ProjectSettings { + simplex: SimplexSettings::default(), + ..ProjectSettings::default() + }; + settings.dependencies.insert( + "math".to_string(), + ManualDependency::Detailed(ManualDependencyDetails { + path: "new_math".to_string(), + context: "simf".to_string(), + }), + ); + + let context = ProjectContext::discover( + &root.join("simf/main.simf"), + &settings, + &[root.to_path_buf()], + ) + .unwrap(); + + assert_eq!( + context.import_root(&root.join("simf/main.simf"), "math"), + Some(fs::canonicalize(root.join("new_math")).unwrap().as_path()) + ); + } + + #[test] + fn source_override_is_used_as_the_dependency_context() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + write( + &root.join(SIMPLEX_MANIFEST), + "[dependencies]\nmath = { path = 'math' }\n", + ); + write(&root.join("contracts/main.simf"), "fn main() {}\n"); + write(&root.join("math/Simplex.toml"), ""); + write(&root.join("math/simf/math.simf"), "pub fn add() {}\n"); + let settings = ProjectSettings { + source_directory: "contracts".to_string(), + ..ProjectSettings::default() + }; + + let context = ProjectContext::discover( + &root.join("contracts/main.simf"), + &settings, + &[root.to_path_buf()], + ) + .unwrap(); + + assert_eq!( + context.dependencies[0].context, + fs::canonicalize(root.join("contracts")).unwrap() + ); + } + + #[test] + fn reports_a_missing_explicit_manifest() { + let temp = TempDir::new().unwrap(); + write(&temp.path().join("simf/main.simf"), "fn main() {}\n"); + let mut settings = ProjectSettings::default(); + settings.simplex.manifest_path = "missing.toml".to_string(); + + let error = ProjectContext::discover( + &temp.path().join("simf/main.simf"), + &settings, + &[temp.path().to_path_buf()], + ) + .unwrap_err(); + + assert!(matches!(error, ProjectError::MissingConfiguredManifest(_))); + } +}