diff --git a/lsp/Cargo.toml b/lsp/Cargo.toml index 494170a0..3aa165b5 100644 --- a/lsp/Cargo.toml +++ b/lsp/Cargo.toml @@ -21,7 +21,7 @@ thiserror = "2.0.17" ropey = "1.6.1" miniscript = "12" -simplicityhl = { version = "0.3.0" } +simplicityhl = "0.5.0-rc.0" nom = "8.0.0" lazy_static = "1.5.0" diff --git a/lsp/src/backend.rs b/lsp/src/backend.rs index f2be1804..fd033140 100644 --- a/lsp/src/backend.rs +++ b/lsp/src/backend.rs @@ -1,5 +1,6 @@ use ropey::Rope; use serde_json::Value; +use simplicityhl::parse::ParseFromStrWithErrors; use std::collections::HashMap; use std::str::FromStr; @@ -14,23 +15,18 @@ use tower_lsp_server::lsp_types::{ DidSaveTextDocumentParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, ExecuteCommandParams, GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverParams, HoverProviderCapability, InitializeParams, InitializeResult, InitializedParams, Location, - 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, + 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, }; use tower_lsp_server::{Client, LanguageServer}; use miniscript::iter::TreeLike; -use simplicityhl::{ - ast, - error::{RichError, WithFile}, - parse, - parse::ParseFromStr, -}; +use simplicityhl::{ast, error::RichError, parse}; use crate::completion::{self, CompletionProvider}; use crate::error::LspError; @@ -38,7 +34,8 @@ use crate::function::Functions; use crate::utils::{ create_signature_info, find_all_references, find_builtin_signature, find_function_call_context, find_function_name_range, find_key_position, find_related_call, get_call_span, - get_comments_from_lines, position_to_span, span_contains, span_to_positions, + get_comments_from_lines, offset_to_position, position_to_span, span_contains, + span_to_positions, }; /// Semantic token type indices - must match the legend order @@ -193,7 +190,10 @@ impl LanguageServer for Backend { let uri = ¶ms.text_document.uri; // .wit files don't have semantic tokens - if uri.path().as_str().ends_with(".wit") { + if std::path::Path::new(uri.path().as_str()) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("wit")) + { return Ok(None); } @@ -210,7 +210,7 @@ impl LanguageServer for Backend { for func in &functions { // Add function name token (declaration) if let Ok(name_range) = find_function_name_range(func, &doc.text) { - let len = func.name().as_inner().len() as u32; + let len = u32::try_from(func.name().as_inner().len()).map_err(LspError::from)?; raw_tokens.push(( name_range.start.line, name_range.start.character, @@ -225,7 +225,7 @@ impl LanguageServer for Backend { .pre_order_iter() .filter_map(|expr| { if let parse::ExprTree::Call(call) = expr { - get_call_span(call).ok().map(|span| (call, span)) + Some((call, get_call_span(call))) } else { None } @@ -233,7 +233,7 @@ impl LanguageServer for Backend { .collect::>(); for (call, span) in calls { - if let Ok((start, _end)) = span_to_positions(&span) { + if let Ok((start, _end)) = span_to_positions(&span, &doc.text) { let name = call.name(); let name_str = name.to_string(); @@ -252,8 +252,7 @@ impl LanguageServer for Backend { // The function name starts after "jet::" (semantic_token_types::FUNCTION, 5) } - parse::CallName::Custom(_) => (semantic_token_types::FUNCTION, 0), - _ => (semantic_token_types::FUNCTION, 0), // Built-in functions + _ => (semantic_token_types::FUNCTION, 0), }; // Add the function name token @@ -266,8 +265,8 @@ impl LanguageServer for Backend { if func_name_len > 0 { raw_tokens.push(( start.line, - start.character + prefix_len as u32, - func_name_len as u32, + start.character + u32::try_from(prefix_len).map_err(LspError::from)?, + u32::try_from(func_name_len).map_err(LspError::from)?, token_type, 0, )); @@ -317,7 +316,10 @@ impl LanguageServer for Backend { let uri = ¶ms.text_document.uri; // .wit files don't have symbols - if uri.path().as_str().ends_with(".wit") { + if std::path::Path::new(uri.path().as_str()) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("wit")) + { return Ok(None); } @@ -334,7 +336,7 @@ impl LanguageServer for Backend { .iter() .filter_map(|func| { // Get the full function range - let (start, end) = span_to_positions(func.span()).ok()?; + let (start, end) = span_to_positions(func.span(), &doc.text).ok()?; let full_range = Range { start, end }; // Get the function name range for selection @@ -394,9 +396,8 @@ impl LanguageServer for Backend { .unwrap_or_default(); // Find function call context: look for unclosed '(' and count commas - let (func_name, active_param) = match find_function_call_context(&line_str) { - Some(ctx) => ctx, - None => return Ok(None), + let Some((func_name, active_param)) = find_function_call_context(&line_str) else { + return Ok(None); }; // Try to find the function signature @@ -468,7 +469,10 @@ impl LanguageServer for Backend { let uri = ¶ms.text_document_position_params.text_document.uri; // .wit files don't have hover info - if uri.path().as_str().ends_with(".wit") { + if std::path::Path::new(uri.path().as_str()) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("wit")) + { return Ok(None); } @@ -482,19 +486,21 @@ impl LanguageServer for Backend { let token_pos = params.text_document_position_params.position; - let token_span = position_to_span(token_pos)?; + let token_span = position_to_span(token_pos, &doc.text)?; let Ok(Some(call)) = find_related_call(&functions, token_span) else { return Ok(None); }; - let call_span = get_call_span(call)?; - let (start, end) = span_to_positions(&call_span)?; + let call_span = get_call_span(call); + let (start, end) = span_to_positions(&call_span, &doc.text)?; let description = match call.name() { parse::CallName::Jet(jet) => { - let element = + let Ok(element) = simplicityhl::simplicity::jet::Elements::from_str(format!("{jet}").as_str()) - .map_err(|err| LspError::ConversionFailed(err.to_string()))?; + else { + return Ok(None); + }; let template = completion::jet::jet_to_template(element); format!( @@ -506,12 +512,9 @@ impl LanguageServer for Backend { ) } parse::CallName::Custom(func) => { - let (function, function_doc) = - doc.functions - .get(func.as_inner()) - .ok_or(LspError::FunctionNotFound(format!( - "Function {func} is not found" - )))?; + let Some((function, function_doc)) = doc.functions.get(func.as_inner()) else { + return Ok(None); + }; let template = completion::function_to_template(function, function_doc); format!( @@ -559,7 +562,7 @@ impl LanguageServer for Backend { let functions = doc.functions.functions(); let token_position = params.text_document_position_params.position; - let token_span = position_to_span(token_position)?; + let token_span = position_to_span(token_position, &doc.text)?; let Ok(Some(call)) = find_related_call(&functions, token_span) else { let Some(func) = functions @@ -588,7 +591,7 @@ impl LanguageServer for Backend { "Function {func} is not found" )))?; - let (start, end) = span_to_positions(function.as_ref())?; + let (start, end) = span_to_positions(function.as_ref(), &doc.text)?; Ok(Some(GotoDefinitionResponse::from(Location::new( uri.clone(), Range::new(start, end), @@ -610,7 +613,7 @@ impl LanguageServer for Backend { let token_position = params.text_document_position.position; - let token_span = position_to_span(token_position)?; + let token_span = position_to_span(token_position, &doc.text)?; let call_name = find_related_call(&functions, token_span)?.map(simplicityhl::parse::Call::name); @@ -619,7 +622,7 @@ impl LanguageServer for Backend { Some(parse::CallName::Custom(_)) | None => {} Some(name) => { return Ok(Some( - find_all_references(&functions, name)? + find_all_references(&doc.text, &functions, name)? .iter() .map(|range| Location { range: *range, @@ -641,14 +644,18 @@ impl LanguageServer for Backend { if (token_position <= range.end && token_position >= range.start) || call_name.is_some() { Ok(Some( - find_all_references(&functions, &parse::CallName::Custom(func.name().clone()))? - .into_iter() - .chain(std::iter::once(range)) - .map(|range| Location { - range, - uri: uri.clone(), - }) - .collect(), + find_all_references( + &doc.text, + &functions, + &parse::CallName::Custom(func.name().clone()), + )? + .into_iter() + .chain(std::iter::once(range)) + .map(|range| Location { + range, + uri: uri.clone(), + }) + .collect(), )) } else { Ok(None) @@ -668,52 +675,39 @@ impl Backend { /// Function which executed on change of file (`did_save`, `did_open` or `did_change` methods) async fn on_change(&self, params: TextDocumentItem<'_>) { // Check if this is a witness file - if params.uri.path().as_str().ends_with(".wit") { + if std::path::Path::new(params.uri.path().as_str()) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("wit")) + { self.on_change_witness(params).await; return; } let (err, document) = parse_program(params.text); - + let rope = Rope::from_str(params.text); let mut documents = self.document_map.write().await; if let Some(doc) = document { documents.insert(params.uri.clone(), doc); } else if let Some(doc) = documents.get_mut(¶ms.uri) { - doc.text = Rope::from_str(params.text); + doc.text = rope.clone(); } - - match err { - None => { - self.client - .publish_diagnostics(params.uri.clone(), vec![], params.version) - .await; - } - Some(err) => { - let (start, end) = match span_to_positions(err.span()) { - Ok(result) => result, - Err(err) => { - self.client - .log_message( - MessageType::ERROR, - format!("Catch error while parsing span: {err}"), - ) - .await; - return; - } + let diagnostics = err + .iter() + .filter_map(|err| { + let Ok((start, end)) = span_to_positions(err.span(), &rope) else { + return None; }; - self.client - .publish_diagnostics( - params.uri.clone(), - vec![Diagnostic::new_simple( - Range::new(start, end), - err.error().to_string(), - )], - params.version, - ) - .await; - } - } + Some(Diagnostic::new_simple( + Range::new(start, end), + err.error().to_string(), + )) + }) + .collect(); + + self.client + .publish_diagnostics(params.uri.clone(), diagnostics, params.version) + .await; } /// Validate witness (.wit) files @@ -743,8 +737,9 @@ fn create_document(program: &simplicityhl::parse::Program, text: &str) -> Docume } }) .for_each(|func| { - let start_line = u32::try_from(func.as_ref().start.line.get()).unwrap_or_default() - 1; - + let start_line = offset_to_position(func.span().start, &document.text) + .unwrap_or_default() + .line; document.functions.insert( func.name().to_string(), func.to_owned(), @@ -755,16 +750,22 @@ fn create_document(program: &simplicityhl::parse::Program, text: &str) -> Docume document } -/// Parse program using [`simplicityhl`] compiler and return [`RichError`], -/// which used in Diagnostic. Also create [`Document`] from parsed program. -fn parse_program(text: &str) -> (Option, Option) { - let program = match parse::Program::parse_from_str(text) { - Ok(p) => p, - Err(e) => return (Some(e), None), +/// Parse and analyze program using [`simplicityhl`] compiler and return an list of [`RichError`] +/// to use in diagnostics. Also creates a [`Document`] if parsing is successfull. +fn parse_program(text: &str) -> (Vec, Option) { + let mut error_collector = simplicityhl::error::ErrorCollector::new(Arc::from(text)); + + let Some(program) = parse::Program::parse_from_str_with_errors(text, &mut error_collector) + else { + return (error_collector.get().to_vec(), None); }; + if let Err(err) = ast::Program::analyze(&program) { + error_collector.update([err]); + } + ( - ast::Program::analyze(&program).with_file(text).err(), + error_collector.get().to_vec(), Some(create_document(&program, text)), ) } @@ -773,74 +774,59 @@ fn parse_program(text: &str) -> (Option, Option) { fn validate_witness_file(text: &str) -> Vec { let mut diagnostics = Vec::new(); - // Try to parse as JSON let json: serde_json::Value = match serde_json::from_str(text) { Ok(v) => v, Err(e) => { - // JSON parse error - find the position - let line = e.line().saturating_sub(1) as u32; - let col = e.column().saturating_sub(1) as u32; + let line = u32::try_from(e.line().saturating_sub(1)).unwrap_or(0); + let col = u32::try_from(e.column().saturating_sub(1)).unwrap_or(0); diagnostics.push(Diagnostic::new_simple( Range::new( tower_lsp_server::lsp_types::Position::new(line, col), tower_lsp_server::lsp_types::Position::new(line, col + 1), ), - format!("JSON syntax error: {}", e), + format!("JSON syntax error: {e}"), )); return diagnostics; } }; - // Must be an object - let obj = match json.as_object() { - Some(o) => o, - None => { - diagnostics.push(Diagnostic::new_simple( - Range::new( - tower_lsp_server::lsp_types::Position::new(0, 0), - tower_lsp_server::lsp_types::Position::new(0, 1), - ), - "Witness file must be a JSON object".to_string(), - )); - return diagnostics; - } + let Some(obj) = json.as_object() else { + diagnostics.push(Diagnostic::new_simple( + Range::new( + tower_lsp_server::lsp_types::Position::new(0, 0), + tower_lsp_server::lsp_types::Position::new(0, 1), + ), + "Witness file must be a JSON object".to_string(), + )); + return diagnostics; }; - // Validate each witness entry for (name, value) in obj { - let witness_obj = match value.as_object() { - Some(o) => o, - None => { - // Find approximate position for this key - if let Some(pos) = find_key_position(text, name) { - diagnostics.push(Diagnostic::new_simple( - Range::new(pos, pos), - format!( - "Witness '{}' must be an object with 'value' and 'type' fields", - name - ), - )); - } - continue; + let Some(witness_obj) = value.as_object() else { + // Find approximate position for this key + if let Some(pos) = find_key_position(text, name) { + diagnostics.push(Diagnostic::new_simple( + Range::new(pos, pos), + format!("Witness '{name}' must be an object with 'value' and 'type' fields"), + )); } + continue; }; - // Check for required 'value' field if !witness_obj.contains_key("value") { if let Some(pos) = find_key_position(text, name) { diagnostics.push(Diagnostic::new_simple( Range::new(pos, pos), - format!("Witness '{}' is missing required 'value' field", name), + format!("Witness '{name}' is missing required 'value' field"), )); } } - // Check for required 'type' field if !witness_obj.contains_key("type") { if let Some(pos) = find_key_position(text, name) { diagnostics.push(Diagnostic::new_simple( Range::new(pos, pos), - format!("Witness '{}' is missing required 'type' field", name), + format!("Witness '{name}' is missing required 'type' field"), )); } } @@ -851,25 +837,26 @@ fn validate_witness_file(text: &str) -> Vec { #[cfg(test)] mod tests { + use simplicityhl::error::Error; + use super::*; fn sample_program() -> &'static str { "fn add(a: u32, b: u32) -> u32 { let (_, res): (bool, u32) = jet::add_32(a, b); res } fn main() {}" } - fn invalid_program_on_ast() -> &'static str { "fn add(a: u32, b: u32) -> u32 {}" } fn invalid_program_on_parsing() -> &'static str { - "fn add(a: u32 b: u32) -> u32 {}" + "fn add(a: u32, b: u32) -> u32 " } #[test] fn test_parse_program_valid() { let (err, doc) = parse_program(sample_program()); - assert!(err.is_none(), "Expected no parsing error"); + assert!(err.is_empty(), "Expected no parsing error"); let doc = doc.expect("Expected Some(Document)"); assert_eq!(doc.functions.map.len(), 2); } @@ -878,7 +865,8 @@ mod tests { fn test_parse_program_invalid_ast() { let (err, doc) = parse_program(invalid_program_on_ast()); assert!( - err.unwrap() + err.first() + .expect("program should produce an error") .to_string() .contains("Expected expression of type `u32`, found type `()`"), "Expected error on return type" @@ -889,10 +877,19 @@ mod tests { #[test] fn test_parse_program_invalid_parse() { let (err, doc) = parse_program(invalid_program_on_parsing()); - assert!( - err.unwrap().to_string().contains("Grammar error"), + assert_eq!( + err.first() + .expect("program should produce an error") + .error() + .clone(), + Error::Syntax { + expected: ["{".to_string()].to_vec(), + label: Some("function body".to_string()), + found: None + }, "Expected `Grammar error`" ); + assert!(doc.is_none(), "Expected no document to return"); } } diff --git a/lsp/src/error.rs b/lsp/src/error.rs index 23a8b0ce..86c06684 100644 --- a/lsp/src/error.rs +++ b/lsp/src/error.rs @@ -60,3 +60,10 @@ impl From for Error { } } } + +/// Convert [`LspError`] to [`tower_lsp_server::jsonrpc::Error`]. +impl From for LspError { + fn from(err: ropey::Error) -> Self { + LspError::Internal(err.to_string()) + } +} diff --git a/lsp/src/utils.rs b/lsp/src/utils.rs index 0a2ac31c..152c6404 100644 --- a/lsp/src/utils.rs +++ b/lsp/src/utils.rs @@ -1,5 +1,3 @@ -use std::num::NonZeroUsize; - use miniscript::iter::TreeLike; use crate::completion; @@ -10,40 +8,88 @@ use tower_lsp_server::lsp_types::{ self, MarkupContent, MarkupKind, ParameterInformation, ParameterLabel, SignatureInformation, }; -fn position_le(a: &simplicityhl::error::Position, b: &simplicityhl::error::Position) -> bool { - (a.line < b.line) || (a.line == b.line && a.col <= b.col) +pub fn span_contains(a: &simplicityhl::error::Span, b: &simplicityhl::error::Span) -> bool { + a.start <= b.start && a.end >= b.end } -fn position_ge(a: &simplicityhl::error::Position, b: &simplicityhl::error::Position) -> bool { - (a.line > b.line) || (a.line == b.line && a.col >= b.col) +/// Convert byte offset to [`lsp_types::Position`]. +/// +/// It's converting to UTF-16 column position because it's default to LSP settings. For more +/// context, see [`lsp_types::PositionEncodingKind`] +pub fn offset_to_position(offset: usize, rope: &Rope) -> Result { + let line = rope.try_byte_to_line(offset)?; + let first_byte_of_line = rope.try_line_to_byte(line)?; + let column = offset - first_byte_of_line; + + let rope_line = rope + .get_line(line) + .ok_or_else(|| LspError::ConversionFailed("Offset to position".to_string()))?; + + let utf16_offset: usize = rope_line + .get_byte_slice(..column) + .ok_or_else(|| LspError::ConversionFailed("Offset to position".to_string()))? + .chars() + .map(char::len_utf16) + .sum(); + + Ok(lsp_types::Position::new( + ::try_from(line)?, + ::try_from(utf16_offset)?, + )) } -pub fn span_contains(a: &simplicityhl::error::Span, b: &simplicityhl::error::Span) -> bool { - position_le(&a.start, &b.start) && position_ge(&a.end, &b.end) +/// Convert [`lsp_types::Position`] to byte offset. +pub fn position_to_offset(position: lsp_types::Position, rope: &Rope) -> Result { + let line_index = usize::try_from(position.line)?; + let target_utf16 = usize::try_from(position.character)?; + + let line = rope + .get_line(line_index) + .ok_or_else(|| LspError::ConversionFailed("Position to offset".to_string()))?; + + let line_start = rope.try_line_to_byte(line_index)?; + let mut utf16_offset_in_line = 0usize; + let mut byte_offset_in_line = 0usize; + + // LSP positions use UTF-16 code units, but Rope is indexed by UTF-8 bytes. Walk the line + // until we reach the requested UTF-16 boundary so navigation features resolve the right byte. + for ch in line.chars() { + if utf16_offset_in_line == target_utf16 { + return Ok(line_start + byte_offset_in_line); + } + + let ch_utf16 = ch.len_utf16(); + // Reject positions that would land inside a single scalar value encoded as multiple + // UTF-16 code units, because spans can only point at byte boundaries between characters. + if utf16_offset_in_line + ch_utf16 > target_utf16 { + return Err(LspError::ConversionFailed( + "Position points inside a UTF-16 code unit sequence".to_string(), + )); + } + + utf16_offset_in_line += ch_utf16; + byte_offset_in_line += ch.len_utf8(); + } + + // LSP allows the cursor to sit at end-of-line, so accept that exact boundary after the scan. + if utf16_offset_in_line == target_utf16 { + Ok(line_start + byte_offset_in_line) + } else { + Err(LspError::ConversionFailed("Position to offset".to_string())) + } } /// Convert [`simplicityhl::error::Span`] to [`tower_lsp_server::lsp_types::Position`] /// -/// Converting is required because `simplicityhl::error::Span` using their own versions of `Position`, -/// which contains non-zero column and line, so they are always starts with one. -/// `Position` required for diagnostic starts with zero +/// Converting is required because [`simplicityhl::error::Span`] contains byte offsets instead of +/// `line` and `col` fields. pub fn span_to_positions( span: &simplicityhl::error::Span, + rope: &Rope, ) -> Result<(lsp_types::Position, lsp_types::Position), LspError> { - let start_line = u32::try_from(span.start.line.get())?; - let start_col = u32::try_from(span.start.col.get())?; - let end_line = u32::try_from(span.end.line.get())?; - let end_col = u32::try_from(span.end.col.get())?; - Ok(( - lsp_types::Position { - line: start_line - 1, - character: start_col - 1, - }, - lsp_types::Position { - line: end_line - 1, - character: end_col - 1, - }, + offset_to_position(span.start, rope)?, + offset_to_position(span.end, rope)?, )) } @@ -52,20 +98,11 @@ pub fn span_to_positions( /// Useful when [`tower_lsp_server::lsp_types::Position`] represents some singular point. pub fn position_to_span( position: lsp_types::Position, + rope: &Rope, ) -> Result { - let start_line = NonZeroUsize::try_from((position.line + 1) as usize)?; - let start_col = NonZeroUsize::try_from((position.character + 1) as usize)?; + let start_line = position_to_offset(position, rope)?; - Ok(simplicityhl::error::Span { - start: simplicityhl::error::Position { - line: start_line, - col: start_col, - }, - end: simplicityhl::error::Position { - line: start_line, - col: start_col, - }, - }) + Ok(simplicityhl::error::Span::new(start_line, start_line)) } /// Get document comments, using lines above given line index. Only used to @@ -144,7 +181,7 @@ pub fn find_related_call<'a>( .filter_map(|expr| { if let parse::ExprTree::Call(call) = expr { // Only include if call span can be obtained - get_call_span(call).ok().map(|span| (call, span)) + Some((call, get_call_span(call))) } else { None } @@ -160,11 +197,11 @@ pub fn find_function_name_range( function: &parse::Function, text: &Rope, ) -> Result { - let start_line = usize::from(function.span().start.line) - 1; + let start_line = offset_to_position(function.span().start, text)?.line; let Some((line, character)) = text.lines() .enumerate() - .skip(start_line) + .skip(start_line as usize) .find_map(|(i, line)| { line.to_string() .find(function.name().as_inner()) @@ -194,23 +231,17 @@ pub fn find_function_name_range( Ok(lsp_types::Range { start, end }) } -pub fn get_call_span( - call: &simplicityhl::parse::Call, -) -> Result { +pub fn get_call_span(call: &simplicityhl::parse::Call) -> simplicityhl::error::Span { let length = call.name().to_string().len(); - let end_column = usize::from(call.span().start.col) + length; - - Ok(simplicityhl::error::Span { + simplicityhl::error::Span { start: call.span().start, - end: simplicityhl::error::Position { - line: call.span().start.line, - col: NonZeroUsize::try_from(end_column)?, - }, - }) + end: call.span().start + length, + } } pub fn find_all_references<'a>( + text: &Rope, functions: &'a [&'a parse::Function], call_name: &CallName, ) -> Result, LspError> { @@ -221,7 +252,7 @@ pub fn find_all_references<'a>( .pre_order_iter() .filter_map(|expr| { if let parse::ExprTree::Call(call) = expr { - get_call_span(call).ok().map(|span| (call, span)) + Some((call, get_call_span(call))) } else { None } @@ -231,7 +262,7 @@ pub fn find_all_references<'a>( .collect::>() }) .map(|span| { - let (start, end) = span_to_positions(&span)?; + let (start, end) = span_to_positions(&span, text)?; Ok(lsp_types::Range { start, end }) }) .collect::, LspError>>() @@ -239,17 +270,20 @@ pub fn find_all_references<'a>( /// Find the position of a key in the JSON text pub fn find_key_position(text: &str, key: &str) -> Option { - let search = format!("\"{}\"", key); + let search = format!("\"{key}\""); for (line_num, line) in text.lines().enumerate() { if let Some(col) = line.find(&search) { - return Some(lsp_types::Position::new(line_num as u32, col as u32)); + return Some(lsp_types::Position::new( + u32::try_from(line_num).ok()?, + u32::try_from(col).ok()?, + )); } } None } /// Find function call context from the current line. -/// Returns (function_name, active_parameter_index) if inside a function call. +/// Returns (`function_name`, `active_parameter_index`) if inside a function call. pub fn find_function_call_context(line: &str) -> Option<(String, u32)> { let mut paren_depth = 0; let mut bracket_depth = 0; @@ -360,7 +394,7 @@ pub fn extract_function_name(text: &str) -> Option { } } -/// Create SignatureInformation from a FunctionTemplate. +/// Create `SignatureInformation` from a `FunctionTemplate`. pub fn create_signature_info( template: &completion::types::FunctionTemplate, ) -> SignatureInformation { @@ -511,4 +545,63 @@ mod tests { // No function call assert_eq!(find_function_call_context("let x = 5"), None); } + + /// Tests for UTF-16 encoding: + #[test] + fn test_span_to_positions_handles_multibyte_utf8_before_span() { + let text = Rope::from_str("/// π\nfn foo() {}"); + + // "/// " = 4 bytes, "π" = 2 bytes, "\n" = 1 byte, so `fn` starts at byte 7. + let span = simplicityhl::error::Span::new(7, 9); + + let (start, end) = span_to_positions(&span, &text).expect("span conversion should succeed"); + + assert_eq!(start, lsp_types::Position::new(1, 0)); + assert_eq!(end, lsp_types::Position::new(1, 2)); + } + + #[test] + fn test_position_to_offset_uses_utf16_columns() { + let text = Rope::from_str("😀x"); + + // In LSP, 😀 occupies two UTF-16 code units, so column 2 is just after the emoji. + let offset = position_to_offset(lsp_types::Position::new(0, 2), &text) + .expect("position conversion should succeed"); + + assert_eq!(offset, 4); + } + + #[test] + fn test_position_to_offset_keeps_line_start_at_zero() { + let text = Rope::from_str("foo"); + + let offset = position_to_offset(lsp_types::Position::new(0, 0), &text) + .expect("line start should convert to byte offset 0"); + + assert_eq!(offset, 0); + } + + #[test] + fn test_position_to_offset_does_not_shift_ascii_columns_left() { + let text = Rope::from_str(" foo()"); + + let offset = position_to_offset(lsp_types::Position::new(0, 4), &text) + .expect("identifier start should map to its exact byte offset"); + let span = position_to_span(lsp_types::Position::new(0, 4), &text) + .expect("identifier start should map to the same byte offset"); + + assert_eq!(offset, 4); + assert_eq!(span, simplicityhl::error::Span::new(4, 4)); + } + + #[test] + fn test_position_to_offset_handles_single_utf16_multibyte_prefix() { + let text = Rope::from_str("πx"); + + // `π` is one UTF-16 code unit but two UTF-8 bytes, so column 1 should land after it. + let offset = position_to_offset(lsp_types::Position::new(0, 1), &text) + .expect("UTF-16 column after a BMP multibyte char should convert correctly"); + + assert_eq!(offset, 2); + } }