diff --git a/Cargo.lock b/Cargo.lock index 8f7522e..0d4375d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -313,16 +313,6 @@ 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 = "find-msvc-tools" version = "0.1.9" @@ -560,17 +550,6 @@ dependencies = [ "bitcoin", ] -[[package]] -name = "mio" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" -dependencies = [ - "libc", - "wasi", - "windows-sys", -] - [[package]] name = "nom" version = "8.0.0" @@ -601,16 +580,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - [[package]] name = "parking_lot_core" version = "0.9.12" @@ -853,16 +822,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "simplicity-lang" version = "0.8.0" @@ -935,16 +894,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "socket2" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys", -] - [[package]] name = "stacker" version = "0.1.24" @@ -1014,14 +963,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", - "libc", - "mio", - "parking_lot", "pin-project-lite", - "signal-hook-registry", - "socket2", "tokio-macros", - "windows-sys", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4f13f77..ef7ea39 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,14 +5,20 @@ edition = "2021" rust-version = "1.85.0" description = "Language Server Protocol (LSP) server for SimplicityHL." license = "MIT OR Apache-2.0" -repository = "https://github.com/BlockstreamResearch/SimplicityHL" -homepage = "https://github.com/BlockstreamResearch/SimplicityHL/tree/master/lsp" +repository = "https://github.com/BlockstreamResearch/simplicityhl-lsp" +homepage = "https://github.com/BlockstreamResearch/simplicityhl-lsp" readme = "README.md" documentation = "https://docs.rs/simplicityhl-lsp" keywords = ["simplicity", "liquid", "bitcoin", "elements", "lsp"] [dependencies] -tokio = { version = "1.47.1", features = ["full"] } +tokio = { version = "1.47.1", features = [ + "rt-multi-thread", + "macros", + "io-std", + "io-util", + "sync", +] } serde_json = "1.0.143" tower-lsp-server = "0.22.1" @@ -24,6 +30,10 @@ miniscript = "12" simplicityhl = {version = "0.6.0", features = ["docs"]} nom = "8.0.0" +[profile.release] +lto = "thin" +strip = true + [lints.rust] unsafe_code = "deny" unused_variables = "warn" diff --git a/src/backend.rs b/src/backend.rs index 0abf8c6..6671e22 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -80,6 +80,12 @@ pub struct Document { /// Source of given document. pub text: Rope, + + /// Version of the text this document was built from, when the client supplied one. + /// + /// Notifications are served concurrently and complete out of order, so a slow + /// analysis must not overwrite the result of a newer edit. + pub version: Option, } #[derive(Debug)] @@ -176,8 +182,14 @@ impl LanguageServer for Backend { } async fn did_change(&self, params: DidChangeTextDocumentParams) { + // Sync is `FULL`, so the last change holds the whole document. Indexing the first + // element instead would panic on the empty list some clients send, and would use + // stale text whenever a client batches several changes into one notification. + let Some(change) = params.content_changes.into_iter().next_back() else { + return; + }; self.on_change(TextDocumentItem { - text: ¶ms.content_changes[0].text, + text: &change.text, uri: params.text_document.uri, version: Some(params.text_document.version), }) @@ -195,7 +207,13 @@ impl LanguageServer for Backend { } } - async fn did_close(&self, _: DidCloseTextDocumentParams) {} + async fn did_close(&self, params: DidCloseTextDocumentParams) { + let uri = params.text_document.uri; + // Without this the parsed document is retained for the rest of the session and the + // editor keeps showing the diagnostics published for a file that is no longer open. + self.document_map.write().await.remove(&uri); + self.client.publish_diagnostics(uri, Vec::new(), None).await; + } async fn semantic_tokens_full( &self, @@ -699,10 +717,25 @@ impl Backend { let (err, document) = parse_program(params.text, path); let rope = Rope::from_str(params.text); let mut documents = self.document_map.write().await; - if let Some(doc) = document { + + // Analysis above runs without the lock, so a concurrent notification for a newer + // version may already have stored its result. Dropping the stale write keeps the + // map consistent with the latest text the client sent. + let stored_version = documents.get(¶ms.uri).and_then(|doc| doc.version); + if matches!((params.version, stored_version), (Some(incoming), Some(stored)) if incoming < stored) + { + return; + } + // `did_save` carries no version; keep the one already recorded rather than + // clearing it, so later edits can still be ordered against it. + let version = params.version.or(stored_version); + + if let Some(mut doc) = document { + doc.version = version; documents.insert(params.uri.clone(), doc); } else if let Some(doc) = documents.get_mut(¶ms.uri) { doc.text = rope.clone(); + doc.version = version; } let diagnostics = err .iter() @@ -751,6 +784,7 @@ fn create_document(program: &simplicityhl::parse::Program, text: &str) -> Docume functions: Functions::new(), text: Rope::from_str(text), linearization_map: Vec::new(), + version: None, }; program diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 069b33e..e80f807 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -55,7 +55,7 @@ impl CompletionProvider { .map(|(&to, &from)| CompletionItem { label: format!("{to} <- {from}"), kind: Some(CompletionItemKind::FUNCTION), - detail: Some(format!("Cast into type `{to}`",)), + detail: Some(format!("Cast into type `{to}`")), documentation: None, insert_text: Some(format!("{from}>::into(${{1:{from}}})")), insert_text_format: Some(InsertTextFormat::SNIPPET), @@ -100,7 +100,7 @@ impl CompletionProvider { return Some(vec![CompletionItem { label: format!("{to} <- {from}"), kind: Some(CompletionItemKind::FUNCTION), - detail: Some(format!("Cast into type `{to}`",)), + detail: Some(format!("Cast into type `{to}`")), documentation: None, insert_text: Some(format!("{from}>::into(${{1:{from}}})")), insert_text_format: Some(InsertTextFormat::SNIPPET), diff --git a/src/error.rs b/src/error.rs index 86c0668..7678525 100644 --- a/src/error.rs +++ b/src/error.rs @@ -3,7 +3,6 @@ use std::num::TryFromIntError; use thiserror::Error; use tower_lsp_server::jsonrpc::Error; -use tower_lsp_server::lsp_types::Uri; /// Custom error type for LSP server. #[derive(Debug, Clone, Error)] @@ -24,10 +23,6 @@ pub enum LspError { #[error("Call not found: {0}")] CallNotFound(String), - /// Failed to find given document inside `documents` map. - #[error("Document not found: {0:?}")] - DocumentNotFound(Uri), - /// A generic or unexpected internal error. #[error("Internal error: {0}")] Internal(String), @@ -43,7 +38,7 @@ impl LspError { LspError::ConversionFailed(_) => 1, LspError::FunctionNotFound(_) => 2, LspError::CallNotFound(_) => 3, - LspError::DocumentNotFound(_) => 4, + // 4 was `DocumentNotFound`; left unused so the remaining codes stay stable. LspError::IntegerConversionFailed(_) => 5, LspError::Internal(_) => 100, } diff --git a/src/utils.rs b/src/utils.rs index 22acffd..27dbc9f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -199,9 +199,10 @@ pub fn find_function_call_context(line: &str) -> Option<(String, u32)> { let mut last_open_paren = None; let mut comma_count = 0; - // Scan from the end to find the innermost unclosed function call - for (i, ch) in line.chars().rev().enumerate() { - let pos = line.len() - 1 - i; + // Scan from the end to find the innermost unclosed function call. + // `char_indices` yields byte offsets, which is what `line` must be sliced by below; + // counting characters instead would mis-address every byte after a multi-byte character. + for (pos, ch) in line.char_indices().rev() { match ch { ')' => paren_depth += 1, '(' => { @@ -214,17 +215,9 @@ pub fn find_function_call_context(line: &str) -> Option<(String, u32)> { } } ']' => bracket_depth += 1, - '[' => { - if bracket_depth > 0 { - bracket_depth -= 1; - } - } + '[' if bracket_depth > 0 => bracket_depth -= 1, '>' => angle_depth += 1, - '<' => { - if angle_depth > 0 { - angle_depth -= 1; - } - } + '<' if angle_depth > 0 => angle_depth -= 1, ',' if paren_depth == 0 && bracket_depth == 0 && angle_depth == 0 => { comma_count += 1; } @@ -248,15 +241,16 @@ pub fn extract_function_name(text: &str) -> Option { // Skip generic parameters if present (e.g., `fold::`) let without_generics = if trimmed.ends_with('>') { - let mut depth = 0; + let mut depth = 0usize; let mut start = None; - for (i, ch) in trimmed.chars().rev().enumerate() { + // As above, `char_indices` keeps `start` a valid byte offset into `trimmed`. + for (i, ch) in trimmed.char_indices().rev() { match ch { '>' => depth += 1, '<' => { - depth -= 1; + depth = depth.saturating_sub(1); if depth == 0 { - start = Some(trimmed.len() - 1 - i); + start = Some(i); break; } } @@ -731,4 +725,28 @@ mod tests { assert_eq!(offset, 2); } + + #[test] + fn test_find_function_call_context_handles_multibyte_arguments() { + assert_eq!( + find_function_call_context("add(é, "), + Some(("add".to_string(), 1)) + ); + assert_eq!( + find_function_call_context("sum(日本, "), + Some(("sum".to_string(), 1)) + ); + assert_eq!( + find_function_call_context("f(éé"), + Some(("f".to_string(), 0)) + ); + } + + #[test] + fn test_extract_function_name_handles_multibyte_before_generics() { + assert_eq!( + extract_function_name("é; fold::"), + Some("fold".to_string()) + ); + } }