diff --git a/src/analysis.rs b/src/analysis.rs deleted file mode 100644 index 7032e0c..0000000 --- a/src/analysis.rs +++ /dev/null @@ -1,644 +0,0 @@ -use std::collections::HashMap; -use std::ops::Index; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use ropey::Rope; -use simplicityhl::ast::ElementsJetHinter; -use simplicityhl::driver::SourceMap; -use simplicityhl::error::{ - Diagnostic as CompilerDiagnostic, DiagnosticManager, Error as CompilerError, - Severity as CompilerSeverity, Span, -}; -use simplicityhl::parse::ParseFromStrWithErrors; -use simplicityhl::resolution::DependencyMap; -use simplicityhl::source::CanonSourceFile; -use simplicityhl::{parse, TemplateProgram, UnstableFeatures}; -use tower_lsp_server::lsp_types::Uri; -use tower_lsp_server::UriExt; - -use crate::config::Settings; -use crate::function::Functions; -use crate::project::ProjectContext; -use crate::utils::{get_comments_from_lines, offset_to_position}; - -/// A compiler source together with the editor identity and text used for LSP ranges. -#[derive(Debug)] -pub struct SourceDocument { - pub uri: Uri, - pub text: Rope, -} - -/// Stable file-id lookup for every source participating in one compiler analysis. -#[derive(Debug)] -pub struct SourceSet { - by_id: HashMap, -} - -impl SourceSet { - fn root(uri: Uri, text: Rope) -> Self { - Self { - by_id: HashMap::from([(0, SourceDocument { uri, text })]), - } - } - - pub fn get(&self, file_id: usize) -> Option<&SourceDocument> { - self.by_id.get(&file_id) - } - - pub fn root_source(&self) -> &SourceDocument { - self.get(0).expect("every source set contains its root") - } - - #[cfg(test)] - pub fn len(&self) -> usize { - self.by_id.len() - } - - fn from_compiler(source_map: &SourceMap, root_text: &Rope) -> Self { - let by_id = source_map - .iter() - .map(|(path, file_id)| { - ( - *file_id, - SourceDocument { - uri: Uri::from_file_path(path.as_path()) - .expect("compiler source path produces a valid file URI"), - text: if *file_id == 0 { - root_text.clone() - } else { - Rope::from_str( - source_map - .content(*file_id) - .expect("compiler source map contains every registered file") - .as_ref(), - ) - }, - }, - ) - }) - .collect(); - Self { by_id } - } -} - -impl Index for SourceSet { - type Output = SourceDocument; - - fn index(&self, index: usize) -> &Self::Output { - &self.by_id[&index] - } -} - -/// Immutable result of analyzing one open root document. -#[derive(Debug)] -pub struct AnalysisSnapshot { - pub functions: Functions, - pub use_declarations: Vec, - pub sources: SourceSet, - pub text: Rope, - pub version: Option, - pub compiler_diagnostics: Vec, - call_scopes: HashMap>>, -} - -#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] -struct FunctionKey { - file_id: usize, - start: usize, - end: usize, -} - -impl FunctionKey { - fn new(function: &parse::Function) -> Self { - let span = function.span(); - Self { - file_id: span.file_id, - start: span.start, - end: span.end, - } - } -} - -impl AnalysisSnapshot { - pub fn new(uri: Uri, text: Rope, version: Option) -> Self { - Self { - functions: Functions::new(), - use_declarations: Vec::new(), - sources: SourceSet::root(uri, text.clone()), - text, - version, - compiler_diagnostics: Vec::new(), - call_scopes: HashMap::new(), - } - } - - pub fn from_program(program: &parse::Program, text: &str, path: &Path) -> Self { - let text = Rope::from_str(text); - let uri = Uri::from_file_path(path).expect("source path produces a valid file URI"); - let mut analysis = Self::new(uri, text, None); - - collect_use_declarations(program.items(), &mut analysis.use_declarations); - for function in program.items().iter().filter_map(|item| match item { - parse::Item::Function(function) => Some(function), - _ => None, - }) { - let start_line = offset_to_position(function.span().start, &analysis.text) - .unwrap_or_default() - .line; - analysis.functions.insert( - function.name().to_string(), - function.clone(), - get_comments_from_lines(start_line, &analysis.text), - ); - } - - analysis - } - - pub fn populate_sources(&mut self, source_map: &SourceMap) { - self.sources = SourceSet::from_compiler(source_map, &self.text); - } - - /// Add imported functions under the names visible from this root, including aliases. - fn populate_visible_functions(&mut self, template_program: &TemplateProgram) { - let Some(source_map) = template_program.source_map() else { - return; - }; - self.populate_sources(source_map); - - let resolved_program = template_program.resolved_program(); - self.populate_call_scopes(resolved_program); - for item in resolved_program.items() { - let parse::Item::Module(module) = item else { - continue; - }; - let Some(0) = module - .name() - .as_inner() - .strip_prefix("unit_") - .and_then(|id| id.parse::().ok()) - else { - continue; - }; - - for inner_item in module.items() { - let parse::Item::Use(use_decl) = inner_item else { - continue; - }; - let path = use_decl.path(); - let Some(target_file_id) = path - .get(1) - .and_then(|segment| segment.as_inner().strip_prefix("unit_")) - .and_then(|id| id.parse::().ok()) - else { - continue; - }; - let items = match use_decl.items() { - parse::UseItems::Single(item) => std::slice::from_ref(item), - parse::UseItems::List(items) => items.as_slice(), - }; - - for (original_name, alias) in items { - let local_name = alias.as_ref().unwrap_or(original_name); - let mut visited = std::collections::HashSet::new(); - let Some(function) = resolve_function( - resolved_program, - target_file_id, - &path[2..], - original_name.as_inner(), - &mut visited, - ) else { - continue; - }; - let Some(source) = self.sources.get(function.span().file_id) else { - continue; - }; - let start_line = offset_to_position(function.span().start, &source.text) - .unwrap_or_default() - .line; - self.functions.insert( - local_name.to_string(), - function.clone(), - get_comments_from_lines(start_line, &source.text), - ); - } - } - } - } - - fn populate_call_scopes(&mut self, program: &parse::Program) { - for item in program.items() { - let parse::Item::Module(unit) = item else { - continue; - }; - collect_call_scopes(program, unit.items(), &mut self.call_scopes); - } - } - - pub(crate) fn resolve_custom_call( - &self, - owner: &parse::Function, - name: &str, - ) -> Option<&parse::Function> { - match self.call_scopes.get(&FunctionKey::new(owner)) { - Some(scope) => scope.get(name), - None => self.functions.get_func(name), - } - } - - /// Parse, resolve, and type-check one root using the same project context as the compiler. - /// A snapshot is returned even when parsing fails so editor features retain the latest text. - pub fn analyze( - text: &str, - path: &Path, - settings: &Settings, - workspace_roots: &[PathBuf], - ) -> Self { - let unstable_features = settings.unstable_features(); - let mut diagnostics = DiagnosticManager::new(); - let shared_text: Arc = Arc::from(text); - let source_file = simplicityhl::source::SourceFile::new(path, Arc::clone(&shared_text)); - let root_uri = Uri::from_file_path(path).expect("source path produces a valid file URI"); - - let Some(program) = parse::Program::parse_from_str_with_errors( - 0, - shared_text.as_ref(), - &unstable_features, - &mut diagnostics, - ) else { - let mut snapshot = Self::new(root_uri, Rope::from_str(text), None); - snapshot.compiler_diagnostics = diagnostics.diagnostics().to_vec(); - return snapshot; - }; - - let mut snapshot = Self::from_program(&program, shared_text.as_ref(), path); - let dependencies = match ProjectContext::discover(path, &settings.project, workspace_roots) - .and_then(|project| project.dependency_map(path)) - { - Ok(dependencies) => dependencies, - Err(err) => { - diagnostics.push(CompilerDiagnostic::new( - CompilerError::CannotParse { - msg: err.to_string(), - }, - Span::new(0, 0..0), - )); - snapshot.compiler_diagnostics = diagnostics.diagnostics().to_vec(); - return snapshot; - } - }; - let canonical_source: CanonSourceFile = source_file - .try_into() - .expect("source file has a canonical name"); - - // The compiler requires an entry point, while editors also analyze library files. - // Add a synthetic main only to the compiler input; all user spans remain unchanged. - let analysis_source = if items_contain_main(program.items()) { - canonical_source.clone() - } else { - CanonSourceFile::new( - canonical_source.name().clone(), - Arc::from(format!("{}\nfn main() {{}}\n", canonical_source.content())), - ) - }; - - snapshot.compiler_diagnostics = match TemplateProgram::new_with_dep( - analysis_source, - &dependencies, - &unstable_features, - Box::new(ElementsJetHinter::new()), - ) { - Ok(template_program) => { - snapshot.populate_visible_functions(&template_program); - template_program.diagnostics().diagnostics().to_vec() - } - Err(diagnostics) => { - if let Some(source_map) = diagnostics.sources() { - snapshot.populate_sources(source_map); - } - remap_imported_main_diagnostics( - &diagnostics, - &program, - &canonical_source, - &dependencies, - &unstable_features, - ) - } - }; - - snapshot - } -} - -fn collect_call_scopes( - program: &parse::Program, - items: &[parse::Item], - call_scopes: &mut HashMap>>, -) { - let mut bindings = items - .iter() - .filter_map(|item| match item { - parse::Item::Function(function) => { - Some((function.name().to_string(), function.clone())) - } - _ => None, - }) - .collect::>(); - - for use_decl in items.iter().filter_map(|item| match item { - parse::Item::Use(use_decl) => Some(use_decl), - _ => None, - }) { - let path = use_decl.path(); - let Some(target_file_id) = path - .get(1) - .and_then(|segment| segment.as_inner().strip_prefix("unit_")) - .and_then(|id| id.parse::().ok()) - else { - continue; - }; - let imported_items = match use_decl.items() { - parse::UseItems::Single(item) => std::slice::from_ref(item), - parse::UseItems::List(items) => items.as_slice(), - }; - for (original, alias) in imported_items { - let mut visited = std::collections::HashSet::new(); - let Some(function) = resolve_function( - program, - target_file_id, - &path[2..], - original.as_inner(), - &mut visited, - ) else { - continue; - }; - bindings.insert( - alias.as_ref().unwrap_or(original).to_string(), - function.clone(), - ); - } - } - - let bindings = Arc::new(bindings); - for item in items { - match item { - parse::Item::Function(function) => { - call_scopes.insert(FunctionKey::new(function), Arc::clone(&bindings)); - } - parse::Item::Module(module) => { - collect_call_scopes(program, module.items(), call_scopes); - } - parse::Item::TypeAlias(_) - | parse::Item::Use(_) - | parse::Item::EnumDeclaration(_) - | parse::Item::Ignored => {} - } - } -} - -fn collect_use_declarations(items: &[parse::Item], declarations: &mut Vec) { - for item in items { - match item { - parse::Item::Use(use_decl) => declarations.push(use_decl.clone()), - parse::Item::Module(module) => { - collect_use_declarations(module.items(), declarations); - } - parse::Item::TypeAlias(_) - | parse::Item::Function(_) - | parse::Item::EnumDeclaration(_) - | parse::Item::Ignored => {} - } - } -} - -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, - }) -} - -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; - }; - 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() -} - -type ResolutionKey = (usize, Vec, String); - -fn resolve_function<'a>( - program: &'a parse::Program, - file_id: usize, - module_path: &[simplicityhl::str::Identifier], - name: &str, - visited: &mut std::collections::HashSet, -) -> Option<&'a parse::Function> { - let key = ( - file_id, - module_path.iter().map(ToString::to_string).collect(), - name.to_string(), - ); - if !visited.insert(key) { - return None; - } - - let items = module_items(program, file_id, module_path)?; - if let Some(function) = items.iter().find_map(|item| match item { - parse::Item::Function(function) if function.name().as_inner() == name => Some(function), - _ => None, - }) { - return Some(function); - } - - for use_decl in items.iter().filter_map(|item| match item { - parse::Item::Use(use_decl) => Some(use_decl), - _ => None, - }) { - let imported_items = match use_decl.items() { - parse::UseItems::Single(item) => std::slice::from_ref(item), - parse::UseItems::List(items) => items.as_slice(), - }; - for (original, alias) in imported_items { - if alias.as_ref().unwrap_or(original).as_inner() != name { - continue; - } - let path = use_decl.path(); - let target_file_id = path - .get(1)? - .as_inner() - .strip_prefix("unit_")? - .parse::() - .ok()?; - if let Some(function) = resolve_function( - program, - target_file_id, - &path[2..], - original.as_inner(), - visited, - ) { - return Some(function); - } - } - } - None -} - -fn module_items<'a>( - program: &'a parse::Program, - file_id: usize, - module_path: &[simplicityhl::str::Identifier], -) -> Option<&'a [parse::Item]> { - let unit_name = format!("unit_{file_id}"); - let unit = program.items().iter().find_map(|item| match item { - parse::Item::Module(module) if module.name().as_inner() == unit_name => Some(module), - _ => None, - })?; - let mut items = unit.items(); - for segment in module_path { - let module = items.iter().find_map(|item| match item { - parse::Item::Module(module) if module.name().as_inner() == segment.as_inner() => { - Some(module) - } - _ => None, - })?; - items = module.items(); - } - Some(items) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn known_scope_does_not_fall_back_to_root_visible_functions() { - let source = "fn target() {}\nfn owner() { target(); }\n"; - let mut diagnostics = DiagnosticManager::new(); - let program = parse::Program::parse_from_str_with_errors( - 0, - source, - &UnstableFeatures::none(), - &mut diagnostics, - ) - .expect("valid program"); - let path = std::env::temp_dir().join("module-scope-test.simf"); - let mut snapshot = AnalysisSnapshot::from_program(&program, source, &path); - let owner = snapshot - .functions - .get_func("owner") - .expect("owner function") - .clone(); - - assert!(snapshot.resolve_custom_call(&owner, "target").is_some()); - snapshot - .call_scopes - .insert(FunctionKey::new(&owner), Arc::new(HashMap::new())); - assert!(snapshot.resolve_custom_call(&owner, "target").is_none()); - } -} diff --git a/src/analysis/entry_point.rs b/src/analysis/entry_point.rs new file mode 100644 index 0000000..1fa2cdf --- /dev/null +++ b/src/analysis/entry_point.rs @@ -0,0 +1,244 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use simplicityhl::driver::SourceMap; +use simplicityhl::error::{ + Diagnostic as CompilerDiagnostic, DiagnosticManager, Error as CompilerError, + Severity as CompilerSeverity, Span, +}; +use simplicityhl::parse::{self, ParseFromStrWithErrors}; +use simplicityhl::resolution::DependencyMap; +use simplicityhl::source::CanonSourceFile; +use simplicityhl::UnstableFeatures; + +pub(super) fn compiler_source( + program: &parse::Program, + canonical_source: &CanonSourceFile, +) -> CanonSourceFile { + if items_contain_main(program.items()) { + canonical_source.clone() + } else { + CanonSourceFile::new( + canonical_source.name().clone(), + Arc::from(format!("{}\nfn main() {{}}\n", canonical_source.content())), + ) + } +} + +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, + }) +} + +fn imports_main( + items: &[parse::Item], + current_source: &CanonSourceFile, + dependencies: &DependencyMap, + unstable_features: &UnstableFeatures, + sources: &SourceMap, + visited: &mut HashSet, +) -> bool { + for item in items { + match item { + parse::Item::Use(use_decl) => { + let Ok(target) = dependencies.resolve_path(current_source.name(), use_decl) else { + continue; + }; + if &target == current_source.name() { + continue; + } + if !visited.insert(target.clone()) { + continue; + } + let Some(file_id) = sources.id(&target) else { + continue; + }; + let Some(source) = sources.content(file_id) else { + continue; + }; + let mut diagnostics = DiagnosticManager::new(); + let Some(program) = parse::Program::parse_from_str_with_errors( + file_id, + &source, + unstable_features, + &mut diagnostics, + ) else { + continue; + }; + + let imported_source = CanonSourceFile::new(target, source); + if items_contain_main(program.items()) + || imports_main( + program.items(), + &imported_source, + dependencies, + unstable_features, + sources, + visited, + ) + { + return true; + } + } + parse::Item::Module(module) => { + if imports_main( + module.items(), + current_source, + dependencies, + unstable_features, + sources, + visited, + ) { + return true; + } + } + parse::Item::TypeAlias(_) + | parse::Item::Function(_) + | parse::Item::EnumDeclaration(_) + | parse::Item::Ignored => {} + } + } + false +} + +fn imported_main_spans( + items: &[parse::Item], + current_source: &CanonSourceFile, + dependencies: &DependencyMap, + unstable_features: &UnstableFeatures, + sources: &SourceMap, + spans: &mut Vec, +) { + for item in items { + match item { + parse::Item::Use(use_decl) => { + let Ok(target) = dependencies.resolve_path(current_source.name(), use_decl) else { + continue; + }; + if &target == current_source.name() { + continue; + } + let Some(file_id) = sources.id(&target) else { + continue; + }; + let Some(source) = sources.content(file_id) else { + continue; + }; + let mut diagnostics = DiagnosticManager::new(); + let Some(program) = parse::Program::parse_from_str_with_errors( + file_id, + &source, + unstable_features, + &mut diagnostics, + ) else { + continue; + }; + let imported_source = CanonSourceFile::new(target.clone(), source); + if items_contain_main(program.items()) + || imports_main( + program.items(), + &imported_source, + dependencies, + unstable_features, + sources, + &mut HashSet::from([target]), + ) + { + spans.push(*use_decl.span()); + } + } + parse::Item::Module(module) => imported_main_spans( + module.items(), + current_source, + dependencies, + unstable_features, + sources, + spans, + ), + parse::Item::TypeAlias(_) + | parse::Item::Function(_) + | parse::Item::EnumDeclaration(_) + | parse::Item::Ignored => {} + } + } +} + +fn is_duplicate_main(diagnostic: &CompilerDiagnostic) -> bool { + matches!( + diagnostic.error(), + CompilerError::FunctionRedefined { name } if name.as_inner() == "main" + ) +} + +pub(super) 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(sources) = diagnostics.sources() else { + return diagnostics.diagnostics().to_vec(); + }; + let mut import_spans = Vec::new(); + imported_main_spans( + program.items(), + current_source, + dependencies, + unstable_features, + sources, + &mut import_spans, + ); + import_spans.sort_unstable_by_key(|span| (span.file_id, span.start, span.end)); + import_spans.dedup(); + if import_spans.is_empty() { + return diagnostics.diagnostics().to_vec(); + } + + let mut duplicate_index = 0; + diagnostics + .diagnostics() + .iter() + .map(|diagnostic| { + if !is_duplicate_main(diagnostic) { + return diagnostic.clone(); + } + let primary_index = duplicate_index.min(import_spans.len() - 1); + duplicate_index += 1; + let import_span = import_spans[primary_index]; + 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 (index, span) in import_spans.iter().enumerate() { + if index != primary_index { + remapped = remapped + .with_secondary(*span, "Another imported `main` enters through this use"); + } + } + 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() +} diff --git a/src/analysis/mod.rs b/src/analysis/mod.rs new file mode 100644 index 0000000..ebe6308 --- /dev/null +++ b/src/analysis/mod.rs @@ -0,0 +1,159 @@ +mod entry_point; +mod resolution; +mod sources; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use ropey::Rope; +use simplicityhl::ast::ElementsJetHinter; +use simplicityhl::error::{ + Diagnostic as CompilerDiagnostic, DiagnosticManager, Error as CompilerError, Span, +}; +use simplicityhl::parse::ParseFromStrWithErrors; +use simplicityhl::source::CanonSourceFile; +use simplicityhl::{parse, TemplateProgram}; +use tower_lsp_server::lsp_types::Uri; +use tower_lsp_server::UriExt; + +pub use sources::{Functions, SourceSet}; + +use self::resolution::collect_use_declarations; +use crate::config::Settings; +use crate::project::ProjectContext; +use crate::text::{get_comments_from_lines, offset_to_position}; + +/// Immutable result of analyzing one open root document. +#[derive(Debug)] +pub struct AnalysisSnapshot { + pub functions: Functions, + pub use_declarations: Vec, + pub sources: SourceSet, + pub text: Rope, + pub compiler_diagnostics: Vec, + call_scopes: HashMap>>, +} + +impl AnalysisSnapshot { + pub fn new(uri: Uri, text: Rope) -> Self { + Self { + functions: Functions::default(), + use_declarations: Vec::new(), + sources: SourceSet::root(uri, text.clone()), + text, + compiler_diagnostics: Vec::new(), + call_scopes: HashMap::new(), + } + } + + pub fn from_program(program: &parse::Program, text: &str, path: &Path) -> Self { + let text = Rope::from_str(text); + let uri = Uri::from_file_path(path).expect("source path produces a valid file URI"); + let mut analysis = Self::new(uri, text); + + collect_use_declarations(program.items(), &mut analysis.use_declarations); + for function in program.items().iter().filter_map(|item| match item { + parse::Item::Function(function) => Some(function), + _ => None, + }) { + let start_line = offset_to_position(function.span().start, &analysis.text) + .unwrap_or_default() + .line; + analysis.functions.insert( + function.name().to_string(), + function.clone(), + get_comments_from_lines(start_line, &analysis.text), + ); + } + + analysis + } + + /// Parse, resolve, and type-check one root using the same project context as the compiler. + /// A snapshot is returned even when parsing fails so editor features retain the latest text. + pub fn analyze( + text: &str, + path: &Path, + settings: &Settings, + workspace_roots: &[PathBuf], + ) -> Self { + let unstable_features = settings.unstable_features(); + let mut diagnostics = DiagnosticManager::new(); + let shared_text: Arc = Arc::from(text); + let source_file = simplicityhl::source::SourceFile::new(path, Arc::clone(&shared_text)); + let root_uri = Uri::from_file_path(path).expect("source path produces a valid file URI"); + + let Some(program) = parse::Program::parse_from_str_with_errors( + 0, + shared_text.as_ref(), + &unstable_features, + &mut diagnostics, + ) else { + let mut snapshot = Self::new(root_uri, Rope::from_str(text)); + snapshot.compiler_diagnostics = diagnostics.diagnostics().to_vec(); + return snapshot; + }; + + let mut snapshot = Self::from_program(&program, shared_text.as_ref(), path); + let dependencies = match ProjectContext::discover(path, &settings.project, workspace_roots) + .and_then(|project| project.dependency_map(path)) + { + Ok(dependencies) => dependencies, + Err(err) => { + diagnostics.push(CompilerDiagnostic::new( + CompilerError::CannotParse { + msg: err.to_string(), + }, + Span::new(0, 0..0), + )); + snapshot.compiler_diagnostics = diagnostics.diagnostics().to_vec(); + return snapshot; + } + }; + let canonical_source: CanonSourceFile = match source_file.try_into() { + Ok(source) => source, + Err(error) => { + diagnostics.push(CompilerDiagnostic::new( + CompilerError::CannotParse { msg: error }, + Span::new(0, 0..0), + )); + snapshot.compiler_diagnostics = diagnostics.diagnostics().to_vec(); + return snapshot; + } + }; + + // The compiler requires an entry point, while editors also analyze library files. + // Add a synthetic main only to the compiler input; all user spans remain unchanged. + let analysis_source = entry_point::compiler_source(&program, &canonical_source); + + snapshot.compiler_diagnostics = match TemplateProgram::new_with_dep( + analysis_source, + &dependencies, + &unstable_features, + Box::new(ElementsJetHinter::new()), + ) { + Ok(template_program) => { + snapshot.populate_visible_functions(&template_program); + template_program.diagnostics().diagnostics().to_vec() + } + Err(diagnostics) => { + if let Some(source_map) = diagnostics.sources() { + snapshot.populate_sources(source_map); + } + entry_point::remap_imported_main_diagnostics( + &diagnostics, + &program, + &canonical_source, + &dependencies, + &unstable_features, + ) + } + }; + + snapshot + } +} + +#[cfg(test)] +mod tests; diff --git a/src/analysis/resolution.rs b/src/analysis/resolution.rs new file mode 100644 index 0000000..a8cd44a --- /dev/null +++ b/src/analysis/resolution.rs @@ -0,0 +1,264 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use simplicityhl::error::Span; +use simplicityhl::parse; +use simplicityhl::TemplateProgram; + +use super::AnalysisSnapshot; +use crate::text::{get_comments_from_lines, offset_to_position}; + +impl AnalysisSnapshot { + /// Add imported functions under the names visible from this root, including aliases. + pub(super) fn populate_visible_functions(&mut self, template_program: &TemplateProgram) { + let Some(source_map) = template_program.source_map() else { + return; + }; + self.populate_sources(source_map); + + let resolved_program = template_program.resolved_program(); + self.populate_call_scopes(resolved_program); + for item in resolved_program.items() { + let parse::Item::Module(module) = item else { + continue; + }; + let Some(0) = module + .name() + .as_inner() + .strip_prefix("unit_") + .and_then(|id| id.parse::().ok()) + else { + continue; + }; + + for inner_item in module.items() { + let parse::Item::Use(use_decl) = inner_item else { + continue; + }; + let path = use_decl.path(); + let Some(target_file_id) = path + .get(1) + .and_then(|segment| segment.as_inner().strip_prefix("unit_")) + .and_then(|id| id.parse::().ok()) + else { + continue; + }; + let items = match use_decl.items() { + parse::UseItems::Single(item) => std::slice::from_ref(item), + parse::UseItems::List(items) => items.as_slice(), + }; + + for (original_name, alias) in items { + let local_name = alias.as_ref().unwrap_or(original_name); + let mut visited = HashSet::new(); + let Some(function) = resolve_function( + resolved_program, + target_file_id, + &path[2..], + original_name.as_inner(), + &mut visited, + ) else { + continue; + }; + let Some(source) = self.sources.get(function.span().file_id) else { + continue; + }; + let start_line = offset_to_position(function.span().start, &source.text) + .unwrap_or_default() + .line; + self.functions.insert( + local_name.to_string(), + function.clone(), + get_comments_from_lines(start_line, &source.text), + ); + } + } + } + } + + fn populate_call_scopes(&mut self, program: &parse::Program) { + for item in program.items() { + let parse::Item::Module(unit) = item else { + continue; + }; + collect_call_scopes(program, unit.items(), &mut self.call_scopes); + } + } + + pub(crate) fn resolve_custom_call( + &self, + owner: &parse::Function, + name: &str, + ) -> Option<&parse::Function> { + match self.call_scopes.get(owner.span()) { + Some(scope) => scope.get(name), + None => self.functions.get_func(name), + } + } +} + +fn collect_call_scopes( + program: &parse::Program, + items: &[parse::Item], + call_scopes: &mut HashMap>>, +) { + let mut bindings = items + .iter() + .filter_map(|item| match item { + parse::Item::Function(function) => { + Some((function.name().to_string(), function.clone())) + } + _ => None, + }) + .collect::>(); + + for use_decl in items.iter().filter_map(|item| match item { + parse::Item::Use(use_decl) => Some(use_decl), + _ => None, + }) { + let path = use_decl.path(); + let Some(target_file_id) = path + .get(1) + .and_then(|segment| segment.as_inner().strip_prefix("unit_")) + .and_then(|id| id.parse::().ok()) + else { + continue; + }; + let imported_items = match use_decl.items() { + parse::UseItems::Single(item) => std::slice::from_ref(item), + parse::UseItems::List(items) => items.as_slice(), + }; + for (original, alias) in imported_items { + let mut visited = HashSet::new(); + let Some(function) = resolve_function( + program, + target_file_id, + &path[2..], + original.as_inner(), + &mut visited, + ) else { + continue; + }; + bindings.insert( + alias.as_ref().unwrap_or(original).to_string(), + function.clone(), + ); + } + } + + let bindings = Arc::new(bindings); + for item in items { + match item { + parse::Item::Function(function) => { + call_scopes.insert(*function.span(), Arc::clone(&bindings)); + } + parse::Item::Module(module) => { + collect_call_scopes(program, module.items(), call_scopes); + } + parse::Item::TypeAlias(_) + | parse::Item::Use(_) + | parse::Item::EnumDeclaration(_) + | parse::Item::Ignored => {} + } + } +} + +pub(super) fn collect_use_declarations( + items: &[parse::Item], + declarations: &mut Vec, +) { + for item in items { + match item { + parse::Item::Use(use_decl) => declarations.push(use_decl.clone()), + parse::Item::Module(module) => { + collect_use_declarations(module.items(), declarations); + } + parse::Item::TypeAlias(_) + | parse::Item::Function(_) + | parse::Item::EnumDeclaration(_) + | parse::Item::Ignored => {} + } + } +} + +type ResolutionKey = (usize, Vec, String); + +fn resolve_function<'a>( + program: &'a parse::Program, + file_id: usize, + module_path: &[simplicityhl::str::Identifier], + name: &str, + visited: &mut HashSet, +) -> Option<&'a parse::Function> { + let key = ( + file_id, + module_path.iter().map(ToString::to_string).collect(), + name.to_string(), + ); + if !visited.insert(key) { + return None; + } + + let items = module_items(program, file_id, module_path)?; + if let Some(function) = items.iter().find_map(|item| match item { + parse::Item::Function(function) if function.name().as_inner() == name => Some(function), + _ => None, + }) { + return Some(function); + } + + for use_decl in items.iter().filter_map(|item| match item { + parse::Item::Use(use_decl) => Some(use_decl), + _ => None, + }) { + let imported_items = match use_decl.items() { + parse::UseItems::Single(item) => std::slice::from_ref(item), + parse::UseItems::List(items) => items.as_slice(), + }; + for (original, alias) in imported_items { + if alias.as_ref().unwrap_or(original).as_inner() != name { + continue; + } + let path = use_decl.path(); + let target_file_id = path + .get(1)? + .as_inner() + .strip_prefix("unit_")? + .parse::() + .ok()?; + if let Some(function) = resolve_function( + program, + target_file_id, + &path[2..], + original.as_inner(), + visited, + ) { + return Some(function); + } + } + } + None +} + +fn module_items<'a>( + program: &'a parse::Program, + file_id: usize, + module_path: &[simplicityhl::str::Identifier], +) -> Option<&'a [parse::Item]> { + let unit_name = format!("unit_{file_id}"); + let unit = program.items().iter().find_map(|item| match item { + parse::Item::Module(module) if module.name().as_inner() == unit_name => Some(module), + _ => None, + })?; + let mut items = unit.items(); + for segment in module_path { + let module = items.iter().find_map(|item| match item { + parse::Item::Module(module) if module.name().as_inner() == segment.as_inner() => { + Some(module) + } + _ => None, + })?; + items = module.items(); + } + Some(items) +} diff --git a/src/analysis/sources.rs b/src/analysis/sources.rs new file mode 100644 index 0000000..d8cd64c --- /dev/null +++ b/src/analysis/sources.rs @@ -0,0 +1,118 @@ +use std::collections::HashMap; +use std::ops::Index; + +use ropey::Rope; +use simplicityhl::driver::SourceMap; +use simplicityhl::parse; +use tower_lsp_server::lsp_types::Uri; +use tower_lsp_server::UriExt; + +use super::AnalysisSnapshot; + +#[derive(Debug, Clone, Default)] +pub struct Functions(HashMap); + +impl Functions { + pub(super) fn insert( + &mut self, + name: String, + function: parse::Function, + documentation: String, + ) { + self.0.insert(name, (function, documentation)); + } + + pub fn get(&self, name: &str) -> Option<(&parse::Function, &str)> { + self.0 + .get(name) + .map(|(function, documentation)| (function, documentation.as_str())) + } + + pub fn get_func(&self, name: &str) -> Option<&parse::Function> { + self.0.get(name).map(|(function, _)| function) + } + + pub fn iter(&self) -> impl Iterator { + self.0.values().map(|(function, _)| function) + } + + pub fn functions_and_docs(&self) -> Vec<(&parse::Function, &str)> { + self.0 + .values() + .map(|(function, documentation)| (function, documentation.as_str())) + .collect() + } +} + +/// A compiler source together with the editor identity and text used for LSP ranges. +#[derive(Debug)] +pub struct SourceDocument { + pub uri: Uri, + pub text: Rope, +} + +/// Stable file-id lookup for every source participating in one compiler analysis. +#[derive(Debug)] +pub struct SourceSet { + by_id: HashMap, +} + +impl SourceSet { + pub(super) fn root(uri: Uri, text: Rope) -> Self { + Self { + by_id: HashMap::from([(0, SourceDocument { uri, text })]), + } + } + + pub fn get(&self, file_id: usize) -> Option<&SourceDocument> { + self.by_id.get(&file_id) + } + + pub fn root_source(&self) -> &SourceDocument { + self.get(0).expect("every source set contains its root") + } + + #[cfg(test)] + pub fn len(&self) -> usize { + self.by_id.len() + } + + fn from_compiler(source_map: &SourceMap, root_uri: &Uri, root_text: &Rope) -> Self { + let by_id = source_map + .iter() + .map(|(path, file_id)| { + let (uri, text) = if *file_id == 0 { + (root_uri.clone(), root_text.clone()) + } else { + ( + Uri::from_file_path(path.as_path()) + .expect("compiler source path produces a valid file URI"), + Rope::from_str( + source_map + .content(*file_id) + .expect("compiler source map contains every registered file") + .as_ref(), + ), + ) + }; + (*file_id, SourceDocument { uri, text }) + }) + .collect(); + Self { by_id } + } +} + +impl Index for SourceSet { + type Output = SourceDocument; + + fn index(&self, index: usize) -> &Self::Output { + &self.by_id[&index] + } +} + +impl AnalysisSnapshot { + pub(super) fn populate_sources(&mut self, source_map: &SourceMap) { + let root_uri = self.sources.root_source().uri.clone(); + self.sources = SourceSet::from_compiler(source_map, &root_uri, &self.text); + } +} diff --git a/src/analysis/tests.rs b/src/analysis/tests.rs new file mode 100644 index 0000000..6d2b1b2 --- /dev/null +++ b/src/analysis/tests.rs @@ -0,0 +1,372 @@ +use super::*; +use crate::workspace::WorkspaceState; +use simplicityhl::error::Location as CompilerLocation; +use simplicityhl::UnstableFeatures; +use tempfile::TempDir; + +fn 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 analyze_in(temp: &TempDir, path: &Path, source: &str, settings: &Settings) -> AnalysisSnapshot { + AnalysisSnapshot::analyze(source, path, settings, &[temp.path().to_path_buf()]) +} + +#[test] +fn parse_failure_retains_the_exact_root_source() { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.path().join("broken.simf"); + let source = "// 😀\nfn broken() -> u32 "; + let snapshot = AnalysisSnapshot::analyze( + source, + &path, + &Settings::default(), + &[temp.path().to_path_buf()], + ); + let expected_uri = Uri::from_file_path(&path).unwrap(); + + assert_eq!(snapshot.text.to_string(), source); + assert_eq!(snapshot.sources.len(), 1); + assert_eq!(snapshot.sources[0].uri, expected_uri); + assert_eq!(snapshot.sources[0].text.to_string(), source); + assert!(snapshot + .compiler_diagnostics + .iter() + .any(|diagnostic| matches!(diagnostic.error(), CompilerError::Syntax { .. }))); +} + +#[test] +fn renamed_or_deleted_root_updates_analysis_with_a_diagnostic() { + let temp = tempfile::TempDir::new().unwrap(); + let source_directory = temp.path().join("simf"); + std::fs::create_dir(&source_directory).unwrap(); + std::fs::write(temp.path().join("Simplex.toml"), "").unwrap(); + let path = source_directory.join("verifier.simf"); + let renamed = source_directory.join("renamed.simf"); + let source = "fn main() {}\n"; + std::fs::write(&path, source).unwrap(); + let roots = [temp.path().to_path_buf()]; + let uri = Uri::from_file_path(&path).unwrap(); + + let mut workspace = WorkspaceState::default(); + let open = workspace.begin_open(&uri, source, Some(1)); + let initial = AnalysisSnapshot::analyze(source, &path, &Settings::default(), &roots); + assert!(initial.compiler_diagnostics.is_empty()); + workspace + .replace_if_current(&uri, initial, open.version, open.generation) + .expect("initial analysis update"); + + std::fs::rename(&path, &renamed).unwrap(); + let change = workspace + .begin_change(&uri, source, Some(2)) + .expect("renamed document remains open"); + let missing = AnalysisSnapshot::analyze(source, &path, &Settings::default(), &roots); + let updates = workspace + .replace_if_current(&uri, missing, change.version, change.generation) + .expect("missing source replaces stale analysis"); + let published = updates + .iter() + .find(|update| update.uri == uri) + .expect("root diagnostic update"); + + assert_eq!(published.version, Some(2)); + assert!(published.diagnostics.iter().any(|diagnostic| { + diagnostic + .message + .contains("Failed to find library target path") + && diagnostic.message.contains("verifier.simf") + })); + let current = workspace.get(&uri).expect("current missing-file snapshot"); + assert_eq!(current.text.to_string(), source); + assert_eq!(current.sources[0].uri, uri); + + let moved = AnalysisSnapshot::analyze(source, &renamed, &Settings::default(), &roots); + assert!(moved.compiler_diagnostics.is_empty()); + std::fs::remove_file(&renamed).unwrap(); + let deleted = AnalysisSnapshot::analyze(source, &renamed, &Settings::default(), &roots); + assert!(deleted.compiler_diagnostics.iter().any(|diagnostic| { + matches!( + diagnostic.error(), + CompilerError::CannotParse { msg } + if msg.contains("Failed to find library target path") + && msg.contains("renamed.simf") + ) + })); +} + +#[test] +fn transiently_missing_dependency_source_is_a_root_diagnostic() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path(); + std::fs::create_dir_all(root.join("simf")).unwrap(); + std::fs::create_dir_all(root.join("vendor/library/simf")).unwrap(); + std::fs::write( + root.join("Simplex.toml"), + "[dependencies]\nlibrary = { path = 'vendor/library' }\n", + ) + .unwrap(); + std::fs::write(root.join("vendor/library/Simplex.toml"), "").unwrap(); + std::fs::write( + root.join("vendor/library/simf/ops.simf"), + "pub fn verify() {}\n", + ) + .unwrap(); + let path = root.join("simf/main.simf"); + let source = "use library::ops::verify;\nfn main() { verify(); }\n"; + std::fs::write(&path, source).unwrap(); + let settings = Settings::from_json(serde_json::json!({ + "experimentalFeatures": { "imports": true } + })) + .unwrap(); + let roots = [root.to_path_buf()]; + + let initial = AnalysisSnapshot::analyze(source, &path, &settings, &roots); + assert!(initial.compiler_diagnostics.is_empty()); + let dependency_source = root.join("vendor/library/simf"); + std::fs::rename(&dependency_source, root.join("vendor/library/simf.moved")).unwrap(); + let missing = AnalysisSnapshot::analyze(source, &path, &settings, &roots); + assert_eq!(missing.text.to_string(), source); + assert_eq!(missing.sources[0].uri, Uri::from_file_path(&path).unwrap()); + let messages = missing + .compiler_diagnostics + .iter() + .map(ToString::to_string) + .collect::>(); + assert!( + missing.compiler_diagnostics.iter().any(|diagnostic| { + matches!( + diagnostic.error(), + CompilerError::CannotParse { msg } + if msg.contains("Unable to resolve") + && msg.contains("library") + && msg.contains("simf") + ) + }), + "unexpected diagnostics: {messages:?}" + ); +} + +#[test] +fn known_scope_does_not_fall_back_to_root_visible_functions() { + let source = "fn target() {}\nfn owner() { target(); }\n"; + let mut diagnostics = DiagnosticManager::new(); + let program = parse::Program::parse_from_str_with_errors( + 0, + source, + &UnstableFeatures::none(), + &mut diagnostics, + ) + .expect("valid program"); + let path = std::env::temp_dir().join("module-scope-test.simf"); + let mut snapshot = AnalysisSnapshot::from_program(&program, source, &path); + let owner = snapshot + .functions + .get_func("owner") + .expect("owner function") + .clone(); + + assert!(snapshot.resolve_custom_call(&owner, "target").is_some()); + snapshot + .call_scopes + .insert(*owner.span(), Arc::new(HashMap::new())); + assert!(snapshot.resolve_custom_call(&owner, "target").is_none()); +} + +#[test] +fn valid_program_collects_functions() { + let source = "fn add(a: u32, b: u32) -> u32 { let (_, sum): (bool, u32) = jet::add_32(a, b); sum }\nfn main() {}"; + let (temp, path) = temp_project(source); + let snapshot = analyze_in(&temp, &path, source, &Settings::default()); + + assert!( + snapshot.compiler_diagnostics.is_empty(), + "unexpected diagnostics: {:?}", + snapshot.compiler_diagnostics + ); + assert_eq!(snapshot.functions.iter().count(), 2); +} + +#[test] +fn library_without_main_keeps_definition_metadata() { + let source = "fn helper() {}\nfn caller() { helper() }\n"; + let (temp, path) = temp_project(source); + let snapshot = analyze_in(&temp, &path, source, &Settings::default()); + + assert!(snapshot.compiler_diagnostics.is_empty()); + let call_start = source.rfind("helper").expect("helper call"); + let call = snapshot + .find_related_call(Span::new(0, call_start + 1..call_start + 1)) + .expect("helper call should remain navigable"); + let function = snapshot + .functions + .get_func(&call.name().to_string()) + .expect("helper definition"); + assert_eq!(function.name().as_inner(), "helper"); + assert_eq!(function.span().file_id, 0); + assert_eq!(snapshot.sources[0].uri, Uri::from_file_path(path).unwrap()); +} + +#[test] +fn nested_main_does_not_conflict_with_the_synthetic_entry_point() { + let source = "mod nested { fn main() {} }\n"; + let (temp, path) = temp_project(source); + let settings = Settings::from_json(serde_json::json!({ + "experimentalFeatures": { "imports": true } + })) + .unwrap(); + let snapshot = analyze_in(&temp, &path, source, &settings); + + assert!(!snapshot.compiler_diagnostics.iter().any(|diagnostic| { + matches!( + diagnostic.error(), + CompilerError::FunctionRedefined { name } if name.as_inner() == "main" + ) + })); + let expected = CompilerError::MainOutOfEntryFile.to_string(); + assert!(snapshot.compiler_diagnostics.iter().any(|diagnostic| { + matches!(diagnostic.error(), CompilerError::CannotParse { msg } if msg == &expected) + })); +} + +#[test] +fn enum_analysis_respects_the_feature_setting() { + let source = "enum Choice { Yes, No, }\nfn main() {}\n"; + let (temp, path) = temp_project(source); + let disabled = analyze_in(&temp, &path, source, &Settings::default()); + assert!(disabled.compiler_diagnostics.iter().any(|diagnostic| { + matches!( + diagnostic.error(), + CompilerError::UnstableFeature { + feature: simplicityhl::UnstableFeature::Enums + } + ) + })); + + let settings = Settings::from_json(serde_json::json!({ + "experimentalFeatures": { "imports": false, "enums": true } + })) + .unwrap(); + let enabled = analyze_in(&temp, &path, source, &settings); + assert!(enabled.compiler_diagnostics.is_empty()); +} + +#[test] +fn invalid_ast_is_reported_without_discarding_the_snapshot() { + let source = "fn add(a: u32, b: u32) -> u32 {}\nfn main() {}"; + let (temp, path) = temp_project(source); + let snapshot = analyze_in(&temp, &path, source, &Settings::default()); + + assert!(snapshot.compiler_diagnostics.iter().any(|diagnostic| { + diagnostic + .to_string() + .contains("Expected expression of type `u32`, found type `()`") + })); + assert!(snapshot.functions.get_func("add").is_some()); +} + +#[test] +fn manifest_dependency_and_custom_source_directory_are_resolved() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + let write = |path: PathBuf, source: &str| { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, source).unwrap(); + }; + 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 { let (_, n): (bool, u32) = jet::add_32(a, a); n }\n", + ); + let source = "use math::ops::double;\nfn main() { let _: u32 = double(2); }\n"; + let path = root.join("contracts/main.simf"); + write(path.clone(), source); + let settings = Settings::from_json(serde_json::json!({ + "experimentalFeatures": { "imports": true } + })) + .unwrap(); + + let snapshot = AnalysisSnapshot::analyze(source, &path, &settings, &[root.to_path_buf()]); + assert!( + snapshot.compiler_diagnostics.is_empty(), + "unexpected diagnostics: {:?}", + snapshot.compiler_diagnostics + ); + assert!(snapshot.functions.get_func("double").is_some()); +} + +#[test] +fn duplicate_imported_mains_point_to_distinct_direct_and_transitive_uses() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + std::fs::write(root.join("Simplex.toml"), "").unwrap(); + std::fs::create_dir(root.join("simf")).unwrap(); + std::fs::write( + root.join("simf/first.simf"), + "pub fn first() {}\nfn main() {}\n", + ) + .unwrap(); + std::fs::write( + root.join("simf/facade.simf"), + "pub use crate::leaf::second;\n", + ) + .unwrap(); + std::fs::write( + root.join("simf/leaf.simf"), + "pub fn second() {}\nfn main() {}\n", + ) + .unwrap(); + let first_import = "use crate::first::first;"; + let second_import = "use crate::facade::second;"; + let source = format!("{first_import}\n{second_import}\nfn main() {{}}\n"); + let path = root.join("simf/main.simf"); + std::fs::write(&path, &source).unwrap(); + let settings = Settings::from_json(serde_json::json!({ + "experimentalFeatures": { "imports": true } + })) + .unwrap(); + + let snapshot = AnalysisSnapshot::analyze(&source, &path, &settings, &[root.to_path_buf()]); + let diagnostic = snapshot + .compiler_diagnostics + .iter() + .find(|diagnostic| { + matches!( + diagnostic.error(), + CompilerError::FunctionRedefined { name } if name.as_inner() == "main" + ) + }) + .expect("duplicate main diagnostic"); + let CompilerLocation::Code(span) = diagnostic.location() else { + panic!("duplicate main should point to source code"); + }; + assert_eq!(span.to_slice(&source), Some(first_import)); + assert!(diagnostic.secondary().iter().any(|label| { + label.span.to_slice(&source) == Some(second_import) + && label.message.contains("Another imported `main`") + })); +} + +#[test] +fn missing_configured_manifest_is_an_analysis_diagnostic() { + let source = "fn main() {}\n"; + let (temp, path) = temp_project(source); + let mut settings = Settings::default(); + settings.project.simplex.manifest_path = "nowhere/Simplex.toml".to_string(); + let snapshot = analyze_in(&temp, &path, source, &settings); + + assert!(snapshot + .compiler_diagnostics + .iter() + .any(|diagnostic| diagnostic + .to_string() + .contains("Simplex manifest was not found"))); +} diff --git a/src/backend.rs b/src/backend.rs deleted file mode 100644 index 4ca8474..0000000 --- a/src/backend.rs +++ /dev/null @@ -1,1915 +0,0 @@ -use serde_json::Value; - -use std::future::Future; -use std::path::PathBuf; -use std::str::FromStr; -use std::sync::Arc; -use tokio::sync::{Mutex, RwLock}; - -use tower_lsp_server::jsonrpc::Result; -use tower_lsp_server::lsp_types::{ - CompletionItem, CompletionOptions, CompletionParams, CompletionResponse, Diagnostic, - 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, SemanticTokens, SemanticTokensFullOptions, SemanticTokensOptions, - SemanticTokensParams, SemanticTokensResult, SemanticTokensServerCapabilities, - ServerCapabilities, SignatureHelp, SignatureHelpOptions, SignatureHelpParams, SymbolKind, - TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions, - TextDocumentSyncSaveOptions, Uri, WorkDoneProgressOptions, WorkspaceFoldersServerCapabilities, - WorkspaceServerCapabilities, -}; -use tower_lsp_server::{Client, LanguageServer, UriExt}; - -use simplicityhl::parse; - -use crate::analysis::AnalysisSnapshot; -use crate::completion::{self, CompletionProvider}; -use crate::config::Settings; -use crate::error::LspError; -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, position_to_offset, position_to_span, span_contains, span_to_positions, -}; -use crate::workspace::{AnalysisInput, DiagnosticUpdate, WorkspaceState}; - -/// 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 -} - -/// 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, - - /// Whether the client supports server-requested file watchers. - watched_files_registration: bool, -} - -#[derive(Debug, Default)] -struct DiagnosticTransaction { - gate: Mutex<()>, -} - -impl DiagnosticTransaction { - async fn run(&self, workspace: &RwLock, transition: F, publish: P) - where - F: FnOnce(&mut WorkspaceState) -> Option>, - P: FnOnce(Vec) -> Fut, - Fut: Future, - { - let _transaction = self.gate.lock().await; - let updates = { - let mut workspace = workspace.write().await; - transition(&mut workspace) - }; - if let Some(updates) = updates { - publish(updates).await; - } - } -} - -#[derive(Debug)] -pub struct Backend { - client: Client, - - workspace: Arc>, - - /// Serializes each workspace diagnostic transition with its complete publication batch. - diagnostic_transaction: DiagnosticTransaction, - - config: Arc>, - - completion_provider: CompletionProvider, -} - -impl LanguageServer for Backend { - async fn initialize(&self, params: InitializeParams) -> Result { - let workspace_roots = workspace_roots(¶ms); - let watched_files_registration = params - .capabilities - .workspace - .as_ref() - .and_then(|workspace| workspace.did_change_watched_files.as_ref()) - .and_then(|capability| capability.dynamic_registration) - .unwrap_or(false); - 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.watched_files_registration = watched_files_registration; - config.settings = settings; - } - - Ok(InitializeResult { - server_info: None, - capabilities: ServerCapabilities { - text_document_sync: Some(TextDocumentSyncCapability::Options( - TextDocumentSyncOptions { - open_close: Some(true), - change: Some(TextDocumentSyncKind::FULL), - save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions { - include_text: Some(true), - })), - ..Default::default() - }, - )), - completion_provider: Some(CompletionOptions { - resolve_provider: Some(false), - // `:`, 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, - }), - workspace: Some(WorkspaceServerCapabilities { - workspace_folders: Some(WorkspaceFoldersServerCapabilities { - supported: Some(true), - change_notifications: Some(OneOf::Left(true)), - }), - file_operations: None, - }), - hover_provider: Some(HoverProviderCapability::Simple(true)), - definition_provider: Some(OneOf::Left(true)), - references_provider: Some(OneOf::Left(true)), - document_symbol_provider: Some(OneOf::Left(true)), - signature_help_provider: Some(SignatureHelpOptions { - trigger_characters: Some(vec!["(".to_string(), ",".to_string()]), - retrigger_characters: Some(vec![",".to_string()]), - work_done_progress_options: WorkDoneProgressOptions::default(), - }), - semantic_tokens_provider: Some( - SemanticTokensServerCapabilities::SemanticTokensOptions( - SemanticTokensOptions { - work_done_progress_options: WorkDoneProgressOptions::default(), - legend: crate::semantic_tokens::legend(), - range: Some(false), - full: Some(SemanticTokensFullOptions::Bool(true)), - }, - ), - ), - ..ServerCapabilities::default() - }, - }) - } - - async fn initialized(&self, _: InitializedParams) { - if !self.config.read().await.watched_files_registration { - return; - } - - let watchers = ["**/*.simf", "**/Simplex.toml", "**/simplex.toml"] - .into_iter() - .map(|glob| FileSystemWatcher { - glob_pattern: GlobPattern::String(glob.to_string()), - kind: None, - }) - .collect(); - let registration = Registration { - id: "simplicityhl-lsp-watched-files".to_string(), - method: "workspace/didChangeWatchedFiles".to_string(), - register_options: serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { - watchers, - }) - .ok(), - }; - if let Err(error) = self.client.register_capability(vec![registration]).await { - self.client - .log_message( - MessageType::WARNING, - format!("Unable to register file watchers: {error}"), - ) - .await; - } - } - - async fn shutdown(&self) -> Result<()> { - Ok(()) - } - - 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, 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, 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) - } - - async fn did_open(&self, params: DidOpenTextDocumentParams) { - if params.text_document.uri.to_file_path().is_none() { - return; - } - let input = self.workspace.write().await.begin_open( - ¶ms.text_document.uri, - ¶ms.text_document.text, - Some(params.text_document.version), - ); - self.on_change(input).await; - } - - 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; - }; - let Some(input) = self.workspace.write().await.begin_change( - ¶ms.text_document.uri, - &change.text, - Some(params.text_document.version), - ) else { - return; - }; - self.on_change(input).await; - } - - async fn did_save(&self, params: DidSaveTextDocumentParams) { - if let Some(text) = params.text { - let Some(input) = - self.workspace - .write() - .await - .begin_change(¶ms.text_document.uri, &text, None) - else { - return; - }; - self.on_change(input).await; - } - } - - async fn did_close(&self, params: DidCloseTextDocumentParams) { - let uri = params.text_document.uri; - let Some(generation) = self.workspace.write().await.begin_close(&uri) else { - return; - }; - // 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.run_diagnostic_transaction(|workspace| workspace.remove_if_current(&uri, generation)) - .await; - } - - async fn semantic_tokens_full( - &self, - params: SemanticTokensParams, - ) -> Result> { - let uri = ¶ms.text_document.uri; - - // .wit files don't have semantic tokens - if std::path::Path::new(uri.path().as_str()) - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("wit")) - { - return Ok(None); - } - - let documents = self.workspace.read().await; - - // Return None if document not found (e.g., file has parse errors) - let Some(doc) = documents.get(uri) else { - return Ok(None); - }; - - Ok(Some(SemanticTokensResult::Tokens(SemanticTokens { - result_id: None, - data: crate::semantic_tokens::tokens(doc), - }))) - } - - async fn document_symbol( - &self, - params: DocumentSymbolParams, - ) -> Result> { - let uri = ¶ms.text_document.uri; - - // .wit files don't have symbols - if std::path::Path::new(uri.path().as_str()) - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("wit")) - { - return Ok(None); - } - - let documents = self.workspace.read().await; - - // Return None if document not found (e.g., file has parse errors) - let Some(doc) = documents.get(uri) else { - return Ok(None); - }; - - let functions = doc.functions.functions(); - - let symbols: Vec = functions - .iter() - .filter_map(|func| { - if func.span().file_id != 0 { - return None; - } - - // Get the full function range - let (start, end) = span_to_positions(func.span(), &doc.text).ok()?; - let full_range = Range { start, end }; - - // Get the function name range for selection - let selection_range = doc.find_function_name_range(func).ok()?; - - // Build parameters detail string - let params_str = func - .params() - .iter() - .map(|p| format!("{p}")) - .collect::>() - .join(", "); - - let return_type = match func.ret() { - Some(ret) => format!("{ret}"), - None => "()".to_string(), - }; - - #[allow(deprecated)] - Some(DocumentSymbol { - name: func.name().to_string(), - detail: Some(format!("fn({params_str}) -> {return_type}")), - kind: SymbolKind::FUNCTION, - tags: None, - range: full_range, - selection_range, - children: None, - deprecated: None, - }) - }) - .collect(); - - Ok(Some(DocumentSymbolResponse::Nested(symbols))) - } - - async fn signature_help(&self, params: SignatureHelpParams) -> Result> { - let documents = self.workspace.read().await; - let uri = ¶ms.text_document_position_params.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 token_pos = params.text_document_position_params.position; - - // Get the current line up to cursor position - let line = doc - .text - .lines() - .nth(token_pos.line as usize) - .ok_or(LspError::Internal("Line not found".into()))?; - - let line_str = line - .get_slice(..token_pos.character as usize) - .map(|s| s.to_string()) - .unwrap_or_default(); - - // Find function call context: look for unclosed '(' and count commas - let Some((func_name, active_param)) = find_function_call_context(&line_str) else { - return Ok(None); - }; - - // Try to find the function signature - let signature_info = if func_name.starts_with("jet::") { - // It's a jet function - let jet_name = func_name.strip_prefix("jet::").unwrap_or(&func_name); - match simplicityhl::simplicity::jet::Elements::from_str(jet_name) { - Ok(element) => { - let template = completion::jet::jet_to_template(element); - Some(create_signature_info(&template)) - } - Err(_) => None, - } - } else if let Some((function, function_doc)) = doc.functions.get(&func_name) { - // It's a custom function - let template = completion::function_to_template(function, function_doc); - Some(create_signature_info(&template)) - } else { - // Try builtin functions - find_builtin_signature(&func_name) - }; - - match signature_info { - Some(sig) => Ok(Some(SignatureHelp { - signatures: vec![sig], - active_signature: Some(0), - active_parameter: Some(active_param), - })), - None => Ok(None), - } - } - - async fn completion(&self, params: CompletionParams) -> Result> { - let uri = ¶ms.text_document_position.text_document.uri; - let pos = params.text_document_position.position; - let (source_prefix, functions) = { - let documents = self.workspace.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()) - }; - - if let Some(context) = ImportCompletionContext::at(&source_prefix, source_prefix.len()) { - return Ok(self - .import_completion(uri, &source_prefix, &context) - .await - .map(CompletionResponse::Array)); - } - - // 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, &functions.functions_and_docs()) - .map(CompletionResponse::Array); - - Ok(completions) - } - - async fn hover(&self, params: HoverParams) -> Result> { - let uri = ¶ms.text_document_position_params.text_document.uri; - - // .wit files don't have hover info - if std::path::Path::new(uri.path().as_str()) - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("wit")) - { - return Ok(None); - } - - let documents = self.workspace.read().await; - - // Return None if document not found (e.g., file has parse errors) - let Some(doc) = documents.get(uri) else { - return Ok(None); - }; - - let token_pos = params.text_document_position_params.position; - - let token_span = position_to_span(token_pos, &doc.text)?; - let Some(call) = doc.find_related_call(token_span) else { - return Ok(None); - }; - - 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 Ok(element) = - simplicityhl::simplicity::jet::Elements::from_str(format!("{jet}").as_str()) - else { - return Ok(None); - }; - - let template = completion::jet::jet_to_template(element); - format!( - "Jet function\n```simplicityhl\nfn {}({}) -> {}\n```\n---\n\n{}", - template.display_name, - template.args.join(", "), - template.return_type, - template.description - ) - } - parse::CallName::Custom(func) => { - 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!( - "```simplicityhl\nfn {}({}) -> {}\n```\n---\n{}", - template.display_name, - template.args.join(", "), - template.return_type, - template.description - ) - } - other => { - let Some(template) = completion::builtin::match_callname(other) else { - return Ok(None); - }; - format!( - "Built-in function\n```simplicityhl\nfn {}({}) -> {}\n```\n---\n{}", - template.display_name, - template.args.join(", "), - template.return_type, - template.description - ) - } - }; - - Ok(Some(Hover { - contents: tower_lsp_server::lsp_types::HoverContents::Markup(MarkupContent { - kind: MarkupKind::Markdown, - value: description, - }), - range: Some(Range { start, end }), - })) - } - - async fn goto_definition( - &self, - params: GotoDefinitionParams, - ) -> Result> { - let documents = self.workspace.read().await; - let uri = ¶ms.text_document_position_params.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 functions = doc.functions.functions(); - - let token_position = params.text_document_position_params.position; - let token_span = position_to_span(token_position, &doc.text)?; - - if let Some(function) = doc.find_imported_function(token_span) { - let Some(source_file) = doc.sources.get(function.span().file_id) else { - return Ok(None); - }; - let (start, end) = span_to_positions(function.as_ref(), &source_file.text)?; - - return Ok(Some(GotoDefinitionResponse::from(Location::new( - source_file.uri.clone(), - Range::new(start, end), - )))); - } - - let Some(call) = doc.find_related_call(token_span) else { - let Some(func) = functions - .iter() - .find(|func| span_contains(func.span(), &token_span)) - else { - return Ok(None); - }; - let range = doc.find_function_name_range(func)?; - - if token_position <= range.end && token_position >= range.start { - return Ok(Some(GotoDefinitionResponse::from(Location::new( - uri.clone(), - range, - )))); - } - return Ok(None); - }; - - match call.name() { - simplicityhl::parse::CallName::Custom(func) => { - let Some(function) = doc.functions.get_func(func.as_inner()) else { - return Ok(None); - }; - - let Some(source_file) = doc.sources.get(function.span().file_id) else { - return Ok(None); - }; - - let (start, end) = span_to_positions(function.as_ref(), &source_file.text)?; - - Ok(Some(GotoDefinitionResponse::from(Location::new( - source_file.uri.clone(), - Range::new(start, end), - )))) - } - _ => Ok(None), - } - } - - async fn references(&self, params: ReferenceParams) -> Result>> { - let documents = self.workspace.read().await; - let uri = ¶ms.text_document_position.text_document.uri; - - let Some(doc) = documents.get(uri) else { - return Ok(None); - }; - let functions = doc.functions.functions(); - - let token_position = params.text_document_position.position; - - let token_span = position_to_span(token_position, &doc.text)?; - - let call_name = doc - .find_related_call(token_span) - .map(simplicityhl::parse::Call::name); - - match call_name { - Some(parse::CallName::Custom(_)) | None => {} - Some(name) => { - return Ok(Some(doc.find_all_references(name)?)); - } - } - - let Some(func) = (match call_name { - Some(parse::CallName::Custom(name)) => doc.functions.get_func(name.as_inner()), - _ => functions - .iter() - .find(|func| span_contains(func.span(), &token_span)) - .copied(), - }) else { - return 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); - } - } - - let Some(identity) = doc.function_identity(func) else { - return Ok(None); - }; - - Ok(Some(documents.find_references_to(&identity))) - } -} - -impl Backend { - pub fn new(client: Client) -> Self { - Self { - client, - workspace: Arc::new(RwLock::new(WorkspaceState::default())), - diagnostic_transaction: DiagnosticTransaction::default(), - config: Arc::new(RwLock::new(ServerConfig::default())), - completion_provider: CompletionProvider::new(), - } - } - - async fn run_diagnostic_transaction(&self, transition: F) - where - F: FnOnce(&mut WorkspaceState) -> Option>, - { - self.diagnostic_transaction - .run(&self.workspace, transition, |updates| { - self.publish_diagnostic_updates(updates) - }) - .await; - } - - async fn publish_diagnostic_updates(&self, updates: Vec) { - for update in updates { - self.client - .publish_diagnostics(update.uri, update.diagnostics, update.version) - .await; - } - } - - 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) { - let documents = self.workspace.write().await.begin_reanalysis(); - for input in documents { - self.on_change(input).await; - } - } - - /// Function which executed on change of file (`did_save`, `did_open` or `did_change` methods) - async fn on_change(&self, params: AnalysisInput) { - let Some(path_buf) = params.uri.to_file_path() else { - return; - }; - let path = path_buf.as_ref(); - - // Check if this is a witness file - if path - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("wit")) - { - self.on_change_witness(params).await; - return; - } - - let (settings, workspace_roots) = { - let config = self.config.read().await; - (config.settings.clone(), config.workspace_roots.clone()) - }; - let snapshot = AnalysisSnapshot::analyze(¶ms.text, path, &settings, &workspace_roots); - self.run_diagnostic_transaction(|workspace| { - workspace.replace_if_current(¶ms.uri, snapshot, params.version, params.generation) - }) - .await; - } - - /// Validate witness (.wit) files - async fn on_change_witness(&self, params: AnalysisInput) { - let diagnostics = validate_witness_file(¶ms.text); - self.run_diagnostic_transaction(|workspace| { - workspace.diagnostics_if_current( - params.uri.clone(), - diagnostics, - params.version, - params.generation, - ) - }) - .await; - } -} - -/// Validate a witness (.wit) file and return diagnostics. -fn validate_witness_file(text: &str) -> Vec { - let mut diagnostics = Vec::new(); - - let json: serde_json::Value = match serde_json::from_str(text) { - Ok(v) => v, - Err(e) => { - 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}"), - )); - 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; - }; - - for (name, value) in obj { - 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; - }; - - 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 '{name}' is missing required 'value' 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 '{name}' is missing required 'type' field"), - )); - } - } - } - - diagnostics -} - -#[cfg(test)] -mod tests { - use std::path::Path; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::{Arc as StdArc, Mutex as StdMutex}; - - use ropey::Rope; - use simplicityhl::error::{ - Diagnostic as CompilerDiagnostic, DiagnosticManager, Error, Location as CompilerLocation, - Span, - }; - use simplicityhl::parse::ParseFromStrWithErrors; - use simplicityhl::UnstableFeatures; - use tempfile::TempDir; - use tokio::sync::Notify; - use tower_lsp_server::lsp_types::SemanticToken; - - use super::*; - use crate::utils::offset_to_position; - - /// `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) - } - - const IMPORTING_ROOT: &str = "use crate::shared::broken;\nfn main() { broken(); }\n"; - - struct DependencyProject { - temp: TempDir, - root_path: PathBuf, - dependency_path: PathBuf, - root_uri: Uri, - dependency_uri: Uri, - settings: Settings, - } - - impl DependencyProject { - fn new(dependency_source: &str) -> Self { - 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 dependency_path = temp.path().join("simf/shared.simf"); - let root_path = temp.path().join("simf/main.simf"); - std::fs::write(&dependency_path, dependency_source).expect("write dependency"); - std::fs::write(&root_path, IMPORTING_ROOT).expect("write root"); - let root_uri = Uri::from_file_path( - std::fs::canonicalize(&root_path).expect("canonical root path"), - ) - .expect("root URI"); - let dependency_uri = Uri::from_file_path( - std::fs::canonicalize(&dependency_path).expect("canonical dependency path"), - ) - .expect("dependency URI"); - let settings = Settings::from_json(serde_json::json!({ - "experimentalFeatures": { "imports": true } - })) - .expect("valid settings"); - Self { - temp, - root_path, - dependency_path, - root_uri, - dependency_uri, - settings, - } - } - - fn root_snapshot(&self) -> AnalysisSnapshot { - AnalysisSnapshot::analyze( - IMPORTING_ROOT, - &self.root_path, - &self.settings, - &[self.temp.path().to_path_buf()], - ) - } - - fn dependency_snapshot(&self, source: &str) -> AnalysisSnapshot { - AnalysisSnapshot::analyze( - source, - &self.dependency_path, - &self.settings, - &[self.temp.path().to_path_buf()], - ) - } - - fn write_dependency(&self, source: &str) { - std::fs::write(&self.dependency_path, source).expect("write dependency"); - } - } - - fn diagnostic_count(updates: &[DiagnosticUpdate], uri: &Uri) -> usize { - updates - .iter() - .find(|update| &update.uri == uri) - .expect("diagnostic update") - .diagnostics - .len() - } - - fn record_count( - events: &StdMutex>, - label: &'static str, - updates: &[DiagnosticUpdate], - uri: &Uri, - ) { - events - .lock() - .expect("event lock") - .push((label, diagnostic_count(updates, uri))); - } - - 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 main() {}" - } - - fn invalid_program_on_parsing() -> &'static str { - "fn add(a: u32, b: u32) -> u32 " - } - - type RawSemanticToken = (u32, u32, u32, u32, u32); - const FUNCTION_TOKEN: u32 = 0; - const NAMESPACE_TOKEN: u32 = 5; - - fn parse_program( - source: &str, - path: &Path, - settings: &Settings, - workspace_roots: &[PathBuf], - ) -> (Vec, Option) { - let snapshot = AnalysisSnapshot::analyze(source, path, settings, workspace_roots); - let mut syntax_diagnostics = DiagnosticManager::new(); - let parsed = parse::Program::parse_from_str_with_errors( - 0, - source, - &settings.unstable_features(), - &mut syntax_diagnostics, - ) - .is_some(); - let diagnostics = snapshot.compiler_diagnostics.clone(); - (diagnostics, parsed.then_some(snapshot)) - } - - fn document_from_source(source: &str) -> AnalysisSnapshot { - let mut diagnostics = DiagnosticManager::new(); - let program = parse::Program::parse_from_str_with_errors( - 0, - source, - &UnstableFeatures::none(), - &mut diagnostics, - ) - .unwrap_or_else(|| panic!("source should parse: {diagnostics:?}")); - let path = std::env::temp_dir().join("semantic_tokens.simf"); - AnalysisSnapshot::from_program(&program, source, &path) - } - - #[tokio::test] - #[allow(clippy::too_many_lines)] - async fn diagnostic_transactions_do_not_interleave_state_and_publication() { - let project = DependencyProject::new("pub fn broken() -> u32 { false }\n"); - let old_snapshot = project.root_snapshot(); - project.write_dependency("pub fn broken() -> u32 { 0 }\n"); - let new_snapshot = project.root_snapshot(); - let root_uri = project.root_uri.clone(); - let dependency_uri = project.dependency_uri.clone(); - - let transaction = StdArc::new(DiagnosticTransaction::default()); - let workspace = StdArc::new(RwLock::new(WorkspaceState::default())); - let old_input = workspace - .write() - .await - .begin_open(&root_uri, IMPORTING_ROOT, Some(1)); - let first_started = StdArc::new(Notify::new()); - let release_first = StdArc::new(Notify::new()); - let second_started = StdArc::new(Notify::new()); - let second_transition_ran = StdArc::new(AtomicBool::new(false)); - let events = StdArc::new(StdMutex::new(Vec::new())); - - let first_wait = first_started.notified(); - let first = { - let transaction = StdArc::clone(&transaction); - let workspace = StdArc::clone(&workspace); - let first_started = StdArc::clone(&first_started); - let release_first = StdArc::clone(&release_first); - let events = StdArc::clone(&events); - let root_uri = root_uri.clone(); - let dependency_uri = dependency_uri.clone(); - tokio::spawn(async move { - transaction - .run( - &workspace, - |workspace| { - workspace.replace_if_current( - &root_uri, - old_snapshot, - old_input.version, - old_input.generation, - ) - }, - |updates| async move { - record_count( - &events, - "first publication started", - &updates, - &dependency_uri, - ); - first_started.notify_one(); - release_first.notified().await; - record_count( - &events, - "first publication finished", - &updates, - &dependency_uri, - ); - }, - ) - .await; - }) - }; - first_wait.await; - let new_input = workspace - .write() - .await - .begin_change(&root_uri, IMPORTING_ROOT, Some(2)) - .expect("new analysis ticket"); - - let second_wait = second_started.notified(); - let second = { - let transaction = StdArc::clone(&transaction); - let workspace = StdArc::clone(&workspace); - let second_started = StdArc::clone(&second_started); - let second_transition_ran = StdArc::clone(&second_transition_ran); - let events = StdArc::clone(&events); - let root_uri = root_uri.clone(); - let dependency_uri = dependency_uri.clone(); - tokio::spawn(async move { - second_started.notify_one(); - transaction - .run( - &workspace, - |workspace| { - second_transition_ran.store(true, Ordering::SeqCst); - workspace.replace_if_current( - &root_uri, - new_snapshot, - new_input.version, - new_input.generation, - ) - }, - |updates| async move { - record_count(&events, "second publication", &updates, &dependency_uri); - }, - ) - .await; - }) - }; - - second_wait.await; - tokio::task::yield_now().await; - assert!(!second_transition_ran.load(Ordering::SeqCst)); - - release_first.notify_one(); - first.await.expect("first transaction"); - second.await.expect("second transaction"); - assert_eq!( - *events.lock().expect("event lock"), - [ - ("first publication started", 1), - ("first publication finished", 1), - ("second publication", 0) - ] - ); - - let workspace = workspace.read().await; - let final_snapshot = workspace.get(&root_uri).expect("latest root snapshot"); - let final_diagnostics = crate::diagnostics::DiagnosticBundle::from_snapshot(final_snapshot); - assert!(final_diagnostics.get(&dependency_uri).is_none()); - } - - #[tokio::test] - async fn closing_document_restores_dependency_diagnostics_through_transaction() { - let broken_dependency = "pub fn broken() -> u32 { false }\n"; - let clean_dependency = "pub fn broken() -> u32 { 0 }\n"; - let project = DependencyProject::new(broken_dependency); - let root_snapshot = project.root_snapshot(); - let clean_snapshot = project.dependency_snapshot(clean_dependency); - let root_uri = project.root_uri.clone(); - let dependency_uri = project.dependency_uri.clone(); - - let transaction = DiagnosticTransaction::default(); - let workspace = RwLock::new(WorkspaceState::default()); - let root_input = workspace - .write() - .await - .begin_open(&root_uri, IMPORTING_ROOT, Some(1)); - workspace - .write() - .await - .replace_if_current( - &root_uri, - root_snapshot, - root_input.version, - root_input.generation, - ) - .expect("root diagnostic update"); - let open = workspace - .write() - .await - .begin_open(&dependency_uri, clean_dependency, Some(7)); - let publications = StdArc::new(StdMutex::new(Vec::new())); - - transaction - .run( - &workspace, - |workspace| { - workspace.replace_if_current( - &dependency_uri, - clean_snapshot, - open.version, - open.generation, - ) - }, - { - let publications = StdArc::clone(&publications); - let dependency_uri = dependency_uri.clone(); - move |updates| async move { - let update = updates - .iter() - .find(|update| update.uri == dependency_uri) - .expect("direct dependency update"); - publications - .lock() - .expect("publication lock") - .push((update.diagnostics.len(), update.version)); - } - }, - ) - .await; - - let close_generation = workspace - .write() - .await - .begin_close(&dependency_uri) - .expect("close ticket"); - transaction - .run( - &workspace, - |workspace| workspace.remove_if_current(&dependency_uri, close_generation), - { - let publications = StdArc::clone(&publications); - let dependency_uri = dependency_uri.clone(); - move |updates| async move { - let update = updates - .iter() - .find(|update| update.uri == dependency_uri) - .expect("restored dependency update"); - publications - .lock() - .expect("publication lock") - .push((update.diagnostics.len(), update.version)); - } - }, - ) - .await; - - assert_eq!( - *publications.lock().expect("publication lock"), - [(0, Some(7)), (1, None)] - ); - } - - fn decode_semantic_tokens(tokens: &[SemanticToken]) -> Vec { - let mut line = 0; - let mut character = 0; - tokens - .iter() - .map(|token| { - line += token.delta_line; - character = if token.delta_line == 0 { - character + token.delta_start - } else { - token.delta_start - }; - ( - line, - character, - token.length, - token.token_type, - token.token_modifiers_bitset, - ) - }) - .collect() - } - - fn expected_token( - source: &str, - offset: usize, - text: &str, - token_type: u32, - ) -> RawSemanticToken { - let rope = Rope::from_str(source); - let start = offset_to_position(offset, &rope).expect("valid token start"); - let end = offset_to_position(offset + text.len(), &rope).expect("valid token end"); - ( - start.line, - start.character, - end.character - start.character, - token_type, - 0, - ) - } - - #[test] - fn semantic_tokens_split_generic_callable_components() { - let source = "// 😀 keeps UTF-16 columns honest\nfn consume_budget(acc: u32, item: u32) -> u32 { acc }\nfn main() { array_fold::(witness::PADDING, true); }\n"; - let document = document_from_source(source); - let decoded = decode_semantic_tokens(&crate::semantic_tokens::tokens(&document)); - let builtin_offset = source.find("array_fold").expect("array_fold call"); - let callback_offset = source.rfind("consume_budget").expect("callback argument"); - - assert!(decoded.contains(&expected_token( - source, - builtin_offset, - "array_fold", - FUNCTION_TOKEN, - ))); - assert!(decoded.contains(&expected_token( - source, - callback_offset, - "consume_budget", - FUNCTION_TOKEN, - ))); - - let bound_offset = source.find("320").expect("array bound"); - let bound_position = offset_to_position(bound_offset, &document.text).unwrap(); - assert!(!decoded.iter().any(|token| { - token.0 == bound_position.line - && token.1 <= bound_position.character - && token.1 + token.2 > bound_position.character - })); - } - - #[test] - fn semantic_tokens_bound_each_builtin_to_its_identifier() { - let source = "fn step(acc: u32, item: u32) -> u32 { acc }\nfn main() {\nfold::(0, 0);\nfor_while::(0);\nunwrap_left::(0);\n::into(0);\njet::add_32(0, 0);\nassert!(true);\n}\n"; - let document = document_from_source(source); - let decoded = decode_semantic_tokens(&crate::semantic_tokens::tokens(&document)); - - for name in ["fold", "for_while", "unwrap_left", "into", "assert!"] { - let offset = source - .find(name) - .unwrap_or_else(|| panic!("missing {name}")); - assert!(decoded.contains(&expected_token(source, offset, name, FUNCTION_TOKEN,))); - } - - let callback_offsets = [ - source.find("step, 2").unwrap(), - source.rfind("step").unwrap(), - ]; - for offset in callback_offsets { - assert!(decoded.contains(&expected_token(source, offset, "step", FUNCTION_TOKEN,))); - } - - let jet_offset = source.find("jet::add_32").unwrap(); - assert!(decoded.contains(&expected_token(source, jet_offset, "jet", NAMESPACE_TOKEN,))); - assert!(decoded.contains(&expected_token( - source, - jet_offset + "jet::".len(), - "add_32", - FUNCTION_TOKEN, - ))); - } - - #[test] - fn test_parse_program_valid() { - 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); - } - - #[test] - fn library_file_without_main_keeps_definition_metadata() { - let source = "fn helper() {}\nfn caller() { helper() }\n"; - 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("library document"); - let call_start = source.rfind("helper").expect("helper call"); - let call = doc - .find_related_call(Span::new(0, call_start + 1..call_start + 1)) - .expect("helper call should remain navigable"); - let function = doc - .functions - .get_func(call.name().to_string().as_str()) - .expect("helper definition"); - let source_file = doc - .sources - .get(function.span().file_id) - .expect("current file should always have source metadata"); - - assert_eq!(function.name().as_inner(), "helper"); - assert_eq!(function.span().file_id, 0); - assert_eq!( - source_file.uri, - Uri::from_file_path(std::fs::canonicalize(&path).expect("canonical path")) - .expect("file URI") - ); - } - - #[test] - fn nested_main_does_not_conflict_with_a_synthetic_entry_point() { - let source = "mod nested { fn main() {} }\n"; - let (temp, path) = in_temp_project(source); - let settings = Settings::from_json(serde_json::json!({ - "experimentalFeatures": { "imports": true } - })) - .expect("valid settings"); - - let (errors, doc) = parse_program(source, &path, &settings, &[temp.path().to_path_buf()]); - - assert!( - doc.is_some(), - "the source should remain available to the LSP" - ); - assert!( - !errors.iter().any(|diagnostic| { - matches!( - diagnostic.error(), - Error::FunctionRedefined { name } if name.as_inner() == "main" - ) - }), - "a nested main must not collide with an injected main: {errors:?}" - ); - let expected = Error::MainOutOfEntryFile.to_string(); - assert!( - errors.iter().any(|diagnostic| { - matches!(diagnostic.error(), Error::CannotParse { msg } if msg == &expected) - }), - "the compiler should still report that main is outside the entry scope: {errors:?}" - ); - } - - #[test] - fn use_items_and_aliases_resolve_to_the_imported_definition() { - 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"); - let dependency_path = root.join("simf/math.simf"); - std::fs::write(&dependency_path, "pub fn add() {}\npub fn subtract() {}\n") - .expect("write module"); - let source = - "use crate::math::{add as plus, subtract};\nfn main() { plus(); subtract() }\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, doc) = parse_program(source, &path, &settings, &[root.to_path_buf()]); - assert!(errors.is_empty(), "unexpected errors: {errors:?}"); - let doc = doc.expect("document"); - let imported_at = |needle: &str| { - let start = source.find(needle).expect("import token"); - doc.find_imported_function(Span::new(0, start + 1..start + 1)) - .expect("imported function") - }; - - let original = imported_at("add as"); - let alias = imported_at("plus,"); - let grouped_item = imported_at("subtract}"); - assert_eq!(original.name().as_inner(), "add"); - assert_eq!(alias.name().as_inner(), "add"); - assert_eq!(grouped_item.name().as_inner(), "subtract"); - assert!(doc - .find_imported_function(Span::new( - 0, - source.find("math").unwrap() + 1..source.find("math").unwrap() + 1, - )) - .is_none()); - - let source_file = doc - .sources - .get(original.span().file_id) - .expect("imported source metadata"); - assert_eq!( - source_file.uri, - Uri::from_file_path( - std::fs::canonicalize(&dependency_path).expect("canonical dependency path"), - ) - .expect("file URI") - ); - } - - #[test] - fn nested_and_transitive_reexports_resolve_to_original_definitions() { - 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"), - "[dependencies]\nmerkle = { path = 'deps/merkle' }\nfacade = { path = 'deps/facade' }\n", - ); - write(root.join("deps/merkle/Simplex.toml"), ""); - let merkle_path = root.join("deps/merkle/simf/build_root.simf"); - write( - merkle_path.clone(), - "pub mod wrapper {\n pub fn get_root() {}\n pub fn hash() {}\n}\npub use crate::wrapper::{get_root, hash};\n", - ); - write( - root.join("deps/facade/Simplex.toml"), - "[dependencies]\nleaf = { path = '../leaf' }\n", - ); - write( - root.join("deps/facade/simf/smth.simf"), - "pub use leaf::ops::hash;\n", - ); - write(root.join("deps/leaf/Simplex.toml"), ""); - let leaf_path = root.join("deps/leaf/simf/ops.simf"); - write(leaf_path.clone(), "pub fn hash() {}\n"); - - let source = "use merkle::build_root::{get_root, hash as and_hash};\nuse facade::smth::hash as or_hash;\nfn main() { get_root(); and_hash(); or_hash(); }\n"; - let path = root.join("simf/main.simf"); - write(path.clone(), source); - let settings = Settings::from_json(serde_json::json!({ - "experimentalFeatures": { "imports": true } - })) - .expect("valid settings"); - - let (errors, doc) = parse_program(source, &path, &settings, &[root.to_path_buf()]); - assert!(errors.is_empty(), "unexpected errors: {errors:?}"); - let doc = doc.expect("document"); - let imported_at = |offset: usize| { - doc.find_imported_function(Span::new(0, offset + 1..offset + 1)) - .expect("imported function") - }; - - let nested = imported_at(source.find("get_root").expect("nested import")); - let nested_alias = imported_at(source.find("and_hash").expect("nested alias")); - let transitive = imported_at(source.rfind("hash as").expect("transitive import")); - let transitive_alias = imported_at(source.find("or_hash").expect("transitive alias")); - - assert_eq!(nested.name().as_inner(), "get_root"); - assert_eq!(nested_alias.name().as_inner(), "hash"); - assert_eq!(transitive.name().as_inner(), "hash"); - assert_eq!(transitive_alias.name().as_inner(), "hash"); - - let definition_uri = |function: &parse::Function| &doc.sources[function.span().file_id].uri; - let merkle_uri = - Uri::from_file_path(std::fs::canonicalize(merkle_path).expect("canonical merkle path")) - .expect("merkle URI"); - let leaf_uri = - Uri::from_file_path(std::fs::canonicalize(leaf_path).expect("canonical leaf path")) - .expect("leaf URI"); - assert_eq!(definition_uri(nested), &merkle_uri); - assert_eq!(definition_uri(nested_alias), &merkle_uri); - assert_eq!(definition_uri(transitive), &leaf_uri); - assert_eq!(definition_uri(transitive_alias), &leaf_uri); - } - - #[test] - fn parse_program_respects_the_enum_feature_setting() { - let source = "enum Choice { Yes, No, }\nfn main() {}\n"; - let (temp, path) = in_temp_project(source); - - let (disabled, _) = parse_program( - source, - &path, - &Settings::default(), - &[temp.path().to_path_buf()], - ); - assert!(disabled.iter().any(|diagnostic| { - matches!( - diagnostic.error(), - Error::UnstableFeature { - feature: simplicityhl::UnstableFeature::Enums - } - ) - })); - - let settings = Settings::from_json(serde_json::json!({ - "experimentalFeatures": { "imports": false, "enums": true } - })) - .expect("valid settings"); - let (enabled, document) = - parse_program(source, &path, &settings, &[temp.path().to_path_buf()]); - - assert!(enabled.is_empty(), "enum should be enabled: {enabled:?}"); - 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() { - 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") - .to_string() - .contains("Expected expression of type `u32`, found type `()`"), - "Expected error on return type" - ); - 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 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 imported_diagnostic_points_to_its_real_file() { - 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"); - let library_path = root.join("simf/library.simf"); - let library_source = "pub fn broken() -> (u1, u256) { 0 }\n"; - std::fs::write(&library_path, library_source).expect("write imported module"); - - let source = "use crate::library::broken;\nfn main() { broken(); }\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, document) = parse_program(source, &path, &settings, &[root.to_path_buf()]); - let document = document.expect("document remains available after analysis errors"); - assert!(document.sources.len() > 1); - let bundle = crate::diagnostics::DiagnosticBundle::from_snapshot(&document); - let library_uri = Uri::from_file_path( - std::fs::canonicalize(&library_path).expect("canonical library path"), - ) - .expect("library URI"); - let root_uri = Uri::from_file_path(std::fs::canonicalize(&path).expect("canonical root")) - .expect("root URI"); - - let imported_error = bundle - .get(&library_uri) - .and_then(|diagnostics| { - diagnostics - .iter() - .find(|diagnostic| diagnostic.message.contains("Expected expression")) - }) - .unwrap_or_else(|| panic!("expected imported diagnostic, got {bundle:?}")); - assert_ne!(imported_error.range, Range::default()); - assert!(!bundle.get(&root_uri).is_some_and(|diagnostics| { - diagnostics - .iter() - .any(|diagnostic| diagnostic.message == imported_error.message) - })); - } - - #[test] - fn lsp_diagnostics_preserve_secondary_labels_notes_and_help() { - let source = "fn main() {}\n"; - let mut document = document_from_source(source); - let diagnostic = CompilerDiagnostic::new( - Error::CannotParse { - msg: "primary".to_string(), - }, - Span::new(0, 3..7), - ) - .with_secondary(Span::new(0, 0..2), "secondary") - .with_note("context") - .with_help("fix it"); - document.compiler_diagnostics = vec![diagnostic]; - let bundle = crate::diagnostics::DiagnosticBundle::from_snapshot(&document); - let uri = &document.sources[0].uri; - let published = &bundle.get(uri).expect("root diagnostic")[0]; - - assert!(published.message.contains("Note: context")); - assert!(published.message.contains("Help: fix it")); - let related = published - .related_information - .as_ref() - .expect("secondary label becomes related information"); - assert_eq!(related.len(), 1); - assert_eq!(related[0].message, "secondary"); - assert_eq!(related[0].location.uri, document.sources[0].uri); - } - - #[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 (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") - .error() - .clone() - { - Error::Syntax { .. } => {} - _ => panic!("Expected `Syntax` error"), - } - - assert!(doc.is_none(), "Expected no document to return"); - } -} diff --git a/src/completion/imports/candidates.rs b/src/completion/imports/candidates.rs new file mode 100644 index 0000000..ef9b3a6 --- /dev/null +++ b/src/completion/imports/candidates.rs @@ -0,0 +1,325 @@ +//! Project- and source-backed candidates for incomplete import declarations. + +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::text::{get_comments_from_lines, offset_to_position}; + +use super::context::{is_identifier, ImportCompletionContext, Query}; + +#[derive(Clone, Debug)] +struct Candidate { + name: String, + kind: CompletionItemKind, + detail: String, + documentation: Option, +} + +impl Candidate { + fn plain(name: impl Into, kind: CompletionItemKind, detail: impl Into) -> Self { + Self { + name: name.into(), + kind, + detail: detail.into(), + documentation: None, + } + } +} + +/// 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::plain( + "crate", + CompletionItemKind::MODULE, + "Current package", + )]; + candidates.extend( + project + .dependency_aliases(current_path) + .into_iter() + .map(|alias| { + let detail = project.import_root(current_path, alias).map_or_else( + || "Project dependency".to_string(), + |path| format!("Project dependency `{}`", path.display()), + ); + Candidate::plain(alias, CompletionItemKind::MODULE, detail) + }), + ); + 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::plain( + name, + CompletionItemKind::MODULE, + format!("Module directory `{}`", path.display()), + ) + }); + } + + (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::plain( + name, + CompletionItemKind::MODULE, + format!("Module file `{}`", path.display()), + ) + }) + }) + .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::plain( + module.name().to_string(), + CompletionItemKind::MODULE, + "Inline module", + )), + _ => 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::plain( + alias.name().to_string(), + CompletionItemKind::TYPE_PARAMETER, + format!("type {} = {}", alias.name(), alias.ty()), + )); + } + parse::Item::EnumDeclaration(declaration) + if matches!(declaration.visibility(), Visibility::Public) => + { + candidates.push(Candidate::plain( + declaration.name().to_string(), + CompletionItemKind::ENUM, + format!("enum {}", declaration.name()), + )); + } + parse::Item::Module(module) if matches!(module.visibility(), Visibility::Public) => { + candidates.push(Candidate::plain( + module.name().to_string(), + CompletionItemKind::MODULE, + "Public inline module", + )); + } + 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::plain( + alias.as_ref().unwrap_or(original).to_string(), + CompletionItemKind::REFERENCE, + "Public re-export", + ) + })); + } + parse::Item::TypeAlias(_) + | parse::Item::Function(_) + | parse::Item::Use(_) + | parse::Item::EnumDeclaration(_) + | parse::Item::Module(_) + | parse::Item::Ignored => {} + } + } + candidates +} diff --git a/src/completion/imports/context.rs b/src/completion/imports/context.rs new file mode 100644 index 0000000..1d6b299 --- /dev/null +++ b/src/completion/imports/context.rs @@ -0,0 +1,156 @@ +use std::collections::BTreeSet; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) 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 [`simplicityhl::parse::UseDecl`] for the +/// compiler to expose. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ImportCompletionContext { + pub(super) use_start: usize, + pub(super) query: Query, + pub(super) partial: String, + pub(super) 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(), + } + } +} + +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'_')) +} + +pub(super) 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) + }) +} diff --git a/src/completion/imports/mod.rs b/src/completion/imports/mod.rs new file mode 100644 index 0000000..f306b06 --- /dev/null +++ b/src/completion/imports/mod.rs @@ -0,0 +1,8 @@ +mod candidates; +mod context; + +pub(crate) use candidates::complete_import; +pub(crate) use context::ImportCompletionContext; + +#[cfg(test)] +mod tests; diff --git a/src/completion/imports/tests.rs b/src/completion/imports/tests.rs new file mode 100644 index 0000000..01301e4 --- /dev/null +++ b/src/completion/imports/tests.rs @@ -0,0 +1,158 @@ +use std::fs; +use std::path::Path; + +use tempfile::TempDir; + +use super::context::Query; +use super::*; +use crate::config::ProjectSettings; +use crate::project::ProjectContext; + +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()); + assert_eq!( + serde_json::to_value(&items).unwrap(), + serde_json::json!([{ + "label": "add", + "kind": 3, + "detail": "fn(a: u32, b: u32) -> u32", + "documentation": { + "kind": "markdown", + "value": "Add two words." + } + }]) + ); +} diff --git a/src/completion/mod.rs b/src/completion/mod.rs index e80f807..22d260b 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -1,6 +1,7 @@ use simplicityhl::parse::Function; pub mod builtin; +pub(crate) mod imports; pub mod jet; pub mod tokens; pub mod type_cast; diff --git a/src/config.rs b/src/config.rs index 7a60448..06dbaf3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -118,6 +118,19 @@ mod tests { assert!(settings.project.simplex.enabled); } + #[test] + fn empty_wrapped_and_bare_configuration_use_the_same_defaults() { + for value in [ + serde_json::json!({}), + serde_json::json!({ "simplicityhl": {} }), + ] { + assert_eq!( + Settings::from_json(value).expect("valid empty settings"), + Settings::default() + ); + } + } + #[test] fn accepts_vscode_wrapped_configuration() { let settings = Settings::from_json(serde_json::json!({ diff --git a/src/function.rs b/src/function.rs deleted file mode 100644 index 126a63c..0000000 --- a/src/function.rs +++ /dev/null @@ -1,46 +0,0 @@ -use simplicityhl::parse::Function; -use std::collections::HashMap; - -/// Container for parsed functions and their corresponding source text. -#[derive(Debug, Clone)] -pub struct Functions { - /// The map from function name to its parsed representation and source text. - pub map: HashMap, -} - -impl Functions { - /// Creates a new, empty `Functions` structure. - pub fn new() -> Self { - Self { - map: HashMap::new(), - } - } - - /// Inserts or updates a function and its document text. - pub fn insert(&mut self, name: String, func: Function, doc: String) { - self.map.insert(name, (func, doc)); - } - - /// Get pair of function and documentation. - pub fn get(&self, name: &str) -> Option<(&Function, &String)> { - self.map.get(name).map(|(func, doc)| (func, doc)) - } - - /// Retrieves a reference to a parsed function by name. - pub fn get_func(&self, name: &str) -> Option<&Function> { - self.map.get(name).map(|(func, _)| func) - } - - /// Returns a vector of all parsed functions. - pub fn functions(&self) -> Vec<&Function> { - self.map.values().map(|(func, _)| func).collect() - } - - /// Returns a vector of (function name, function) pairs. - pub fn functions_and_docs(&self) -> Vec<(&Function, &str)> { - self.map - .values() - .map(|(func, doc)| (func, doc.as_str())) - .collect() - } -} diff --git a/src/imports.rs b/src/imports.rs deleted file mode 100644 index 028fefd..0000000 --- a/src/imports.rs +++ /dev/null @@ -1,613 +0,0 @@ -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/lib.rs b/src/lib.rs new file mode 100644 index 0000000..03ccd67 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,25 @@ +#![warn(clippy::all, clippy::pedantic)] + +mod analysis; +mod completion; +mod config; +mod error; +mod navigation; +mod project; +mod semantic_tokens; +mod server; +mod signature_help; +mod text; +mod witness; +mod workspace; + +use server::Backend; +use tower_lsp_server::{LspService, Server}; + +/// Serve the `SimplicityHL` language server over the process standard streams. +pub async fn run_stdio() { + let (service, socket) = LspService::new(Backend::new); + Server::new(tokio::io::stdin(), tokio::io::stdout(), socket) + .serve(service) + .await; +} diff --git a/src/main.rs b/src/main.rs index 1638711..1ec713e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,27 +1,5 @@ -#![warn(clippy::all, clippy::pedantic)] - -mod analysis; -mod backend; -mod completion; -mod config; -mod diagnostics; -mod error; -mod function; -mod imports; -mod navigation; -mod project; -mod semantic_tokens; -mod utils; -mod workspace; - -use backend::Backend; -use tower_lsp_server::{LspService, Server}; - #[tokio::main] async fn main() { env_logger::init(); - let (stdin, stdout) = (tokio::io::stdin(), tokio::io::stdout()); - - let (service, socket) = LspService::new(Backend::new); - Server::new(stdin, stdout, socket).serve(service).await; + simplicityhl_lsp::run_stdio().await; } diff --git a/src/navigation.rs b/src/navigation/mod.rs similarity index 55% rename from src/navigation.rs rename to src/navigation/mod.rs index b348ed0..83235b2 100644 --- a/src/navigation.rs +++ b/src/navigation/mod.rs @@ -1,113 +1,63 @@ -use std::collections::HashSet; +mod references; + +pub(crate) use references::FunctionIdentity; use miniscript::iter::TreeLike; use simplicityhl::parse::{self, CallName}; -use tower_lsp_server::lsp_types::{self, Uri}; +use tower_lsp_server::lsp_types::{self, GotoDefinitionResponse, Location, Position, Range, Uri}; use crate::analysis::AnalysisSnapshot; use crate::error::LspError; -use crate::utils::{get_call_span, offset_to_position, span_contains, span_to_positions}; - -/// Stable identity for one function definition across independently analyzed roots. -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct FunctionIdentity { - uri: Uri, - start: usize, - end: usize, -} +use crate::text::{get_call_span, offset_to_position, span_contains, span_to_positions}; impl AnalysisSnapshot { - pub(crate) fn function_identity(&self, function: &parse::Function) -> Option { - let span = function.span(); - let source = self.sources.get(span.file_id)?; - Some(FunctionIdentity { - uri: source.uri.clone(), - start: span.start, - end: span.end, - }) - } - - pub fn find_all_references( + pub fn definition_at( &self, - call_name: &CallName, - ) -> Result, LspError> { - self.functions - .functions() - .iter() - .filter_map(|function| { - let source = self.sources.get(function.span().file_id)?; - Some( - parse::ExprTree::Expression(function.body()) - .pre_order_iter() - .filter_map(|expression| match expression { - parse::ExprTree::Call(call) => Some((call, get_call_span(call))), - _ => None, - }) - .filter(|(call, _)| call.name() == call_name) - .map(|(_, span)| (span, source)) - .collect::>(), - ) - }) - .flatten() - .map(|(span, source)| { - let (start, end) = span_to_positions(&span, &source.text)?; - Ok(lsp_types::Location { - range: lsp_types::Range { start, end }, - uri: source.uri.clone(), - }) - }) - .collect() - } + uri: &Uri, + token_position: Position, + ) -> Result, LspError> { + let token_span = crate::text::position_to_span(token_position, &self.text)?; - /// Find calls whose locally visible name resolves to the requested definition. - pub(crate) fn find_references_to( - &self, - target: &FunctionIdentity, - ) -> Result, LspError> { - let mut seen_functions = HashSet::new(); - self.functions - .functions() - .iter() - .filter_map(|function| { - let identity = self.function_identity(function)?; - let key = ( - identity.uri.as_str().to_owned(), - identity.start, - identity.end, - ); - if !seen_functions.insert(key) { - return None; - } - let source = self.sources.get(function.span().file_id)?; - Some( - parse::ExprTree::Expression(function.body()) - .pre_order_iter() - .filter_map(|expression| match expression { - parse::ExprTree::Call(call) => Some((call, get_call_span(call))), - _ => None, - }) - .filter(|(call, _)| { - let CallName::Custom(name) = call.name() else { - return false; - }; - self.resolve_custom_call(function, name.as_inner()) - .and_then(|resolved| self.function_identity(resolved)) - .as_ref() - == Some(target) - }) - .map(|(_, span)| (span, source)) - .collect::>(), - ) - }) - .flatten() - .map(|(span, source)| { - let (start, end) = span_to_positions(&span, &source.text)?; - Ok(lsp_types::Location { - range: lsp_types::Range { start, end }, - uri: source.uri.clone(), - }) - }) - .collect() + if let Some(function) = self.find_imported_function(token_span) { + let Some(source) = self.sources.get(function.span().file_id) else { + return Ok(None); + }; + let (start, end) = span_to_positions(function.as_ref(), &source.text)?; + return Ok(Some(GotoDefinitionResponse::from(Location::new( + source.uri.clone(), + Range::new(start, end), + )))); + } + + let Some(call) = self.find_related_call(token_span) else { + let Some(function) = self + .functions + .iter() + .find(|function| span_contains(function.span(), &token_span)) + else { + return Ok(None); + }; + let range = self.find_function_name_range(function)?; + return Ok( + (token_position >= range.start && token_position <= range.end) + .then(|| GotoDefinitionResponse::from(Location::new(uri.clone(), range))), + ); + }; + + let CallName::Custom(name) = call.name() else { + return Ok(None); + }; + let Some(function) = self.functions.get_func(name.as_inner()) else { + return Ok(None); + }; + let Some(source) = self.sources.get(function.span().file_id) else { + return Ok(None); + }; + let (start, end) = span_to_positions(function.as_ref(), &source.text)?; + Ok(Some(GotoDefinitionResponse::from(Location::new( + source.uri.clone(), + Range::new(start, end), + )))) } pub fn find_function_name_range( @@ -225,7 +175,7 @@ impl AnalysisSnapshot { &self, token_span: simplicityhl::error::Span, ) -> Option<&simplicityhl::parse::Call> { - let function = self.functions.functions().into_iter().find(|function| { + let function = self.functions.iter().find(|function| { function.span().file_id == 0 && span_contains(function.span(), &token_span) })?; @@ -240,3 +190,6 @@ impl AnalysisSnapshot { .last() } } + +#[cfg(test)] +mod tests; diff --git a/src/navigation/references.rs b/src/navigation/references.rs new file mode 100644 index 0000000..b5d9ca1 --- /dev/null +++ b/src/navigation/references.rs @@ -0,0 +1,98 @@ +use std::collections::HashSet; + +use miniscript::iter::TreeLike; +use simplicityhl::parse::{self, CallName}; +use tower_lsp_server::lsp_types; + +use crate::analysis::AnalysisSnapshot; +use crate::error::LspError; +use crate::text::{get_call_span, span_to_positions}; + +/// Stable identity for one function definition across independently analyzed roots. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct FunctionIdentity { + uri: lsp_types::Uri, + start: usize, + end: usize, +} + +impl AnalysisSnapshot { + pub(crate) fn function_identity(&self, function: &parse::Function) -> Option { + let span = function.span(); + let source = self.sources.get(span.file_id)?; + Some(FunctionIdentity { + uri: source.uri.clone(), + start: span.start, + end: span.end, + }) + } + + pub fn find_all_references( + &self, + call_name: &CallName, + ) -> Result, LspError> { + self.reference_locations(|_| true, |_, call| call.name() == call_name) + } + + /// Find calls whose locally visible name resolves to the requested definition. + pub(crate) fn find_references_to( + &self, + target: &FunctionIdentity, + ) -> Result, LspError> { + let mut seen = HashSet::new(); + self.reference_locations( + |function| { + self.function_identity(function).is_some_and(|identity| { + seen.insert(( + identity.uri.as_str().to_owned(), + identity.start, + identity.end, + )) + }) + }, + |function, call| { + let CallName::Custom(name) = call.name() else { + return false; + }; + self.resolve_custom_call(function, name.as_inner()) + .and_then(|resolved| self.function_identity(resolved)) + .as_ref() + == Some(target) + }, + ) + } + + fn reference_locations( + &self, + mut include_function: impl FnMut(&parse::Function) -> bool, + mut include_call: impl FnMut(&parse::Function, &parse::Call) -> bool, + ) -> Result, LspError> { + self.functions + .iter() + .filter(|function| include_function(function)) + .filter_map(|function| { + let source = self.sources.get(function.span().file_id)?; + Some( + parse::ExprTree::Expression(function.body()) + .pre_order_iter() + .filter_map(|expression| match expression { + parse::ExprTree::Call(call) if include_call(function, call) => { + Some(get_call_span(call)) + } + _ => None, + }) + .map(|span| (span, source)) + .collect::>(), + ) + }) + .flatten() + .map(|(span, source)| { + let (start, end) = span_to_positions(&span, &source.text)?; + Ok(lsp_types::Location { + range: lsp_types::Range { start, end }, + uri: source.uri.clone(), + }) + }) + .collect() + } +} diff --git a/src/navigation/tests.rs b/src/navigation/tests.rs new file mode 100644 index 0000000..b144032 --- /dev/null +++ b/src/navigation/tests.rs @@ -0,0 +1,197 @@ +use std::path::Path; + +use ropey::Rope; +use simplicityhl::error::Span; +use tempfile::TempDir; +use tower_lsp_server::UriExt; + +use super::*; +use crate::config::Settings; +use crate::text::offset_to_position; + +fn imports_enabled() -> Settings { + Settings::from_json(serde_json::json!({ + "experimentalFeatures": { "imports": true } + })) + .expect("valid settings") +} + +fn write(path: impl AsRef, source: &str) { + let path = path.as_ref(); + std::fs::create_dir_all(path.parent().expect("path has parent")).unwrap(); + std::fs::write(path, source).unwrap(); +} + +fn temp_snapshot(source: &str) -> (TempDir, AnalysisSnapshot) { + let temp = TempDir::new().unwrap(); + write(temp.path().join("Simplex.toml"), ""); + let path = temp.path().join("simf/main.simf"); + write(&path, source); + let snapshot = AnalysisSnapshot::analyze( + source, + &path, + &Settings::default(), + &[temp.path().to_path_buf()], + ); + (temp, snapshot) +} + +#[test] +fn grouped_items_and_aliases_resolve_to_imported_definitions() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + write(root.join("Simplex.toml"), ""); + let dependency = root.join("simf/math.simf"); + write(&dependency, "pub fn add() {}\npub fn subtract() {}\n"); + let source = "use crate::math::{add as plus, subtract};\nfn main() { plus(); subtract() }\n"; + let path = root.join("simf/main.simf"); + write(&path, source); + let snapshot = + AnalysisSnapshot::analyze(source, &path, &imports_enabled(), &[root.to_path_buf()]); + assert!(snapshot.compiler_diagnostics.is_empty()); + + for (needle, expected_name) in [ + ("add as", "add"), + ("plus,", "add"), + ("subtract}", "subtract"), + ] { + let offset = source.find(needle).unwrap() + 1; + let function = snapshot + .find_imported_function(Span::new(0, offset..offset)) + .expect("imported function"); + assert_eq!(function.name().as_inner(), expected_name); + assert_eq!( + snapshot.sources[function.span().file_id].uri, + Uri::from_file_path(std::fs::canonicalize(&dependency).unwrap()).unwrap() + ); + } + let module = source.find("math").unwrap() + 1; + assert!(snapshot + .find_imported_function(Span::new(0, module..module)) + .is_none()); +} + +#[test] +fn non_crate_reexports_keep_original_uri_span_and_alias_identity() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + write( + root.join("Simplex.toml"), + "[dependencies]\nmerkle = { path = 'deps/merkle' }\nfacade = { path = 'deps/facade' }\n", + ); + write(root.join("deps/merkle/Simplex.toml"), ""); + let merkle = root.join("deps/merkle/simf/build_root.simf"); + write( + &merkle, + "pub mod wrapper {\n pub fn get_root() {}\n pub fn hash() {}\n}\npub use crate::wrapper::{get_root, hash};\n", + ); + write( + root.join("deps/facade/Simplex.toml"), + "[dependencies]\nleaf = { path = '../leaf' }\n", + ); + write( + root.join("deps/facade/simf/smth.simf"), + "pub use leaf::ops::hash;\n", + ); + write(root.join("deps/leaf/Simplex.toml"), ""); + let leaf = root.join("deps/leaf/simf/ops.simf"); + write(&leaf, "pub fn hash() {}\n"); + let source = "use merkle::build_root::{get_root, hash as and_hash};\nuse facade::smth::hash as or_hash;\nfn main() { get_root(); and_hash(); or_hash(); }\n"; + let path = root.join("simf/main.simf"); + write(&path, source); + let snapshot = + AnalysisSnapshot::analyze(source, &path, &imports_enabled(), &[root.to_path_buf()]); + assert!(snapshot.compiler_diagnostics.is_empty()); + + let imported = |offset: usize| { + snapshot + .find_imported_function(Span::new(0, offset + 1..offset + 1)) + .expect("imported function") + }; + let cases = [ + (source.find("get_root").unwrap(), "get_root", &merkle), + (source.find("and_hash").unwrap(), "hash", &merkle), + (source.rfind("hash as").unwrap(), "hash", &leaf), + (source.find("or_hash").unwrap(), "hash", &leaf), + ]; + for (offset, expected_name, expected_path) in cases { + let function = imported(offset); + assert_eq!(function.name().as_inner(), expected_name); + assert_eq!( + snapshot.sources[function.span().file_id].uri, + Uri::from_file_path(std::fs::canonicalize(expected_path).unwrap()).unwrap() + ); + } + + let root_uri = Uri::from_file_path(&path).unwrap(); + let merkle_uri = Uri::from_file_path(std::fs::canonicalize(&merkle).unwrap()).unwrap(); + let leaf_uri = Uri::from_file_path(std::fs::canonicalize(&leaf).unwrap()).unwrap(); + let merkle_root = Location::new( + merkle_uri.clone(), + Range::new(Position::new(1, 1), Position::new(1, 21)), + ); + let merkle_hash = Location::new( + merkle_uri, + Range::new(Position::new(2, 1), Position::new(2, 17)), + ); + let leaf_hash = Location::new( + leaf_uri, + Range::new(Position::new(0, 0), Position::new(0, 16)), + ); + let definition_cases = [ + (source.find("get_root").unwrap(), &merkle_root), + (source.find("hash as").unwrap(), &merkle_hash), + (source.find("and_hash").unwrap(), &merkle_hash), + (source.rfind("hash as").unwrap(), &leaf_hash), + (source.find("or_hash").unwrap(), &leaf_hash), + (source.rfind("get_root").unwrap(), &merkle_root), + (source.rfind("and_hash").unwrap(), &merkle_hash), + (source.rfind("or_hash").unwrap(), &leaf_hash), + ]; + for (offset, expected) in definition_cases { + let position = offset_to_position(offset + 1, &snapshot.text).unwrap(); + let GotoDefinitionResponse::Scalar(location) = snapshot + .definition_at(&root_uri, position) + .unwrap() + .expect("definition") + else { + panic!("expected one location"); + }; + assert_eq!(&location, expected); + } +} + +#[test] +fn function_selection_range_is_inside_its_full_range() { + let source = "/* 😀 */ fn main() {}"; + let (_temp, snapshot) = temp_snapshot(source); + let function = snapshot.functions.get_func("main").expect("main function"); + let (start, end) = span_to_positions(function.span(), &snapshot.text).unwrap(); + let selection = snapshot.find_function_name_range(function).unwrap(); + let name_start = source.find("main").unwrap(); + + assert!(selection.start >= start && selection.end <= end); + assert_eq!( + selection, + Range::new( + offset_to_position(name_start, &snapshot.text).unwrap(), + offset_to_position(name_start + "main".len(), &snapshot.text).unwrap(), + ) + ); +} + +#[test] +fn stale_text_cannot_produce_an_out_of_bounds_selection_range() { + let source = "fn main() {}"; + let (_temp, mut snapshot) = temp_snapshot(source); + let function = snapshot.functions.get_func("main").unwrap().clone(); + snapshot.text = Rope::from_str(&format!("// {}\n{source}", "x".repeat(100))); + + assert!(snapshot.find_function_name_range(&function).is_err()); +} + +#[test] +fn looking_for_a_call_outside_a_function_is_empty() { + let (_temp, snapshot) = temp_snapshot("/* heading */\nfn main() {}"); + assert!(snapshot.find_related_call(Span::new(0, 0..0)).is_none()); +} diff --git a/src/project.rs b/src/project/mod.rs similarity index 68% rename from src/project.rs rename to src/project/mod.rs index 314f846..d3b5709 100644 --- a/src/project.rs +++ b/src/project/mod.rs @@ -211,8 +211,7 @@ impl ProjectContext { 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()) + .find(|root| canonical_document.starts_with(root)) .map_or(self.source_root.as_path(), PathBuf::as_path) } @@ -235,8 +234,7 @@ impl ProjectContext { } self.visible_mappings(document_path) - .filter(|mapping| mapping.alias == alias) - .max_by_key(|mapping| mapping.context.as_os_str().len()) + .find(|mapping| mapping.alias == alias) .map(|mapping| mapping.target.as_path()) } @@ -440,185 +438,4 @@ fn hashed_repository_path(url: &str) -> Result { } #[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() - ) - ); - 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] - 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(_))); - } -} +mod tests; diff --git a/src/project/tests.rs b/src/project/tests.rs new file mode 100644 index 0000000..45474e4 --- /dev/null +++ b/src/project/tests.rs @@ -0,0 +1,210 @@ +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() + ) + ); + 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] +fn dependency_removed_after_discovery_is_reported() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + let document = root.join("simf/main.simf"); + let dependency_source = root.join("vendor/library/simf"); + write( + &root.join(SIMPLEX_MANIFEST), + "[dependencies]\nlibrary = { path = 'vendor/library' }\n", + ); + write(&document, "fn main() {}\n"); + write(&root.join("vendor/library/Simplex.toml"), ""); + write(&dependency_source.join("ops.simf"), "pub fn verify() {}\n"); + + let context = ProjectContext::discover( + &document, + &ProjectSettings::default(), + &[root.to_path_buf()], + ) + .unwrap(); + fs::rename(&dependency_source, root.join("vendor/library/simf.moved")).unwrap(); + + let error = context.dependency_map(&document).unwrap_err(); + let ProjectError::Compiler(message) = error else { + panic!("expected compiler dependency-map error, got {error}"); + }; + assert!(message.contains("Failed to find library target path")); + assert!(message.contains("simf")); +} + +#[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(_))); +} diff --git a/src/semantic_tokens.rs b/src/semantic_tokens.rs index 84e855c..2115a92 100644 --- a/src/semantic_tokens.rs +++ b/src/semantic_tokens.rs @@ -6,7 +6,7 @@ use tower_lsp_server::lsp_types::{ }; use crate::analysis::AnalysisSnapshot; -use crate::utils::span_to_positions; +use crate::text::span_to_positions; mod token_type { pub const FUNCTION: u32 = 0; @@ -39,7 +39,7 @@ pub fn tokens(snapshot: &AnalysisSnapshot) -> Vec { .unwrap_or_default(); let mut raw_tokens = Vec::new(); - for function in snapshot.functions.functions() { + for function in snapshot.functions.iter() { if function.span().file_id != 0 { continue; } @@ -177,3 +177,125 @@ fn encode(mut tokens: Vec) -> Vec { }) .collect() } + +#[cfg(test)] +mod tests { + use ropey::Rope; + use simplicityhl::error::DiagnosticManager; + use simplicityhl::parse::ParseFromStrWithErrors; + use simplicityhl::UnstableFeatures; + + use super::*; + use crate::text::offset_to_position; + + fn snapshot(source: &str) -> AnalysisSnapshot { + let mut diagnostics = DiagnosticManager::new(); + let program = parse::Program::parse_from_str_with_errors( + 0, + source, + &UnstableFeatures::none(), + &mut diagnostics, + ) + .unwrap_or_else(|| panic!("source should parse: {diagnostics:?}")); + AnalysisSnapshot::from_program( + &program, + source, + &std::env::temp_dir().join("semantic_tokens.simf"), + ) + } + + fn decode(tokens: &[SemanticToken]) -> Vec { + let mut line = 0; + let mut character = 0; + tokens + .iter() + .map(|token| { + line += token.delta_line; + character = if token.delta_line == 0 { + character + token.delta_start + } else { + token.delta_start + }; + ( + line, + character, + token.length, + token.token_type, + token.token_modifiers_bitset, + ) + }) + .collect() + } + + fn expected(source: &str, offset: usize, text: &str, kind: u32) -> RawToken { + let rope = Rope::from_str(source); + let start = offset_to_position(offset, &rope).expect("valid token start"); + let end = offset_to_position(offset + text.len(), &rope).expect("valid token end"); + ( + start.line, + start.character, + end.character - start.character, + kind, + 0, + ) + } + + #[test] + fn splits_generic_callable_components() { + let source = "// 😀 keeps UTF-16 columns honest\nfn consume_budget(acc: u32, item: u32) -> u32 { acc }\nfn main() { array_fold::(witness::PADDING, true); }\n"; + let snapshot = snapshot(source); + let decoded = decode(&tokens(&snapshot)); + let builtin = source.find("array_fold").expect("array_fold call"); + let callback = source.rfind("consume_budget").expect("callback argument"); + + assert!(decoded.contains(&expected( + source, + builtin, + "array_fold", + token_type::FUNCTION + ))); + assert!(decoded.contains(&expected( + source, + callback, + "consume_budget", + token_type::FUNCTION, + ))); + + let bound = source.find("320").expect("array bound"); + let bound = offset_to_position(bound, &snapshot.text).unwrap(); + assert!(!decoded.iter().any(|token| { + token.0 == bound.line + && token.1 <= bound.character + && token.1 + token.2 > bound.character + })); + } + + #[test] + fn bounds_each_builtin_to_its_identifier() { + let source = "fn step(acc: u32, item: u32) -> u32 { acc }\nfn main() {\nfold::(0, 0);\nfor_while::(0);\nunwrap_left::(0);\n::into(0);\njet::add_32(0, 0);\nassert!(true);\n}\n"; + let decoded = decode(&tokens(&snapshot(source))); + + for name in ["fold", "for_while", "unwrap_left", "into", "assert!"] { + let offset = source + .find(name) + .unwrap_or_else(|| panic!("missing {name}")); + assert!(decoded.contains(&expected(source, offset, name, token_type::FUNCTION,))); + } + + for offset in [ + source.find("step, 2").unwrap(), + source.rfind("step").unwrap(), + ] { + assert!(decoded.contains(&expected(source, offset, "step", token_type::FUNCTION,))); + } + + let jet = source.find("jet::add_32").unwrap(); + assert!(decoded.contains(&expected(source, jet, "jet", token_type::NAMESPACE))); + assert!(decoded.contains(&expected( + source, + jet + "jet::".len(), + "add_32", + token_type::FUNCTION, + ))); + } +} diff --git a/src/server/capabilities.rs b/src/server/capabilities.rs new file mode 100644 index 0000000..88b5d2d --- /dev/null +++ b/src/server/capabilities.rs @@ -0,0 +1,103 @@ +use std::path::PathBuf; + +use tower_lsp_server::lsp_types::{ + CompletionOptions, FileSystemWatcher, GlobPattern, HoverProviderCapability, InitializeParams, + InitializeResult, OneOf, SaveOptions, SemanticTokensFullOptions, SemanticTokensOptions, + SemanticTokensServerCapabilities, ServerCapabilities, SignatureHelpOptions, + TextDocumentSyncCapability, TextDocumentSyncKind, TextDocumentSyncOptions, + TextDocumentSyncSaveOptions, WorkDoneProgressOptions, WorkspaceFoldersServerCapabilities, + WorkspaceServerCapabilities, +}; +use tower_lsp_server::UriExt; + +/// Collect the workspace folders the client opened with, falling back to the +/// deprecated `root_uri` for clients that do not send folders. +pub(super) 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 +} + +pub(super) fn initialize_result() -> InitializeResult { + InitializeResult { + server_info: None, + capabilities: ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Options( + TextDocumentSyncOptions { + open_close: Some(true), + change: Some(TextDocumentSyncKind::FULL), + save: Some(TextDocumentSyncSaveOptions::SaveOptions(SaveOptions { + include_text: Some(true), + })), + ..Default::default() + }, + )), + completion_provider: Some(CompletionOptions { + resolve_provider: Some(false), + // `:`, 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, + }), + workspace: Some(WorkspaceServerCapabilities { + workspace_folders: Some(WorkspaceFoldersServerCapabilities { + supported: Some(true), + change_notifications: Some(OneOf::Left(true)), + }), + file_operations: None, + }), + hover_provider: Some(HoverProviderCapability::Simple(true)), + definition_provider: Some(OneOf::Left(true)), + references_provider: Some(OneOf::Left(true)), + document_symbol_provider: Some(OneOf::Left(true)), + signature_help_provider: Some(SignatureHelpOptions { + trigger_characters: Some(vec!["(".to_string(), ",".to_string()]), + retrigger_characters: Some(vec![",".to_string()]), + work_done_progress_options: WorkDoneProgressOptions::default(), + }), + semantic_tokens_provider: Some( + SemanticTokensServerCapabilities::SemanticTokensOptions(SemanticTokensOptions { + work_done_progress_options: WorkDoneProgressOptions::default(), + legend: crate::semantic_tokens::legend(), + range: Some(false), + full: Some(SemanticTokensFullOptions::Bool(true)), + }), + ), + ..ServerCapabilities::default() + }, + } +} + +pub(super) fn watched_files() -> Vec { + ["**/*.simf", "**/Simplex.toml", "**/simplex.toml"] + .into_iter() + .map(|glob| FileSystemWatcher { + glob_pattern: GlobPattern::String(glob.to_string()), + kind: None, + }) + .collect() +} diff --git a/src/server/handlers.rs b/src/server/handlers.rs new file mode 100644 index 0000000..d9e0319 --- /dev/null +++ b/src/server/handlers.rs @@ -0,0 +1,590 @@ +use serde_json::Value; + +use std::str::FromStr; + +use tower_lsp_server::jsonrpc::Result; +use tower_lsp_server::lsp_types::{ + CompletionItem, CompletionParams, CompletionResponse, DidChangeConfigurationParams, + DidChangeTextDocumentParams, DidChangeWatchedFilesParams, + DidChangeWatchedFilesRegistrationOptions, DidChangeWorkspaceFoldersParams, + DidCloseTextDocumentParams, DidOpenTextDocumentParams, DidSaveTextDocumentParams, + DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, ExecuteCommandParams, + GotoDefinitionParams, GotoDefinitionResponse, Hover, HoverParams, InitializeParams, + InitializeResult, InitializedParams, Location, MarkupContent, MarkupKind, MessageType, Range, + ReferenceParams, Registration, SemanticTokens, SemanticTokensParams, SemanticTokensResult, + SignatureHelp, SignatureHelpParams, SymbolKind, Uri, +}; +use tower_lsp_server::{LanguageServer, UriExt}; + +use simplicityhl::parse; + +use crate::analysis::AnalysisSnapshot; +use crate::completion; +use crate::completion::imports::{self, ImportCompletionContext}; +use crate::config::Settings; +use crate::project::{ProjectContext, SIMPLEX_MANIFEST}; +use crate::text::{ + get_call_span, position_to_offset, position_to_span, span_contains, span_to_positions, +}; +use crate::workspace::{AnalysisInput, DiagnosticUpdate, WorkspaceState}; + +use super::capabilities::{initialize_result, watched_files, workspace_roots}; +use super::Backend; + +impl LanguageServer for Backend { + async fn initialize(&self, params: InitializeParams) -> Result { + let workspace_roots = workspace_roots(¶ms); + let watched_files_registration = params + .capabilities + .workspace + .as_ref() + .and_then(|workspace| workspace.did_change_watched_files.as_ref()) + .and_then(|capability| capability.dynamic_registration) + .unwrap_or(false); + 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.watched_files_registration = watched_files_registration; + config.settings = settings; + } + + Ok(initialize_result()) + } + + async fn initialized(&self, _: InitializedParams) { + if !self.config.read().await.watched_files_registration { + return; + } + + let watchers = watched_files(); + let registration = Registration { + id: "simplicityhl-lsp-watched-files".to_string(), + method: "workspace/didChangeWatchedFiles".to_string(), + register_options: serde_json::to_value(DidChangeWatchedFilesRegistrationOptions { + watchers, + }) + .ok(), + }; + if let Err(error) = self.client.register_capability(vec![registration]).await { + self.client + .log_message( + MessageType::WARNING, + format!("Unable to register file watchers: {error}"), + ) + .await; + } + } + + async fn shutdown(&self) -> Result<()> { + Ok(()) + } + + 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, 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, 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) + } + + async fn did_open(&self, params: DidOpenTextDocumentParams) { + if params.text_document.uri.to_file_path().is_none() { + return; + } + let input = self.workspace.write().await.begin_open( + ¶ms.text_document.uri, + ¶ms.text_document.text, + Some(params.text_document.version), + ); + self.on_change(input).await; + } + + 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; + }; + let Some(input) = self.workspace.write().await.begin_change( + ¶ms.text_document.uri, + &change.text, + Some(params.text_document.version), + ) else { + return; + }; + self.on_change(input).await; + } + + async fn did_save(&self, params: DidSaveTextDocumentParams) { + if let Some(text) = params.text { + let Some(input) = + self.workspace + .write() + .await + .begin_change(¶ms.text_document.uri, &text, None) + else { + return; + }; + self.on_change(input).await; + } + } + + async fn did_close(&self, params: DidCloseTextDocumentParams) { + let uri = params.text_document.uri; + let Some(generation) = self.workspace.write().await.begin_close(&uri) else { + return; + }; + // 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.run_diagnostic_transaction(|workspace| workspace.remove_if_current(&uri, generation)) + .await; + } + + async fn semantic_tokens_full( + &self, + params: SemanticTokensParams, + ) -> Result> { + let uri = ¶ms.text_document.uri; + + // .wit files don't have semantic tokens + if std::path::Path::new(uri.path().as_str()) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("wit")) + { + return Ok(None); + } + + let documents = self.workspace.read().await; + + // Return None if document not found (e.g., file has parse errors) + let Some(doc) = documents.get(uri) else { + return Ok(None); + }; + + Ok(Some(SemanticTokensResult::Tokens(SemanticTokens { + result_id: None, + data: crate::semantic_tokens::tokens(doc), + }))) + } + + async fn document_symbol( + &self, + params: DocumentSymbolParams, + ) -> Result> { + let uri = ¶ms.text_document.uri; + + // .wit files don't have symbols + if std::path::Path::new(uri.path().as_str()) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("wit")) + { + return Ok(None); + } + + let documents = self.workspace.read().await; + + // Return None if document not found (e.g., file has parse errors) + let Some(doc) = documents.get(uri) else { + return Ok(None); + }; + + let functions = doc.functions.iter(); + + let symbols: Vec = functions + .filter_map(|func| { + if func.span().file_id != 0 { + return None; + } + + // Get the full function range + let (start, end) = span_to_positions(func.span(), &doc.text).ok()?; + let full_range = Range { start, end }; + + // Get the function name range for selection + let selection_range = doc.find_function_name_range(func).ok()?; + + // Build parameters detail string + let params_str = func + .params() + .iter() + .map(|p| format!("{p}")) + .collect::>() + .join(", "); + + let return_type = match func.ret() { + Some(ret) => format!("{ret}"), + None => "()".to_string(), + }; + + #[allow(deprecated)] + Some(DocumentSymbol { + name: func.name().to_string(), + detail: Some(format!("fn({params_str}) -> {return_type}")), + kind: SymbolKind::FUNCTION, + tags: None, + range: full_range, + selection_range, + children: None, + deprecated: None, + }) + }) + .collect(); + + Ok(Some(DocumentSymbolResponse::Nested(symbols))) + } + + async fn signature_help(&self, params: SignatureHelpParams) -> Result> { + let documents = self.workspace.read().await; + let uri = ¶ms.text_document_position_params.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); + }; + + Ok(crate::signature_help::at( + doc, + params.text_document_position_params.position, + )?) + } + + async fn completion(&self, params: CompletionParams) -> Result> { + let uri = ¶ms.text_document_position.text_document.uri; + let pos = params.text_document_position.position; + let (source_prefix, functions) = { + let documents = self.workspace.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()) + }; + + if let Some(context) = ImportCompletionContext::at(&source_prefix, source_prefix.len()) { + return Ok(self + .import_completion(uri, &source_prefix, &context) + .await + .map(CompletionResponse::Array)); + } + + // 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, &functions.functions_and_docs()) + .map(CompletionResponse::Array); + + Ok(completions) + } + + async fn hover(&self, params: HoverParams) -> Result> { + let uri = ¶ms.text_document_position_params.text_document.uri; + + // .wit files don't have hover info + if std::path::Path::new(uri.path().as_str()) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("wit")) + { + return Ok(None); + } + + let documents = self.workspace.read().await; + + // Return None if document not found (e.g., file has parse errors) + let Some(doc) = documents.get(uri) else { + return Ok(None); + }; + + let token_pos = params.text_document_position_params.position; + + let token_span = position_to_span(token_pos, &doc.text)?; + let Some(call) = doc.find_related_call(token_span) else { + return Ok(None); + }; + + 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 Ok(element) = + simplicityhl::simplicity::jet::Elements::from_str(format!("{jet}").as_str()) + else { + return Ok(None); + }; + + let template = completion::jet::jet_to_template(element); + format!( + "Jet function\n```simplicityhl\nfn {}({}) -> {}\n```\n---\n\n{}", + template.display_name, + template.args.join(", "), + template.return_type, + template.description + ) + } + parse::CallName::Custom(func) => { + 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!( + "```simplicityhl\nfn {}({}) -> {}\n```\n---\n{}", + template.display_name, + template.args.join(", "), + template.return_type, + template.description + ) + } + other => { + let Some(template) = completion::builtin::match_callname(other) else { + return Ok(None); + }; + format!( + "Built-in function\n```simplicityhl\nfn {}({}) -> {}\n```\n---\n{}", + template.display_name, + template.args.join(", "), + template.return_type, + template.description + ) + } + }; + + Ok(Some(Hover { + contents: tower_lsp_server::lsp_types::HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value: description, + }), + range: Some(Range { start, end }), + })) + } + + async fn goto_definition( + &self, + params: GotoDefinitionParams, + ) -> Result> { + let documents = self.workspace.read().await; + let uri = ¶ms.text_document_position_params.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); + }; + Ok(doc.definition_at(uri, params.text_document_position_params.position)?) + } + + async fn references(&self, params: ReferenceParams) -> Result>> { + let documents = self.workspace.read().await; + let uri = ¶ms.text_document_position.text_document.uri; + + let Some(doc) = documents.get(uri) else { + return Ok(None); + }; + let token_position = params.text_document_position.position; + + let token_span = position_to_span(token_position, &doc.text)?; + + let call_name = doc + .find_related_call(token_span) + .map(simplicityhl::parse::Call::name); + + match call_name { + Some(parse::CallName::Custom(_)) | None => {} + Some(name) => { + return Ok(Some(doc.find_all_references(name)?)); + } + } + + let Some(func) = (match call_name { + Some(parse::CallName::Custom(name)) => doc.functions.get_func(name.as_inner()), + _ => doc + .functions + .iter() + .find(|func| span_contains(func.span(), &token_span)), + }) else { + return 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); + } + } + + let Some(identity) = doc.function_identity(func) else { + return Ok(None); + }; + + Ok(Some(documents.find_references_to(&identity))) + } +} + +impl Backend { + async fn run_diagnostic_transaction(&self, transition: F) + where + F: FnOnce(&mut WorkspaceState) -> Option>, + { + self.diagnostic_transaction + .run(&self.workspace, transition, |updates| { + self.publish_diagnostic_updates(updates) + }) + .await; + } + + async fn publish_diagnostic_updates(&self, updates: Vec) { + for update in updates { + self.client + .publish_diagnostics(update.uri, update.diagnostics, update.version) + .await; + } + } + + 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) { + let documents = self.workspace.write().await.begin_reanalysis(); + for input in documents { + self.on_change(input).await; + } + } + + /// Function which executed on change of file (`did_save`, `did_open` or `did_change` methods) + async fn on_change(&self, params: AnalysisInput) { + let Some(path_buf) = params.uri.to_file_path() else { + return; + }; + let path = path_buf.as_ref(); + + // Check if this is a witness file + if path + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("wit")) + { + self.on_change_witness(params).await; + return; + } + + let (settings, workspace_roots) = { + let config = self.config.read().await; + (config.settings.clone(), config.workspace_roots.clone()) + }; + let snapshot = AnalysisSnapshot::analyze(¶ms.text, path, &settings, &workspace_roots); + self.run_diagnostic_transaction(|workspace| { + workspace.replace_if_current(¶ms.uri, snapshot, params.version, params.generation) + }) + .await; + } + + /// Validate witness (.wit) files + async fn on_change_witness(&self, params: AnalysisInput) { + // Parsing is input-dependent work. Keep it outside the global diagnostic gate and + // workspace write lock; the generation check below safely discards stale results. + let diagnostics = crate::witness::validate(¶ms.text); + self.run_diagnostic_transaction(|workspace| { + workspace.diagnostics_if_current( + params.uri.clone(), + diagnostics, + params.version, + params.generation, + ) + }) + .await; + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..92a2669 --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,47 @@ +mod capabilities; +mod handlers; +mod transaction; + +use std::path::PathBuf; +use std::sync::Arc; + +use tokio::sync::RwLock; +use tower_lsp_server::Client; + +use self::transaction::DiagnosticTransaction; +use crate::completion::CompletionProvider; +use crate::config::Settings; +use crate::workspace::WorkspaceState; + +/// 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_roots: Vec, + watched_files_registration: bool, +} + +#[derive(Debug)] +pub struct Backend { + client: Client, + workspace: Arc>, + diagnostic_transaction: DiagnosticTransaction, + config: Arc>, + completion_provider: CompletionProvider, +} + +impl Backend { + pub fn new(client: Client) -> Self { + Self { + client, + workspace: Arc::new(RwLock::new(WorkspaceState::default())), + diagnostic_transaction: DiagnosticTransaction::default(), + config: Arc::new(RwLock::new(ServerConfig::default())), + completion_provider: CompletionProvider::new(), + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src/server/tests.rs b/src/server/tests.rs new file mode 100644 index 0000000..c988fd4 --- /dev/null +++ b/src/server/tests.rs @@ -0,0 +1,968 @@ +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc as StdArc, Mutex as StdMutex}; + +use ropey::Rope; +use simplicityhl::error::{ + Diagnostic as CompilerDiagnostic, DiagnosticManager, Error, Location as CompilerLocation, Span, +}; +use simplicityhl::parse::{self, ParseFromStrWithErrors}; +use simplicityhl::UnstableFeatures; +use tempfile::TempDir; +use tokio::sync::Notify; +use tower_lsp_server::lsp_types::{Range, Uri}; +use tower_lsp_server::UriExt; + +use super::*; +use crate::analysis::AnalysisSnapshot; +use crate::config::Settings; +use crate::text::{offset_to_position, span_to_positions}; +use crate::workspace::{AnalysisInput, DiagnosticUpdate, WorkspaceState}; + +#[test] +fn watched_file_registration_matches_the_protocol_contract() { + assert_eq!( + serde_json::to_value(super::capabilities::watched_files()).unwrap(), + serde_json::json!([ + { "globPattern": "**/*.simf" }, + { "globPattern": "**/Simplex.toml" }, + { "globPattern": "**/simplex.toml" } + ]) + ); +} + +type WitnessPublications = StdArc, Vec)>>>; + +async fn capture_witness_update( + transaction: &DiagnosticTransaction, + workspace: &RwLock, + input: AnalysisInput, + publications: &WitnessPublications, +) { + let diagnostics = crate::witness::validate(&input.text); + transaction + .run( + workspace, + |workspace| { + workspace.diagnostics_if_current( + input.uri, + diagnostics, + input.version, + input.generation, + ) + }, + { + let publications = StdArc::clone(publications); + move |updates| async move { + let update = &updates[0]; + publications.lock().unwrap().push(( + update.version, + update + .diagnostics + .iter() + .map(|item| item.message.clone()) + .collect(), + )); + } + }, + ) + .await; +} + +#[tokio::test] +async fn stale_witness_diagnostics_cannot_overwrite_a_newer_generation() { + let uri = Uri::from_file_path(std::env::temp_dir().join("contract.wit")).unwrap(); + let workspace = RwLock::new(WorkspaceState::default()); + let transaction = DiagnosticTransaction::default(); + let publications = StdArc::new(StdMutex::new(Vec::new())); + let invalid = r#"{"amount":{"value":1}}"#; + let stale = workspace.write().await.begin_open(&uri, invalid, Some(1)); + + let valid = r#"{"amount":{"value":1,"type":"u32"}}"#; + let current = workspace + .write() + .await + .begin_change(&uri, valid, Some(2)) + .expect("open witness change"); + capture_witness_update(&transaction, &workspace, current, &publications).await; + capture_witness_update(&transaction, &workspace, stale, &publications).await; + + assert!(workspace.read().await.get(&uri).is_none()); + assert_eq!(*publications.lock().unwrap(), [(Some(2), Vec::new())]); +} + +/// `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) +} + +const IMPORTING_ROOT: &str = "use crate::shared::broken;\nfn main() { broken(); }\n"; + +struct DependencyProject { + temp: TempDir, + root_path: PathBuf, + dependency_path: PathBuf, + root_uri: Uri, + dependency_uri: Uri, + settings: Settings, +} + +impl DependencyProject { + fn new(dependency_source: &str) -> Self { + 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 dependency_path = temp.path().join("simf/shared.simf"); + let root_path = temp.path().join("simf/main.simf"); + std::fs::write(&dependency_path, dependency_source).expect("write dependency"); + std::fs::write(&root_path, IMPORTING_ROOT).expect("write root"); + let root_uri = + Uri::from_file_path(std::fs::canonicalize(&root_path).expect("canonical root path")) + .expect("root URI"); + let dependency_uri = Uri::from_file_path( + std::fs::canonicalize(&dependency_path).expect("canonical dependency path"), + ) + .expect("dependency URI"); + let settings = Settings::from_json(serde_json::json!({ + "experimentalFeatures": { "imports": true } + })) + .expect("valid settings"); + Self { + temp, + root_path, + dependency_path, + root_uri, + dependency_uri, + settings, + } + } + + fn root_snapshot(&self) -> AnalysisSnapshot { + AnalysisSnapshot::analyze( + IMPORTING_ROOT, + &self.root_path, + &self.settings, + &[self.temp.path().to_path_buf()], + ) + } + + fn dependency_snapshot(&self, source: &str) -> AnalysisSnapshot { + AnalysisSnapshot::analyze( + source, + &self.dependency_path, + &self.settings, + &[self.temp.path().to_path_buf()], + ) + } + + fn write_dependency(&self, source: &str) { + std::fs::write(&self.dependency_path, source).expect("write dependency"); + } +} + +fn diagnostic_count(updates: &[DiagnosticUpdate], uri: &Uri) -> usize { + updates + .iter() + .find(|update| &update.uri == uri) + .expect("diagnostic update") + .diagnostics + .len() +} + +fn record_count( + events: &StdMutex>, + label: &'static str, + updates: &[DiagnosticUpdate], + uri: &Uri, +) { + events + .lock() + .expect("event lock") + .push((label, diagnostic_count(updates, uri))); +} + +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 main() {}" +} + +fn invalid_program_on_parsing() -> &'static str { + "fn add(a: u32, b: u32) -> u32 " +} + +fn parse_program( + source: &str, + path: &Path, + settings: &Settings, + workspace_roots: &[PathBuf], +) -> (Vec, Option) { + let snapshot = AnalysisSnapshot::analyze(source, path, settings, workspace_roots); + let mut syntax_diagnostics = DiagnosticManager::new(); + let parsed = parse::Program::parse_from_str_with_errors( + 0, + source, + &settings.unstable_features(), + &mut syntax_diagnostics, + ) + .is_some(); + let diagnostics = snapshot.compiler_diagnostics.clone(); + (diagnostics, parsed.then_some(snapshot)) +} + +fn document_from_source(source: &str) -> AnalysisSnapshot { + let mut diagnostics = DiagnosticManager::new(); + let program = parse::Program::parse_from_str_with_errors( + 0, + source, + &UnstableFeatures::none(), + &mut diagnostics, + ) + .unwrap_or_else(|| panic!("source should parse: {diagnostics:?}")); + let path = std::env::temp_dir().join("semantic_tokens.simf"); + AnalysisSnapshot::from_program(&program, source, &path) +} + +#[tokio::test] +#[allow(clippy::too_many_lines)] +async fn diagnostic_transactions_do_not_interleave_state_and_publication() { + let project = DependencyProject::new("pub fn broken() -> u32 { false }\n"); + let old_snapshot = project.root_snapshot(); + project.write_dependency("pub fn broken() -> u32 { 0 }\n"); + let new_snapshot = project.root_snapshot(); + let root_uri = project.root_uri.clone(); + let dependency_uri = project.dependency_uri.clone(); + + let transaction = StdArc::new(DiagnosticTransaction::default()); + let workspace = StdArc::new(RwLock::new(WorkspaceState::default())); + let old_input = workspace + .write() + .await + .begin_open(&root_uri, IMPORTING_ROOT, Some(1)); + let first_started = StdArc::new(Notify::new()); + let release_first = StdArc::new(Notify::new()); + let second_started = StdArc::new(Notify::new()); + let second_transition_ran = StdArc::new(AtomicBool::new(false)); + let events = StdArc::new(StdMutex::new(Vec::new())); + + let first_wait = first_started.notified(); + let first = { + let transaction = StdArc::clone(&transaction); + let workspace = StdArc::clone(&workspace); + let first_started = StdArc::clone(&first_started); + let release_first = StdArc::clone(&release_first); + let events = StdArc::clone(&events); + let root_uri = root_uri.clone(); + let dependency_uri = dependency_uri.clone(); + tokio::spawn(async move { + transaction + .run( + &workspace, + |workspace| { + workspace.replace_if_current( + &root_uri, + old_snapshot, + old_input.version, + old_input.generation, + ) + }, + |updates| async move { + record_count( + &events, + "first publication started", + &updates, + &dependency_uri, + ); + first_started.notify_one(); + release_first.notified().await; + record_count( + &events, + "first publication finished", + &updates, + &dependency_uri, + ); + }, + ) + .await; + }) + }; + first_wait.await; + let new_input = workspace + .write() + .await + .begin_change(&root_uri, IMPORTING_ROOT, Some(2)) + .expect("new analysis ticket"); + + let second_wait = second_started.notified(); + let second = { + let transaction = StdArc::clone(&transaction); + let workspace = StdArc::clone(&workspace); + let second_started = StdArc::clone(&second_started); + let second_transition_ran = StdArc::clone(&second_transition_ran); + let events = StdArc::clone(&events); + let root_uri = root_uri.clone(); + let dependency_uri = dependency_uri.clone(); + tokio::spawn(async move { + second_started.notify_one(); + transaction + .run( + &workspace, + |workspace| { + second_transition_ran.store(true, Ordering::SeqCst); + workspace.replace_if_current( + &root_uri, + new_snapshot, + new_input.version, + new_input.generation, + ) + }, + |updates| async move { + record_count(&events, "second publication", &updates, &dependency_uri); + }, + ) + .await; + }) + }; + + second_wait.await; + tokio::task::yield_now().await; + assert!(!second_transition_ran.load(Ordering::SeqCst)); + + release_first.notify_one(); + first.await.expect("first transaction"); + second.await.expect("second transaction"); + assert_eq!( + *events.lock().expect("event lock"), + [ + ("first publication started", 1), + ("first publication finished", 1), + ("second publication", 0) + ] + ); + + let workspace = workspace.read().await; + let final_snapshot = workspace.get(&root_uri).expect("latest root snapshot"); + let final_diagnostics = + crate::workspace::diagnostics::DiagnosticBundle::from_snapshot(final_snapshot); + assert!(final_diagnostics.get(&dependency_uri).is_none()); +} + +#[tokio::test] +async fn closing_document_restores_dependency_diagnostics_through_transaction() { + let broken_dependency = "pub fn broken() -> u32 { false }\n"; + let clean_dependency = "pub fn broken() -> u32 { 0 }\n"; + let project = DependencyProject::new(broken_dependency); + let root_snapshot = project.root_snapshot(); + let clean_snapshot = project.dependency_snapshot(clean_dependency); + let root_uri = project.root_uri.clone(); + let dependency_uri = project.dependency_uri.clone(); + + let transaction = DiagnosticTransaction::default(); + let workspace = RwLock::new(WorkspaceState::default()); + let root_input = workspace + .write() + .await + .begin_open(&root_uri, IMPORTING_ROOT, Some(1)); + workspace + .write() + .await + .replace_if_current( + &root_uri, + root_snapshot, + root_input.version, + root_input.generation, + ) + .expect("root diagnostic update"); + let open = workspace + .write() + .await + .begin_open(&dependency_uri, clean_dependency, Some(7)); + let publications = StdArc::new(StdMutex::new(Vec::new())); + + transaction + .run( + &workspace, + |workspace| { + workspace.replace_if_current( + &dependency_uri, + clean_snapshot, + open.version, + open.generation, + ) + }, + { + let publications = StdArc::clone(&publications); + let dependency_uri = dependency_uri.clone(); + move |updates| async move { + let update = updates + .iter() + .find(|update| update.uri == dependency_uri) + .expect("direct dependency update"); + publications + .lock() + .expect("publication lock") + .push((update.diagnostics.len(), update.version)); + } + }, + ) + .await; + + let close_generation = workspace + .write() + .await + .begin_close(&dependency_uri) + .expect("close ticket"); + transaction + .run( + &workspace, + |workspace| workspace.remove_if_current(&dependency_uri, close_generation), + { + let publications = StdArc::clone(&publications); + let dependency_uri = dependency_uri.clone(); + move |updates| async move { + let update = updates + .iter() + .find(|update| update.uri == dependency_uri) + .expect("restored dependency update"); + publications + .lock() + .expect("publication lock") + .push((update.diagnostics.len(), update.version)); + } + }, + ) + .await; + + assert_eq!( + *publications.lock().expect("publication lock"), + [(0, Some(7)), (1, None)] + ); +} + +#[test] +fn test_parse_program_valid() { + 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.iter().count(), 2); +} + +#[test] +fn library_file_without_main_keeps_definition_metadata() { + let source = "fn helper() {}\nfn caller() { helper() }\n"; + 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("library document"); + let call_start = source.rfind("helper").expect("helper call"); + let call = doc + .find_related_call(Span::new(0, call_start + 1..call_start + 1)) + .expect("helper call should remain navigable"); + let function = doc + .functions + .get_func(call.name().to_string().as_str()) + .expect("helper definition"); + let source_file = doc + .sources + .get(function.span().file_id) + .expect("current file should always have source metadata"); + + assert_eq!(function.name().as_inner(), "helper"); + assert_eq!(function.span().file_id, 0); + assert_eq!( + source_file.uri, + Uri::from_file_path(&path).expect("file URI") + ); +} + +#[test] +fn nested_main_does_not_conflict_with_a_synthetic_entry_point() { + let source = "mod nested { fn main() {} }\n"; + let (temp, path) = in_temp_project(source); + let settings = Settings::from_json(serde_json::json!({ + "experimentalFeatures": { "imports": true } + })) + .expect("valid settings"); + + let (errors, doc) = parse_program(source, &path, &settings, &[temp.path().to_path_buf()]); + + assert!( + doc.is_some(), + "the source should remain available to the LSP" + ); + assert!( + !errors.iter().any(|diagnostic| { + matches!( + diagnostic.error(), + Error::FunctionRedefined { name } if name.as_inner() == "main" + ) + }), + "a nested main must not collide with an injected main: {errors:?}" + ); + let expected = Error::MainOutOfEntryFile.to_string(); + assert!( + errors.iter().any(|diagnostic| { + matches!(diagnostic.error(), Error::CannotParse { msg } if msg == &expected) + }), + "the compiler should still report that main is outside the entry scope: {errors:?}" + ); +} + +#[test] +fn use_items_and_aliases_resolve_to_the_imported_definition() { + 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"); + let dependency_path = root.join("simf/math.simf"); + std::fs::write(&dependency_path, "pub fn add() {}\npub fn subtract() {}\n") + .expect("write module"); + let source = "use crate::math::{add as plus, subtract};\nfn main() { plus(); subtract() }\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, doc) = parse_program(source, &path, &settings, &[root.to_path_buf()]); + assert!(errors.is_empty(), "unexpected errors: {errors:?}"); + let doc = doc.expect("document"); + let imported_at = |needle: &str| { + let start = source.find(needle).expect("import token"); + doc.find_imported_function(Span::new(0, start + 1..start + 1)) + .expect("imported function") + }; + + let original = imported_at("add as"); + let alias = imported_at("plus,"); + let grouped_item = imported_at("subtract}"); + assert_eq!(original.name().as_inner(), "add"); + assert_eq!(alias.name().as_inner(), "add"); + assert_eq!(grouped_item.name().as_inner(), "subtract"); + assert!(doc + .find_imported_function(Span::new( + 0, + source.find("math").unwrap() + 1..source.find("math").unwrap() + 1, + )) + .is_none()); + + let source_file = doc + .sources + .get(original.span().file_id) + .expect("imported source metadata"); + assert_eq!( + source_file.uri, + Uri::from_file_path( + std::fs::canonicalize(&dependency_path).expect("canonical dependency path"), + ) + .expect("file URI") + ); +} + +#[test] +fn nested_and_transitive_reexports_resolve_to_original_definitions() { + 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"), + "[dependencies]\nmerkle = { path = 'deps/merkle' }\nfacade = { path = 'deps/facade' }\n", + ); + write(root.join("deps/merkle/Simplex.toml"), ""); + let merkle_path = root.join("deps/merkle/simf/build_root.simf"); + write( + merkle_path.clone(), + "pub mod wrapper {\n pub fn get_root() {}\n pub fn hash() {}\n}\npub use crate::wrapper::{get_root, hash};\n", + ); + write( + root.join("deps/facade/Simplex.toml"), + "[dependencies]\nleaf = { path = '../leaf' }\n", + ); + write( + root.join("deps/facade/simf/smth.simf"), + "pub use leaf::ops::hash;\n", + ); + write(root.join("deps/leaf/Simplex.toml"), ""); + let leaf_path = root.join("deps/leaf/simf/ops.simf"); + write(leaf_path.clone(), "pub fn hash() {}\n"); + + let source = "use merkle::build_root::{get_root, hash as and_hash};\nuse facade::smth::hash as or_hash;\nfn main() { get_root(); and_hash(); or_hash(); }\n"; + let path = root.join("simf/main.simf"); + write(path.clone(), source); + let settings = Settings::from_json(serde_json::json!({ + "experimentalFeatures": { "imports": true } + })) + .expect("valid settings"); + + let (errors, doc) = parse_program(source, &path, &settings, &[root.to_path_buf()]); + assert!(errors.is_empty(), "unexpected errors: {errors:?}"); + let doc = doc.expect("document"); + let imported_at = |offset: usize| { + doc.find_imported_function(Span::new(0, offset + 1..offset + 1)) + .expect("imported function") + }; + + let nested = imported_at(source.find("get_root").expect("nested import")); + let nested_alias = imported_at(source.find("and_hash").expect("nested alias")); + let transitive = imported_at(source.rfind("hash as").expect("transitive import")); + let transitive_alias = imported_at(source.find("or_hash").expect("transitive alias")); + + assert_eq!(nested.name().as_inner(), "get_root"); + assert_eq!(nested_alias.name().as_inner(), "hash"); + assert_eq!(transitive.name().as_inner(), "hash"); + assert_eq!(transitive_alias.name().as_inner(), "hash"); + + let definition_uri = |function: &parse::Function| &doc.sources[function.span().file_id].uri; + let merkle_uri = + Uri::from_file_path(std::fs::canonicalize(merkle_path).expect("canonical merkle path")) + .expect("merkle URI"); + let leaf_uri = + Uri::from_file_path(std::fs::canonicalize(leaf_path).expect("canonical leaf path")) + .expect("leaf URI"); + assert_eq!(definition_uri(nested), &merkle_uri); + assert_eq!(definition_uri(nested_alias), &merkle_uri); + assert_eq!(definition_uri(transitive), &leaf_uri); + assert_eq!(definition_uri(transitive_alias), &leaf_uri); +} + +#[test] +fn parse_program_respects_the_enum_feature_setting() { + let source = "enum Choice { Yes, No, }\nfn main() {}\n"; + let (temp, path) = in_temp_project(source); + + let (disabled, _) = parse_program( + source, + &path, + &Settings::default(), + &[temp.path().to_path_buf()], + ); + assert!(disabled.iter().any(|diagnostic| { + matches!( + diagnostic.error(), + Error::UnstableFeature { + feature: simplicityhl::UnstableFeature::Enums + } + ) + })); + + let settings = Settings::from_json(serde_json::json!({ + "experimentalFeatures": { "imports": false, "enums": true } + })) + .expect("valid settings"); + let (enabled, document) = parse_program(source, &path, &settings, &[temp.path().to_path_buf()]); + + assert!(enabled.is_empty(), "enum should be enabled: {enabled:?}"); + 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 + .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 + .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() { + 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") + .to_string() + .contains("Expected expression of type `u32`, found type `()`"), + "Expected error on return type" + ); + 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 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 imported_diagnostic_points_to_its_real_file() { + 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"); + let library_path = root.join("simf/library.simf"); + let library_source = "pub fn broken() -> (u1, u256) { 0 }\n"; + std::fs::write(&library_path, library_source).expect("write imported module"); + + let source = "use crate::library::broken;\nfn main() { broken(); }\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, document) = parse_program(source, &path, &settings, &[root.to_path_buf()]); + let document = document.expect("document remains available after analysis errors"); + assert!(document.sources.len() > 1); + let bundle = crate::workspace::diagnostics::DiagnosticBundle::from_snapshot(&document); + let library_uri = + Uri::from_file_path(std::fs::canonicalize(&library_path).expect("canonical library path")) + .expect("library URI"); + let root_uri = Uri::from_file_path(std::fs::canonicalize(&path).expect("canonical root")) + .expect("root URI"); + + let imported_error = bundle + .get(&library_uri) + .and_then(|diagnostics| { + diagnostics + .iter() + .find(|diagnostic| diagnostic.message.contains("Expected expression")) + }) + .unwrap_or_else(|| panic!("expected imported diagnostic, got {bundle:?}")); + assert_ne!(imported_error.range, Range::default()); + assert!(!bundle.get(&root_uri).is_some_and(|diagnostics| { + diagnostics + .iter() + .any(|diagnostic| diagnostic.message == imported_error.message) + })); +} + +#[test] +fn lsp_diagnostics_preserve_secondary_labels_notes_and_help() { + let source = "fn main() {}\n"; + let mut document = document_from_source(source); + let diagnostic = CompilerDiagnostic::new( + Error::CannotParse { + msg: "primary".to_string(), + }, + Span::new(0, 3..7), + ) + .with_secondary(Span::new(0, 0..2), "secondary") + .with_note("context") + .with_help("fix it"); + document.compiler_diagnostics = vec![diagnostic]; + let bundle = crate::workspace::diagnostics::DiagnosticBundle::from_snapshot(&document); + let uri = &document.sources[0].uri; + let published = &bundle.get(uri).expect("root diagnostic")[0]; + + assert!(published.message.contains("Note: context")); + assert!(published.message.contains("Help: fix it")); + let related = published + .related_information + .as_ref() + .expect("secondary label becomes related information"); + assert_eq!(related.len(), 1); + assert_eq!(related[0].message, "secondary"); + assert_eq!(related[0].location.uri, document.sources[0].uri); +} + +#[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 (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") + .error() + .clone() + { + Error::Syntax { .. } => {} + _ => panic!("Expected `Syntax` error"), + } + + assert!(doc.is_none(), "Expected no document to return"); +} diff --git a/src/server/transaction.rs b/src/server/transaction.rs new file mode 100644 index 0000000..f5d0af0 --- /dev/null +++ b/src/server/transaction.rs @@ -0,0 +1,33 @@ +use std::future::Future; + +use tokio::sync::{Mutex, RwLock}; + +use crate::workspace::{DiagnosticUpdate, WorkspaceState}; + +/// Serializes one workspace diagnostic transition with its complete publication batch. +#[derive(Debug, Default)] +pub(super) struct DiagnosticTransaction { + gate: Mutex<()>, +} + +impl DiagnosticTransaction { + pub(super) async fn run( + &self, + workspace: &RwLock, + transition: F, + publish: P, + ) where + F: FnOnce(&mut WorkspaceState) -> Option>, + P: FnOnce(Vec) -> Fut, + Fut: Future, + { + let _transaction = self.gate.lock().await; + let updates = { + let mut workspace = workspace.write().await; + transition(&mut workspace) + }; + if let Some(updates) = updates { + publish(updates).await; + } + } +} diff --git a/src/signature_help.rs b/src/signature_help.rs new file mode 100644 index 0000000..855d11f --- /dev/null +++ b/src/signature_help.rs @@ -0,0 +1,242 @@ +use std::str::FromStr; + +use simplicityhl::parse::CallName; +use tower_lsp_server::lsp_types::{ + self, MarkupContent, MarkupKind, ParameterInformation, ParameterLabel, Position, SignatureHelp, + SignatureInformation, +}; + +use crate::analysis::AnalysisSnapshot; +use crate::completion; +use crate::error::LspError; +use crate::text::position_to_offset; + +pub fn at( + snapshot: &AnalysisSnapshot, + position: Position, +) -> Result, LspError> { + let line_index = usize::try_from(position.line)?; + let source_line = snapshot + .text + .get_line(line_index) + .ok_or_else(|| LspError::Internal("Line not found".to_string()))?; + if usize::try_from(position.character)? + > source_line.chars().map(char::len_utf16).sum::() + { + return Ok(None); + } + + let cursor = position_to_offset(position, &snapshot.text)?; + let line_start = snapshot.text.try_line_to_byte(line_index)?; + let line = snapshot + .text + .get_byte_slice(line_start..cursor) + .ok_or_else(|| LspError::Internal("Cursor is outside the current line".to_string()))?; + let Some((function_name, active_parameter)) = call_context(&line.to_string()) else { + return Ok(None); + }; + + let signature = if let Some(jet_name) = function_name.strip_prefix("jet::") { + simplicityhl::simplicity::jet::Elements::from_str(jet_name) + .ok() + .map(completion::jet::jet_to_template) + .as_ref() + .map(signature_information) + } else if let Some((function, documentation)) = snapshot.functions.get(&function_name) { + Some(signature_information(&completion::function_to_template( + function, + documentation, + ))) + } else { + builtin_signature(&function_name) + }; + + Ok(signature.map(|signature| SignatureHelp { + signatures: vec![signature], + active_signature: Some(0), + active_parameter: Some(active_parameter), + })) +} + +/// Find the innermost unclosed function call and its active argument. +fn call_context(line: &str) -> Option<(String, u32)> { + let mut parenthesis_depth = 0; + let mut bracket_depth = 0; + let mut angle_depth = 0; + let mut comma_count = 0; + let open_parenthesis = line + .char_indices() + .rev() + .find_map(|(position, character)| { + match character { + ')' => parenthesis_depth += 1, + '(' if parenthesis_depth > 0 => parenthesis_depth -= 1, + '(' => return Some(position), + ']' => bracket_depth += 1, + '[' if bracket_depth > 0 => bracket_depth -= 1, + '>' => angle_depth += 1, + '<' if angle_depth > 0 => angle_depth -= 1, + ',' if parenthesis_depth == 0 && bracket_depth == 0 && angle_depth == 0 => { + comma_count += 1; + } + _ => {} + } + None + })?; + + function_name(&line[..open_parenthesis]).map(|name| (name, comma_count)) +} + +/// Extract an identifier, qualified name, or generic callable before `(`. +fn function_name(text: &str) -> Option { + let trimmed = text.trim_end(); + let without_generics = if trimmed.ends_with('>') { + let mut depth = 0usize; + let start = trimmed + .char_indices() + .rev() + .find_map(|(position, character)| { + match character { + '>' => depth += 1, + '<' => { + depth = depth.saturating_sub(1); + if depth == 0 { + return Some(position); + } + } + _ => {} + } + None + }); + start.map_or(trimmed, |position| { + let before = &trimmed[..position]; + before.strip_suffix("::").unwrap_or(before) + }) + } else { + trimmed + }; + + let start = without_generics + .char_indices() + .rev() + .take_while(|(_, character)| { + character.is_alphanumeric() || *character == '_' || *character == ':' + }) + .map(|(position, _)| position) + .last()?; + let name = without_generics[start..].trim_start_matches(':'); + (!name.is_empty()).then(|| name.to_string()) +} + +fn signature_information(template: &completion::types::FunctionTemplate) -> SignatureInformation { + SignatureInformation { + label: format!( + "fn {}({}) -> {}", + template.display_name, + template.args.join(", "), + template.return_type + ), + documentation: (!template.description.is_empty()).then(|| { + lsp_types::Documentation::MarkupContent(MarkupContent { + kind: MarkupKind::Markdown, + value: template.description.clone(), + }) + }), + parameters: Some( + template + .args + .iter() + .cloned() + .map(|label| ParameterInformation { + label: ParameterLabel::Simple(label), + documentation: None, + }) + .collect(), + ), + active_parameter: None, + } +} + +fn builtin_signature(name: &str) -> Option { + use simplicityhl::str::AliasName; + use simplicityhl::types::AliasedType; + + let generic = AliasedType::from(AliasName::from_str_unchecked("T")); + let call = match name { + "unwrap_left" => CallName::UnwrapLeft(generic.clone()), + "unwrap_right" => CallName::UnwrapRight(generic.clone()), + "unwrap" => CallName::Unwrap, + "is_none" => CallName::IsNone(generic), + "assert!" => CallName::Assert, + "panic!" => CallName::Panic, + "dbg!" => CallName::Debug, + _ => return None, + }; + completion::builtin::match_callname(&call) + .as_ref() + .map(signature_information) +} + +#[cfg(test)] +mod tests { + use super::*; + use tower_lsp_server::UriExt; + + #[test] + fn extracts_callable_names() { + let cases = [ + ("foo", Some("foo")), + ("my_func", Some("my_func")), + ("jet::add_32", Some("jet::add_32")), + ("fold::", Some("fold")), + ("unwrap_left::", Some("unwrap_left")), + ("let x = foo", Some("foo")), + ("é; fold::", Some("fold")), + ("", None), + ]; + for (text, expected) in cases { + assert_eq!(function_name(text).as_deref(), expected); + } + } + + #[test] + fn finds_nested_call_context_and_active_parameter() { + let cases = [ + ("foo(", Some(("foo", 0))), + ("foo(a, ", Some(("foo", 1))), + ("foo(a, b, ", Some(("foo", 2))), + ("outer(inner(x), ", Some(("outer", 1))), + ("jet::add_32(a, ", Some(("jet::add_32", 1))), + ("add(é, ", Some(("add", 1))), + ("sum(日本, ", Some(("sum", 1))), + ("f(éé", Some(("f", 0))), + ("let x = 5", None), + ]; + for (line, expected) in cases { + assert_eq!( + call_context(line), + expected.map(|(name, index)| (name.to_string(), index)) + ); + } + } + + #[test] + fn utf16_cursor_selects_the_exact_line_prefix() { + let source = "😀 jet::add_32(1, "; + let snapshot = AnalysisSnapshot::new( + tower_lsp_server::lsp_types::Uri::from_file_path( + std::env::temp_dir().join("signature.simf"), + ) + .unwrap(), + ropey::Rope::from_str(source), + ); + let help = at(&snapshot, Position::new(0, 18)) + .expect("valid UTF-16 cursor") + .expect("jet signature"); + + assert_eq!(help.active_parameter, Some(1)); + assert!(help.signatures[0].label.starts_with("fn add_32(")); + assert!(at(&snapshot, Position::new(0, 1)).is_err()); + assert!(at(&snapshot, Position::new(0, 19)).unwrap().is_none()); + } +} diff --git a/src/text.rs b/src/text.rs new file mode 100644 index 0000000..a9f27c7 --- /dev/null +++ b/src/text.rs @@ -0,0 +1,245 @@ +use ropey::Rope; +use tower_lsp_server::lsp_types::{self}; + +use crate::error::LspError; + +pub fn span_contains(a: &simplicityhl::error::Span, b: &simplicityhl::error::Span) -> bool { + a.file_id == b.file_id && a.start <= b.start && a.end >= b.end +} + +/// 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)?, + )) +} + +/// 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`] 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> { + Ok(( + offset_to_position(span.start, rope)?, + offset_to_position(span.end, rope)?, + )) +} + +/// Convert [`tower_lsp_server::lsp_types::Position`] to [`simplicityhl::error::Span`] +/// +/// 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 = position_to_offset(position, rope)?; + + Ok(simplicityhl::error::Span::new(0, start_line..start_line)) +} + +/// Get document comments, using lines above given line index. Only used to +/// get documentation for custom functions. +pub fn get_comments_from_lines(line: u32, rope: &Rope) -> String { + let mut lines = Vec::new(); + + if line == 0 { + return String::new(); + } + + for i in (0..line).rev() { + let Some(rope_slice) = rope.get_line(i as usize) else { + break; + }; + let text = rope_slice.to_string(); + + if text.starts_with("///") { + let doc = text + .strip_prefix("///") + .unwrap_or("") + .trim_end() + .to_string(); + lines.push(doc); + } else { + break; + } + } + + lines.reverse(); + + let mut result = String::new(); + let mut prev_line_was_text = false; + + for line in lines { + let trimmed = line.trim(); + + let is_md_block = trimmed.is_empty() + || trimmed.starts_with('#') + || trimmed.starts_with('-') + || trimmed.starts_with('*') + || trimmed.starts_with('>') + || trimmed.starts_with("```") + || trimmed.starts_with(" "); + + if result.is_empty() { + result.push_str(trimmed); + } else if prev_line_was_text && !is_md_block { + result.push(' '); + result.push_str(trimmed); + } else { + result.push('\n'); + result.push_str(trimmed); + } + + prev_line_was_text = !trimmed.is_empty() && !is_md_block; + } + + result +} + +pub fn get_call_span(call: &simplicityhl::parse::Call) -> simplicityhl::error::Span { + let length = call.name().to_string().len(); + + simplicityhl::error::Span::new( + call.span().file_id, + call.span().start..call.span().start + length, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use ropey::Rope; + + #[test] + fn extracts_contiguous_markdown_comments() { + let cases = [ + ( + "/// This is a test.\n/// It has two lines.\nfn func() {}", + 2, + "This is a test. It has two lines.", + ), + ( + "/// # Title\n/// - Point one\n/// - Point two\nfn func() {}", + 3, + "# Title\n- Point one\n- Point two", + ), + ( + "/// This is not part of the doc \n\n/// This is part of the doc\nfn func() {}", + 3, + "This is part of the doc", + ), + ("fn func() {}", 0, ""), + ]; + for (source, line, expected) in cases { + assert_eq!( + get_comments_from_lines(line, &Rope::from_str(source)), + expected + ); + } + } + + /// 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(0, 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 position_to_offset_uses_utf16_boundaries() { + assert!( + position_to_offset(lsp_types::Position::new(0, 1), &Rope::from_str("😀x")).is_err(), + "a cursor cannot point into the middle of a UTF-16 surrogate pair" + ); + + for (source, utf16_column, byte_offset) in [ + ("😀x", 2, 4), + ("foo", 0, 0), + (" foo()", 4, 4), + ("Ï€x", 1, 2), + ] { + assert_eq!( + position_to_offset( + lsp_types::Position::new(0, utf16_column), + &Rope::from_str(source) + ) + .unwrap(), + byte_offset + ); + } + assert_eq!( + position_to_span(lsp_types::Position::new(0, 4), &Rope::from_str(" foo()")).unwrap(), + simplicityhl::error::Span::new(0, 4..4) + ); + } +} diff --git a/src/utils.rs b/src/utils.rs deleted file mode 100644 index 522301d..0000000 --- a/src/utils.rs +++ /dev/null @@ -1,529 +0,0 @@ -use ropey::Rope; -use tower_lsp_server::lsp_types::{ - self, MarkupContent, MarkupKind, ParameterInformation, ParameterLabel, SignatureInformation, -}; - -use crate::completion; -use crate::error::LspError; -use simplicityhl::parse::CallName; - -pub fn span_contains(a: &simplicityhl::error::Span, b: &simplicityhl::error::Span) -> bool { - a.file_id == b.file_id && a.start <= b.start && a.end >= b.end -} - -/// 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)?, - )) -} - -/// 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`] 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> { - Ok(( - offset_to_position(span.start, rope)?, - offset_to_position(span.end, rope)?, - )) -} - -/// Convert [`tower_lsp_server::lsp_types::Position`] to [`simplicityhl::error::Span`] -/// -/// 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 = position_to_offset(position, rope)?; - - Ok(simplicityhl::error::Span::new(0, start_line..start_line)) -} - -/// Get document comments, using lines above given line index. Only used to -/// get documentation for custom functions. -pub fn get_comments_from_lines(line: u32, rope: &Rope) -> String { - let mut lines = Vec::new(); - - if line == 0 { - return String::new(); - } - - for i in (0..line).rev() { - let Some(rope_slice) = rope.get_line(i as usize) else { - break; - }; - let text = rope_slice.to_string(); - - if text.starts_with("///") { - let doc = text - .strip_prefix("///") - .unwrap_or("") - .trim_end() - .to_string(); - lines.push(doc); - } else { - break; - } - } - - lines.reverse(); - - let mut result = String::new(); - let mut prev_line_was_text = false; - - for line in lines { - let trimmed = line.trim(); - - let is_md_block = trimmed.is_empty() - || trimmed.starts_with('#') - || trimmed.starts_with('-') - || trimmed.starts_with('*') - || trimmed.starts_with('>') - || trimmed.starts_with("```") - || trimmed.starts_with(" "); - - if result.is_empty() { - result.push_str(trimmed); - } else if prev_line_was_text && !is_md_block { - result.push(' '); - result.push_str(trimmed); - } else { - result.push('\n'); - result.push_str(trimmed); - } - - prev_line_was_text = !trimmed.is_empty() && !is_md_block; - } - - result -} - -pub fn get_call_span(call: &simplicityhl::parse::Call) -> simplicityhl::error::Span { - let length = call.name().to_string().len(); - - simplicityhl::error::Span::new( - call.span().file_id, - call.span().start..call.span().start + length, - ) -} - -/// Find the position of a key in the JSON text -pub fn find_key_position(text: &str, key: &str) -> Option { - 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( - 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. -pub fn find_function_call_context(line: &str) -> Option<(String, u32)> { - let mut paren_depth = 0; - let mut bracket_depth = 0; - let mut angle_depth = 0; - let mut last_open_paren = None; - let mut comma_count = 0; - - // 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, - '(' => { - if paren_depth > 0 { - paren_depth -= 1; - } else { - // Found unclosed '(' - this is our function call - last_open_paren = Some(pos); - break; - } - } - ']' => bracket_depth += 1, - '[' if bracket_depth > 0 => bracket_depth -= 1, - '>' => angle_depth += 1, - '<' if angle_depth > 0 => angle_depth -= 1, - ',' if paren_depth == 0 && bracket_depth == 0 && angle_depth == 0 => { - comma_count += 1; - } - _ => {} - } - } - - let open_paren_pos = last_open_paren?; - - // Extract function name before the '(' - let before_paren = &line[..open_paren_pos]; - let func_name = extract_function_name(before_paren)?; - - Some((func_name, comma_count)) -} - -/// Extract function name from text before an opening parenthesis. -/// Handles patterns like: `func_name`, `jet::add_32`, `fold::` -pub fn extract_function_name(text: &str) -> Option { - let trimmed = text.trim_end(); - - // Skip generic parameters if present (e.g., `fold::`) - let without_generics = if trimmed.ends_with('>') { - let mut depth = 0usize; - let mut start = None; - // 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 = depth.saturating_sub(1); - if depth == 0 { - start = Some(i); - break; - } - } - _ => {} - } - } - match start { - Some(pos) => { - let before = &trimmed[..pos]; - // Remove the `::` before `<` if present - before.strip_suffix("::").unwrap_or(before) - } - None => trimmed, - } - } else { - trimmed - }; - - // Now find the function name - it should be an identifier possibly with `::` - let mut name_chars = Vec::new(); - - for ch in without_generics.chars().rev() { - if ch.is_alphanumeric() || ch == '_' || ch == ':' { - name_chars.push(ch); - } else { - break; - } - } - - if name_chars.is_empty() { - return None; - } - - name_chars.reverse(); - let name: String = name_chars.into_iter().collect(); - - // Clean up leading colons - let cleaned = name.trim_start_matches(':'); - if cleaned.is_empty() { - None - } else { - Some(cleaned.to_string()) - } -} - -/// Create `SignatureInformation` from a `FunctionTemplate`. -pub fn create_signature_info( - template: &completion::types::FunctionTemplate, -) -> SignatureInformation { - let params: Vec = template - .args - .iter() - .map(|arg| ParameterInformation { - label: ParameterLabel::Simple(arg.clone()), - documentation: None, - }) - .collect(); - - let signature_label = format!( - "fn {}({}) -> {}", - template.display_name, - template.args.join(", "), - template.return_type - ); - - SignatureInformation { - label: signature_label, - documentation: if template.description.is_empty() { - None - } else { - Some(lsp_types::Documentation::MarkupContent(MarkupContent { - kind: MarkupKind::Markdown, - value: template.description.clone(), - })) - }, - parameters: Some(params), - active_parameter: None, - } -} - -/// Find signature for builtin functions. -pub fn find_builtin_signature(name: &str) -> Option { - use simplicityhl::str::AliasName; - use simplicityhl::types::AliasedType; - - let ty = AliasedType::from(AliasName::from_str_unchecked("T")); - - // Match common builtin function names - let call_name = match name { - "unwrap_left" => Some(CallName::UnwrapLeft(ty.clone())), - "unwrap_right" => Some(CallName::UnwrapRight(ty.clone())), - "unwrap" => Some(CallName::Unwrap), - "is_none" => Some(CallName::IsNone(ty.clone())), - "assert!" => Some(CallName::Assert), - "panic!" => Some(CallName::Panic), - "dbg!" => Some(CallName::Debug), - _ => None, - }; - - let call_name = call_name?; - let template = completion::builtin::match_callname(&call_name)?; - Some(create_signature_info(&template)) -} - -#[cfg(test)] -mod tests { - use super::*; - use ropey::Rope; - - #[test] - fn test_get_comments_from_lines() { - let text = Rope::from_str("/// This is a test.\n/// It has two lines.\nfn func() {}"); - let result = get_comments_from_lines(2, &text); - assert_eq!(result, "This is a test. It has two lines."); - - let text = Rope::from_str("/// # Title\n/// - Point one\n/// - Point two\nfn func() {}"); - let result = get_comments_from_lines(3, &text); - assert_eq!(result, "# Title\n- Point one\n- Point two"); - - let text = Rope::from_str( - "/// This is not part of the doc \n\n/// This is part of the doc\nfn func() {}", - ); - let result = get_comments_from_lines(3, &text); - assert_eq!(result, "This is part of the doc"); - - let text = Rope::from_str("fn func() {}"); - let result = get_comments_from_lines(0, &text); - assert_eq!(result, ""); - } - - #[test] - fn test_extract_function_name() { - // Simple function name - assert_eq!(extract_function_name("foo"), Some("foo".to_string())); - assert_eq!( - extract_function_name("my_func"), - Some("my_func".to_string()) - ); - - // With module prefix - assert_eq!( - extract_function_name("jet::add_32"), - Some("jet::add_32".to_string()) - ); - - // With generic parameters - assert_eq!( - extract_function_name("fold::"), - Some("fold".to_string()) - ); - assert_eq!( - extract_function_name("unwrap_left::"), - Some("unwrap_left".to_string()) - ); - - // With leading whitespace/expressions - assert_eq!( - extract_function_name("let x = foo"), - Some("foo".to_string()) - ); - - // Empty input - assert_eq!(extract_function_name(""), None); - } - - #[test] - fn test_find_function_call_context() { - // Simple function call - assert_eq!( - find_function_call_context("foo("), - Some(("foo".to_string(), 0)) - ); - assert_eq!( - find_function_call_context("foo(a, "), - Some(("foo".to_string(), 1)) - ); - assert_eq!( - find_function_call_context("foo(a, b, "), - Some(("foo".to_string(), 2)) - ); - - // Nested function calls - assert_eq!( - find_function_call_context("outer(inner(x), "), - Some(("outer".to_string(), 1)) - ); - - // With module prefix - assert_eq!( - find_function_call_context("jet::add_32(a, "), - Some(("jet::add_32".to_string(), 1)) - ); - - // 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(0, 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(0, 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); - } - - #[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()) - ); - } -} diff --git a/src/witness/mod.rs b/src/witness/mod.rs new file mode 100644 index 0000000..5ffdce5 --- /dev/null +++ b/src/witness/mod.rs @@ -0,0 +1,161 @@ +use std::collections::HashMap; + +use tower_lsp_server::lsp_types::{Diagnostic, Position, Range}; + +/// Validate a witness (`.wit`) document and return diagnostics in LSP coordinates. +pub fn validate(text: &str) -> Vec { + let json: serde_json::Value = match serde_json::from_str(text) { + Ok(value) => value, + Err(error) => { + let position = json_error_position(text, &error); + return vec![Diagnostic::new_simple( + Range::new( + position, + Position::new(position.line, position.character.saturating_add(1)), + ), + format!("JSON syntax error: {error}"), + )]; + } + }; + + let Some(witnesses) = json.as_object() else { + return vec![Diagnostic::new_simple( + Range::new(Position::new(0, 0), Position::new(0, 1)), + "Witness file must be a JSON object".to_string(), + )]; + }; + + let positions = key_positions(text); + let mut diagnostics = Vec::new(); + for (name, value) in witnesses { + let Some(witness) = value.as_object() else { + push_at_key( + &mut diagnostics, + &positions, + name, + format!("Witness '{name}' must be an object with 'value' and 'type' fields"), + ); + continue; + }; + + for field in ["value", "type"] { + if !witness.contains_key(field) { + push_at_key( + &mut diagnostics, + &positions, + name, + format!("Witness '{name}' is missing required '{field}' field"), + ); + } + } + } + diagnostics +} + +fn push_at_key( + diagnostics: &mut Vec, + positions: &HashMap, + key: &str, + message: String, +) { + if let Some(&position) = positions.get(key) { + diagnostics.push(Diagnostic::new_simple( + Range::new(position, position), + message, + )); + } +} + +// TODO: Replace this scanner with a standard span-aware JSON parser once one can provide decoded +// top-level object keys together with their original source ranges. +fn key_positions(text: &str) -> HashMap { + let mut positions = HashMap::new(); + let mut depth = 0_usize; + let mut line = 0_usize; + let mut utf16_column = 0_usize; + let mut expecting_top_level_key = false; + let mut in_string = false; + let mut escaped = false; + let mut string_is_key = false; + let mut string_start = 0_usize; + let mut string_position = Position::new(0, 0); + + for (byte_index, character) in text.char_indices() { + if in_string { + if escaped { + escaped = false; + } else if character == '\\' { + escaped = true; + } else if character == '"' { + in_string = false; + if string_is_key { + let quoted = &text[string_start..byte_index + character.len_utf8()]; + if let Ok(key) = serde_json::from_str::(quoted) { + positions.insert(key, string_position); + } + expecting_top_level_key = false; + } + } + } else { + match character { + '"' => { + in_string = true; + string_is_key = depth == 1 && expecting_top_level_key; + string_start = byte_index; + string_position = Position::new( + u32::try_from(line).unwrap_or(u32::MAX), + u32::try_from(utf16_column).unwrap_or(u32::MAX), + ); + } + '{' | '[' => { + depth += 1; + if depth == 1 && character == '{' { + expecting_top_level_key = true; + } + } + '}' | ']' => { + if depth == 1 { + expecting_top_level_key = false; + } + depth = depth.saturating_sub(1); + } + ',' if depth == 1 => expecting_top_level_key = true, + _ => {} + } + } + + if character == '\n' { + line += 1; + utf16_column = 0; + } else { + utf16_column += character.len_utf16(); + } + } + + positions +} + +fn json_error_position(text: &str, error: &serde_json::Error) -> Position { + let line_number = error.line().saturating_sub(1); + let byte_column = error.column().saturating_sub(1); + let utf16_column = text + .lines() + .nth(line_number) + .map(|line| utf16_column_at_byte(line, byte_column)) + .unwrap_or_default(); + Position::new( + u32::try_from(line_number).unwrap_or(u32::MAX), + u32::try_from(utf16_column).unwrap_or(u32::MAX), + ) +} + +fn utf16_column_at_byte(line: &str, byte_column: usize) -> usize { + let mut boundary = byte_column.min(line.len()); + while !line.is_char_boundary(boundary) { + boundary -= 1; + } + line[..boundary].encode_utf16().count() +} + +#[cfg(test)] +mod tests; diff --git a/src/witness/tests.rs b/src/witness/tests.rs new file mode 100644 index 0000000..8dfac7f --- /dev/null +++ b/src/witness/tests.rs @@ -0,0 +1,88 @@ +use super::*; + +#[test] +fn validates_witness_shape_and_required_fields() { + let cases = [ + ("[]", "Witness file must be a JSON object"), + ( + r#"{"amount": 1}"#, + "Witness 'amount' must be an object with 'value' and 'type' fields", + ), + ( + r#"{"amount":{"type":"u32"}}"#, + "Witness 'amount' is missing required 'value' field", + ), + ( + r#"{"amount":{"value":1}}"#, + "Witness 'amount' is missing required 'type' field", + ), + ]; + + for (text, expected) in cases { + assert_eq!(validate(text)[0].message, expected); + } + assert!(validate(r#"{"amount":{"value":1,"type":"u32"}}"#).is_empty()); +} + +#[test] +fn key_diagnostics_use_utf16_columns() { + let text = r#"{"😀":{"value":1,"type":"u32"},"amount":{"value":1}}"#; + let diagnostic = validate(text) + .into_iter() + .find(|item| item.message.contains("amount")) + .expect("missing type diagnostic"); + + assert_eq!( + diagnostic.range, + Range::new(Position::new(0, 31), Position::new(0, 31)) + ); +} + +#[test] +fn key_diagnostics_point_to_top_level_escaped_keys() { + let text = r#"{"first":{"value":"a\"😀","type":"str"},"a\"😀":{"value":1}}"#; + let key_start = text.rfind(r#""a\"😀""#).expect("top-level key"); + let expected_column = text[..key_start].encode_utf16().count(); + let diagnostic = validate(text) + .into_iter() + .find(|item| item.message.contains(r#"a"😀"#)) + .expect("missing type diagnostic"); + + assert_eq!( + diagnostic.range, + Range::new( + Position::new(0, u32::try_from(expected_column).unwrap()), + Position::new(0, u32::try_from(expected_column).unwrap()), + ) + ); +} + +#[test] +fn duplicate_decoded_keys_point_to_the_surviving_member() { + let text = r#"{"\u0061":{"value":1,"type":"u32"},"a":{"value":1}}"#; + let key_start = text.rfind(r#""a""#).expect("surviving key"); + let expected_column = text[..key_start].encode_utf16().count(); + let diagnostic = validate(text) + .into_iter() + .find(|item| item.message.contains("Witness 'a'")) + .expect("missing type diagnostic"); + + assert_eq!( + diagnostic.range, + Range::new( + Position::new(0, u32::try_from(expected_column).unwrap()), + Position::new(0, u32::try_from(expected_column).unwrap()), + ) + ); +} + +#[test] +fn syntax_diagnostics_use_utf16_columns_and_json_lines() { + let diagnostic = &validate("{\n \"😀\": 1, ]\n}")[0]; + + assert!(diagnostic.message.starts_with("JSON syntax error:")); + assert_eq!( + diagnostic.range, + Range::new(Position::new(1, 11), Position::new(1, 12)) + ); +} diff --git a/src/workspace.rs b/src/workspace.rs deleted file mode 100644 index d80a2ec..0000000 --- a/src/workspace.rs +++ /dev/null @@ -1,778 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use tower_lsp_server::lsp_types::{Diagnostic, Uri}; - -use crate::analysis::AnalysisSnapshot; -use crate::diagnostics::DiagnosticBundle; -use crate::navigation::FunctionIdentity; - -#[derive(Debug)] -struct RootAnalysis { - snapshot: AnalysisSnapshot, - diagnostics: DiagnosticBundle, -} - -#[derive(Clone, Debug, Default)] -struct DocumentState { - generation: u64, - open: bool, - text: Option>, - version: Option, -} - -pub(crate) type AnalysisGeneration = u64; - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct AnalysisInput { - pub uri: Uri, - pub text: Arc, - pub version: Option, - pub generation: AnalysisGeneration, -} - -/// One atomic diagnostic publication generated by replacing or removing a root analysis. -#[derive(Debug)] -pub struct DiagnosticUpdate { - pub uri: Uri, - pub diagnostics: Vec, - pub version: Option, -} - -/// Owns open-root analyses and all policy for cross-root diagnostic aggregation. -#[derive(Debug, Default)] -pub struct WorkspaceState { - roots: HashMap, - documents: HashMap, -} - -impl WorkspaceState { - pub fn get(&self, uri: &Uri) -> Option<&AnalysisSnapshot> { - self.roots.get(uri).map(|root| &root.snapshot) - } - - pub fn values(&self) -> impl Iterator { - self.roots.values().map(|root| &root.snapshot) - } - - pub(crate) fn begin_open( - &mut self, - uri: &Uri, - text: &str, - version: Option, - ) -> AnalysisInput { - let text = Arc::::from(text); - let state = self.documents.entry(uri.clone()).or_default(); - state.generation = state.generation.wrapping_add(1); - state.open = true; - state.text = Some(Arc::clone(&text)); - state.version = version; - AnalysisInput { - uri: uri.clone(), - text, - version, - generation: state.generation, - } - } - - pub(crate) fn begin_change( - &mut self, - uri: &Uri, - text: &str, - version: Option, - ) -> Option { - let state = self.documents.get_mut(uri)?; - if !state.open { - return None; - } - if matches!( - (version, state.version), - (Some(incoming), Some(current)) if incoming < current - ) { - return None; - } - let text = Arc::::from(text); - state.generation = state.generation.wrapping_add(1); - state.text = Some(Arc::clone(&text)); - if version.is_some() { - state.version = version; - } - Some(AnalysisInput { - uri: uri.clone(), - text, - version: state.version, - generation: state.generation, - }) - } - - /// Snapshot all open buffers and reserve their analysis generations atomically. - pub(crate) fn begin_reanalysis(&mut self) -> Vec { - self.documents - .iter_mut() - .filter_map(|(uri, state)| { - if !state.open { - return None; - } - let text = Arc::clone( - state - .text - .as_ref() - .expect("open documents retain their source text"), - ); - state.generation = state.generation.wrapping_add(1); - Some(AnalysisInput { - uri: uri.clone(), - text, - version: state.version, - generation: state.generation, - }) - }) - .collect() - } - - pub(crate) fn begin_close(&mut self, uri: &Uri) -> Option { - let state = self.documents.get_mut(uri)?; - if !state.open { - return None; - } - state.generation = state.generation.wrapping_add(1); - state.open = false; - state.text = None; - state.version = None; - Some(state.generation) - } - - fn is_current(&self, uri: &Uri, generation: AnalysisGeneration, open: bool) -> bool { - self.documents - .get(uri) - .is_some_and(|state| state.generation == generation && state.open == open) - } - - pub(crate) fn replace_if_current( - &mut self, - origin: &Uri, - snapshot: AnalysisSnapshot, - incoming_version: Option, - generation: AnalysisGeneration, - ) -> Option> { - self.is_current(origin, generation, true) - .then(|| self.replace_inner(origin, snapshot, incoming_version)) - } - - pub(crate) fn remove_if_current( - &mut self, - origin: &Uri, - generation: AnalysisGeneration, - ) -> Option> { - self.is_current(origin, generation, false) - .then(|| self.remove(origin)) - } - - pub(crate) fn diagnostics_if_current( - &self, - uri: Uri, - diagnostics: Vec, - version: Option, - generation: AnalysisGeneration, - ) -> Option> { - self.is_current(&uri, generation, true).then(|| { - vec![DiagnosticUpdate { - uri, - diagnostics, - version, - }] - }) - } - - pub(crate) fn find_references_to( - &self, - target: &FunctionIdentity, - ) -> Vec { - let mut locations = self - .values() - .filter_map(|snapshot| snapshot.find_references_to(target).ok()) - .flatten() - .collect::>(); - locations.sort_unstable_by(|left, right| { - left.uri - .as_str() - .cmp(right.uri.as_str()) - .then(left.range.start.line.cmp(&right.range.start.line)) - .then(left.range.start.character.cmp(&right.range.start.character)) - .then(left.range.end.line.cmp(&right.range.end.line)) - .then(left.range.end.character.cmp(&right.range.end.character)) - }); - locations.dedup(); - locations - } - - fn replace_inner( - &mut self, - origin: &Uri, - mut snapshot: AnalysisSnapshot, - version: Option, - ) -> Vec { - snapshot.version = version; - let diagnostics = DiagnosticBundle::from_snapshot(&snapshot); - let mut affected = self - .roots - .get(origin) - .into_iter() - .flat_map(|root| root.diagnostics.uris().cloned()) - .chain(diagnostics.uris().cloned()) - .collect::>(); - affected.push(origin.clone()); - - self.roots.insert( - origin.clone(), - RootAnalysis { - snapshot, - diagnostics, - }, - ); - self.updates(affected, Some((origin, version))) - } - - pub fn remove(&mut self, origin: &Uri) -> Vec { - let mut affected = self - .roots - .remove(origin) - .map(|root| root.diagnostics.uris().cloned().collect::>()) - .unwrap_or_default(); - affected.push(origin.clone()); - self.updates(affected, None) - } - - fn updates( - &self, - mut affected: Vec, - direct: Option<(&Uri, Option)>, - ) -> Vec { - affected.sort_unstable_by(|left, right| left.as_str().cmp(right.as_str())); - affected.dedup(); - affected - .into_iter() - .map(|uri| DiagnosticUpdate { - diagnostics: self.diagnostics_for(&uri), - version: direct - .filter(|(direct_uri, _)| *direct_uri == &uri) - .and_then(|(_, version)| version), - uri, - }) - .collect() - } - - fn diagnostics_for(&self, target: &Uri) -> Vec { - // An open file's unsaved analysis is authoritative over saved dependency copies. - if let Some(root) = self.roots.get(target) { - return root - .diagnostics - .get(target) - .map(<[Diagnostic]>::to_vec) - .unwrap_or_default(); - } - - let mut origins = self.roots.iter().collect::>(); - origins.sort_unstable_by(|(left, _), (right, _)| left.as_str().cmp(right.as_str())); - let mut diagnostics = Vec::new(); - for (_, root) in origins { - let Some(contributed) = root.diagnostics.get(target) else { - continue; - }; - for diagnostic in contributed { - if !diagnostics.contains(diagnostic) { - diagnostics.push(diagnostic.clone()); - } - } - } - diagnostics - } -} - -#[cfg(test)] -mod tests { - use std::path::Path; - - use tempfile::TempDir; - use tower_lsp_server::UriExt; - - use super::*; - use crate::config::Settings; - - fn write(path: impl AsRef, source: &str) { - let path = path.as_ref(); - std::fs::create_dir_all(path.parent().expect("path has parent")).expect("create directory"); - std::fs::write(path, source).expect("write source"); - } - - fn imports_enabled() -> Settings { - Settings::from_json(serde_json::json!({ - "experimentalFeatures": { "imports": true } - })) - .expect("valid settings") - } - - fn analyze(source: &str, path: &Path, root: &Path) -> AnalysisSnapshot { - AnalysisSnapshot::analyze(source, path, &imports_enabled(), &[root.to_path_buf()]) - } - - fn update_for<'a>(updates: &'a [DiagnosticUpdate], uri: &Uri) -> &'a DiagnosticUpdate { - updates - .iter() - .find(|update| &update.uri == uri) - .expect("URI should be republished") - } - - fn canonical_uri(path: &Path) -> Uri { - Uri::from_file_path(std::fs::canonicalize(path).expect("canonical path")).expect("file URI") - } - - fn temporary_uri(name: &str) -> Uri { - Uri::from_file_path(std::env::temp_dir().join(name)).expect("temporary file URI") - } - - fn insert_analysis(state: &mut WorkspaceState, source: &str, path: &Path, root: &Path) -> Uri { - let uri = canonical_uri(path); - state.replace_inner(&uri, analyze(source, path, root), Some(1)); - uri - } - - #[test] - fn references_exclude_unrelated_same_named_functions() { - let temp = TempDir::new().expect("temp dir"); - let root = temp.path(); - write(root.join("Simplex.toml"), ""); - let source = "fn helper() {}\nfn main() { helper(); }\n"; - let first_path = root.join("simf/first.simf"); - let second_path = root.join("simf/second.simf"); - write(&first_path, source); - write(&second_path, source); - - let mut state = WorkspaceState::default(); - let first_uri = insert_analysis(&mut state, source, &first_path, root); - let second_uri = insert_analysis(&mut state, source, &second_path, root); - let target = { - let snapshot = state.get(&first_uri).expect("first analysis"); - snapshot - .function_identity( - snapshot - .functions - .get_func("helper") - .expect("helper function"), - ) - .expect("helper identity") - }; - - let references = state.find_references_to(&target); - assert_eq!(references.len(), 1); - assert_eq!(references[0].uri, first_uri); - assert_ne!(references[0].uri, second_uri); - } - - #[test] - fn references_follow_aliases_to_one_definition_across_roots() { - let temp = TempDir::new().expect("temp dir"); - let root = temp.path(); - write(root.join("Simplex.toml"), ""); - write(root.join("simf/shared.simf"), "pub fn target() {}\n"); - let first_source = "use crate::shared::target as alpha;\nfn main() { alpha(); }\n"; - let second_source = "use crate::shared::target as beta;\nfn main() { beta(); }\n"; - let first_path = root.join("simf/first.simf"); - let second_path = root.join("simf/second.simf"); - write(&first_path, first_source); - write(&second_path, second_source); - - let mut state = WorkspaceState::default(); - let first_uri = insert_analysis(&mut state, first_source, &first_path, root); - let second_uri = insert_analysis(&mut state, second_source, &second_path, root); - let target = { - let snapshot = state.get(&first_uri).expect("first analysis"); - snapshot - .function_identity( - snapshot - .functions - .get_func("alpha") - .expect("aliased target"), - ) - .expect("target identity") - }; - - let references = state.find_references_to(&target); - assert_eq!(references.len(), 2); - assert_eq!(references[0].uri, first_uri); - assert_eq!(references[1].uri, second_uri); - } - - #[test] - fn references_deduplicate_shared_dependency_locations() { - let temp = TempDir::new().expect("temp dir"); - let root = temp.path(); - write(root.join("Simplex.toml"), ""); - let shared_path = root.join("simf/shared.simf"); - write( - &shared_path, - "pub fn target() {}\npub fn invoke() { target(); }\n", - ); - let source = "use crate::shared::{target, invoke};\nfn main() { target(); invoke(); }\n"; - let first_path = root.join("simf/first.simf"); - let second_path = root.join("simf/second.simf"); - write(&first_path, source); - write(&second_path, source); - - let mut state = WorkspaceState::default(); - let first_uri = insert_analysis(&mut state, source, &first_path, root); - let second_uri = insert_analysis(&mut state, source, &second_path, root); - let shared_uri = canonical_uri(&shared_path); - let target = { - let snapshot = state.get(&first_uri).expect("first analysis"); - snapshot - .function_identity( - snapshot - .functions - .get_func("target") - .expect("imported target"), - ) - .expect("target identity") - }; - - let references = state.find_references_to(&target); - assert_eq!(references.len(), 3); - assert_eq!( - references - .iter() - .filter(|location| location.uri == shared_uri) - .count(), - 1 - ); - assert!(references.iter().any(|location| location.uri == first_uri)); - assert!(references.iter().any(|location| location.uri == second_uri)); - } - - #[test] - fn dependency_references_use_the_owning_module_scope() { - let temp = TempDir::new().expect("temp dir"); - let root = temp.path(); - write(root.join("Simplex.toml"), ""); - write(root.join("simf/a.simf"), "pub fn target() {}\n"); - let second_dependency = root.join("simf/b.simf"); - write( - &second_dependency, - "pub fn target() {}\npub fn wrapper() { target(); }\n", - ); - let source = - "use crate::a::target;\nuse crate::b::wrapper;\nfn main() { target(); wrapper(); }\n"; - let root_path = root.join("simf/main.simf"); - write(&root_path, source); - - let mut state = WorkspaceState::default(); - let root_uri = insert_analysis(&mut state, source, &root_path, root); - let second_dependency_uri = canonical_uri(&second_dependency); - let target = { - let snapshot = state.get(&root_uri).expect("root analysis"); - snapshot - .function_identity( - snapshot - .functions - .get_func("target") - .expect("first dependency target"), - ) - .expect("target identity") - }; - - let references = state.find_references_to(&target); - assert_eq!(references.len(), 1); - assert_eq!(references[0].uri, root_uri); - assert!(!references - .iter() - .any(|location| location.uri == second_dependency_uri)); - } - - #[test] - fn close_generation_rejects_an_in_flight_analysis() { - let temp = TempDir::new().expect("temp dir"); - let root = temp.path(); - write(root.join("Simplex.toml"), ""); - let source = "fn main() {}\n"; - let path = root.join("simf/main.simf"); - write(&path, source); - let uri = canonical_uri(&path); - let mut state = WorkspaceState::default(); - - let analysis_input = state.begin_open(&uri, source, Some(1)); - let snapshot = analyze(source, &path, root); - let close_generation = state.begin_close(&uri).expect("close ticket"); - - assert!(state - .replace_if_current(&uri, snapshot, Some(1), analysis_input.generation) - .is_none()); - assert!(state.remove_if_current(&uri, close_generation).is_some()); - assert!(state.get(&uri).is_none()); - } - - #[test] - fn newer_generation_wins_when_document_versions_are_equal() { - let temp = TempDir::new().expect("temp dir"); - let root = temp.path(); - write(root.join("Simplex.toml"), ""); - let old_source = "fn old() {}\nfn main() {}\n"; - let new_source = "fn new() {}\nfn main() {}\n"; - let path = root.join("simf/main.simf"); - write(&path, new_source); - let uri = canonical_uri(&path); - let mut state = WorkspaceState::default(); - - let old_input = state.begin_open(&uri, old_source, Some(1)); - let old_snapshot = analyze(old_source, &path, root); - let new_input = state - .begin_change(&uri, new_source, Some(1)) - .expect("open document"); - let new_snapshot = analyze(new_source, &path, root); - - assert!(state - .replace_if_current(&uri, new_snapshot, Some(1), new_input.generation) - .is_some()); - assert!(state - .replace_if_current(&uri, old_snapshot, Some(1), old_input.generation) - .is_none()); - assert_eq!( - state.get(&uri).expect("current analysis").text.to_string(), - new_source - ); - } - - #[test] - fn older_change_is_rejected_before_analysis() { - let uri = temporary_uri("change-order.simf"); - let mut state = WorkspaceState::default(); - state.begin_open(&uri, "fn initial() {}\n", Some(1)); - state - .begin_change(&uri, "fn newest() {}\n", Some(3)) - .expect("newer change"); - - assert!(state - .begin_change(&uri, "fn stale() {}\n", Some(2)) - .is_none()); - let current = state.begin_reanalysis().pop().expect("current document"); - assert_eq!(current.text.as_ref(), "fn newest() {}\n"); - assert_eq!(current.version, Some(3)); - } - - #[test] - fn reanalysis_includes_documents_with_pending_initial_analysis() { - let temp = TempDir::new().expect("temp dir"); - let root = temp.path(); - write(root.join("Simplex.toml"), ""); - let source = "fn main() {}\n"; - let path = root.join("simf/main.simf"); - write(&path, source); - let uri = canonical_uri(&path); - let mut state = WorkspaceState::default(); - - let initial_input = state.begin_open(&uri, source, Some(1)); - assert!(state.get(&uri).is_none()); - - let requests = state.begin_reanalysis(); - assert_eq!(requests.len(), 1); - let request = &requests[0]; - assert_eq!(request.uri, uri); - assert_eq!(request.text.as_ref(), source); - assert_eq!(request.version, Some(1)); - assert_ne!(request.generation, initial_input.generation); - } - - #[test] - fn pre_close_reanalysis_cannot_overwrite_a_reopened_document() { - let temp = TempDir::new().expect("temp dir"); - let root = temp.path(); - write(root.join("Simplex.toml"), ""); - let old_source = "fn old() {}\nfn main() {}\n"; - let new_source = "fn new() {}\nfn main() {}\n"; - let path = root.join("simf/main.simf"); - write(&path, new_source); - let uri = canonical_uri(&path); - let mut state = WorkspaceState::default(); - - let old_input = state.begin_open(&uri, old_source, Some(5)); - state - .replace_if_current( - &uri, - analyze(old_source, &path, root), - old_input.version, - old_input.generation, - ) - .expect("initial analysis"); - let stale_request = state.begin_reanalysis().pop().expect("reanalysis request"); - let close_generation = state.begin_close(&uri).expect("close ticket"); - let reopened_input = state.begin_open(&uri, new_source, Some(1)); - - assert!(state.remove_if_current(&uri, close_generation).is_none()); - assert!(state - .replace_if_current( - &uri, - analyze(old_source, &path, root), - stale_request.version, - stale_request.generation, - ) - .is_none()); - assert!(state - .replace_if_current( - &uri, - analyze(new_source, &path, root), - Some(1), - reopened_input.generation, - ) - .is_some()); - assert_eq!( - state.get(&uri).expect("reopened analysis").text.to_string(), - new_source - ); - assert_eq!(state.get(&uri).expect("reopened analysis").version, Some(1)); - } - - #[test] - fn save_ticket_keeps_the_last_known_version() { - let uri = temporary_uri("save-ticket.simf"); - let mut state = WorkspaceState::default(); - state.begin_open(&uri, "fn main() {}\n", Some(7)); - - let save = state - .begin_change(&uri, "fn main() {}\n", None) - .expect("save ticket"); - - assert_eq!(save.version, Some(7)); - } - - #[test] - fn closing_document_releases_its_buffer_payload() { - let uri = temporary_uri("closed-buffer.simf"); - let mut state = WorkspaceState::default(); - state.begin_open(&uri, "fn main() {}\n", Some(3)); - - state.begin_close(&uri).expect("close ticket"); - - let document = state.documents.get(&uri).expect("generation tombstone"); - assert!(!document.open); - assert!(document.text.is_none()); - assert_eq!(document.version, None); - assert!(state.begin_close(&uri).is_none()); - - let unknown = temporary_uri("unknown-close.simf"); - assert!(state.begin_close(&unknown).is_none()); - assert!(!state.documents.contains_key(&unknown)); - } - - #[test] - fn shared_dependencies_deduplicate_and_open_buffers_take_precedence() { - let temp = TempDir::new().expect("temp dir"); - let root = temp.path(); - write(root.join("Simplex.toml"), ""); - let dependency_path = root.join("simf/shared.simf"); - write(&dependency_path, "pub fn broken() -> u32 { false }\n"); - let root_source = "use crate::shared::broken;\nfn main() { broken(); }\n"; - let root_a = root.join("simf/a.simf"); - let root_b = root.join("simf/b.simf"); - write(&root_a, root_source); - write(&root_b, root_source); - - let dependency_uri = Uri::from_file_path( - std::fs::canonicalize(&dependency_path).expect("canonical dependency"), - ) - .expect("dependency URI"); - let first_root_uri = - Uri::from_file_path(std::fs::canonicalize(&root_a).expect("canonical root")) - .expect("root URI"); - let second_root_uri = - Uri::from_file_path(std::fs::canonicalize(&root_b).expect("canonical root")) - .expect("root URI"); - let mut state = WorkspaceState::default(); - - let updates = state.replace_inner( - &first_root_uri, - analyze(root_source, &root_a, root), - Some(1), - ); - assert_eq!(update_for(&updates, &first_root_uri).version, Some(1)); - let imported = update_for(&updates, &dependency_uri); - assert_eq!(imported.version, None); - assert_eq!(imported.diagnostics.len(), 1); - - let updates = state.replace_inner( - &second_root_uri, - analyze(root_source, &root_b, root), - Some(1), - ); - assert_eq!(update_for(&updates, &dependency_uri).diagnostics.len(), 1); - - // The buffer differs from the saved dependency used by both roots. Its direct clean - // analysis is authoritative until that document closes. - let clean_dependency = "pub fn broken() -> u32 { 0 }\n"; - let updates = state.replace_inner( - &dependency_uri, - analyze(clean_dependency, &dependency_path, root), - Some(7), - ); - let direct = update_for(&updates, &dependency_uri); - assert!(direct.diagnostics.is_empty()); - assert_eq!(direct.version, Some(7)); - - let updates = state.remove(&dependency_uri); - let restored = update_for(&updates, &dependency_uri); - assert_eq!(restored.version, None); - assert_eq!(restored.diagnostics.len(), 1); - } - - #[test] - fn replacing_roots_clears_removed_dependency_diagnostics() { - let temp = TempDir::new().expect("temp dir"); - let root = temp.path(); - write(root.join("Simplex.toml"), ""); - let dependency_path = root.join("simf/shared.simf"); - write(&dependency_path, "pub fn broken() -> u32 { false }\n"); - let root_path = root.join("simf/main.simf"); - let importing = "use crate::shared::broken;\nfn main() { broken(); }\n"; - write(&root_path, importing); - - let root_uri = - Uri::from_file_path(std::fs::canonicalize(&root_path).expect("canonical root")) - .expect("root URI"); - let dependency_uri = Uri::from_file_path( - std::fs::canonicalize(&dependency_path).expect("canonical dependency"), - ) - .expect("dependency URI"); - let mut state = WorkspaceState::default(); - let initial = state.begin_open(&root_uri, importing, Some(1)); - state - .replace_if_current( - &root_uri, - analyze(importing, &root_path, root), - initial.version, - initial.generation, - ) - .expect("initial analysis"); - - let clean = "fn main() {}\n"; - let stale_ticket = state - .begin_change(&root_uri, importing, Some(2)) - .expect("stale ticket"); - let current = state - .begin_change(&root_uri, clean, Some(3)) - .expect("current ticket"); - let updates = state - .replace_if_current( - &root_uri, - analyze(clean, &root_path, root), - current.version, - current.generation, - ) - .expect("newer analysis"); - assert!(update_for(&updates, &dependency_uri).diagnostics.is_empty()); - - let stale_result = state.replace_if_current( - &root_uri, - analyze(importing, &root_path, root), - stale_ticket.version, - stale_ticket.generation, - ); - assert!(stale_result.is_none()); - } -} diff --git a/src/diagnostics.rs b/src/workspace/diagnostics.rs similarity index 96% rename from src/diagnostics.rs rename to src/workspace/diagnostics.rs index 6af3e1f..d584065 100644 --- a/src/diagnostics.rs +++ b/src/workspace/diagnostics.rs @@ -1,3 +1,5 @@ +//! Conversion and aggregation inputs for compiler diagnostics owned by workspace state. + use std::collections::HashMap; use simplicityhl::error::{ @@ -9,7 +11,7 @@ use tower_lsp_server::lsp_types::{ }; use crate::analysis::AnalysisSnapshot; -use crate::utils::span_to_positions; +use crate::text::span_to_positions; /// Diagnostics produced by one analysis root, grouped by the source that owns each range. #[derive(Debug, Default)] @@ -110,3 +112,6 @@ fn message(diagnostic: &CompilerDiagnostic) -> String { } message } + +#[cfg(test)] +mod tests; diff --git a/src/workspace/diagnostics/tests.rs b/src/workspace/diagnostics/tests.rs new file mode 100644 index 0000000..2851173 --- /dev/null +++ b/src/workspace/diagnostics/tests.rs @@ -0,0 +1,101 @@ +use simplicityhl::error::Span; +use tempfile::TempDir; +use tower_lsp_server::lsp_types::Position; +use tower_lsp_server::UriExt; + +use super::*; +use crate::config::Settings; + +#[test] +fn imported_diagnostic_is_owned_by_its_real_source() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + std::fs::write(root.join("Simplex.toml"), "").unwrap(); + std::fs::create_dir(root.join("simf")).unwrap(); + let library_path = root.join("simf/library.simf"); + std::fs::write(&library_path, "pub fn broken() -> (u1, u256) { 0 }\n").unwrap(); + let source = "use crate::library::broken;\nfn main() { broken(); }\n"; + let root_path = root.join("simf/main.simf"); + std::fs::write(&root_path, source).unwrap(); + let settings = Settings::from_json(serde_json::json!({ + "experimentalFeatures": { "imports": true } + })) + .unwrap(); + let snapshot = AnalysisSnapshot::analyze(source, &root_path, &settings, &[root.to_path_buf()]); + let bundle = DiagnosticBundle::from_snapshot(&snapshot); + let library_uri = Uri::from_file_path(std::fs::canonicalize(library_path).unwrap()).unwrap(); + let root_uri = Uri::from_file_path(std::fs::canonicalize(root_path).unwrap()).unwrap(); + + let imported = bundle + .get(&library_uri) + .and_then(|diagnostics| { + diagnostics + .iter() + .find(|diagnostic| diagnostic.message.contains("Expected expression")) + }) + .unwrap_or_else(|| panic!("expected imported diagnostic, got {bundle:?}")); + assert_ne!(imported.range, Range::default()); + assert!(!bundle.get(&root_uri).is_some_and(|diagnostics| { + diagnostics + .iter() + .any(|diagnostic| diagnostic.message == imported.message) + })); +} + +#[test] +fn cross_file_secondary_labels_notes_and_help_are_preserved() { + let temp = TempDir::new().unwrap(); + let root = temp.path(); + std::fs::write(root.join("Simplex.toml"), "").unwrap(); + std::fs::create_dir(root.join("simf")).unwrap(); + let library_path = root.join("simf/library.simf"); + std::fs::write(&library_path, "pub fn helper() {}\n").unwrap(); + let source = "use crate::library::helper;\nfn main() { helper(); }\n"; + let root_path = root.join("simf/main.simf"); + std::fs::write(&root_path, source).unwrap(); + let settings = Settings::from_json(serde_json::json!({ + "experimentalFeatures": { "imports": true } + })) + .unwrap(); + let mut snapshot = + AnalysisSnapshot::analyze(source, &root_path, &settings, &[root.to_path_buf()]); + let imported_file_id = snapshot + .functions + .get_func("helper") + .expect("imported function") + .span() + .file_id; + assert_ne!(imported_file_id, 0); + snapshot.compiler_diagnostics = vec![CompilerDiagnostic::new( + CompilerError::CannotParse { + msg: "primary".to_string(), + }, + Span::new(0, 0..3), + ) + .with_secondary(Span::new(imported_file_id, 7..13), "secondary") + .with_note("context") + .with_help("fix it")]; + let bundle = DiagnosticBundle::from_snapshot(&snapshot); + let published = &bundle.get(&snapshot.sources[0].uri).unwrap()[0]; + + assert_eq!( + published.range, + Range::new(Position::new(0, 0), Position::new(0, 3)) + ); + assert!(published.message.contains("Note: context")); + assert!(published.message.contains("Help: fix it")); + let related = published + .related_information + .as_ref() + .expect("secondary related information"); + assert_eq!(related.len(), 1); + assert_eq!(related[0].message, "secondary"); + assert_eq!( + related[0].location.uri, + snapshot.sources[imported_file_id].uri + ); + assert_eq!( + related[0].location.range, + Range::new(Position::new(0, 7), Position::new(0, 13)) + ); +} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs new file mode 100644 index 0000000..3d113bc --- /dev/null +++ b/src/workspace/mod.rs @@ -0,0 +1,304 @@ +pub(crate) mod diagnostics; + +use std::collections::HashMap; +use std::sync::Arc; + +use tower_lsp_server::lsp_types::{Diagnostic, Uri}; + +use crate::analysis::AnalysisSnapshot; +use crate::navigation::FunctionIdentity; + +use self::diagnostics::DiagnosticBundle; + +#[derive(Debug)] +struct RootAnalysis { + snapshot: AnalysisSnapshot, + diagnostics: DiagnosticBundle, + generation: Option, + version: Option, +} + +#[derive(Clone, Debug, Default)] +struct DocumentState { + generation: u64, + text: Option>, + version: Option, +} + +pub(crate) type AnalysisGeneration = u64; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct AnalysisInput { + pub uri: Uri, + pub text: Arc, + pub version: Option, + pub generation: AnalysisGeneration, +} + +/// One atomic diagnostic publication generated by replacing or removing a root analysis. +#[derive(Debug)] +pub struct DiagnosticUpdate { + pub uri: Uri, + pub diagnostics: Vec, + pub version: Option, +} + +/// Owns open-root analyses and all policy for cross-root diagnostic aggregation. +#[derive(Debug, Default)] +pub struct WorkspaceState { + roots: HashMap, + documents: HashMap, +} + +impl WorkspaceState { + pub fn get(&self, uri: &Uri) -> Option<&AnalysisSnapshot> { + self.roots.get(uri).map(|root| &root.snapshot) + } + + pub fn values(&self) -> impl Iterator { + self.roots.values().map(|root| &root.snapshot) + } + + pub(crate) fn begin_open( + &mut self, + uri: &Uri, + text: &str, + version: Option, + ) -> AnalysisInput { + let text = Arc::::from(text); + let state = self.documents.entry(uri.clone()).or_default(); + state.generation = state.generation.wrapping_add(1); + state.text = Some(Arc::clone(&text)); + state.version = version; + AnalysisInput { + uri: uri.clone(), + text, + version, + generation: state.generation, + } + } + + pub(crate) fn begin_change( + &mut self, + uri: &Uri, + text: &str, + version: Option, + ) -> Option { + let state = self.documents.get_mut(uri)?; + state.text.as_ref()?; + if matches!( + (version, state.version), + (Some(incoming), Some(current)) if incoming < current + ) { + return None; + } + let text = Arc::::from(text); + state.generation = state.generation.wrapping_add(1); + state.text = Some(Arc::clone(&text)); + if version.is_some() { + state.version = version; + } + Some(AnalysisInput { + uri: uri.clone(), + text, + version: state.version, + generation: state.generation, + }) + } + + /// Snapshot all open buffers and reserve their analysis generations atomically. + pub(crate) fn begin_reanalysis(&mut self) -> Vec { + self.documents + .iter_mut() + .filter_map(|(uri, state)| { + let text = Arc::clone(state.text.as_ref()?); + state.generation = state.generation.wrapping_add(1); + Some(AnalysisInput { + uri: uri.clone(), + text, + version: state.version, + generation: state.generation, + }) + }) + .collect() + } + + pub(crate) fn begin_close(&mut self, uri: &Uri) -> Option { + let state = self.documents.get_mut(uri)?; + state.text.as_ref()?; + state.generation = state.generation.wrapping_add(1); + state.text = None; + state.version = None; + Some(state.generation) + } + + fn is_current(&self, uri: &Uri, generation: AnalysisGeneration, open: bool) -> bool { + self.documents + .get(uri) + .is_some_and(|state| state.generation == generation && state.text.is_some() == open) + } + + pub(crate) fn replace_if_current( + &mut self, + origin: &Uri, + snapshot: AnalysisSnapshot, + incoming_version: Option, + generation: AnalysisGeneration, + ) -> Option> { + self.is_current(origin, generation, true).then(|| { + self.replace_inner_with_generation(origin, snapshot, incoming_version, Some(generation)) + }) + } + + pub(crate) fn remove_if_current( + &mut self, + origin: &Uri, + generation: AnalysisGeneration, + ) -> Option> { + self.is_current(origin, generation, false) + .then(|| self.remove(origin)) + } + + pub(crate) fn diagnostics_if_current( + &self, + uri: Uri, + diagnostics: Vec, + version: Option, + generation: AnalysisGeneration, + ) -> Option> { + self.is_current(&uri, generation, true).then(|| { + vec![DiagnosticUpdate { + uri, + diagnostics, + version, + }] + }) + } + + pub(crate) fn find_references_to( + &self, + target: &FunctionIdentity, + ) -> Vec { + let mut locations = self + .values() + .filter_map(|snapshot| snapshot.find_references_to(target).ok()) + .flatten() + .collect::>(); + locations.sort_unstable_by(|left, right| { + left.uri + .as_str() + .cmp(right.uri.as_str()) + .then(left.range.start.line.cmp(&right.range.start.line)) + .then(left.range.start.character.cmp(&right.range.start.character)) + .then(left.range.end.line.cmp(&right.range.end.line)) + .then(left.range.end.character.cmp(&right.range.end.character)) + }); + locations.dedup(); + locations + } + + fn replace_inner_with_generation( + &mut self, + origin: &Uri, + snapshot: AnalysisSnapshot, + version: Option, + generation: Option, + ) -> Vec { + let diagnostics = DiagnosticBundle::from_snapshot(&snapshot); + let mut affected = self + .roots + .get(origin) + .into_iter() + .flat_map(|root| root.diagnostics.uris().cloned()) + .chain(diagnostics.uris().cloned()) + .collect::>(); + affected.push(origin.clone()); + + self.roots.insert( + origin.clone(), + RootAnalysis { + snapshot, + diagnostics, + generation, + version, + }, + ); + self.updates(affected, Some((origin, version))) + } + + pub fn remove(&mut self, origin: &Uri) -> Vec { + let mut affected = self + .roots + .remove(origin) + .map(|root| root.diagnostics.uris().cloned().collect::>()) + .unwrap_or_default(); + affected.push(origin.clone()); + self.updates(affected, None) + } + + fn updates( + &self, + mut affected: Vec, + direct: Option<(&Uri, Option)>, + ) -> Vec { + affected.sort_unstable_by(|left, right| left.as_str().cmp(right.as_str())); + affected.dedup(); + affected + .into_iter() + .filter_map(|uri| { + if self.direct_analysis_is_pending(&uri) { + return None; + } + let version = direct + .filter(|(direct_uri, _)| *direct_uri == &uri) + .and_then(|(_, version)| version) + .or_else(|| self.roots.get(&uri).and_then(|root| root.version)); + Some(DiagnosticUpdate { + diagnostics: self.diagnostics_for(&uri), + version, + uri, + }) + }) + .collect() + } + + fn direct_analysis_is_pending(&self, target: &Uri) -> bool { + let Some(document) = self.documents.get(target) else { + return false; + }; + match (&document.text, self.roots.get(target)) { + (Some(_), Some(root)) => root.generation != Some(document.generation), + (Some(_), None) | (None, Some(_)) => true, + (None, None) => false, + } + } + + fn diagnostics_for(&self, target: &Uri) -> Vec { + // An open file's unsaved analysis is authoritative over saved dependency copies. + if let Some(root) = self.roots.get(target) { + return root + .diagnostics + .get(target) + .map(<[Diagnostic]>::to_vec) + .unwrap_or_default(); + } + + let mut origins = self.roots.iter().collect::>(); + origins.sort_unstable_by(|(left, _), (right, _)| left.as_str().cmp(right.as_str())); + let mut diagnostics = Vec::new(); + for (_, root) in origins { + let Some(contributed) = root.diagnostics.get(target) else { + continue; + }; + for diagnostic in contributed { + if !diagnostics.contains(diagnostic) { + diagnostics.push(diagnostic.clone()); + } + } + } + diagnostics + } +} + +#[cfg(test)] +mod tests; diff --git a/src/workspace/tests.rs b/src/workspace/tests.rs new file mode 100644 index 0000000..643a443 --- /dev/null +++ b/src/workspace/tests.rs @@ -0,0 +1,558 @@ +use std::path::Path; + +use tempfile::TempDir; +use tower_lsp_server::UriExt; + +use super::*; +use crate::config::Settings; + +fn write(path: impl AsRef, source: &str) { + let path = path.as_ref(); + std::fs::create_dir_all(path.parent().expect("path has parent")).expect("create directory"); + std::fs::write(path, source).expect("write source"); +} + +fn imports_enabled() -> Settings { + Settings::from_json(serde_json::json!({ + "experimentalFeatures": { "imports": true } + })) + .expect("valid settings") +} + +fn analyze(source: &str, path: &Path, root: &Path) -> AnalysisSnapshot { + let path = std::fs::canonicalize(path).expect("canonical analysis path"); + AnalysisSnapshot::analyze(source, &path, &imports_enabled(), &[root.to_path_buf()]) +} + +fn update_for<'a>(updates: &'a [DiagnosticUpdate], uri: &Uri) -> &'a DiagnosticUpdate { + updates + .iter() + .find(|update| &update.uri == uri) + .expect("URI should be republished") +} + +fn canonical_uri(path: &Path) -> Uri { + Uri::from_file_path(std::fs::canonicalize(path).expect("canonical path")).expect("file URI") +} + +fn temporary_uri(name: &str) -> Uri { + Uri::from_file_path(std::env::temp_dir().join(name)).expect("temporary file URI") +} + +fn insert_analysis(state: &mut WorkspaceState, source: &str, path: &Path, root: &Path) -> Uri { + let uri = canonical_uri(path); + replace_analysis(state, &uri, analyze(source, path, root), Some(1)); + uri +} + +fn replace_analysis( + state: &mut WorkspaceState, + uri: &Uri, + snapshot: AnalysisSnapshot, + version: Option, +) -> Vec { + state.replace_inner_with_generation(uri, snapshot, version, None) +} + +#[test] +fn references_exclude_unrelated_same_named_functions() { + let temp = TempDir::new().expect("temp dir"); + let root = temp.path(); + write(root.join("Simplex.toml"), ""); + let source = "fn helper() {}\nfn main() { helper(); }\n"; + let first_path = root.join("simf/first.simf"); + let second_path = root.join("simf/second.simf"); + write(&first_path, source); + write(&second_path, source); + + let mut state = WorkspaceState::default(); + let first_uri = insert_analysis(&mut state, source, &first_path, root); + let second_uri = insert_analysis(&mut state, source, &second_path, root); + let target = { + let snapshot = state.get(&first_uri).expect("first analysis"); + snapshot + .function_identity( + snapshot + .functions + .get_func("helper") + .expect("helper function"), + ) + .expect("helper identity") + }; + + let references = state.find_references_to(&target); + assert_eq!(references.len(), 1); + assert_eq!(references[0].uri, first_uri); + assert_ne!(references[0].uri, second_uri); +} + +#[test] +fn references_follow_aliases_to_one_definition_across_roots() { + let temp = TempDir::new().expect("temp dir"); + let root = temp.path(); + write(root.join("Simplex.toml"), ""); + write(root.join("simf/shared.simf"), "pub fn target() {}\n"); + let first_source = "use crate::shared::target as alpha;\nfn main() { alpha(); }\n"; + let second_source = "use crate::shared::target as beta;\nfn main() { beta(); }\n"; + let first_path = root.join("simf/first.simf"); + let second_path = root.join("simf/second.simf"); + write(&first_path, first_source); + write(&second_path, second_source); + + let mut state = WorkspaceState::default(); + let first_uri = insert_analysis(&mut state, first_source, &first_path, root); + let second_uri = insert_analysis(&mut state, second_source, &second_path, root); + let target = { + let snapshot = state.get(&first_uri).expect("first analysis"); + snapshot + .function_identity( + snapshot + .functions + .get_func("alpha") + .expect("aliased target"), + ) + .expect("target identity") + }; + + let references = state.find_references_to(&target); + assert_eq!(references.len(), 2); + assert_eq!(references[0].uri, first_uri); + assert_eq!(references[1].uri, second_uri); +} + +#[test] +fn references_deduplicate_shared_dependency_locations() { + let temp = TempDir::new().expect("temp dir"); + let root = temp.path(); + write(root.join("Simplex.toml"), ""); + let shared_path = root.join("simf/shared.simf"); + write( + &shared_path, + "pub fn target() {}\npub fn invoke() { target(); }\n", + ); + let source = "use crate::shared::{target, invoke};\nfn main() { target(); invoke(); }\n"; + let first_path = root.join("simf/first.simf"); + let second_path = root.join("simf/second.simf"); + write(&first_path, source); + write(&second_path, source); + + let mut state = WorkspaceState::default(); + let first_uri = insert_analysis(&mut state, source, &first_path, root); + let second_uri = insert_analysis(&mut state, source, &second_path, root); + let shared_uri = canonical_uri(&shared_path); + let target = { + let snapshot = state.get(&first_uri).expect("first analysis"); + snapshot + .function_identity( + snapshot + .functions + .get_func("target") + .expect("imported target"), + ) + .expect("target identity") + }; + + let references = state.find_references_to(&target); + assert_eq!(references.len(), 3); + assert_eq!( + references + .iter() + .filter(|location| location.uri == shared_uri) + .count(), + 1 + ); + assert!(references.iter().any(|location| location.uri == first_uri)); + assert!(references.iter().any(|location| location.uri == second_uri)); +} + +#[test] +fn dependency_references_use_the_owning_module_scope() { + let temp = TempDir::new().expect("temp dir"); + let root = temp.path(); + write(root.join("Simplex.toml"), ""); + write(root.join("simf/a.simf"), "pub fn target() {}\n"); + let second_dependency = root.join("simf/b.simf"); + write( + &second_dependency, + "pub fn target() {}\npub fn wrapper() { target(); }\n", + ); + let source = + "use crate::a::target;\nuse crate::b::wrapper;\nfn main() { target(); wrapper(); }\n"; + let root_path = root.join("simf/main.simf"); + write(&root_path, source); + + let mut state = WorkspaceState::default(); + let root_uri = insert_analysis(&mut state, source, &root_path, root); + let second_dependency_uri = canonical_uri(&second_dependency); + let target = { + let snapshot = state.get(&root_uri).expect("root analysis"); + snapshot + .function_identity( + snapshot + .functions + .get_func("target") + .expect("first dependency target"), + ) + .expect("target identity") + }; + + let references = state.find_references_to(&target); + assert_eq!(references.len(), 1); + assert_eq!(references[0].uri, root_uri); + assert!(!references + .iter() + .any(|location| location.uri == second_dependency_uri)); +} + +#[test] +fn close_generation_rejects_an_in_flight_analysis() { + let temp = TempDir::new().expect("temp dir"); + let root = temp.path(); + write(root.join("Simplex.toml"), ""); + let source = "fn main() {}\n"; + let path = root.join("simf/main.simf"); + write(&path, source); + let uri = canonical_uri(&path); + let mut state = WorkspaceState::default(); + + let analysis_input = state.begin_open(&uri, source, Some(1)); + let snapshot = analyze(source, &path, root); + let close_generation = state.begin_close(&uri).expect("close ticket"); + + assert!(state + .replace_if_current(&uri, snapshot, Some(1), analysis_input.generation) + .is_none()); + assert!(state.remove_if_current(&uri, close_generation).is_some()); + assert!(state.get(&uri).is_none()); +} + +#[test] +fn newer_generation_wins_when_document_versions_are_equal() { + let temp = TempDir::new().expect("temp dir"); + let root = temp.path(); + write(root.join("Simplex.toml"), ""); + let old_source = "fn old() {}\nfn main() {}\n"; + let new_source = "fn new() {}\nfn main() {}\n"; + let path = root.join("simf/main.simf"); + write(&path, new_source); + let uri = canonical_uri(&path); + let mut state = WorkspaceState::default(); + + let old_input = state.begin_open(&uri, old_source, Some(1)); + let old_snapshot = analyze(old_source, &path, root); + let new_input = state + .begin_change(&uri, new_source, Some(1)) + .expect("open document"); + let new_snapshot = analyze(new_source, &path, root); + + assert!(state + .replace_if_current(&uri, new_snapshot, Some(1), new_input.generation) + .is_some()); + assert!(state + .replace_if_current(&uri, old_snapshot, Some(1), old_input.generation) + .is_none()); + assert_eq!( + state.get(&uri).expect("current analysis").text.to_string(), + new_source + ); +} + +#[test] +fn older_change_is_rejected_before_analysis() { + let uri = temporary_uri("change-order.simf"); + let mut state = WorkspaceState::default(); + state.begin_open(&uri, "fn initial() {}\n", Some(1)); + state + .begin_change(&uri, "fn newest() {}\n", Some(3)) + .expect("newer change"); + + assert!(state + .begin_change(&uri, "fn stale() {}\n", Some(2)) + .is_none()); + let current = state.begin_reanalysis().pop().expect("current document"); + assert_eq!(current.text.as_ref(), "fn newest() {}\n"); + assert_eq!(current.version, Some(3)); +} + +#[test] +fn reanalysis_includes_documents_with_pending_initial_analysis() { + let temp = TempDir::new().expect("temp dir"); + let root = temp.path(); + write(root.join("Simplex.toml"), ""); + let source = "fn main() {}\n"; + let path = root.join("simf/main.simf"); + write(&path, source); + let uri = canonical_uri(&path); + let mut state = WorkspaceState::default(); + + let initial_input = state.begin_open(&uri, source, Some(1)); + assert!(state.get(&uri).is_none()); + + let requests = state.begin_reanalysis(); + assert_eq!(requests.len(), 1); + let request = &requests[0]; + assert_eq!(request.uri, uri); + assert_eq!(request.text.as_ref(), source); + assert_eq!(request.version, Some(1)); + assert_ne!(request.generation, initial_input.generation); +} + +#[test] +fn pre_close_reanalysis_cannot_overwrite_a_reopened_document() { + let temp = TempDir::new().expect("temp dir"); + let root = temp.path(); + write(root.join("Simplex.toml"), ""); + let old_source = "fn old() {}\nfn main() {}\n"; + let new_source = "fn new() {}\nfn main() {}\n"; + let path = root.join("simf/main.simf"); + write(&path, new_source); + let uri = canonical_uri(&path); + let mut state = WorkspaceState::default(); + + let old_input = state.begin_open(&uri, old_source, Some(5)); + state + .replace_if_current( + &uri, + analyze(old_source, &path, root), + old_input.version, + old_input.generation, + ) + .expect("initial analysis"); + let stale_request = state.begin_reanalysis().pop().expect("reanalysis request"); + let close_generation = state.begin_close(&uri).expect("close ticket"); + let reopened_input = state.begin_open(&uri, new_source, Some(1)); + + assert!(state.remove_if_current(&uri, close_generation).is_none()); + assert!(state + .replace_if_current( + &uri, + analyze(old_source, &path, root), + stale_request.version, + stale_request.generation, + ) + .is_none()); + assert!(state + .replace_if_current( + &uri, + analyze(new_source, &path, root), + Some(1), + reopened_input.generation, + ) + .is_some()); + assert_eq!( + state.get(&uri).expect("reopened analysis").text.to_string(), + new_source + ); +} + +#[test] +fn save_ticket_keeps_the_last_known_version() { + let uri = temporary_uri("save-ticket.simf"); + let mut state = WorkspaceState::default(); + state.begin_open(&uri, "fn main() {}\n", Some(7)); + + let save = state + .begin_change(&uri, "fn main() {}\n", None) + .expect("save ticket"); + + assert_eq!(save.version, Some(7)); +} + +#[test] +fn closing_document_releases_its_buffer_payload() { + let uri = temporary_uri("closed-buffer.simf"); + let mut state = WorkspaceState::default(); + state.begin_open(&uri, "fn main() {}\n", Some(3)); + + state.begin_close(&uri).expect("close ticket"); + + let document = state.documents.get(&uri).expect("generation tombstone"); + assert!(document.text.is_none()); + assert_eq!(document.version, None); + assert!(state.begin_close(&uri).is_none()); + + let unknown = temporary_uri("unknown-close.simf"); + assert!(state.begin_close(&unknown).is_none()); + assert!(!state.documents.contains_key(&unknown)); +} + +#[test] +fn shared_dependencies_deduplicate_and_open_buffers_take_precedence() { + let temp = TempDir::new().expect("temp dir"); + let root = temp.path(); + write(root.join("Simplex.toml"), ""); + let dependency_path = root.join("simf/shared.simf"); + write(&dependency_path, "pub fn broken() -> u32 { false }\n"); + let root_source = "use crate::shared::broken;\nfn main() { broken(); }\n"; + let root_a = root.join("simf/a.simf"); + let root_b = root.join("simf/b.simf"); + write(&root_a, root_source); + write(&root_b, root_source); + + let dependency_uri = + Uri::from_file_path(std::fs::canonicalize(&dependency_path).expect("canonical dependency")) + .expect("dependency URI"); + let first_root_uri = + Uri::from_file_path(std::fs::canonicalize(&root_a).expect("canonical root")) + .expect("root URI"); + let second_root_uri = + Uri::from_file_path(std::fs::canonicalize(&root_b).expect("canonical root")) + .expect("root URI"); + let mut state = WorkspaceState::default(); + + let updates = replace_analysis( + &mut state, + &first_root_uri, + analyze(root_source, &root_a, root), + Some(1), + ); + assert_eq!(update_for(&updates, &first_root_uri).version, Some(1)); + let imported = update_for(&updates, &dependency_uri); + assert_eq!(imported.version, None); + assert_eq!(imported.diagnostics.len(), 1); + + let updates = replace_analysis( + &mut state, + &second_root_uri, + analyze(root_source, &root_b, root), + Some(1), + ); + assert_eq!(update_for(&updates, &dependency_uri).diagnostics.len(), 1); + + // The buffer differs from the saved dependency used by both roots. Its direct clean + // analysis is authoritative until that document closes. + let clean_dependency = "pub fn broken() -> u32 { 0 }\n"; + let updates = replace_analysis( + &mut state, + &dependency_uri, + analyze(clean_dependency, &dependency_path, root), + Some(7), + ); + let direct = update_for(&updates, &dependency_uri); + assert!(direct.diagnostics.is_empty()); + assert_eq!(direct.version, Some(7)); + + let updates = state.remove(&dependency_uri); + let restored = update_for(&updates, &dependency_uri); + assert_eq!(restored.version, None); + assert_eq!(restored.diagnostics.len(), 1); +} + +#[test] +fn pending_open_buffer_analysis_suppresses_cross_root_republication() { + let temp = TempDir::new().expect("temp dir"); + let root = temp.path(); + write(root.join("Simplex.toml"), ""); + let dependency_path = root.join("simf/shared.simf"); + let broken_dependency = "pub fn broken() -> u32 { false }\n"; + write(&dependency_path, broken_dependency); + let root_source = "use crate::shared::broken;\nfn main() { broken(); }\n"; + let root_path = root.join("simf/main.simf"); + write(&root_path, root_source); + let root_uri = canonical_uri(&root_path); + let dependency_uri = canonical_uri(&dependency_path); + let clean_dependency = "pub fn broken() -> u32 { 0 }\n"; + let mut state = WorkspaceState::default(); + + let root_open = state.begin_open(&root_uri, root_source, Some(1)); + state + .replace_if_current( + &root_uri, + analyze(root_source, &root_path, root), + root_open.version, + root_open.generation, + ) + .expect("root analysis"); + let dependency_open = state.begin_open(&dependency_uri, clean_dependency, Some(1)); + state + .replace_if_current( + &dependency_uri, + analyze(clean_dependency, &dependency_path, root), + dependency_open.version, + dependency_open.generation, + ) + .expect("dependency analysis"); + + let pending = state + .begin_change(&dependency_uri, clean_dependency, Some(2)) + .expect("pending dependency analysis"); + let root_change = state + .begin_change(&root_uri, root_source, Some(2)) + .expect("root change"); + let cross_root_updates = state + .replace_if_current( + &root_uri, + analyze(root_source, &root_path, root), + root_change.version, + root_change.generation, + ) + .expect("root reanalysis"); + assert!(!cross_root_updates + .iter() + .any(|update| update.uri == dependency_uri)); + + let current = state + .replace_if_current( + &dependency_uri, + analyze(clean_dependency, &dependency_path, root), + pending.version, + pending.generation, + ) + .expect("current dependency analysis"); + let dependency = update_for(¤t, &dependency_uri); + assert_eq!(dependency.version, Some(2)); + assert!(dependency.diagnostics.is_empty()); +} + +#[test] +fn replacing_roots_clears_removed_dependency_diagnostics() { + let temp = TempDir::new().expect("temp dir"); + let root = temp.path(); + write(root.join("Simplex.toml"), ""); + let dependency_path = root.join("simf/shared.simf"); + write(&dependency_path, "pub fn broken() -> u32 { false }\n"); + let root_path = root.join("simf/main.simf"); + let importing = "use crate::shared::broken;\nfn main() { broken(); }\n"; + write(&root_path, importing); + + let root_uri = Uri::from_file_path(std::fs::canonicalize(&root_path).expect("canonical root")) + .expect("root URI"); + let dependency_uri = + Uri::from_file_path(std::fs::canonicalize(&dependency_path).expect("canonical dependency")) + .expect("dependency URI"); + let mut state = WorkspaceState::default(); + let initial = state.begin_open(&root_uri, importing, Some(1)); + state + .replace_if_current( + &root_uri, + analyze(importing, &root_path, root), + initial.version, + initial.generation, + ) + .expect("initial analysis"); + + let clean = "fn main() {}\n"; + let stale_ticket = state + .begin_change(&root_uri, importing, Some(2)) + .expect("stale ticket"); + let current = state + .begin_change(&root_uri, clean, Some(3)) + .expect("current ticket"); + let updates = state + .replace_if_current( + &root_uri, + analyze(clean, &root_path, root), + current.version, + current.generation, + ) + .expect("newer analysis"); + assert!(update_for(&updates, &dependency_uri).diagnostics.is_empty()); + + let stale_result = state.replace_if_current( + &root_uri, + analyze(importing, &root_path, root), + stale_ticket.version, + stale_ticket.generation, + ); + assert!(stale_result.is_none()); +} diff --git a/tests/fixtures/initialize_result.json b/tests/fixtures/initialize_result.json new file mode 100644 index 0000000..517a04c --- /dev/null +++ b/tests/fixtures/initialize_result.json @@ -0,0 +1,58 @@ +{ + "capabilities": { + "textDocumentSync": { + "openClose": true, + "change": 1, + "save": { + "includeText": true + } + }, + "hoverProvider": true, + "completionProvider": { + "resolveProvider": false, + "triggerCharacters": [ + ":", + "<", + " ", + "{", + "," + ] + }, + "signatureHelpProvider": { + "triggerCharacters": [ + "(", + "," + ], + "retriggerCharacters": [ + "," + ] + }, + "definitionProvider": true, + "referencesProvider": true, + "documentSymbolProvider": true, + "workspace": { + "workspaceFolders": { + "supported": true, + "changeNotifications": true + } + }, + "semanticTokensProvider": { + "legend": { + "tokenTypes": [ + "function", + "parameter", + "variable", + "type", + "keyword", + "namespace" + ], + "tokenModifiers": [ + "declaration", + "definition" + ] + }, + "range": false, + "full": true + } + } +} diff --git a/tests/missing_files.rs b/tests/missing_files.rs new file mode 100644 index 0000000..6bebd08 --- /dev/null +++ b/tests/missing_files.rs @@ -0,0 +1,74 @@ +mod support; + +use std::fs; + +use serde_json::json; +use tempfile::TempDir; + +use support::{file_uri, LspProcess}; + +#[test] +fn renamed_configured_target_is_recoverable_and_clears_stale_diagnostics() { + let root = TempDir::new().expect("workspace"); + let source_directory = root.path().join("simf"); + fs::create_dir(&source_directory).expect("source directory"); + fs::write(root.path().join("Simplex.toml"), "").expect("manifest"); + let original = source_directory.join("verifier.simf"); + let renamed = source_directory.join("verifier.moved.simf"); + let source = "fn main() {}\n"; + fs::write(&original, source).expect("configured source"); + + let root_uri = file_uri(root.path()); + let document_uri = file_uri(&original); + let mut server = LspProcess::spawn(); + server.initialize(&root_uri, &json!({})); + fs::rename(&original, &renamed).expect("rename configured target"); + + server.notify( + "textDocument/didOpen", + &json!({ + "textDocument": { + "uri": document_uri, + "languageId": "simplicityhl", + "version": 1, + "text": source + } + }), + ); + for version in 1..=6 { + if version > 1 { + server.notify( + "textDocument/didChange", + &json!({ + "textDocument": { "uri": document_uri, "version": version }, + "contentChanges": [{ "text": source }] + }), + ); + } + let publication = server.diagnostics(&document_uri, version); + assert!(publication["params"]["diagnostics"] + .as_array() + .is_some_and(|items| items + .iter() + .any( + |item| item["message"].as_str().is_some_and(|message| message + .contains("Failed to find library target path") + && message.contains("verifier.simf")) + ))); + } + + fs::rename(&renamed, &original).expect("restore configured target"); + server.notify( + "textDocument/didChange", + &json!({ + "textDocument": { "uri": document_uri, "version": 7 }, + "contentChanges": [{ "text": source }] + }), + ); + let recovered = server.diagnostics(&document_uri, 7); + assert_eq!(recovered["params"]["diagnostics"], json!([])); + + // A successful shutdown response proves the repeated filesystem failures did not panic or + // leave the process in the extension's restart loop failure mode. + server.shutdown(); +} diff --git a/tests/protocol.rs b/tests/protocol.rs new file mode 100644 index 0000000..6524b65 --- /dev/null +++ b/tests/protocol.rs @@ -0,0 +1,286 @@ +mod support; + +use std::fs; + +use serde_json::{json, Value}; +use tempfile::TempDir; + +use support::{file_uri, LspProcess}; + +#[test] +fn initialize_response_matches_the_public_protocol_fixture() { + let root = TempDir::new().expect("workspace"); + let root_uri = file_uri(root.path()); + let expected: Value = serde_json::from_str(include_str!("fixtures/initialize_result.json")) + .expect("initialize fixture"); + let mut server = LspProcess::spawn(); + + let response = server.initialize(&root_uri, &json!({})); + + assert_eq!(response.get("result"), Some(&expected)); + assert!(response.get("error").is_none()); + server.shutdown(); +} + +#[test] +fn witness_diagnostics_follow_document_versions_over_stdio() { + let root = TempDir::new().expect("workspace"); + let root_uri = file_uri(root.path()); + let witness_uri = file_uri(&root.path().join("contract.wit")); + let mut server = LspProcess::spawn(); + server.initialize(&root_uri, &json!({})); + + server.notify( + "textDocument/didOpen", + &json!({ + "textDocument": { + "uri": witness_uri, + "languageId": "simplicityhl-witness", + "version": 1, + "text": r#"{"amount":{"value":1}}"# + } + }), + ); + let invalid = server.diagnostics(&witness_uri, 1); + assert!(invalid["params"]["diagnostics"] + .as_array() + .is_some_and(|items| items.iter().any(|item| item["message"] + .as_str() + .is_some_and(|message| message.contains("missing required 'type'"))))); + + server.notify( + "textDocument/didChange", + &json!({ + "textDocument": { "uri": witness_uri, "version": 2 }, + "contentChanges": [{ "text": r#"{"amount":{"value":1,"type":"u32"}}"# }] + }), + ); + let valid = server.diagnostics(&witness_uri, 2); + assert_eq!(valid["params"]["diagnostics"], json!([])); + server.shutdown(); +} + +#[test] +fn incomplete_import_completion_survives_parse_failure_and_respects_the_feature_gate() { + let root = TempDir::new().expect("workspace"); + fs::create_dir_all(root.path().join("simf")).expect("root source directory"); + fs::create_dir_all(root.path().join("deps/merkle/simf")).expect("dependency source directory"); + fs::write( + root.path().join("Simplex.toml"), + "[dependencies]\nmerkle = { path = 'deps/merkle' }\n", + ) + .expect("root manifest"); + fs::write(root.path().join("deps/merkle/Simplex.toml"), "").expect("dependency manifest"); + let dependency = root.path().join("deps/merkle/simf/tree.simf"); + fs::write(&dependency, "pub fn root() {}\n").expect("dependency source"); + let dependency = fs::canonicalize(dependency).expect("canonical dependency source"); + let source = "/* 😀 */ use merkle::"; + let root_path = root.path().join("simf/main.simf"); + fs::write(&root_path, source).expect("root source"); + let root_uri = file_uri(root.path()); + let document_uri = file_uri(&root_path); + let position = source.encode_utf16().count(); + + let request_completion = |initialization_options: Value| { + let mut server = LspProcess::spawn(); + server.initialize(&root_uri, &initialization_options); + server.notify( + "textDocument/didOpen", + &json!({ + "textDocument": { + "uri": document_uri, + "languageId": "simplicityhl", + "version": 1, + "text": source + } + }), + ); + let _diagnostics = server.diagnostics(&document_uri, 1); + let response = server.request( + 2, + "textDocument/completion", + &json!({ + "textDocument": { "uri": document_uri }, + "position": { "line": 0, "character": position } + }), + ); + server.shutdown(); + response + }; + + let disabled = request_completion(json!({})); + assert!(disabled["error"].is_null(), "{disabled}"); + assert!(disabled["result"].is_null(), "{disabled}"); + + let enabled = request_completion( + json!({ "simplicityhl": { "experimentalFeatures": { "imports": true } } }), + ); + assert_eq!( + enabled["result"], + json!([{ + "label": "tree", + "kind": 9, + "detail": format!("Module file `{}`", dependency.display()) + }]) + ); + assert!(enabled["error"].is_null(), "{enabled}"); +} + +fn assert_diagnostics_use_opened_uri(root: &TempDir, document_uri: &str) { + let root_uri = file_uri(root.path()); + let source = "fn main() -> u32 { false }\n"; + let mut server = LspProcess::spawn(); + server.initialize(&root_uri, &json!({})); + server.notify( + "textDocument/didOpen", + &json!({ + "textDocument": { + "uri": document_uri, + "languageId": "simplicityhl", + "version": 1, + "text": source + } + }), + ); + let diagnostics = server.diagnostics(document_uri, 1); + assert_eq!(diagnostics["params"]["uri"], document_uri); + assert!( + diagnostics["params"]["diagnostics"] + .as_array() + .is_some_and(|items| !items.is_empty()), + "{diagnostics}" + ); + server.shutdown(); +} + +#[test] +fn diagnostics_preserve_a_dot_segment_editor_uri() { + let root = TempDir::new().expect("workspace"); + fs::create_dir_all(root.path().join("simf")).expect("source directory"); + fs::write(root.path().join("Simplex.toml"), "").expect("manifest"); + fs::write( + root.path().join("simf/main.simf"), + "fn main() -> u32 { false }\n", + ) + .expect("source"); + let document_uri = format!("{}/simf/../simf/main.simf", file_uri(root.path())); + + assert_diagnostics_use_opened_uri(&root, &document_uri); +} + +#[cfg(unix)] +#[test] +fn diagnostics_preserve_a_symlink_editor_uri() { + let root = TempDir::new().expect("workspace"); + fs::create_dir_all(root.path().join("simf")).expect("source directory"); + fs::write(root.path().join("Simplex.toml"), "").expect("manifest"); + let source_path = root.path().join("simf/main.simf"); + fs::write(&source_path, "fn main() -> u32 { false }\n").expect("source"); + let alias_path = root.path().join("main-link.simf"); + std::os::unix::fs::symlink(source_path, &alias_path).expect("source symlink"); + let document_uri = file_uri(&alias_path); + + assert_diagnostics_use_opened_uri(&root, &document_uri); +} + +#[test] +fn definition_resolves_imports_and_calls_across_non_crate_reexports_over_stdio() { + let root = TempDir::new().expect("workspace"); + fs::create_dir_all(root.path().join("simf")).expect("root source directory"); + fs::create_dir_all(root.path().join("deps/merkle/simf")).expect("dependency source directory"); + fs::create_dir_all(root.path().join("deps/facade/simf")).expect("facade source directory"); + fs::create_dir_all(root.path().join("deps/leaf/simf")).expect("leaf source directory"); + fs::write( + root.path().join("Simplex.toml"), + "[dependencies]\nmerkle = { path = 'deps/merkle' }\nfacade = { path = 'deps/facade' }\n", + ) + .expect("root manifest"); + fs::write(root.path().join("deps/merkle/Simplex.toml"), "").expect("dependency manifest"); + fs::write( + root.path().join("deps/facade/Simplex.toml"), + "[dependencies]\nleaf = { path = '../leaf' }\n", + ) + .expect("facade manifest"); + fs::write(root.path().join("deps/leaf/Simplex.toml"), "").expect("leaf manifest"); + let merkle = root.path().join("deps/merkle/simf/build_root.simf"); + let leaf = root.path().join("deps/leaf/simf/ops.simf"); + fs::write(&merkle, "pub fn get_root() {}\npub fn hash() {}\n").expect("merkle source"); + fs::write( + root.path().join("deps/facade/simf/smth.simf"), + "pub use leaf::ops::hash;\n", + ) + .expect("facade source"); + fs::write(&leaf, "pub fn hash() {}\n").expect("leaf source"); + let source = "use merkle::build_root::{get_root, hash as and_hash};\nuse facade::smth::hash as or_hash;\nfn main() { get_root(); and_hash(); or_hash(); }\n"; + let root_path = root.path().join("simf/main.simf"); + fs::write(&root_path, source).expect("root source"); + let root_uri = file_uri(root.path()); + let document_uri = file_uri(&root_path); + let merkle_uri = file_uri(&fs::canonicalize(merkle).expect("canonical merkle source")); + let leaf_uri = file_uri(&fs::canonicalize(leaf).expect("canonical leaf source")); + let mut server = LspProcess::spawn(); + server.initialize( + &root_uri, + &json!({ "simplicityhl": { "experimentalFeatures": { "imports": true } } }), + ); + server.notify( + "textDocument/didOpen", + &json!({ + "textDocument": { + "uri": document_uri, + "languageId": "simplicityhl", + "version": 1, + "text": source + } + }), + ); + let diagnostics = server.diagnostics(&document_uri, 1); + assert_eq!(diagnostics["params"]["diagnostics"], json!([])); + + let lines = source.lines().collect::>(); + let merkle_root = json!({ + "uri": merkle_uri, + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 20 } + } + }); + let merkle_hash = json!({ + "uri": merkle_uri, + "range": { + "start": { "line": 1, "character": 0 }, + "end": { "line": 1, "character": 16 } + } + }); + let leaf_hash = json!({ + "uri": leaf_uri, + "range": { + "start": { "line": 0, "character": 0 }, + "end": { "line": 0, "character": 16 } + } + }); + let cases = [ + ("original import", 0, "get_root", &merkle_root), + ("aliased original import", 0, "hash as", &merkle_hash), + ("import alias", 0, "and_hash", &merkle_hash), + ("transitive original import", 1, "hash as", &leaf_hash), + ("transitive import alias", 1, "or_hash", &leaf_hash), + ("original call", 2, "get_root", &merkle_root), + ("aliased call", 2, "and_hash", &merkle_hash), + ("transitive reexport call", 2, "or_hash", &leaf_hash), + ]; + for (index, (label, line, needle, expected)) in cases.into_iter().enumerate() { + let character = lines[line].find(needle).expect("definition token"); + let response = server.request( + i32::try_from(index).expect("request id") + 2, + "textDocument/definition", + &json!({ + "textDocument": { "uri": document_uri }, + "position": { "line": line, "character": character + 1 } + }), + ); + assert_eq!(&response["result"], expected, "{label}"); + assert!(response["error"].is_null(), "{label}: {response}"); + } + server.shutdown(); +} diff --git a/tests/support/mod.rs b/tests/support/mod.rs new file mode 100644 index 0000000..54eb1ec --- /dev/null +++ b/tests/support/mod.rs @@ -0,0 +1,205 @@ +use std::collections::VecDeque; +use std::io::{BufRead, BufReader, Write}; +use std::path::Path; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; +use tower_lsp_server::lsp_types::Uri; +use tower_lsp_server::UriExt; + +const MESSAGE_TIMEOUT: Duration = Duration::from_secs(10); + +pub fn file_uri(path: &Path) -> String { + Uri::from_file_path(path) + .expect("absolute test path produces a file URI") + .as_str() + .to_string() +} + +pub struct LspProcess { + child: Child, + stdin: Option, + messages: Receiver>, + pending: VecDeque, +} + +impl LspProcess { + pub fn spawn() -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_simplicityhl-lsp")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .expect("spawn language server"); + let stdout = child.stdout.take().expect("language server stdout"); + let stdin = child.stdin.take().expect("language server stdin"); + let (sender, messages) = mpsc::channel(); + std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + match read_message(&mut reader) { + Ok(Some(message)) => { + if sender.send(Ok(message)).is_err() { + return; + } + } + Ok(None) => return, + Err(error) => { + let _ = sender.send(Err(error)); + return; + } + } + } + }); + + Self { + child, + stdin: Some(stdin), + messages, + pending: VecDeque::new(), + } + } + + pub fn initialize(&mut self, root_uri: &str, initialization_options: &Value) -> Value { + let response = self.request( + 1, + "initialize", + &json!({ + "processId": null, + "rootUri": root_uri, + "workspaceFolders": [{ "uri": root_uri, "name": "integration" }], + "capabilities": {}, + "initializationOptions": initialization_options + }), + ); + self.notify("initialized", &json!({})); + response + } + + pub fn request(&mut self, id: i32, method: &str, params: &Value) -> Value { + self.send(&json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params + })); + self.receive_where(|message| message.get("id") == Some(&json!(id))) + } + + pub fn notify(&mut self, method: &str, params: &Value) { + self.send(&json!({ + "jsonrpc": "2.0", + "method": method, + "params": params + })); + } + + pub fn diagnostics(&mut self, uri: &str, version: i32) -> Value { + self.receive_where(|message| { + message.get("method").and_then(Value::as_str) == Some("textDocument/publishDiagnostics") + && message.pointer("/params/uri").and_then(Value::as_str) == Some(uri) + && message.pointer("/params/version").and_then(Value::as_i64) + == Some(i64::from(version)) + }) + } + + pub fn shutdown(&mut self) { + let response = self.request(999, "shutdown", &json!(null)); + assert!( + response.get("error").is_none(), + "shutdown failed: {response}" + ); + self.notify("exit", &json!(null)); + self.stdin.take(); + + let deadline = Instant::now() + MESSAGE_TIMEOUT; + loop { + if let Some(status) = self.child.try_wait().expect("poll language server") { + assert!(status.success(), "language server exited with {status}"); + return; + } + if Instant::now() >= deadline { + let _ = self.child.kill(); + panic!("language server did not exit after shutdown"); + } + std::thread::sleep(Duration::from_millis(10)); + } + } + + fn send(&mut self, message: &Value) { + let body = serde_json::to_vec(&message).expect("serialize JSON-RPC message"); + let stdin = self.stdin.as_mut().expect("language server stdin is open"); + write!(stdin, "Content-Length: {}\r\n\r\n", body.len()).expect("write JSON-RPC header"); + stdin.write_all(&body).expect("write JSON-RPC body"); + stdin.flush().expect("flush JSON-RPC message"); + } + + fn receive_where(&mut self, mut predicate: impl FnMut(&Value) -> bool) -> Value { + if let Some(index) = self.pending.iter().position(&mut predicate) { + return self.pending.remove(index).expect("pending message index"); + } + + let deadline = Instant::now() + MESSAGE_TIMEOUT; + loop { + let timeout = deadline.saturating_duration_since(Instant::now()); + match self.messages.recv_timeout(timeout) { + Ok(Ok(message)) if predicate(&message) => return message, + Ok(Ok(message)) => self.pending.push_back(message), + Ok(Err(error)) => panic!("invalid language server output: {error}"), + Err(RecvTimeoutError::Disconnected) => { + panic!("language server output closed before the expected message") + } + Err(RecvTimeoutError::Timeout) => { + panic!("timed out waiting for language server message") + } + } + } + } +} + +impl Drop for LspProcess { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } +} + +fn read_message(reader: &mut impl BufRead) -> Result, String> { + let mut content_length = None; + loop { + let mut header = String::new(); + let read = reader + .read_line(&mut header) + .map_err(|error| error.to_string())?; + if read == 0 { + return Ok(None); + } + if header == "\r\n" || header == "\n" { + break; + } + if let Some(value) = header + .strip_prefix("Content-Length:") + .or_else(|| header.strip_prefix("content-length:")) + { + content_length = Some( + value + .trim() + .parse::() + .map_err(|error| error.to_string())?, + ); + } + } + + let length = content_length.ok_or_else(|| "missing Content-Length header".to_string())?; + let mut body = vec![0; length]; + reader + .read_exact(&mut body) + .map_err(|error| error.to_string())?; + serde_json::from_slice(&body) + .map(Some) + .map_err(|error| error.to_string()) +}