Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 0 additions & 57 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 13 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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"
Expand Down
40 changes: 37 additions & 3 deletions src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<i32>,
}

#[derive(Debug)]
Expand Down Expand Up @@ -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: &params.content_changes[0].text,
text: &change.text,
uri: params.text_document.uri,
version: Some(params.text_document.version),
})
Expand All @@ -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,
Expand Down Expand Up @@ -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(&params.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(&params.uri) {
doc.text = rope.clone();
doc.version = version;
}
let diagnostics = err
.iter()
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/completion/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
Expand Down
7 changes: 1 addition & 6 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -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),
Expand All @@ -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,
}
Expand Down
52 changes: 35 additions & 17 deletions src/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
'(' => {
Expand All @@ -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;
}
Expand All @@ -248,15 +241,16 @@ pub fn extract_function_name(text: &str) -> Option<String> {

// Skip generic parameters if present (e.g., `fold::<f, 8>`)
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;
}
}
Expand Down Expand Up @@ -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::<f, 8>"),
Some("fold".to_string())
);
}
}
Loading