From 7aaca2dcafd1c1d008a8031fb6713d619c29d48a Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Thu, 30 Jul 2026 15:16:30 +0300 Subject: [PATCH] feat: import hints and pub funcs discovery --- Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 3 +- src/backend.rs | 432 +++++++++++++++++++++++++++++----- src/error.rs | 7 +- src/imports.rs | 613 +++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/project.rs | 48 +++- src/utils.rs | 84 ++++--- 9 files changed, 1076 insertions(+), 116 deletions(-) create mode 100644 src/imports.rs diff --git a/Cargo.lock b/Cargo.lock index 8724afa..567a611 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -948,7 +948,7 @@ dependencies = [ [[package]] name = "simplicityhl-lsp" -version = "0.7.0" +version = "0.7.1" dependencies = [ "env_logger", "miniscript", diff --git a/Cargo.toml b/Cargo.toml index 1f0937c..3d59d2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "simplicityhl-lsp" -version = "0.7.0" +version = "0.7.1" edition = "2021" rust-version = "1.85.0" description = "Language Server Protocol (LSP) server for SimplicityHL." diff --git a/README.md b/README.md index 20a449b..c1ffcc8 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ Language Server for [SimplicityHL language](https://simplicity-lang.org/). ![diagnostics](assets/diagnostics.gif) -- Completions of built-ins, jets and functions +- Completions of built-ins, jets and functions, plus context-aware `use` paths and + public importable items ![completion](assets/completion.gif) diff --git a/src/backend.rs b/src/backend.rs index 20c43ce..af66e8e 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -12,20 +12,21 @@ use tokio::sync::RwLock; use tower_lsp_server::jsonrpc::Result; use tower_lsp_server::lsp_types::{ - CompletionOptions, CompletionParams, CompletionResponse, Diagnostic, DiagnosticSeverity, - DidChangeConfigurationParams, DidChangeTextDocumentParams, DidChangeWatchedFilesParams, - DidChangeWatchedFilesRegistrationOptions, DidChangeWorkspaceFoldersParams, - DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams, - DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, ExecuteCommandParams, - FileSystemWatcher, GlobPattern, GotoDefinitionParams, GotoDefinitionResponse, Hover, - HoverParams, HoverProviderCapability, InitializeParams, InitializeResult, InitializedParams, - Location, MarkupContent, MarkupKind, MessageType, OneOf, Range, ReferenceParams, Registration, - SaveOptions, SemanticToken, SemanticTokenModifier, SemanticTokenType, SemanticTokens, - SemanticTokensFullOptions, SemanticTokensLegend, SemanticTokensOptions, SemanticTokensParams, - SemanticTokensResult, SemanticTokensServerCapabilities, ServerCapabilities, SignatureHelp, - SignatureHelpOptions, SignatureHelpParams, SymbolKind, TextDocumentSyncCapability, - TextDocumentSyncKind, TextDocumentSyncOptions, TextDocumentSyncSaveOptions, Uri, - WorkDoneProgressOptions, WorkspaceFoldersServerCapabilities, WorkspaceServerCapabilities, + CompletionItem, CompletionOptions, CompletionParams, CompletionResponse, Diagnostic, + DiagnosticSeverity, DidChangeConfigurationParams, DidChangeTextDocumentParams, + DidChangeWatchedFilesParams, DidChangeWatchedFilesRegistrationOptions, + DidChangeWorkspaceFoldersParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, + DidSaveTextDocumentParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, + ExecuteCommandParams, FileSystemWatcher, GlobPattern, GotoDefinitionParams, + GotoDefinitionResponse, Hover, HoverParams, HoverProviderCapability, InitializeParams, + InitializeResult, InitializedParams, Location, MarkupContent, MarkupKind, MessageType, OneOf, + Range, ReferenceParams, Registration, 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}; @@ -34,17 +35,20 @@ use simplicityhl::error::{ Diagnostic as CompilerDiagnostic, DiagnosticManager, Error as CompilerError, Location as CompilerLocation, Severity as CompilerSeverity, Span, }; -use simplicityhl::parse; +use simplicityhl::resolution::DependencyMap; +use simplicityhl::source::CanonSourceFile; +use simplicityhl::{parse, UnstableFeatures}; use crate::completion::{self, CompletionProvider}; use crate::config::Settings; use crate::error::LspError; use crate::function::Functions; +use crate::imports::{self, ImportCompletionContext}; 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, - span_to_positions, + get_call_span, get_comments_from_lines, offset_to_position, position_to_offset, + position_to_span, span_contains, span_to_positions, }; /// Semantic token type indices - must match the legend order @@ -185,7 +189,15 @@ impl LanguageServer for Backend { )), completion_provider: Some(CompletionOptions { resolve_provider: Some(false), - trigger_characters: Some(vec![":".to_string(), "<".to_string()]), + // `:`, space, `{`, and `,` cover the useful stages of a `use` + // declaration. `<` remains the trigger for type-cast completion. + trigger_characters: Some(vec![ + ":".to_string(), + "<".to_string(), + " ".to_string(), + "{".to_string(), + ",".to_string(), + ]), work_done_progress_options: WorkDoneProgressOptions::default(), all_commit_characters: None, completion_item: None, @@ -611,31 +623,46 @@ impl LanguageServer for Backend { } async fn completion(&self, params: CompletionParams) -> Result> { - let documents = self.document_map.read().await; let uri = ¶ms.text_document_position.text_document.uri; - - // Return None if document not found (e.g., file has parse errors) - let Some(doc) = documents.get(uri) else { - return Ok(None); - }; - let pos = params.text_document_position.position; - - let Some(line) = doc.text.lines().nth(pos.line as usize) else { - return Ok(None); + let (source_prefix, functions) = { + let documents = self.document_map.read().await; + let Some(doc) = documents.get(uri) else { + return Ok(None); + }; + let Ok(offset) = position_to_offset(pos, &doc.text) else { + return Ok(None); + }; + let Some(prefix) = doc.text.get_byte_slice(..offset) else { + return Ok(None); + }; + (prefix.to_string(), doc.functions.clone()) }; - let Some(slice) = line.get_slice(..pos.character as usize) else { - return Ok(None); - }; + if let Some(context) = ImportCompletionContext::at(&source_prefix, source_prefix.len()) { + return Ok(self + .import_completion(uri, &source_prefix, &context) + .await + .map(CompletionResponse::Array)); + } - let Some(prefix) = slice.as_str() else { + // The extra trigger characters above exist solely for import completion. Avoid opening + // the generic function list after every space, comma, or block brace in normal code. + if params + .context + .as_ref() + .and_then(|context| context.trigger_character.as_deref()) + .is_some_and(|character| matches!(character, " " | "{" | ",")) + { return Ok(None); - }; + } + let prefix = source_prefix + .rsplit_once('\n') + .map_or(source_prefix.as_str(), |(_, line)| line); let completions = self .completion_provider - .process_completions(prefix, &doc.functions.functions_and_docs()) + .process_completions(prefix, &functions.functions_and_docs()) .map(CompletionResponse::Array); Ok(completions) @@ -662,7 +689,7 @@ impl LanguageServer for Backend { let token_pos = params.text_document_position_params.position; let token_span = position_to_span(token_pos, &doc.text)?; - let Ok(Some(call)) = doc.find_related_call(token_span) else { + let Some(call) = doc.find_related_call(token_span) else { return Ok(None); }; @@ -739,7 +766,7 @@ impl LanguageServer for Backend { let token_position = params.text_document_position_params.position; let token_span = position_to_span(token_position, &doc.text)?; - let Ok(Some(call)) = doc.find_related_call(token_span) else { + let Some(call) = doc.find_related_call(token_span) else { let Some(func) = functions .iter() .find(|func| span_contains(func.span(), &token_span)) @@ -792,7 +819,7 @@ impl LanguageServer for Backend { let token_span = position_to_span(token_position, &doc.text)?; let call_name = doc - .find_related_call(token_span)? + .find_related_call(token_span) .map(simplicityhl::parse::Call::name); match call_name { @@ -809,23 +836,24 @@ impl LanguageServer for Backend { return Ok(None); }; - let range = doc.find_function_name_range(func)?; - - if (token_position <= range.end && token_position >= range.start) || call_name.is_some() { - Ok(Some( - documents - .values() - .filter_map(|document| { - document - .find_all_references(&parse::CallName::Custom(func.name().clone())) - .ok() - }) - .flatten() - .collect(), - )) - } else { - Ok(None) + if call_name.is_none() { + let range = doc.find_function_name_range(func)?; + if !(range.start..=range.end).contains(&token_position) { + return Ok(None); + } } + + Ok(Some( + documents + .values() + .filter_map(|document| { + document + .find_all_references(&parse::CallName::Custom(func.name().clone())) + .ok() + }) + .flatten() + .collect(), + )) } } @@ -839,6 +867,31 @@ impl Backend { } } + async fn import_completion( + &self, + uri: &Uri, + source: &str, + context: &ImportCompletionContext, + ) -> Option> { + let path = uri.to_file_path()?; + let (project_settings, workspace_roots) = { + let config = self.config.read().await; + if !config.settings.experimental_features.imports { + return None; + } + ( + config.settings.project.clone(), + config.workspace_roots.clone(), + ) + }; + + Some( + ProjectContext::discover(path.as_ref(), &project_settings, &workspace_roots) + .map(|project| imports::complete_import(context, source, path.as_ref(), &project)) + .unwrap_or_default(), + ) + } + /// Re-run analysis for every open document, after configuration that affects /// dependency resolution has changed. async fn reanalyze_open_documents(&self) { @@ -895,13 +948,16 @@ impl Backend { // 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; - } + // A parse failure invalidates every old analysis span, but the latest text must remain + // available so completion still works while the user is typing incomplete syntax. + let mut document = document.unwrap_or_else(|| Document { + functions: Functions::new(), + linearization_map: Vec::new(), + text: rope.clone(), + version, + }); + document.version = version; + documents.insert(params.uri.clone(), document); let diagnostics = err .iter() .filter_map(|err| { @@ -995,6 +1051,134 @@ fn create_document(program: &simplicityhl::parse::Program, text: &str) -> Docume document } +fn items_contain_main(items: &[parse::Item]) -> bool { + items.iter().any(|item| match item { + parse::Item::Function(function) => function.name().as_inner() == "main", + parse::Item::Module(module) => items_contain_main(module.items()), + parse::Item::TypeAlias(_) + | parse::Item::Use(_) + | parse::Item::EnumDeclaration(_) + | parse::Item::Ignored => false, + }) +} + +/// Find the root-file import that pulls another `main` function into the flattened program. +/// +/// The compiler currently attaches `FunctionRedefined(main)` to the entire flattened program. +/// For an editor diagnostic, the actionable source location is the `use` declaration that loaded +/// the file containing the extra entry point. +fn imported_main_span( + items: &[parse::Item], + current_source: &CanonSourceFile, + dependencies: &DependencyMap, + unstable_features: &UnstableFeatures, +) -> Option { + for item in items { + match item { + parse::Item::Use(use_decl) => { + let Ok(target) = dependencies.resolve_path(current_source.name(), use_decl) else { + continue; + }; + + // `use crate::::...` does not introduce another source file. + if &target == current_source.name() { + continue; + } + + let Ok(source) = std::fs::read_to_string(target.as_path()) else { + continue; + }; + let mut diagnostics = DiagnosticManager::new(); + let Some(program) = parse::Program::parse_from_str_with_errors( + 0, + &source, + unstable_features, + &mut diagnostics, + ) else { + continue; + }; + + if items_contain_main(program.items()) { + return Some(*use_decl.span()); + } + } + parse::Item::Module(module) => { + if let Some(span) = imported_main_span( + module.items(), + current_source, + dependencies, + unstable_features, + ) { + return Some(span); + } + } + parse::Item::TypeAlias(_) + | parse::Item::Function(_) + | parse::Item::EnumDeclaration(_) + | parse::Item::Ignored => {} + } + } + + None +} + +fn is_duplicate_main(diagnostic: &CompilerDiagnostic) -> bool { + matches!( + diagnostic.error(), + CompilerError::FunctionRedefined { name } if name.as_inner() == "main" + ) +} + +fn remap_imported_main_diagnostics( + diagnostics: &DiagnosticManager, + program: &parse::Program, + current_source: &CanonSourceFile, + dependencies: &DependencyMap, + unstable_features: &UnstableFeatures, +) -> Vec { + if !diagnostics.diagnostics().iter().any(is_duplicate_main) { + return diagnostics.diagnostics().to_vec(); + } + + let Some(import_span) = imported_main_span( + program.items(), + current_source, + dependencies, + unstable_features, + ) else { + return diagnostics.diagnostics().to_vec(); + }; + + diagnostics + .diagnostics() + .iter() + .map(|diagnostic| { + if !is_duplicate_main(diagnostic) { + return diagnostic.clone(); + } + + let mut remapped = match diagnostic.severity() { + CompilerSeverity::Error => { + CompilerDiagnostic::new(diagnostic.error().clone(), import_span) + } + CompilerSeverity::Warning => { + CompilerDiagnostic::warning(diagnostic.error().clone(), import_span) + } + }; + for label in diagnostic.secondary() { + remapped = remapped.with_secondary(label.span, label.message.clone()); + } + for note in diagnostic.notes() { + remapped = remapped.with_note(note.clone()); + } + if let Some(help) = diagnostic.help() { + remapped = remapped.with_help(help.clone()); + } + remapped + }) + .collect() +} + /// Parse and analyze a program using the [`simplicityhl`] compiler. /// Also create a [`Document`] when parsing succeeds. fn parse_program( @@ -1035,8 +1219,12 @@ fn parse_program( return (diagnostics.diagnostics().to_vec(), Some(document)); } }; + let canonical_source: CanonSourceFile = source_file + .clone() + .try_into() + .expect("name was defined above"); let compiler_diagnostics = match TemplateProgram::new_with_dep( - source_file.try_into().expect("name was defined above"), + canonical_source.clone(), &dependencies, &unstable_features, Box::new(ElementsJetHinter::new()), @@ -1045,7 +1233,13 @@ fn parse_program( document.populate_visible_functions(&template_program); template_program.diagnostics().diagnostics().to_vec() } - Err(diagnostics) => diagnostics.diagnostics().to_vec(), + Err(diagnostics) => remap_imported_main_diagnostics( + &diagnostics, + &program, + &canonical_source, + &dependencies, + &unstable_features, + ), }; (compiler_diagnostics, Some(document)) @@ -1192,6 +1386,82 @@ mod tests { assert!(document.is_some()); } + #[test] + fn function_selection_range_is_inside_its_document_symbol_range() { + let source = "/* 😀 */ fn main() {}"; + let (temp, path) = in_temp_project(source); + let (errors, doc) = parse_program( + source, + &path, + &Settings::default(), + &[temp.path().to_path_buf()], + ); + assert!(errors.is_empty(), "unexpected errors: {errors:?}"); + let doc = doc.expect("document"); + let function = doc + .functions + .functions() + .into_iter() + .find(|function| function.name().as_inner() == "main") + .expect("main function"); + let (start, end) = span_to_positions(function.span(), &doc.text).unwrap(); + let full_range = Range::new(start, end); + let selection_range = doc.find_function_name_range(function).unwrap(); + + assert!(selection_range.start >= full_range.start); + assert!(selection_range.end <= full_range.end); + let name_start = source.find("main").expect("function name"); + assert_eq!( + selection_range, + Range::new( + offset_to_position(name_start, &doc.text).unwrap(), + offset_to_position(name_start + "main".len(), &doc.text).unwrap(), + ) + ); + } + + #[test] + fn stale_analysis_cannot_produce_an_out_of_bounds_selection_range() { + let source = "fn main() {}"; + let (temp, path) = in_temp_project(source); + let (_, doc) = parse_program( + source, + &path, + &Settings::default(), + &[temp.path().to_path_buf()], + ); + let mut doc = doc.expect("document"); + let function = doc + .functions + .functions() + .into_iter() + .find(|function| function.name().as_inner() == "main") + .expect("main function") + .clone(); + + doc.text = Rope::from_str(&format!("// {}\n{source}", "x".repeat(100))); + + assert!(doc.find_function_name_range(&function).is_err()); + } + + #[test] + fn looking_for_a_call_outside_a_function_is_an_empty_result() { + let source = "/* heading */\nfn main() {}"; + let (temp, path) = in_temp_project(source); + let (errors, doc) = parse_program( + source, + &path, + &Settings::default(), + &[temp.path().to_path_buf()], + ); + assert!(errors.is_empty(), "unexpected errors: {errors:?}"); + let doc = doc.expect("document"); + + assert!(doc + .find_related_call(simplicityhl::error::Span::new(0, 0..0)) + .is_none()); + } + #[test] #[ignore = "TODO we need to also create a file with a path so that could work"] fn test_parse_program_invalid_ast() { @@ -1251,6 +1521,44 @@ mod tests { assert!(doc.is_some(), "expected a document"); } + #[test] + fn duplicate_imported_main_points_to_the_import() { + let temp = TempDir::new().expect("temp dir"); + let root = temp.path(); + std::fs::write(root.join("Simplex.toml"), "").expect("write manifest"); + std::fs::create_dir(root.join("simf")).expect("create source dir"); + std::fs::write( + root.join("simf/library.simf"), + "pub fn helper() {}\nfn main() {}\n", + ) + .expect("write imported module"); + + let import = "use crate::library::helper;"; + let source = format!("{import}\nfn main() {{}}\n"); + let path = root.join("simf/main.simf"); + std::fs::write(&path, &source).expect("write entry file"); + let settings = Settings::from_json(serde_json::json!({ + "experimentalFeatures": { "imports": true } + })) + .expect("valid settings"); + + let (errors, _) = parse_program(&source, &path, &settings, &[root.to_path_buf()]); + let duplicate_main = errors + .iter() + .find(|error| { + matches!( + error.error(), + Error::FunctionRedefined { name } if name.as_inner() == "main" + ) + }) + .expect("duplicate main diagnostic"); + + let CompilerLocation::Code(span) = duplicate_main.location() else { + panic!("duplicate main should point to source code"); + }; + assert_eq!(span.to_slice(&source), Some(import)); + } + #[test] fn parse_program_reports_a_missing_configured_manifest() { let (temp, path) = in_temp_project(sample_program()); diff --git a/src/error.rs b/src/error.rs index 7678525..4903c47 100644 --- a/src/error.rs +++ b/src/error.rs @@ -19,10 +19,6 @@ pub enum LspError { #[error("Function not found: {0}")] FunctionNotFound(String), - /// Failed to find call inside function. - #[error("Call not found: {0}")] - CallNotFound(String), - /// A generic or unexpected internal error. #[error("Internal error: {0}")] Internal(String), @@ -37,7 +33,8 @@ impl LspError { match self { LspError::ConversionFailed(_) => 1, LspError::FunctionNotFound(_) => 2, - LspError::CallNotFound(_) => 3, + // 3 was `CallNotFound`; no callable under the cursor is a normal empty result, + // rather than an RPC error. // 4 was `DocumentNotFound`; left unused so the remaining codes stay stable. LspError::IntegerConversionFailed(_) => 5, LspError::Internal(_) => 100, diff --git a/src/imports.rs b/src/imports.rs new file mode 100644 index 0000000..028fefd --- /dev/null +++ b/src/imports.rs @@ -0,0 +1,613 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::Path; + +use ropey::Rope; +use simplicityhl::error::DiagnosticManager; +use simplicityhl::parse::{self, ParseFromStrWithErrors, Visibility}; +use simplicityhl::UnstableFeatures; +use tower_lsp_server::lsp_types::{ + CompletionItem, CompletionItemKind, Documentation, MarkupContent, MarkupKind, +}; + +use crate::completion; +use crate::project::ProjectContext; +use crate::utils::{get_comments_from_lines, offset_to_position}; + +#[derive(Clone, Debug)] +struct Candidate { + name: String, + kind: CompletionItemKind, + detail: String, + documentation: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Query { + Roots, + Path(Vec), + Suppressed, +} + +/// The unfinished `use` declaration surrounding a completion request. +/// +/// This is intentionally derived from source text rather than the compiler AST: while a user is +/// typing `use crate::math::`, there is no complete [`parse::UseDecl`] for the compiler to expose. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ImportCompletionContext { + use_start: usize, + query: Query, + partial: String, + already_imported: BTreeSet, +} + +impl ImportCompletionContext { + /// Locate an unfinished `use` declaration at `offset`. + pub(crate) fn at(source: &str, offset: usize) -> Option { + let prefix = source.get(..offset)?; + let use_range = last_use_keyword(prefix)?; + let declaration = prefix.get(use_range.end..)?.trim_start(); + + // A semicolon ends the declaration. In that case normal expression completion should be + // allowed to take over again. + if declaration.contains(';') { + return None; + } + + if let Some(open_brace) = declaration.rfind('{') { + if declaration[open_brace + 1..].contains('}') { + return Some(Self::suppressed(use_range.start)); + } + + let raw_path = declaration[..open_brace].trim(); + let Some(path) = raw_path.strip_suffix("::") else { + return Some(Self::suppressed(use_range.start)); + }; + let list_prefix = &declaration[open_brace + 1..]; + let (completed, partial) = list_prefix + .rsplit_once(',') + .map_or(("", list_prefix), |(completed, partial)| { + (completed, partial) + }); + let already_imported = completed + .split(',') + .filter_map(imported_name) + .collect::>(); + + return Some(Self::path( + use_range.start, + path, + partial.trim(), + already_imported, + )); + } + + if let Some((path, partial)) = declaration.rsplit_once("::") { + return Some(Self::path( + use_range.start, + path.trim(), + partial.trim(), + BTreeSet::new(), + )); + } + + let partial = declaration.trim(); + if !is_identifier_prefix(partial) { + return Some(Self::suppressed(use_range.start)); + } + + Some(Self { + use_start: use_range.start, + query: Query::Roots, + partial: partial.to_string(), + already_imported: BTreeSet::new(), + }) + } + + fn path( + use_start: usize, + path: &str, + partial: &str, + already_imported: BTreeSet, + ) -> Self { + let segments = path + .split("::") + .map(str::trim) + .map(str::to_string) + .collect::>(); + let valid_path = !segments.is_empty() + && (segments[0] == "crate" || is_identifier(&segments[0])) + && segments[1..].iter().all(|segment| is_identifier(segment)) + && is_identifier_prefix(partial); + + Self { + use_start, + query: if valid_path { + Query::Path(segments) + } else { + Query::Suppressed + }, + partial: partial.to_string(), + already_imported, + } + } + + fn suppressed(use_start: usize) -> Self { + Self { + use_start, + query: Query::Suppressed, + partial: String::new(), + already_imported: BTreeSet::new(), + } + } +} + +/// Complete the module path or item list described by `context`. +pub(crate) fn complete_import( + context: &ImportCompletionContext, + source: &str, + current_path: &Path, + project: &ProjectContext, +) -> Vec { + let candidates = match &context.query { + Query::Roots => root_candidates(project, current_path), + Query::Path(segments) => { + let Some((root_alias, relative_segments)) = segments.split_first() else { + return Vec::new(); + }; + let Some(root) = project.import_root(current_path, root_alias) else { + return Vec::new(); + }; + + let source_before_use = source.get(..context.use_start).unwrap_or_default(); + let mut candidates = + candidates_at(root, relative_segments, current_path, source_before_use); + + // Top-level items in the current file are already in scope, but its inline modules + // are useful path segments. Parse only the complete text before the unfinished use. + if root_alias == "crate" && relative_segments.is_empty() { + candidates.extend(parse_inline_module_candidates(source_before_use)); + } + candidates + } + Query::Suppressed => Vec::new(), + }; + + to_completion_items(candidates, &context.partial, &context.already_imported) +} + +fn root_candidates(project: &ProjectContext, current_path: &Path) -> Vec { + let mut candidates = vec![Candidate { + name: "crate".to_string(), + kind: CompletionItemKind::MODULE, + detail: "Current package".to_string(), + documentation: None, + }]; + candidates.extend( + project + .dependency_aliases(current_path) + .into_iter() + .map(|alias| Candidate { + name: alias.to_string(), + kind: CompletionItemKind::MODULE, + detail: project.import_root(current_path, alias).map_or_else( + || "Project dependency".to_string(), + |path| format!("Project dependency `{}`", path.display()), + ), + documentation: None, + }), + ); + candidates +} + +fn to_completion_items( + candidates: Vec, + partial: &str, + already_imported: &BTreeSet, +) -> Vec { + let mut unique = BTreeMap::new(); + for candidate in candidates { + if candidate.name.starts_with(partial) && !already_imported.contains(&candidate.name) { + unique.insert( + (candidate.name.clone(), candidate.detail.clone()), + candidate, + ); + } + } + + unique + .into_values() + .map(|candidate| CompletionItem { + label: candidate.name, + kind: Some(candidate.kind), + detail: Some(candidate.detail), + documentation: candidate.documentation, + ..CompletionItem::default() + }) + .collect() +} + +fn candidates_at( + root: &Path, + segments: &[String], + current_path: &Path, + source_before_use: &str, +) -> Vec { + let mut cursor = root.to_path_buf(); + for (index, segment) in segments.iter().enumerate() { + let directory = cursor.join(segment); + if directory.is_dir() { + cursor = directory; + continue; + } + + let file = cursor.join(format!("{segment}.simf")); + if file.is_file() { + return parse_file_candidates(&file, &segments[index + 1..]); + } + + // If filesystem routing never left the package root, `crate::` may instead be + // navigating inline modules in the current source file. + if cursor == root { + return parse_source_candidates(source_before_use, &segments[index..]); + } + return Vec::new(); + } + + list_directory(&cursor, current_path) +} + +fn list_directory(directory: &Path, current_path: &Path) -> Vec { + let Ok(entries) = fs::read_dir(directory) else { + return Vec::new(); + }; + let canonical_current = + fs::canonicalize(current_path).unwrap_or_else(|_| current_path.to_path_buf()); + + entries + .filter_map(Result::ok) + .filter_map(|entry| { + let path = entry.path(); + let canonical_path = fs::canonicalize(&path).unwrap_or_else(|_| path.clone()); + if canonical_path == canonical_current { + return None; + } + if path.is_dir() { + return path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| is_identifier(name)) + .map(|name| Candidate { + name: name.to_string(), + kind: CompletionItemKind::MODULE, + detail: format!("Module directory `{}`", path.display()), + documentation: None, + }); + } + + (path + .extension() + .is_some_and(|extension| extension == "simf")) + .then(|| path.file_stem().and_then(|name| name.to_str())) + .flatten() + .filter(|name| is_identifier(name)) + .map(|name| Candidate { + name: name.to_string(), + kind: CompletionItemKind::MODULE, + detail: format!("Module file `{}`", path.display()), + documentation: None, + }) + }) + .collect() +} + +fn parse_file_candidates(path: &Path, inline_segments: &[String]) -> Vec { + let Ok(source) = fs::read_to_string(path) else { + return Vec::new(); + }; + parse_source_candidates(&source, inline_segments) +} + +fn parse_program(source: &str) -> Option { + let mut diagnostics = DiagnosticManager::new(); + parse::Program::parse_from_str_with_errors( + 0, + source, + &UnstableFeatures::all(), + &mut diagnostics, + ) +} + +fn parse_source_candidates(source: &str, inline_segments: &[String]) -> Vec { + let Some(program) = parse_program(source) else { + return Vec::new(); + }; + candidates_from_items(program.items(), source, inline_segments) +} + +fn parse_inline_module_candidates(source: &str) -> Vec { + let Some(program) = parse_program(source) else { + return Vec::new(); + }; + + program + .items() + .iter() + .filter_map(|item| match item { + parse::Item::Module(module) => Some(Candidate { + name: module.name().to_string(), + kind: CompletionItemKind::MODULE, + detail: "Inline module".to_string(), + documentation: None, + }), + _ => None, + }) + .collect() +} + +fn candidates_from_items( + items: &[parse::Item], + source: &str, + inline_segments: &[String], +) -> Vec { + if let Some((segment, rest)) = inline_segments.split_first() { + let Some(module) = items.iter().find_map(|item| match item { + parse::Item::Module(module) if module.name().as_inner() == segment => Some(module), + _ => None, + }) else { + return Vec::new(); + }; + return candidates_from_items(module.items(), source, rest); + } + + let rope = Rope::from_str(source); + let mut candidates = Vec::new(); + for item in items { + match item { + parse::Item::Function(function) + if matches!(function.visibility(), Visibility::Public) => + { + let start_line = offset_to_position(function.span().start, &rope) + .unwrap_or_default() + .line; + let documentation = get_comments_from_lines(start_line, &rope); + let template = completion::function_to_template(function, &documentation); + candidates.push(Candidate { + name: function.name().to_string(), + kind: CompletionItemKind::FUNCTION, + detail: template.get_signature(), + documentation: (!documentation.is_empty()).then_some( + Documentation::MarkupContent(MarkupContent { + kind: MarkupKind::Markdown, + value: documentation, + }), + ), + }); + } + parse::Item::TypeAlias(alias) if matches!(alias.visibility(), Visibility::Public) => { + candidates.push(Candidate { + name: alias.name().to_string(), + kind: CompletionItemKind::TYPE_PARAMETER, + detail: format!("type {} = {}", alias.name(), alias.ty()), + documentation: None, + }); + } + parse::Item::EnumDeclaration(declaration) + if matches!(declaration.visibility(), Visibility::Public) => + { + candidates.push(Candidate { + name: declaration.name().to_string(), + kind: CompletionItemKind::ENUM, + detail: format!("enum {}", declaration.name()), + documentation: None, + }); + } + parse::Item::Module(module) if matches!(module.visibility(), Visibility::Public) => { + candidates.push(Candidate { + name: module.name().to_string(), + kind: CompletionItemKind::MODULE, + detail: "Public inline module".to_string(), + documentation: None, + }); + } + parse::Item::Use(use_decl) if matches!(use_decl.visibility(), Visibility::Public) => { + let items = match use_decl.items() { + parse::UseItems::Single(item) => std::slice::from_ref(item), + parse::UseItems::List(items) => items.as_slice(), + }; + candidates.extend(items.iter().map(|(original, alias)| Candidate { + name: alias.as_ref().unwrap_or(original).to_string(), + kind: CompletionItemKind::REFERENCE, + detail: "Public re-export".to_string(), + documentation: None, + })); + } + parse::Item::TypeAlias(_) + | parse::Item::Function(_) + | parse::Item::Use(_) + | parse::Item::EnumDeclaration(_) + | parse::Item::Module(_) + | parse::Item::Ignored => {} + } + } + candidates +} + +fn imported_name(item: &str) -> Option { + let name = item.split_whitespace().next()?; + is_identifier(name).then(|| name.to_string()) +} + +fn is_identifier_prefix(value: &str) -> bool { + value.is_empty() + || (value + .bytes() + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == b'_') + && value + .bytes() + .skip(1) + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')) +} + +fn is_identifier(value: &str) -> bool { + is_identifier_prefix(value) && !value.is_empty() && !simplicityhl::lexer::is_keyword(value) +} + +fn last_use_keyword(source: &str) -> Option> { + let (tokens, _) = simplicityhl::lexer::lex(0, source, 0); + let tokens = tokens?; + let last_token_end = tokens.last().map_or(0, |(_, span)| span.end); + if !source.get(last_token_end..)?.trim().is_empty() { + return None; + } + + tokens.into_iter().rev().find_map(|(token, span)| { + matches!(token, simplicityhl::lexer::Token::Use).then_some(span.start..span.end) + }) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + use crate::config::ProjectSettings; + + fn write(path: &Path, source: &str) { + fs::create_dir_all(path.parent().expect("test file has a parent")).unwrap(); + fs::write(path, source).unwrap(); + } + + fn labels_for(root: &Path, source: &str) -> Vec { + write(root, source); + let project = ProjectContext::discover( + root, + &ProjectSettings::default(), + &[root + .parent() + .and_then(Path::parent) + .expect("source has a project root") + .to_path_buf()], + ) + .unwrap(); + let context = ImportCompletionContext::at(source, source.len()).expect("import context"); + complete_import(&context, source, root, &project) + .into_iter() + .map(|item| item.label) + .collect() + } + + #[test] + fn completes_dependency_roots_modules_and_public_functions() { + let temp = TempDir::new().unwrap(); + write( + &temp.path().join("Simplex.toml"), + "[dependencies]\nstd = { path = 'vendor/std' }\n", + ); + write(&temp.path().join("vendor/std/Simplex.toml"), ""); + write( + &temp.path().join("vendor/std/simf/math.simf"), + "// Add two words.\npub fn add(a: u32, b: u32) -> u32 { a }\nfn hidden() {}\n", + ); + let root = temp.path().join("simf/main.simf"); + + assert_eq!(labels_for(&root, "use st"), vec!["std"]); + assert_eq!(labels_for(&root, "use std::ma"), vec!["math"]); + assert_eq!(labels_for(&root, "use std::math::"), vec!["add"]); + } + + #[test] + fn completes_grouped_imports_without_repeating_selected_items() { + let temp = TempDir::new().unwrap(); + write(&temp.path().join("Simplex.toml"), ""); + write( + &temp.path().join("simf/math.simf"), + "pub fn add() {}\npub fn subtract() {}\n", + ); + let root = temp.path().join("simf/main.simf"); + + assert_eq!( + labels_for(&root, "use crate::math::{add, "), + vec!["subtract"] + ); + } + + #[test] + fn completes_current_file_inline_modules() { + let temp = TempDir::new().unwrap(); + write(&temp.path().join("Simplex.toml"), ""); + let root = temp.path().join("simf/main.simf"); + let source = "pub mod math { pub fn add() {} fn hidden() {} }\nuse crate::math::"; + + assert_eq!(labels_for(&root, source), vec!["add"]); + } + + #[test] + fn crate_root_excludes_the_current_file_and_its_already_visible_items() { + let temp = TempDir::new().unwrap(); + write(&temp.path().join("Simplex.toml"), ""); + let root = temp.path().join("simf/main.simf"); + let source = "pub fn helper() {}\nfn hidden() {}\nmod inline_math {}\nuse crate::"; + let labels = labels_for(&root, source); + + assert!(!labels.contains(&"main".to_string())); + assert!(!labels.contains(&"helper".to_string())); + assert!(!labels.contains(&"hidden".to_string())); + assert!(labels.contains(&"inline_math".to_string())); + } + + #[test] + fn ignores_use_keywords_in_comments() { + let source = "fn main() {}\n// use crate::"; + assert!(ImportCompletionContext::at(source, source.len()).is_none()); + + let source = "fn main() {}\n/* use crate:: */"; + assert!(ImportCompletionContext::at(source, source.len()).is_none()); + + let source = "fn main() {}\n/* use crate::"; + assert!(ImportCompletionContext::at(source, source.len()).is_none()); + + let source = "use crate::\n/* unfinished"; + assert!(ImportCompletionContext::at(source, source.len()).is_none()); + + let source = "use crate::\n// unfinished"; + assert!(ImportCompletionContext::at(source, source.len()).is_none()); + } + + #[test] + fn malformed_import_paths_do_not_offer_misleading_candidates() { + for source in ["use crate::::", "use crate::math:{", "use ::math::"] { + let context = ImportCompletionContext::at(source, source.len()).unwrap(); + assert_eq!(context.query, Query::Suppressed, "{source}"); + } + } + + #[test] + fn function_completion_includes_signature_and_documentation() { + let temp = TempDir::new().unwrap(); + write(&temp.path().join("Simplex.toml"), ""); + write( + &temp.path().join("simf/math.simf"), + "/// Add two words.\npub fn add(a: u32, b: u32) -> u32 { a }\n", + ); + let root = temp.path().join("simf/main.simf"); + let source = "use crate::math::"; + write(&root, source); + let project = ProjectContext::discover( + &root, + &ProjectSettings::default(), + &[temp.path().to_path_buf()], + ) + .unwrap(); + let context = ImportCompletionContext::at(source, source.len()).unwrap(); + let items = complete_import(&context, source, &root, &project); + + assert_eq!( + items[0].detail.as_deref(), + Some("fn(a: u32, b: u32) -> u32") + ); + assert!(items[0].documentation.is_some()); + assert!(items[0].insert_text.is_none()); + } +} diff --git a/src/main.rs b/src/main.rs index 1163561..caf7705 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod completion; mod config; mod error; mod function; +mod imports; mod project; mod utils; diff --git a/src/project.rs b/src/project.rs index 8245f41..314f846 100644 --- a/src/project.rs +++ b/src/project.rs @@ -216,28 +216,44 @@ impl ProjectContext { .map_or(self.source_root.as_path(), PathBuf::as_path) } + fn visible_mappings<'a>( + &'a self, + document_path: &Path, + ) -> impl Iterator + 'a { + let canonical_document = + fs::canonicalize(document_path).unwrap_or_else(|_| document_path.to_path_buf()); + self.dependencies + .iter() + .filter(move |mapping| canonical_document.starts_with(&mapping.context)) + } + /// 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) - }) + self.visible_mappings(document_path) + .filter(|mapping| mapping.alias == alias) .max_by_key(|mapping| mapping.context.as_os_str().len()) .map(|mapping| mapping.target.as_path()) } + + /// Return dependency aliases that are visible from `document_path`. + /// + /// A package can provide both a broad mapping and a more specific override for the + /// same alias. Completion only needs to display that logical alias once; [`Self::import_root`] + /// selects the effective target when the user continues the path. + pub fn dependency_aliases(&self, document_path: &Path) -> Vec<&str> { + let mut aliases = self + .visible_mappings(document_path) + .map(|mapping| mapping.alias.as_str()) + .collect::>(); + aliases.sort_unstable(); + aliases.dedup(); + aliases + } } impl ProjectCollector { @@ -485,6 +501,14 @@ mod tests { .as_path() ) ); + assert_eq!( + context.dependency_aliases(&root.join("contracts/main.simf")), + vec!["merkle"] + ); + assert_eq!( + context.dependency_aliases(&root.join("vendor/merkle/simf/root.simf")), + vec!["math"] + ); } #[test] diff --git a/src/utils.rs b/src/utils.rs index 5c0df24..ee30b1e 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -395,54 +395,70 @@ impl Document { &self, function: &parse::Function, ) -> Result { - let start_line = offset_to_position(function.span().start, &self.text)?.line; - let Some((line, character)) = self - .text - .lines() - .enumerate() - .skip(start_line as usize) - .find_map(|(i, line)| { - line.to_string() - .find(function.name().as_inner()) - .map(|col| (i, col)) - }) - else { + let function_span = function.span(); + let source = match function_span.file_id { + 0 => &self.text, + file_id => { + &self + .linearization_map + .get(file_id) + .ok_or_else(|| { + LspError::FunctionNotFound(format!( + "Source file for function {} not found", + function.name() + )) + })? + .text + } + }; + let function_source = source + .get_byte_slice(function_span.start..function_span.end) + .ok_or_else(|| { + LspError::FunctionNotFound(format!( + "Source span for function {} is outside its document", + function.name() + )) + })? + .to_string(); + let (tokens, _) = simplicityhl::lexer::lex(function_span.file_id, &function_source, 0); + let Some(tokens) = tokens else { return Err(LspError::FunctionNotFound(format!( "Function with name {} not found", function.name() ))); }; - - let func_size = u32::try_from(function.name().as_inner().len()).map_err(LspError::from)?; - - let (line, character) = ( - u32::try_from(line).map_err(LspError::from)?, - u32::try_from(character).map_err(LspError::from)?, - ); - - let (start, end) = ( - lsp_types::Position { line, character }, - lsp_types::Position { - line, - character: character + func_size, - }, - ); - Ok(lsp_types::Range { start, end }) + let name_span = tokens + .windows(2) + .find_map(|pair| match pair { + [ + (simplicityhl::lexer::Token::Fn, _), + (simplicityhl::lexer::Token::Ident(name), span), + ] if *name == function.name().as_inner() => Some(*span), + _ => None, + }) + .ok_or_else(|| { + LspError::FunctionNotFound(format!( + "Function with name {} not found inside its source span", + function.name() + )) + })?; + + Ok(lsp_types::Range { + start: offset_to_position(function_span.start + name_span.start, source)?, + end: offset_to_position(function_span.start + name_span.end, source)?, + }) } /// Find [`simplicityhl::parse::Call`] which contains given [`simplicityhl::error::Span`], which also have minimal Span. pub fn find_related_call( &self, token_span: simplicityhl::error::Span, - ) -> Result, LspError> { + ) -> Option<&simplicityhl::parse::Call> { let func = self .functions .functions() .into_iter() - .find(|func| span_contains(func.span(), &token_span) && func.span().file_id == 0) - .ok_or(LspError::CallNotFound( - "Span of the call is not inside function.".into(), - ))?; + .find(|func| span_contains(func.span(), &token_span) && func.span().file_id == 0)?; let call = parse::ExprTree::Expression(func.body()) .pre_order_iter() @@ -458,7 +474,7 @@ impl Document { .map(|(call, _)| call) .last(); - Ok(call) + call } /// Append functions imported via `use` declarations to [`Document`],