diff --git a/Cargo.lock b/Cargo.lock index cdb48dc..08aeda3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -948,7 +948,7 @@ dependencies = [ [[package]] name = "simplicityhl-lsp" -version = "0.7.2" +version = "0.7.3" dependencies = [ "env_logger", "miniscript", diff --git a/Cargo.toml b/Cargo.toml index 91b9941..37921e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "simplicityhl-lsp" -version = "0.7.2" +version = "0.7.3" edition = "2021" rust-version = "1.85.0" description = "Language Server Protocol (LSP) server for SimplicityHL." diff --git a/src/analysis.rs b/src/analysis.rs new file mode 100644 index 0000000..7032e0c --- /dev/null +++ b/src/analysis.rs @@ -0,0 +1,644 @@ +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/backend.rs b/src/backend.rs index 83c5d76..4ca8474 100644 --- a/src/backend.rs +++ b/src/backend.rs @@ -1,61 +1,43 @@ -use ropey::Rope; use serde_json::Value; -use simplicityhl::ast::ElementsJetHinter; -use simplicityhl::parse::ParseFromStrWithErrors; -use simplicityhl::TemplateProgram; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use std::future::Future; +use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; -use tokio::sync::RwLock; +use tokio::sync::{Mutex, RwLock}; use tower_lsp_server::jsonrpc::Result; use tower_lsp_server::lsp_types::{ CompletionItem, CompletionOptions, CompletionParams, CompletionResponse, Diagnostic, - DiagnosticSeverity, DidChangeConfigurationParams, DidChangeTextDocumentParams, - DidChangeWatchedFilesParams, DidChangeWatchedFilesRegistrationOptions, - DidChangeWorkspaceFoldersParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, - DidSaveTextDocumentParams, DocumentSymbol, DocumentSymbolParams, DocumentSymbolResponse, - ExecuteCommandParams, FileSystemWatcher, GlobPattern, GotoDefinitionParams, - GotoDefinitionResponse, Hover, HoverParams, HoverProviderCapability, InitializeParams, - InitializeResult, InitializedParams, Location, MarkupContent, MarkupKind, MessageType, OneOf, - Range, ReferenceParams, Registration, SaveOptions, SemanticToken, SemanticTokenModifier, - SemanticTokenType, SemanticTokens, SemanticTokensFullOptions, SemanticTokensLegend, - SemanticTokensOptions, SemanticTokensParams, SemanticTokensResult, - SemanticTokensServerCapabilities, ServerCapabilities, SignatureHelp, SignatureHelpOptions, - SignatureHelpParams, SymbolKind, TextDocumentSyncCapability, TextDocumentSyncKind, - TextDocumentSyncOptions, TextDocumentSyncSaveOptions, Uri, WorkDoneProgressOptions, - WorkspaceFoldersServerCapabilities, WorkspaceServerCapabilities, + 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 miniscript::iter::TreeLike; -use simplicityhl::error::{ - Diagnostic as CompilerDiagnostic, DiagnosticManager, Error as CompilerError, - Location as CompilerLocation, Severity as CompilerSeverity, Span, -}; -use simplicityhl::resolution::DependencyMap; -use simplicityhl::source::CanonSourceFile; -use simplicityhl::{parse, UnstableFeatures}; +use simplicityhl::parse; +use crate::analysis::AnalysisSnapshot; use crate::completion::{self, CompletionProvider}; use crate::config::Settings; use crate::error::LspError; -use crate::function::Functions; use crate::imports::{self, ImportCompletionContext}; use crate::project::{ProjectContext, SIMPLEX_MANIFEST}; use crate::utils::{ create_signature_info, find_builtin_signature, find_function_call_context, find_key_position, - get_call_span, get_comments_from_lines, offset_to_position, position_to_offset, - position_to_span, span_contains, span_to_positions, + get_call_span, position_to_offset, position_to_span, span_contains, span_to_positions, }; - -/// Semantic token type indices - must match the legend order -mod semantic_token_types { - pub const FUNCTION: u32 = 0; - pub const NAMESPACE: u32 = 5; -} +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. @@ -81,70 +63,6 @@ fn workspace_roots(params: &InitializeParams) -> Vec { roots } -/// Get the semantic token legend for this server -fn get_semantic_token_legend() -> SemanticTokensLegend { - SemanticTokensLegend { - token_types: vec![ - SemanticTokenType::FUNCTION, - SemanticTokenType::PARAMETER, - SemanticTokenType::VARIABLE, - SemanticTokenType::TYPE, - SemanticTokenType::KEYWORD, - SemanticTokenType::NAMESPACE, - ], - token_modifiers: vec![ - SemanticTokenModifier::DECLARATION, - SemanticTokenModifier::DEFINITION, - ], - } -} - -#[derive(Debug)] -pub struct SourceFile { - pub uri: Uri, - pub text: Rope, -} - -#[derive(Debug)] -pub struct Document { - /// Functions defined in file and imported modules. - pub functions: Functions, - - /// `use` declarations written in this document. - /// - /// The compiler's resolved function table is keyed by the local import name, while - /// navigation requests carry only a source position. Keeping the declarations lets us - /// bridge those two representations without reparsing on every request. - pub use_declarations: Vec, - - /// Mapping from module id to its Uri. - pub linearization_map: Vec, - - /// Source of given document. - pub text: Rope, - - /// Version of the text this document was built from, when the client supplied one. - /// - /// Notifications are served concurrently and complete out of order, so a slow - /// analysis must not overwrite the result of a newer edit. - pub version: Option, -} - -impl Document { - fn new(uri: Uri, text: Rope, version: Option) -> Self { - Self { - functions: Functions::new(), - use_declarations: Vec::new(), - linearization_map: vec![SourceFile { - uri, - text: text.clone(), - }], - text, - version, - } - } -} - /// Client-supplied configuration, kept separate from the document cache so a /// settings change does not need the document lock. #[derive(Debug, Default)] @@ -158,23 +76,43 @@ struct ServerConfig { 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, - document_map: Arc>>, + workspace: Arc>, + + /// Serializes each workspace diagnostic transition with its complete publication batch. + diagnostic_transaction: DiagnosticTransaction, config: Arc>, completion_provider: CompletionProvider, } -struct TextDocumentItem<'a> { - uri: Uri, - text: &'a str, - version: Option, -} - impl LanguageServer for Backend { async fn initialize(&self, params: InitializeParams) -> Result { let workspace_roots = workspace_roots(¶ms); @@ -244,7 +182,7 @@ impl LanguageServer for Backend { SemanticTokensServerCapabilities::SemanticTokensOptions( SemanticTokensOptions { work_done_progress_options: WorkDoneProgressOptions::default(), - legend: get_semantic_token_legend(), + legend: crate::semantic_tokens::legend(), range: Some(false), full: Some(SemanticTokensFullOptions::Bool(true)), }, @@ -347,12 +285,15 @@ impl LanguageServer for Backend { } async fn did_open(&self, params: DidOpenTextDocumentParams) { - self.on_change(TextDocumentItem { - uri: params.text_document.uri, - text: ¶ms.text_document.text, - version: Some(params.text_document.version), - }) - .await; + 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) { @@ -362,31 +303,39 @@ impl LanguageServer for Backend { let Some(change) = params.content_changes.into_iter().next_back() else { return; }; - self.on_change(TextDocumentItem { - text: &change.text, - uri: params.text_document.uri, - version: Some(params.text_document.version), - }) - .await; + 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 { - self.on_change(TextDocumentItem { - uri: params.text_document.uri, - text: &text, - version: None, - }) - .await; + 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.document_map.write().await.remove(&uri); - self.client.publish_diagnostics(uri, Vec::new(), None).await; + self.run_diagnostic_transaction(|workspace| workspace.remove_if_current(&uri, generation)) + .await; } async fn semantic_tokens_full( @@ -403,119 +352,16 @@ impl LanguageServer for Backend { return Ok(None); } - let documents = self.document_map.read().await; + 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 mut raw_tokens: Vec<(u32, u32, u32, u32, u32)> = Vec::new(); // (line, col, len, type, modifiers) - - for func in &functions { - if func.span().file_id != 0 { - continue; - } - - // Add function name token (declaration) - if let Ok(name_range) = doc.find_function_name_range(func) { - let len = u32::try_from(func.name().as_inner().len()).map_err(LspError::from)?; - raw_tokens.push(( - name_range.start.line, - name_range.start.character, - len, - semantic_token_types::FUNCTION, - 0b11, // DECLARATION | DEFINITION - )); - } - - // Add function call tokens by walking the expression tree - let calls = parse::ExprTree::Expression(func.body()) - .pre_order_iter() - .filter_map(|expr| { - if let parse::ExprTree::Call(call) = expr { - Some((call, get_call_span(call))) - } else { - None - } - }) - .collect::>(); - - for (call, span) in calls { - if let Ok((start, _end)) = span_to_positions(&span, &doc.text) { - let name = call.name(); - let name_str = name.to_string(); - - // Determine token type based on call name - let (token_type, prefix_len) = match name { - parse::CallName::Jet(_) => { - // jet::xxx - add namespace token for "jet" and function for xxx - // First add "jet" as namespace - raw_tokens.push(( - start.line, - start.character, - 3, // "jet" - semantic_token_types::NAMESPACE, - 0, - )); - // The function name starts after "jet::" - (semantic_token_types::FUNCTION, 5) - } - _ => (semantic_token_types::FUNCTION, 0), - }; - - // Add the function name token - let func_name_len = if prefix_len > 0 { - name_str.len().saturating_sub(prefix_len) - } else { - name_str.len() - }; - - if func_name_len > 0 { - raw_tokens.push(( - start.line, - start.character + u32::try_from(prefix_len).map_err(LspError::from)?, - u32::try_from(func_name_len).map_err(LspError::from)?, - token_type, - 0, - )); - } - } - } - } - - // Sort tokens by position (line, then column) - raw_tokens.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1))); - - // Convert to delta-encoded semantic tokens - let mut semantic_tokens = Vec::new(); - let mut prev_line = 0u32; - let mut prev_char = 0u32; - - for (line, col, len, token_type, modifiers) in raw_tokens { - let delta_line = line - prev_line; - let delta_start = if delta_line == 0 { - col - prev_char - } else { - col - }; - - semantic_tokens.push(SemanticToken { - delta_line, - delta_start, - length: len, - token_type, - token_modifiers_bitset: modifiers, - }); - - prev_line = line; - prev_char = col; - } - Ok(Some(SemanticTokensResult::Tokens(SemanticTokens { result_id: None, - data: semantic_tokens, + data: crate::semantic_tokens::tokens(doc), }))) } @@ -533,7 +379,7 @@ impl LanguageServer for Backend { return Ok(None); } - let documents = self.document_map.read().await; + 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 { @@ -587,7 +433,7 @@ impl LanguageServer for Backend { } async fn signature_help(&self, params: SignatureHelpParams) -> Result> { - let documents = self.document_map.read().await; + 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) @@ -648,7 +494,7 @@ impl LanguageServer for Backend { let uri = ¶ms.text_document_position.text_document.uri; let pos = params.text_document_position.position; let (source_prefix, functions) = { - let documents = self.document_map.read().await; + let documents = self.workspace.read().await; let Some(doc) = documents.get(uri) else { return Ok(None); }; @@ -701,7 +547,7 @@ impl LanguageServer for Backend { return Ok(None); } - let documents = self.document_map.read().await; + 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 { @@ -776,7 +622,7 @@ impl LanguageServer for Backend { &self, params: GotoDefinitionParams, ) -> Result> { - let documents = self.document_map.read().await; + 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) @@ -789,7 +635,7 @@ impl LanguageServer for Backend { 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.linearization_map.get(function.span().file_id) else { + 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)?; @@ -824,7 +670,7 @@ impl LanguageServer for Backend { return Ok(None); }; - let Some(source_file) = doc.linearization_map.get(function.span().file_id) else { + let Some(source_file) = doc.sources.get(function.span().file_id) else { return Ok(None); }; @@ -840,7 +686,7 @@ impl LanguageServer for Backend { } async fn references(&self, params: ReferenceParams) -> Result>> { - let documents = self.document_map.read().await; + let documents = self.workspace.read().await; let uri = ¶ms.text_document_position.text_document.uri; let Some(doc) = documents.get(uri) else { @@ -863,9 +709,12 @@ impl LanguageServer for Backend { } } - let Some(func) = functions.iter().find(|func| match call_name { - Some(parse::CallName::Custom(name)) => func.name() == name, - _ => span_contains(func.span(), &token_span), + 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); }; @@ -877,17 +726,11 @@ impl LanguageServer for Backend { } } - Ok(Some( - documents - .values() - .filter_map(|document| { - document - .find_all_references(&parse::CallName::Custom(func.name().clone())) - .ok() - }) - .flatten() - .collect(), - )) + let Some(identity) = doc.function_identity(func) else { + return Ok(None); + }; + + Ok(Some(documents.find_references_to(&identity))) } } @@ -895,12 +738,32 @@ impl Backend { pub fn new(client: Client) -> Self { Self { client, - document_map: Arc::new(RwLock::new(HashMap::new())), + 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, @@ -929,25 +792,14 @@ impl Backend { /// Re-run analysis for every open document, after configuration that affects /// dependency resolution has changed. async fn reanalyze_open_documents(&self) { - let documents = { - let documents = self.document_map.read().await; - documents - .iter() - .map(|(uri, doc)| (uri.clone(), doc.text.to_string(), doc.version)) - .collect::>() - }; - for (uri, text, version) in documents { - self.on_change(TextDocumentItem { - uri, - text: &text, - version, - }) - .await; + 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: TextDocumentItem<'_>) { + async fn on_change(&self, params: AnalysisInput) { let Some(path_buf) = params.uri.to_file_path() else { return; }; @@ -966,343 +818,26 @@ impl Backend { let config = self.config.read().await; (config.settings.clone(), config.workspace_roots.clone()) }; - let (err, document) = parse_program(params.text, path, &settings, &workspace_roots); - let rope = Rope::from_str(params.text); - let mut documents = self.document_map.write().await; - - // Analysis above runs without the lock, so a concurrent notification for a newer - // version may already have stored its result. Dropping the stale write keeps the - // map consistent with the latest text the client sent. - let stored_version = documents.get(¶ms.uri).and_then(|doc| doc.version); - if matches!((params.version, stored_version), (Some(incoming), Some(stored)) if incoming < stored) - { - return; - } - // `did_save` carries no version; keep the one already recorded rather than - // clearing it, so later edits can still be ordered against it. - let version = params.version.or(stored_version); - - // A parse failure invalidates every old analysis span, but the latest text must remain - // available so completion still works while the user is typing incomplete syntax. - let mut document = - document.unwrap_or_else(|| Document::new(params.uri.clone(), rope.clone(), version)); - document.version = version; - documents.insert(params.uri.clone(), document); - let diagnostics = err - .iter() - .filter_map(|err| { - // Library analysis normally injects a synthetic entry point. Keep entry-point - // diagnostics hidden as a defensive fallback so opening a library or an invalidly - // nested `main` does not produce editor noise unrelated to the file's definitions. - match err.error() { - simplicityhl::error::Error::MainRequired => return None, - simplicityhl::error::Error::CannotParse { msg } - if msg.clone() - == simplicityhl::error::Error::MainOutOfEntryFile.to_string() => - { - return None; - } - _ => {} - } - - // This merged backend owns one open document at a time. Compiler 0.7 - // diagnostics can point into imported files; - // TODO: until multi-document publication is implemented, keep those visible on the - // root document without pretending their byte offsets belong to the root source. - let range = match err.location() { - CompilerLocation::Code(span) if span.file_id == 0 => { - let Ok((start, end)) = span_to_positions(span, &rope) else { - return None; - }; - Range::new(start, end) - } - CompilerLocation::Code(_) - | CompilerLocation::File(_) - | CompilerLocation::Global => Range::default(), - }; - let severity = match err.severity() { - CompilerSeverity::Error => DiagnosticSeverity::ERROR, - CompilerSeverity::Warning => DiagnosticSeverity::WARNING, - }; - - Some(Diagnostic { - range, - severity: Some(severity), - source: Some("simplicityhl".to_string()), - message: err.error().to_string(), - ..Diagnostic::default() - }) - }) - .collect(); - - self.client - .publish_diagnostics(params.uri.clone(), diagnostics, params.version) - .await; + 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: TextDocumentItem<'_>) { - let diagnostics = validate_witness_file(params.text); - self.client - .publish_diagnostics(params.uri.clone(), diagnostics, params.version) - .await; - } -} - -impl Document { - /// Create a document from a successfully parsed program and its source. - fn from_program(program: &simplicityhl::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 document = Self::new(uri, text, None); - - collect_use_declarations(program.items(), &mut document.use_declarations); - - program - .items() - .iter() - .filter_map(|item| { - if let parse::Item::Function(func) = item { - Some(func) - } else { - None - } - }) - .for_each(|func| { - let start_line = offset_to_position(func.span().start, &document.text) - .unwrap_or_default() - .line; - document.functions.insert( - func.name().to_string(), - func.to_owned(), - get_comments_from_lines(start_line, &document.text), - ); - }); - - document - } -} - -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, - }) -} - -/// Find the root-file import that pulls another `main` function into the flattened program. -/// -/// The compiler currently attaches `FunctionRedefined(main)` to the entire flattened program. -/// For an editor diagnostic, the actionable source location is the `use` declaration that loaded -/// the file containing the extra entry point. -fn imported_main_span( - items: &[parse::Item], - current_source: &CanonSourceFile, - dependencies: &DependencyMap, - unstable_features: &UnstableFeatures, -) -> Option { - for item in items { - match item { - parse::Item::Use(use_decl) => { - let Ok(target) = dependencies.resolve_path(current_source.name(), use_decl) else { - continue; - }; - - // `use crate::::...` does not introduce another source file. - if &target == current_source.name() { - continue; - } - - let Ok(source) = std::fs::read_to_string(target.as_path()) else { - continue; - }; - let mut diagnostics = DiagnosticManager::new(); - let Some(program) = parse::Program::parse_from_str_with_errors( - 0, - &source, - unstable_features, - &mut diagnostics, - ) else { - continue; - }; - - if items_contain_main(program.items()) { - return Some(*use_decl.span()); - } - } - parse::Item::Module(module) => { - if let Some(span) = imported_main_span( - module.items(), - current_source, - dependencies, - unstable_features, - ) { - return Some(span); - } - } - parse::Item::TypeAlias(_) - | parse::Item::Function(_) - | parse::Item::EnumDeclaration(_) - | parse::Item::Ignored => {} - } - } - - None -} - -fn is_duplicate_main(diagnostic: &CompilerDiagnostic) -> bool { - matches!( - diagnostic.error(), - CompilerError::FunctionRedefined { name } if name.as_inner() == "main" - ) -} - -fn remap_imported_main_diagnostics( - diagnostics: &DiagnosticManager, - program: &parse::Program, - current_source: &CanonSourceFile, - dependencies: &DependencyMap, - unstable_features: &UnstableFeatures, -) -> Vec { - if !diagnostics.diagnostics().iter().any(is_duplicate_main) { - return diagnostics.diagnostics().to_vec(); - } - - let Some(import_span) = imported_main_span( - program.items(), - current_source, - dependencies, - unstable_features, - ) else { - return diagnostics.diagnostics().to_vec(); - }; - - diagnostics - .diagnostics() - .iter() - .map(|diagnostic| { - if !is_duplicate_main(diagnostic) { - return diagnostic.clone(); - } - - let mut remapped = match diagnostic.severity() { - CompilerSeverity::Error => { - CompilerDiagnostic::new(diagnostic.error().clone(), import_span) - } - CompilerSeverity::Warning => { - CompilerDiagnostic::warning(diagnostic.error().clone(), import_span) - } - }; - for label in diagnostic.secondary() { - remapped = remapped.with_secondary(label.span, label.message.clone()); - } - for note in diagnostic.notes() { - remapped = remapped.with_note(note.clone()); - } - if let Some(help) = diagnostic.help() { - remapped = remapped.with_help(help.clone()); - } - remapped + 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, + ) }) - .collect() -} - -/// Parse and analyze a program using the [`simplicityhl`] compiler. -/// Also create a [`Document`] when parsing succeeds. -fn parse_program( - text: &str, - path: &Path, - settings: &Settings, - workspace_roots: &[PathBuf], -) -> (Vec, Option) { - let unstable_features = settings.unstable_features(); - let mut diagnostics = DiagnosticManager::new(); - let text: Arc = Arc::from(text); - let source_file = simplicityhl::source::SourceFile::new(path, Arc::clone(&text)); - let Some(program) = parse::Program::parse_from_str_with_errors( - 0, - text.as_ref(), - &unstable_features, - &mut diagnostics, - ) else { - return (diagnostics.diagnostics().to_vec(), None); - }; - - let mut document = Document::from_program(&program, text.as_ref(), path); - - // Import roots come from the Simplex manifest and the client's settings rather than - // from the containing directory, so dependencies resolve the way `simplex` builds them. - 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), - )); - - return (diagnostics.diagnostics().to_vec(), Some(document)); - } - }; - let canonical_source: CanonSourceFile = source_file - .clone() - .try_into() - .expect("name was defined above"); - - // FIXME: The compiler analyzes programs from an entry-file perspective and rejects a library file - // that has no `main`. Editors open those files directly, though, and still need the resolved - // import graph for navigation. A synthetic entry point lets analysis build that graph while - // leaving every span in the user's source unchanged. It is never added to `Document`. - 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())), - ) - }; - let compiler_diagnostics = match TemplateProgram::new_with_dep( - analysis_source, - &dependencies, - &unstable_features, - Box::new(ElementsJetHinter::new()), - ) { - Ok(template_program) => { - document.populate_visible_functions(&template_program); - template_program.diagnostics().diagnostics().to_vec() - } - Err(diagnostics) => remap_imported_main_diagnostics( - &diagnostics, - &program, - &canonical_source, - &dependencies, - &unstable_features, - ), - }; - - (compiler_diagnostics, Some(document)) + .await; + } } /// Validate a witness (.wit) file and return diagnostics. @@ -1372,10 +907,23 @@ fn validate_witness_file(text: &str) -> Vec { #[cfg(test)] mod tests { - use simplicityhl::error::Error; + 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. @@ -1388,6 +936,92 @@ mod tests { (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() {}" @@ -1401,6 +1035,359 @@ mod tests { "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()); @@ -1437,7 +1424,7 @@ mod tests { .get_func(call.name().to_string().as_str()) .expect("helper definition"); let source_file = doc - .linearization_map + .sources .get(function.span().file_id) .expect("current file should always have source metadata"); @@ -1524,7 +1511,7 @@ mod tests { .is_none()); let source_file = doc - .linearization_map + .sources .get(original.span().file_id) .expect("imported source metadata"); assert_eq!( @@ -1593,8 +1580,7 @@ mod tests { assert_eq!(transitive.name().as_inner(), "hash"); assert_eq!(transitive_alias.name().as_inner(), "hash"); - let definition_uri = - |function: &parse::Function| &doc.linearization_map[function.span().file_id].uri; + 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"); @@ -1811,6 +1797,80 @@ mod tests { 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()); diff --git a/src/diagnostics.rs b/src/diagnostics.rs new file mode 100644 index 0000000..6af3e1f --- /dev/null +++ b/src/diagnostics.rs @@ -0,0 +1,112 @@ +use std::collections::HashMap; + +use simplicityhl::error::{ + Diagnostic as CompilerDiagnostic, Error as CompilerError, Location as CompilerLocation, + Severity as CompilerSeverity, +}; +use tower_lsp_server::lsp_types::{ + Diagnostic, DiagnosticRelatedInformation, DiagnosticSeverity, Location, Range, Uri, +}; + +use crate::analysis::AnalysisSnapshot; +use crate::utils::span_to_positions; + +/// Diagnostics produced by one analysis root, grouped by the source that owns each range. +#[derive(Debug, Default)] +pub struct DiagnosticBundle(HashMap>); + +impl DiagnosticBundle { + pub fn from_snapshot(snapshot: &AnalysisSnapshot) -> Self { + let mut bundle = Self::default(); + + for diagnostic in snapshot + .compiler_diagnostics + .iter() + .filter(|diagnostic| !hidden(diagnostic)) + { + let (source, range) = match diagnostic.location() { + CompilerLocation::Code(span) => { + let Some(source) = snapshot.sources.get(span.file_id) else { + continue; + }; + let Ok((start, end)) = span_to_positions(span, &source.text) else { + continue; + }; + (source, Range::new(start, end)) + } + CompilerLocation::File(file_id) => { + let Some(source) = snapshot.sources.get(*file_id) else { + continue; + }; + (source, Range::default()) + } + CompilerLocation::Global => (snapshot.sources.root_source(), Range::default()), + }; + + let related_information = diagnostic + .secondary() + .iter() + .filter_map(|label| { + let related_source = snapshot.sources.get(label.span.file_id)?; + let (start, end) = span_to_positions(&label.span, &related_source.text).ok()?; + Some(DiagnosticRelatedInformation { + location: Location::new(related_source.uri.clone(), Range::new(start, end)), + message: label.message.clone(), + }) + }) + .collect::>(); + + bundle + .0 + .entry(source.uri.clone()) + .or_default() + .push(Diagnostic { + range, + severity: Some(match diagnostic.severity() { + CompilerSeverity::Error => DiagnosticSeverity::ERROR, + CompilerSeverity::Warning => DiagnosticSeverity::WARNING, + }), + source: Some("simplicityhl".to_string()), + message: message(diagnostic), + related_information: (!related_information.is_empty()) + .then_some(related_information), + ..Diagnostic::default() + }); + } + + bundle + } + + pub fn get(&self, uri: &Uri) -> Option<&[Diagnostic]> { + self.0.get(uri).map(Vec::as_slice) + } + + pub fn uris(&self) -> impl Iterator { + self.0.keys() + } +} + +fn hidden(diagnostic: &CompilerDiagnostic) -> bool { + match diagnostic.error() { + CompilerError::MainRequired => true, + CompilerError::CannotParse { msg } + if msg == &CompilerError::MainOutOfEntryFile.to_string() => + { + true + } + _ => false, + } +} + +fn message(diagnostic: &CompilerDiagnostic) -> String { + let mut message = diagnostic.error().to_string(); + for note in diagnostic.notes() { + message.push_str("\n\nNote: "); + message.push_str(note); + } + if let Some(help) = diagnostic.help() { + message.push_str("\n\nHelp: "); + message.push_str(help); + } + message +} diff --git a/src/main.rs b/src/main.rs index caf7705..1638711 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,18 @@ #![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}; diff --git a/src/navigation.rs b/src/navigation.rs new file mode 100644 index 0000000..b348ed0 --- /dev/null +++ b/src/navigation.rs @@ -0,0 +1,242 @@ +use std::collections::HashSet; + +use miniscript::iter::TreeLike; +use simplicityhl::parse::{self, CallName}; +use tower_lsp_server::lsp_types::{self, 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, +} + +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.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() + } + + /// 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() + } + + pub fn find_function_name_range( + &self, + function: &parse::Function, + ) -> Result { + let function_span = function.span(); + let source = match function_span.file_id { + 0 => &self.text, + file_id => { + &self + .sources + .get(file_id) + .ok_or_else(|| { + LspError::FunctionNotFound(format!( + "Source file for function {} not found", + function.name() + )) + })? + .text + } + }; + let function_source = source + .get_byte_slice(function_span.start..function_span.end) + .ok_or_else(|| { + LspError::FunctionNotFound(format!( + "Source span for function {} is outside its document", + function.name() + )) + })? + .to_string(); + let (tokens, _) = simplicityhl::lexer::lex(function_span.file_id, &function_source, 0); + let Some(tokens) = tokens else { + return Err(LspError::FunctionNotFound(format!( + "Function with name {} not found", + function.name() + ))); + }; + let name_span = tokens + .windows(2) + .find_map(|pair| match pair { + [ + (simplicityhl::lexer::Token::Fn, _), + (simplicityhl::lexer::Token::Ident(name), span), + ] if *name == function.name().as_inner() => Some(*span), + _ => None, + }) + .ok_or_else(|| { + LspError::FunctionNotFound(format!( + "Function with name {} not found inside its source span", + function.name() + )) + })?; + + Ok(lsp_types::Range { + start: offset_to_position(function_span.start + name_span.start, source)?, + end: offset_to_position(function_span.start + name_span.end, source)?, + }) + } + + /// Resolve the imported function named by a cursor inside a `use` declaration. + pub fn find_imported_function( + &self, + token_span: simplicityhl::error::Span, + ) -> Option<&parse::Function> { + let use_decl = self + .use_declarations + .iter() + .filter(|use_decl| span_contains(use_decl.span(), &token_span)) + .min_by_key(|use_decl| use_decl.span().end - use_decl.span().start)?; + + let source = self.text.to_string(); + let tokens = simplicityhl::lexer::lex(0, &source, 0).0?; + let identifiers = tokens + .iter() + .filter(|(_, span)| { + span.start >= use_decl.span().start && span.end <= use_decl.span().end + }) + .skip_while(|(token, _)| !matches!(token, simplicityhl::lexer::Token::Use)) + .skip(1) + .filter(|(token, _)| { + matches!( + token, + simplicityhl::lexer::Token::Crate | simplicityhl::lexer::Token::Ident(_) + ) + }) + .collect::>(); + let selected_index = identifiers + .iter() + .position(|(_, span)| span_contains(span, &token_span))?; + + let mut item_index = use_decl.path().len(); + let 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 items { + let selected_original = selected_index == item_index; + item_index += 1; + let selected_alias = alias.is_some() && selected_index == item_index; + if alias.is_some() { + item_index += 1; + } + if selected_original || selected_alias { + return self + .functions + .get_func(alias.as_ref().unwrap_or(original).as_inner()); + } + } + None + } + + /// Find the smallest call whose callable span contains the requested source position. + pub fn find_related_call( + &self, + token_span: simplicityhl::error::Span, + ) -> Option<&simplicityhl::parse::Call> { + let function = self.functions.functions().into_iter().find(|function| { + function.span().file_id == 0 && span_contains(function.span(), &token_span) + })?; + + 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(|(_, span)| span_contains(span, &token_span)) + .map(|(call, _)| call) + .last() + } +} diff --git a/src/semantic_tokens.rs b/src/semantic_tokens.rs new file mode 100644 index 0000000..84e855c --- /dev/null +++ b/src/semantic_tokens.rs @@ -0,0 +1,179 @@ +use miniscript::iter::TreeLike; +use simplicityhl::error::Span; +use simplicityhl::parse; +use tower_lsp_server::lsp_types::{ + SemanticToken, SemanticTokenModifier, SemanticTokenType, SemanticTokensLegend, +}; + +use crate::analysis::AnalysisSnapshot; +use crate::utils::span_to_positions; + +mod token_type { + pub const FUNCTION: u32 = 0; + pub const NAMESPACE: u32 = 5; +} + +type RawToken = (u32, u32, u32, u32, u32); + +pub fn legend() -> SemanticTokensLegend { + SemanticTokensLegend { + token_types: vec![ + SemanticTokenType::FUNCTION, + SemanticTokenType::PARAMETER, + SemanticTokenType::VARIABLE, + SemanticTokenType::TYPE, + SemanticTokenType::KEYWORD, + SemanticTokenType::NAMESPACE, + ], + token_modifiers: vec![ + SemanticTokenModifier::DECLARATION, + SemanticTokenModifier::DEFINITION, + ], + } +} + +pub fn tokens(snapshot: &AnalysisSnapshot) -> Vec { + let source = snapshot.text.to_string(); + let lexer_tokens = simplicityhl::lexer::lex(0, &source, 0) + .0 + .unwrap_or_default(); + let mut raw_tokens = Vec::new(); + + for function in snapshot.functions.functions() { + if function.span().file_id != 0 { + continue; + } + + if let Ok(name_range) = snapshot.find_function_name_range(function) { + if name_range.start.line == name_range.end.line + && name_range.start.character < name_range.end.character + { + raw_tokens.push(( + name_range.start.line, + name_range.start.character, + name_range.end.character - name_range.start.character, + token_type::FUNCTION, + 0b11, + )); + } + } + + for expression in parse::ExprTree::Expression(function.body()).pre_order_iter() { + let parse::ExprTree::Call(call) = expression else { + continue; + }; + for (span, token_type) in call_spans(call, &lexer_tokens) { + if let Some(token) = raw_token(&span, &snapshot.text, token_type, 0) { + raw_tokens.push(token); + } + } + } + } + + encode(raw_tokens) +} + +fn raw_token(span: &Span, text: &ropey::Rope, token_type: u32, modifiers: u32) -> Option { + let (start, end) = span_to_positions(span, text).ok()?; + if start.line != end.line || start.character >= end.character { + return None; + } + Some(( + start.line, + start.character, + end.character - start.character, + token_type, + modifiers, + )) +} + +fn call_spans(call: &parse::Call, tokens: &simplicityhl::lexer::Tokens<'_>) -> Vec<(Span, u32)> { + use simplicityhl::lexer::Token; + + let call_start = call.span().start; + let token_at_start = tokens.iter().find(|(_, span)| span.start == call_start); + let find_ident = |name: &str, must_start_call: bool| { + tokens + .iter() + .find(|(token, span)| { + (!must_start_call || span.start == call_start) + && span.start >= call_start + && span.end <= call.span().end + && matches!(token, Token::Ident(value) if *value == name) + }) + .map(|(_, span)| *span) + }; + + let function = token_type::FUNCTION; + match call.name() { + parse::CallName::Jet(_) => token_at_start + .filter(|(token, _)| matches!(token, Token::Jet(_))) + .map(|(_, span)| { + vec![ + ( + Span::new(span.file_id, span.start..span.start + 3), + token_type::NAMESPACE, + ), + (Span::new(span.file_id, span.start + 5..span.end), function), + ] + }) + .unwrap_or_default(), + parse::CallName::Assert | parse::CallName::Panic | parse::CallName::Debug => token_at_start + .filter(|(token, _)| matches!(token, Token::Macro(_))) + .map(|(_, span)| vec![(*span, function)]) + .unwrap_or_default(), + name => { + let (callable, callable_starts_call, callback) = match name { + parse::CallName::Custom(name) => (name.as_inner(), true, None), + parse::CallName::Fold(name, _) => ("fold", true, Some(name.as_inner())), + parse::CallName::ArrayFold(name, _) => ("array_fold", true, Some(name.as_inner())), + parse::CallName::ForWhile(name) => ("for_while", true, Some(name.as_inner())), + parse::CallName::UnwrapLeft(_) => ("unwrap_left", true, None), + parse::CallName::UnwrapRight(_) => ("unwrap_right", true, None), + parse::CallName::Unwrap => ("unwrap", true, None), + parse::CallName::IsNone(_) => ("is_none", true, None), + parse::CallName::TypeCast(_) => ("into", false, None), + parse::CallName::Jet(_) + | parse::CallName::Assert + | parse::CallName::Panic + | parse::CallName::Debug => unreachable!(), + }; + [ + find_ident(callable, callable_starts_call), + callback.and_then(|name| find_ident(name, false)), + ] + .into_iter() + .flatten() + .map(|span| (span, function)) + .collect() + } + } +} + +fn encode(mut tokens: Vec) -> Vec { + tokens.sort_unstable(); + tokens.dedup(); + + let mut previous_line = 0; + let mut previous_character = 0; + tokens + .into_iter() + .map(|(line, character, length, token_type, modifiers)| { + let delta_line = line - previous_line; + let delta_start = if delta_line == 0 { + character - previous_character + } else { + character + }; + previous_line = line; + previous_character = character; + SemanticToken { + delta_line, + delta_start, + length, + token_type, + token_modifiers_bitset: modifiers, + } + }) + .collect() +} diff --git a/src/utils.rs b/src/utils.rs index 911505e..522301d 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -3,13 +3,9 @@ use tower_lsp_server::lsp_types::{ self, MarkupContent, MarkupKind, ParameterInformation, ParameterLabel, SignatureInformation, }; -use miniscript::iter::TreeLike; -use simplicityhl::parse::{self, CallName}; -use tower_lsp_server::UriExt; - -use crate::backend::{Document, SourceFile}; 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 @@ -355,373 +351,6 @@ pub fn find_builtin_signature(name: &str) -> Option { Some(create_signature_info(&template)) } -impl Document { - pub fn find_all_references( - &self, - call_name: &CallName, - ) -> Result, LspError> { - self.functions - .functions() - .iter() - .filter_map(|func| { - let uri = self.linearization_map.get(func.span().file_id)?; - Some( - parse::ExprTree::Expression(func.body()) - .pre_order_iter() - .filter_map(|expr| { - if let parse::ExprTree::Call(call) = expr { - Some((call, get_call_span(call))) - } else { - None - } - }) - .filter(|(call, _)| call.name() == call_name) - .map(|(_, span)| (span, uri)) - .collect::>(), - ) - }) - .flatten() - .map(|(span, source_file)| { - let (start, end) = span_to_positions(&span, &source_file.text)?; - Ok(lsp_types::Location { - range: lsp_types::Range { start, end }, - uri: source_file.uri.clone(), - }) - }) - .collect::, LspError>>() - } - - pub fn find_function_name_range( - &self, - function: &parse::Function, - ) -> Result { - let function_span = function.span(); - let source = match function_span.file_id { - 0 => &self.text, - file_id => { - &self - .linearization_map - .get(file_id) - .ok_or_else(|| { - LspError::FunctionNotFound(format!( - "Source file for function {} not found", - function.name() - )) - })? - .text - } - }; - let function_source = source - .get_byte_slice(function_span.start..function_span.end) - .ok_or_else(|| { - LspError::FunctionNotFound(format!( - "Source span for function {} is outside its document", - function.name() - )) - })? - .to_string(); - let (tokens, _) = simplicityhl::lexer::lex(function_span.file_id, &function_source, 0); - let Some(tokens) = tokens else { - return Err(LspError::FunctionNotFound(format!( - "Function with name {} not found", - function.name() - ))); - }; - let name_span = tokens - .windows(2) - .find_map(|pair| match pair { - [ - (simplicityhl::lexer::Token::Fn, _), - (simplicityhl::lexer::Token::Ident(name), span), - ] if *name == function.name().as_inner() => Some(*span), - _ => None, - }) - .ok_or_else(|| { - LspError::FunctionNotFound(format!( - "Function with name {} not found inside its source span", - function.name() - )) - })?; - - Ok(lsp_types::Range { - start: offset_to_position(function_span.start + name_span.start, source)?, - end: offset_to_position(function_span.start + name_span.end, source)?, - }) - } - - /// Resolve the imported function named by a cursor inside a `use` declaration. - /// - /// `UseDecl` stores names but not an individual span for each path/item segment. The lexer - /// supplies those spans; the declaration supplies the semantic split between path segments, - /// imported names, and aliases. Combining them prevents a path component that happens to have - /// the same spelling as a function from being treated as the imported item. - pub fn find_imported_function( - &self, - token_span: simplicityhl::error::Span, - ) -> Option<&parse::Function> { - let use_decl = self - .use_declarations - .iter() - .filter(|use_decl| span_contains(use_decl.span(), &token_span)) - .min_by_key(|use_decl| use_decl.span().end - use_decl.span().start)?; - - let source = self.text.to_string(); - let (tokens, _) = simplicityhl::lexer::lex(0, &source, 0); - let tokens = tokens?; - let identifiers = tokens - .iter() - .filter(|(_, span)| { - span.start >= use_decl.span().start && span.end <= use_decl.span().end - }) - .skip_while(|(token, _)| !matches!(token, simplicityhl::lexer::Token::Use)) - .skip(1) - .filter(|(token, _)| { - matches!( - token, - simplicityhl::lexer::Token::Crate | simplicityhl::lexer::Token::Ident(_) - ) - }) - .collect::>(); - let selected_index = identifiers - .iter() - .position(|(_, span)| span_contains(span, &token_span))?; - - // All identifiers before this boundary belong to the module path. The remainder is - // `(original, optional alias)` for each imported item, in source order. - let mut item_index = use_decl.path().len(); - let 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 items { - let selected_original = selected_index == item_index; - item_index += 1; - let selected_alias = alias.is_some() && selected_index == item_index; - if alias.is_some() { - item_index += 1; - } - - if selected_original || selected_alias { - let local_name = alias.as_ref().unwrap_or(original); - return self.functions.get_func(local_name.as_inner()); - } - } - - None - } - - /// Find [`simplicityhl::parse::Call`] which contains given [`simplicityhl::error::Span`], which also have minimal Span. - pub fn find_related_call( - &self, - token_span: simplicityhl::error::Span, - ) -> Option<&simplicityhl::parse::Call> { - let func = self - .functions - .functions() - .into_iter() - .find(|func| span_contains(func.span(), &token_span) && func.span().file_id == 0)?; - - let call = parse::ExprTree::Expression(func.body()) - .pre_order_iter() - .filter_map(|expr| { - if let parse::ExprTree::Call(call) = expr { - // Only include if call span can be obtained - Some((call, get_call_span(call))) - } else { - None - } - }) - .filter(|(_, span)| span_contains(span, &token_span)) - .map(|(call, _)| call) - .last(); - - call - } - - /// Append functions imported via `use` declarations to [`Document`], - /// respecting aliases (e.g. `use crate::a::func as func2`). - pub fn populate_visible_functions(&mut self, template_program: &simplicityhl::TemplateProgram) { - let Some(source_map) = template_program.source_map() else { - return; - }; - - // Populate linearization_map from module_registry. - let mut modules: Vec<_> = source_map.iter().map(|(p, id)| (*id, p)).collect(); - modules.sort_by_key(|(id, _)| *id); - - self.linearization_map = modules - .iter() - .map(|(file_id, path)| { - let uri = lsp_types::Uri::from_file_path(path.as_path()).expect("valid file URI"); - let text = if *file_id == 0 { - self.text.clone() - } else { - Rope::from_str( - &std::fs::read_to_string(path.as_path()) - .expect("failed to read module source"), - ) - }; - SourceFile { uri, text } - }) - .collect(); - - let resolved_program = template_program.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(|s| s.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_module_str) = path.get(1) else { - continue; - }; - let Some(target_file_id) = target_module_str - .as_inner() - .strip_prefix("unit_") - .and_then(|s| s.parse::().ok()) - else { - continue; - }; - - let items = match use_decl.items() { - parse::UseItems::Single(elem) => std::slice::from_ref(elem), - parse::UseItems::List(elems) => elems.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(func) = resolve_function( - resolved_program, - target_file_id, - &path[2..], - original_name.as_inner(), - &mut visited, - ) else { - continue; - }; - - let Some(source_file) = self.linearization_map.get(func.span().file_id) else { - continue; - }; - - let start_line = offset_to_position(func.span().start, &source_file.text) - .unwrap_or_default() - .line; - let doc_comments = get_comments_from_lines(start_line, &source_file.text); - - self.functions - .insert(local_name.to_string(), (*func).clone(), doc_comments); - } - } - } - } -} - -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 { - let local_name = alias.as_ref().unwrap_or(original); - if local_name.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::*; diff --git a/src/workspace.rs b/src/workspace.rs new file mode 100644 index 0000000..d80a2ec --- /dev/null +++ b/src/workspace.rs @@ -0,0 +1,778 @@ +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()); + } +}