diff --git a/Cargo.lock b/Cargo.lock index 885b43ea..ddf7233c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1216,6 +1216,7 @@ dependencies = [ name = "ndc_lsp" version = "0.3.0" dependencies = [ + "ahash", "ndc_analyser", "ndc_core", "ndc_interpreter", diff --git a/ext/andy-cpp/README.md b/ext/andy-cpp/README.md index 50d119cd..5f77dfcc 100644 --- a/ext/andy-cpp/README.md +++ b/ext/andy-cpp/README.md @@ -2,9 +2,18 @@ ## Features -This extension offers basic syntax highlighting for the Andy C++ programming language. +This extension provides editor support for the Andy C++ programming language: -## Known Issues +- Syntax highlighting for `.ndc` files +- Language server features (via the bundled `ndc` binary): + - Diagnostics (lexer, parser, and type errors) + - Inlay type hints + - Hover (inferred types; signatures and docs for built-ins) + - Completion (method-call style on `.`, plus locals and keywords) + - Document symbols (outline) + - Go-to-definition +- "Run Script" command -Not all the syntax of the language is highlighted correctly +## Known Issues +Not all the syntax of the language is highlighted correctly. diff --git a/manual/src/SUMMARY.md b/manual/src/SUMMARY.md index 08c6946c..edf4f18e 100644 --- a/manual/src/SUMMARY.md +++ b/manual/src/SUMMARY.md @@ -34,5 +34,8 @@ - [Memoization](./features/memoization.md) - [Tracing](./features/tracing.md) +# Tooling +- [Editor support](./tooling/editor-support.md) + # Troubleshooting - [Overload dispatch with collections](./troubleshooting/overload-dispatch-collections.md) diff --git a/manual/src/tooling/editor-support.md b/manual/src/tooling/editor-support.md new file mode 100644 index 00000000..d30a2ebc --- /dev/null +++ b/manual/src/tooling/editor-support.md @@ -0,0 +1,34 @@ +# Editor support + +Andy C++ ships a language server (LSP) so editors can offer rich feedback as you +write `.ndc` files. The server is built into the `ndc` binary and is started with: + +```bash +ndc lsp --stdio +``` + +Most users don't run this by hand — the [VS Code extension](https://open-vsx.org/) +launches it automatically. Any LSP-capable editor can use it by pointing at the +`ndc lsp --stdio` command for the `andy-cpp` language and the `.ndc` file extension. + +## What the language server provides + +- **Diagnostics** — lexer, parser, and semantic/type errors are reported inline as + you type. +- **Inlay type hints** — inferred types are shown after `let` bindings and function + parameters, and inferred return types after function signatures. Hints are only + shown where you didn't already write an annotation. +- **Hover** — hovering an expression shows its inferred type; hovering a built-in + function shows its signature and documentation. +- **Completion** — typing `.` offers functions whose first parameter accepts the + receiver's type (method-call style). General completion offers built-in functions, + in-scope variables, and language keywords. +- **Document symbols** — an outline of the top-level and nested functions and + variable declarations in the file. +- **Go-to-definition** — jump from a variable or function usage to its declaration. + +## Notes + +- The server uses full-document synchronisation and re-analyses on each edit. +- While the buffer is mid-edit and doesn't parse, the last successful analysis is + retained so hints and dot-completion keep working. diff --git a/ndc_lsp/Cargo.toml b/ndc_lsp/Cargo.toml index d074abb0..fe860ac6 100644 --- a/ndc_lsp/Cargo.toml +++ b/ndc_lsp/Cargo.toml @@ -6,6 +6,7 @@ version.workspace = true [dependencies] tokio = { version = "1.49.0", features = ["full"] } +ahash.workspace = true ndc_analyser.workspace = true ndc_lexer.workspace = true ndc_interpreter.workspace = true diff --git a/ndc_lsp/src/backend.rs b/ndc_lsp/src/backend.rs index 5bd4747d..b4ae6cdb 100644 --- a/ndc_lsp/src/backend.rs +++ b/ndc_lsp/src/backend.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use ahash::AHashMap; use ndc_core::FunctionRegistry; use ndc_interpreter::{Interpreter, NativeFunction}; @@ -7,28 +7,41 @@ use tokio::sync::RwLock; use tower_lsp::jsonrpc::Result as JsonRPCResult; use tower_lsp::lsp_types::{ CompletionItem, CompletionOptions, CompletionParams, CompletionResponse, - DidChangeTextDocumentParams, DidOpenTextDocumentParams, InitializeParams, InitializeResult, + DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams, + DocumentSymbolParams, DocumentSymbolResponse, GotoDefinitionParams, GotoDefinitionResponse, + Hover, HoverParams, HoverProviderCapability, InitializeParams, InitializeResult, InitializedParams, InlayHint, InlayHintParams, MessageType, OneOf, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, Url, WorkDoneProgressOptions, }; use tower_lsp::{Client, LanguageServer}; use crate::diagnostics; -use crate::features::{completion, inlay_hints}; +use crate::features::completion::FunctionInfo; +use crate::features::{completion, definition, hover, inlay_hints, symbols}; use crate::state::DocumentState; pub struct Backend { pub client: Client, - documents: RwLock>, + documents: RwLock>, configure: fn(&mut FunctionRegistry>), + /// Native-function metadata, snapshotted once at startup. The set of native + /// functions never changes, so completion and hover read this instead of + /// rebuilding an interpreter per request. + functions: Vec, } impl Backend { pub fn new(client: Client, configure: fn(&mut FunctionRegistry>)) -> Self { + let functions = { + let mut interpreter = Interpreter::capturing(); + interpreter.configure(configure); + FunctionInfo::collect(&interpreter) + }; Self { client, - documents: RwLock::new(HashMap::new()), + documents: RwLock::new(AHashMap::new()), configure, + functions, } } @@ -38,63 +51,88 @@ impl Backend { interpreter } - /// Update the source text immediately so concurrent requests (e.g. completion - /// triggered by `.`) always see the latest document content. - async fn update_source(&self, uri: &Url, text: &str) { + // Staleness & concurrency model + // ------------------------------ + // `didChange` notifications can interleave at await points, so any cached + // state is tagged with the client's monotonic document `version`. Two rules + // keep async edits sound: + // 1. `update_source` runs synchronously under the lock and bumps `version`, + // so completion (triggered by `.`) always sees the latest text. + // 2. `validate` runs analysis off the lock, then commits/publishes only if + // the stored `version` still equals the one it analysed — a slow run + // can't roll the buffer back to older text. + // AST-backed features (hover, go-to-def, symbols, inlay hints) additionally + // gate on `analysis_matches_source`, so they never map stale spans onto + // edited text. The completion caches (`variable_types` by name, + // `expression_types` by end-offset) are intentionally resilient: dot + // completion fires on a buffer that doesn't parse (`x.`), and the data it + // reads sits at the cursor where offsets are stable for the appended `.`. + + /// Update the cached source text immediately and bump the document version. + async fn update_source(&self, uri: &Url, text: &str, version: i32) { let mut docs = self.documents.write().await; - match docs.get_mut(uri) { - Some(state) => state.source = text.to_string(), - None => { - docs.insert( - uri.clone(), - DocumentState { - hints: Vec::new(), - source: text.to_string(), - variable_types: HashMap::new(), - expression_types: HashMap::new(), - }, - ); - } + if let Some(state) = docs.get_mut(uri) { + state.source = text.to_string(); + state.line_index = crate::util::LineIndex::new(text); + state.version = version; + // The AST now predates this edit; `validate` re-sets this to true + // if the new source parses. + state.analysis_matches_source = false; + } else { + let mut state = DocumentState::from_source(text.to_string()); + state.version = version; + docs.insert(uri.clone(), state); } } - /// Run diagnostics and semantic analysis, updating cached hints and types. - /// The source text must already be updated via `update_source` before calling this. - async fn validate(&self, uri: &Url, text: &str) { + /// Run diagnostics and semantic analysis for `version` of the document, then + /// commit the analysis and publish diagnostics only if no later edit has + /// superseded it. `update_source` must have run for this `version` first. + async fn validate(&self, uri: &Url, text: &str, version: i32) { let (mut diagnostics, _ast) = diagnostics::lex_and_parse(text); - // Run full semantic analysis and collect inlay hints + variable types. - // The interpreter uses Rc internally (non-Send), so it must be fully - // dropped before the next await point. - let analysis = { + // Run full semantic analysis. The interpreter uses Rc internally + // (non-Send), so it must be fully dropped before the next await point. + let analysed = { let mut interpreter = self.make_interpreter(); interpreter .analyse_str(text) .ok() .map(|(expressions, analysis_result)| { - // Convert analysis errors to LSP diagnostics. for err in &analysis_result.errors { diagnostics.push(diagnostics::analysis_error_to_diagnostic(text, err)); } - inlay_hints::collect(&expressions, &analysis_result, text) + (expressions, analysis_result) }) }; + // Hold the write lock across the commit AND the publish. `did_close` + // also takes this lock to remove state + clear diagnostics, so holding + // it here serializes the two: either close runs first (and we then see + // the document gone and skip), or we publish first and close clears it + // afterwards. Without this, a close could slip between an unlocked + // version check and the publish, leaving stale diagnostics on a closed + // file. The publish is a fire-and-forget notification, so the lock is + // held only briefly. + let mut docs = self.documents.write().await; + let Some(state) = docs.get_mut(uri) else { + return; + }; + // A later edit already moved the buffer on — drop this stale run + // rather than committing old AST or publishing old diagnostics. + if state.version != version { + return; + } + // On success, commit in place (keeps the current source/line_index). + // On parse failure, keep the last good AST; `analysis_matches_source` + // is already false, so AST-backed features stay disabled. + if let Some((ast, analysis)) = analysed { + state.set_analysis(ast, analysis); + } + self.client - .publish_diagnostics(uri.clone(), diagnostics, None) + .publish_diagnostics(uri.clone(), diagnostics, Some(version)) .await; - - // Only update document state when analysis succeeds. On failure (e.g. - // incomplete syntax while typing `x.`), keep the last good hints and - // variable types so inlay hints stay visible and dot-completion works. - if let Some(info) = analysis { - let mut docs = self.documents.write().await; - if let Some(state) = docs.get_mut(uri) { - state.hints = info.hints; - state.variable_types = info.variable_types; - state.expression_types = info.expression_types; - } - } } } @@ -116,6 +154,9 @@ impl LanguageServer for Backend { completion_item: None, }), inlay_hint_provider: Some(OneOf::Left(true)), + hover_provider: Some(HoverProviderCapability::Simple(true)), + document_symbol_provider: Some(OneOf::Left(true)), + definition_provider: Some(OneOf::Left(true)), ..Default::default() }, ..Default::default() @@ -135,46 +176,100 @@ impl LanguageServer for Backend { async fn did_open(&self, params: DidOpenTextDocumentParams) { let uri = params.text_document.uri; let text = params.text_document.text; - self.update_source(&uri, &text).await; - self.validate(&uri, &text).await; + let version = params.text_document.version; + self.update_source(&uri, &text, version).await; + self.validate(&uri, &text, version).await; } async fn did_change(&self, params: DidChangeTextDocumentParams) { let uri = params.text_document.uri; - for change in params.content_changes { - self.update_source(&uri, &change.text).await; - self.validate(&uri, &change.text).await; + let version = params.text_document.version; + // Full-document sync: the last change carries the whole buffer, so only + // the final one matters. + if let Some(change) = params.content_changes.into_iter().next_back() { + self.update_source(&uri, &change.text, version).await; + self.validate(&uri, &change.text, version).await; } } + async fn did_close(&self, params: DidCloseTextDocumentParams) { + let uri = params.text_document.uri; + // Drop cached state so the map doesn't grow unbounded, and clear the + // document's diagnostics. Hold the lock across the publish so it can't + // interleave with a `validate` publish (see the note in `validate`). + let mut docs = self.documents.write().await; + docs.remove(&uri); + self.client.publish_diagnostics(uri, Vec::new(), None).await; + } + async fn inlay_hint(&self, params: InlayHintParams) -> JsonRPCResult>> { + let docs = self.documents.read().await; + Ok(docs.get(¶ms.text_document.uri).map(|state| { + if !state.analysis_matches_source { + return Vec::new(); + } + inlay_hints::collect( + &state.ast, + &state.analysis, + &state.source, + &state.line_index, + ) + })) + } + + async fn hover(&self, params: HoverParams) -> JsonRPCResult> { + let docs = self.documents.read().await; + let uri = ¶ms.text_document_position_params.text_document.uri; + let position = params.text_document_position_params.position; + Ok(docs + .get(uri) + .filter(|state| state.analysis_matches_source) + .and_then(|state| hover::hover(state, position, &self.functions))) + } + + async fn goto_definition( + &self, + params: GotoDefinitionParams, + ) -> JsonRPCResult> { + let docs = self.documents.read().await; + let uri = ¶ms.text_document_position_params.text_document.uri; + let position = params.text_document_position_params.position; + Ok(docs + .get(uri) + .filter(|state| state.analysis_matches_source) + .and_then(|state| { + definition::goto_definition(state, position, uri.clone()) + .map(GotoDefinitionResponse::Scalar) + })) + } + + async fn document_symbol( + &self, + params: DocumentSymbolParams, + ) -> JsonRPCResult> { let docs = self.documents.read().await; Ok(docs .get(¶ms.text_document.uri) - .map(|state| state.hints.clone())) + .filter(|state| state.analysis_matches_source) + .map(|state| { + DocumentSymbolResponse::Nested(symbols::document_symbols( + &state.ast, + &state.source, + &state.line_index, + )) + })) } async fn completion( &self, params: CompletionParams, ) -> JsonRPCResult> { - let state = { - let docs = self.documents.read().await; - let uri = ¶ms.text_document_position.text_document.uri; - docs.get(uri).map(|s| { - // Clone what completion needs so we can drop the lock. - DocumentState { - hints: Vec::new(), // not needed for completion - source: s.source.clone(), - variable_types: s.variable_types.clone(), - expression_types: s.expression_types.clone(), - } - }) - }; - - let interpreter = self.make_interpreter(); + let docs = self.documents.read().await; + let uri = ¶ms.text_document_position.text_document.uri; let position = params.text_document_position.position; - let response = completion::complete(state.as_ref(), position, &interpreter); + // Completion is synchronous and never awaits, so we can hold the read + // lock and borrow the state directly (no cloning). + let response = completion::complete(docs.get(uri), position, &self.functions); Ok(Some(response)) } diff --git a/ndc_lsp/src/features/completion.rs b/ndc_lsp/src/features/completion.rs index 26dca030..a58bcbfc 100644 --- a/ndc_lsp/src/features/completion.rs +++ b/ndc_lsp/src/features/completion.rs @@ -1,3 +1,4 @@ +use ahash::AHashMap; use ndc_core::StaticType; use ndc_interpreter::Interpreter; use tower_lsp::lsp_types::{ @@ -5,17 +6,40 @@ use tower_lsp::lsp_types::{ Documentation, MarkupContent, MarkupKind, Position, }; +use crate::scope_resolve::{collect_declarations, file_scope, is_visible}; use crate::state::DocumentState; -use crate::util::offset_from_position; + +/// A `Send` snapshot of a registered native function, built once at startup so +/// completion (and hover) never have to rebuild the interpreter per request. +#[derive(Debug, Clone)] +pub struct FunctionInfo { + pub name: String, + pub static_type: StaticType, + pub documentation: Option, +} + +impl FunctionInfo { + /// Snapshot every function registered in an interpreter. + pub fn collect(interpreter: &Interpreter) -> Vec { + interpreter + .functions() + .map(|fun| Self { + name: fun.name.clone(), + static_type: fun.static_type.clone(), + documentation: fun.documentation.clone(), + }) + .collect() + } +} /// Build completion response for the given cursor position and document state. pub fn complete( state: Option<&DocumentState>, position: Position, - interpreter: &Interpreter, + functions: &[FunctionInfo], ) -> CompletionResponse { let receiver_type = state.and_then(|s| { - let offset = offset_from_position(&s.source, position)?; + let offset = s.line_index.offset(&s.source, position)?; let dot_offset = find_dot_before(s.source.as_bytes(), offset)?; // Try expression type map first (handles `func(args).` and any expression), // then fall back to variable name lookup for simple `ident.` cases. @@ -27,7 +51,7 @@ pub fn complete( let is_dot = receiver_type.is_some(); - let items = interpreter.functions().filter_map(|fun| { + let function_items = functions.iter().filter_map(|fun| { if !is_normal_ident(&fun.name) { return None; } @@ -68,12 +92,22 @@ pub fn complete( }) }); - let items: Vec<_> = if is_dot { - items.collect() - } else { - items.chain(keyword_completions()).collect() - }; + if is_dot { + return CompletionResponse::Array(function_items.collect()); + } + // General (non-dot) completion: functions + in-scope locals + keywords. + let mut items: Vec = function_items.collect(); + if let Some(state) = state { + // Locals come from walking the AST's declaration spans, so they are only + // trustworthy when the AST matches the current source. Mid-edit (last + // parse failed) the spans are stale, so skip them — the resilient + // dot-completion caches above are unaffected. + if state.analysis_matches_source { + items.extend(local_completions(state, position)); + } + } + items.extend(keyword_completions()); CompletionResponse::Array(items) } @@ -100,14 +134,55 @@ fn format_function_signature(typ: &StaticType, is_dot: bool) -> (String, String) } } +/// Language keywords offered in general (non-dot) completion. +const KEYWORDS: &[&str] = &[ + "let", "fn", "if", "else", "while", "for", "in", "return", "break", "continue", "true", "false", +]; + fn keyword_completions() -> impl Iterator { - ["true", "false"].into_iter().map(|kw| CompletionItem { - label: String::from(kw), - kind: Some(CompletionItemKind::VALUE), + KEYWORDS.iter().map(|kw| CompletionItem { + label: String::from(*kw), + kind: Some(CompletionItemKind::KEYWORD), ..Default::default() }) } +/// Collect in-scope local variables from the last successfully analysed AST. +/// Uses lexical-scope visibility (enclosing scope + declared-before-use), so a +/// local declared in one function is not offered inside another. +fn local_completions(state: &DocumentState, position: Position) -> Vec { + let Some(offset) = state.line_index.offset(&state.source, position) else { + return Vec::new(); + }; + let Some(source_id) = state.ast.first().map(|e| e.span.source_id()) else { + return Vec::new(); + }; + let scope = file_scope(source_id, state.source.len()); + + let mut names: AHashMap> = AHashMap::new(); + for decl in collect_declarations(&state.ast, scope) { + if is_visible(&decl, offset) { + // Type is a best-effort hint from the name-keyed map (a shadowed name + // may show the wrong type until the analyser resolution is exposed). + let typ = state.variable_types.get(&decl.name).cloned(); + names.insert(decl.name, typ); + } + } + + names + .into_iter() + .map(|(name, typ)| CompletionItem { + label: name, + label_details: typ.as_ref().map(|t| CompletionItemLabelDetails { + detail: None, + description: Some(t.to_string()), + }), + kind: Some(CompletionItemKind::VARIABLE), + ..Default::default() + }) + .collect() +} + fn is_normal_ident(input: &str) -> bool { input .chars() @@ -156,8 +231,25 @@ fn identifier_before_dot(text: &str, offset: usize) -> Option<&str> { #[cfg(test)] mod tests { use super::*; - use crate::state::DocumentState; - use std::collections::HashMap; + + fn functions() -> Vec { + let mut interpreter = Interpreter::capturing(); + interpreter.configure(ndc_stdlib::register); + FunctionInfo::collect(&interpreter) + } + + /// Build a document state whose `variable_types` / `expression_types` are set + /// directly, simulating the cached-after-analysis state used by completion. + fn state_with( + source: &str, + variable_types: AHashMap, + expression_types: AHashMap, + ) -> DocumentState { + let mut state = DocumentState::from_source(source.to_string()); + state.variable_types = variable_types; + state.expression_types = expression_types; + state + } #[test] fn identifier_before_dot_simple() { @@ -205,23 +297,15 @@ mod tests { #[test] fn dot_completion_filters_by_receiver_type() { - let mut interpreter = Interpreter::capturing(); - interpreter.configure(ndc_stdlib::register); - // Simulate: user typed `let x = [1,2,3]` then `x.` - // variable_types has x as List(Int), source has the dot - let state = DocumentState { - hints: Vec::new(), - source: "let x = [1,2,3]\nx.".to_string(), - variable_types: HashMap::from([( - "x".to_string(), - StaticType::List(Box::new(StaticType::Int)), - )]), - expression_types: HashMap::new(), - }; + let state = state_with( + "let x = [1,2,3]\nx.", + AHashMap::from([("x".to_string(), StaticType::List(Box::new(StaticType::Int)))]), + AHashMap::new(), + ); // Cursor is after the dot: line 1, character 2 - let response = complete(Some(&state), Position::new(1, 2), &interpreter); + let response = complete(Some(&state), Position::new(1, 2), &functions()); let CompletionResponse::Array(items) = response else { panic!("expected Array response"); }; @@ -241,32 +325,23 @@ mod tests { #[test] fn dot_completion_works_with_preserved_types_after_parse_failure() { - // This tests the key scenario: source has been updated to contain the dot, - // but variable_types are preserved from a previous successful analysis. - let mut interpreter = Interpreter::capturing(); - interpreter.configure(ndc_stdlib::register); - - let state = DocumentState { - hints: Vec::new(), - // Current source is invalid (has trailing dot) - source: "let x = 42\nx.".to_string(), - // Types from the last successful analysis - variable_types: HashMap::from([("x".to_string(), StaticType::Int)]), - expression_types: HashMap::new(), - }; + // Source has been updated to contain the dot, but variable_types are + // preserved from a previous successful analysis. + let state = state_with( + "let x = 42\nx.", + AHashMap::from([("x".to_string(), StaticType::Int)]), + AHashMap::new(), + ); - let response = complete(Some(&state), Position::new(1, 2), &interpreter); + let response = complete(Some(&state), Position::new(1, 2), &functions()); let CompletionResponse::Array(items) = response else { panic!("expected Array response"); }; - // Should be dot-completion (no keywords) assert!( !items.iter().any(|i| i.label == "true"), "should be dot-completion, not general" ); - - // Should include functions that accept Int assert!( items.iter().any(|i| i.label == "abs"), "dot-completion on Int should include `abs`" @@ -277,34 +352,24 @@ mod tests { fn dot_completion_on_call_expression_via_expression_types() { // Simulates `read_file("foo").` where the expression type map knows // that the call expression `read_file("foo")` returns String. - let mut interpreter = Interpreter::capturing(); - interpreter.configure(ndc_stdlib::register); - - // 0 1 - // 0123456789012345678 let source = r#"read_file("foo")."#; - // The call expression `read_file("foo")` spans bytes 0..16, - // so its end offset is 16 (just before the dot at byte 16). - let state = DocumentState { - hints: Vec::new(), - source: source.to_string(), - variable_types: HashMap::new(), - expression_types: HashMap::from([(16, StaticType::String)]), - }; + // The call expression spans bytes 0..16, so its end offset is 16. + let state = state_with( + source, + AHashMap::new(), + AHashMap::from([(16, StaticType::String)]), + ); // Cursor is at end: line 0, character 17 (after the dot) - let response = complete(Some(&state), Position::new(0, 17), &interpreter); + let response = complete(Some(&state), Position::new(0, 17), &functions()); let CompletionResponse::Array(items) = response else { panic!("expected Array response"); }; - // Should be dot-completion (no keywords) assert!( !items.iter().any(|i| i.label == "true"), "should be dot-completion, not general" ); - - // Should include string-compatible functions like `len` assert!( items.iter().any(|i| i.label == "len"), "dot-completion on String should include `len`" @@ -313,25 +378,93 @@ mod tests { #[test] fn general_completion_includes_keywords() { + let state = state_with( + "let x = 42\n", + AHashMap::from([("x".to_string(), StaticType::Int)]), + AHashMap::new(), + ); + + // No dot — general completion + let response = complete(Some(&state), Position::new(1, 0), &functions()); + let CompletionResponse::Array(items) = response else { + panic!("expected Array response"); + }; + + assert!( + items.iter().any(|i| i.label == "true"), + "general completion should include keywords" + ); + assert!( + items.iter().any(|i| i.label == "fn"), + "general completion should include the `fn` keyword" + ); + } + + #[test] + fn general_completion_includes_in_scope_locals() { let mut interpreter = Interpreter::capturing(); interpreter.configure(ndc_stdlib::register); + let source = "let greeting = \"hi\";\n"; + let (ast, analysis) = interpreter.analyse_str(source).expect("analysis succeeds"); + let state = DocumentState::from_analysis(source.to_string(), ast, analysis); - let state = DocumentState { - hints: Vec::new(), - source: "let x = 42\n".to_string(), - variable_types: HashMap::from([("x".to_string(), StaticType::Int)]), - expression_types: HashMap::new(), + // Cursor on the (empty) second line — `greeting` is in scope. + let response = complete(Some(&state), Position::new(1, 0), &functions()); + let CompletionResponse::Array(items) = response else { + panic!("expected Array response"); }; - // No dot — general completion - let response = complete(Some(&state), Position::new(1, 0), &interpreter); + assert!( + items + .iter() + .any(|i| i.label == "greeting" && i.kind == Some(CompletionItemKind::VARIABLE)), + "general completion should include the in-scope local `greeting`" + ); + } + + #[test] + fn locals_do_not_leak_across_functions() { + let mut interpreter = Interpreter::capturing(); + interpreter.configure(ndc_stdlib::register); + // `foo` is local to `a`; completing inside `b` must not offer it. + let source = "fn a() { let foo = 1; }\nfn b() {\n\n}\n"; + let (ast, analysis) = interpreter.analyse_str(source).expect("analysis succeeds"); + let state = DocumentState::from_analysis(source.to_string(), ast, analysis); + + // The blank line 2 is inside b's body. + let response = complete(Some(&state), Position::new(2, 0), &functions()); let CompletionResponse::Array(items) = response else { panic!("expected Array response"); }; assert!( - items.iter().any(|i| i.label == "true"), - "general completion should include keywords" + !items.iter().any(|i| i.label == "foo"), + "a local from another function must not be suggested" + ); + } + + #[test] + fn stale_analysis_suppresses_locals_but_keeps_keywords() { + let mut interpreter = Interpreter::capturing(); + interpreter.configure(ndc_stdlib::register); + let source = "let greeting = \"hi\";\n"; + let (ast, analysis) = interpreter.analyse_str(source).expect("analysis succeeds"); + let mut state = DocumentState::from_analysis(source.to_string(), ast, analysis); + // Simulate a mid-edit buffer whose last parse failed: the AST is stale. + state.analysis_matches_source = false; + + let response = complete(Some(&state), Position::new(1, 0), &functions()); + let CompletionResponse::Array(items) = response else { + panic!("expected Array response"); + }; + + assert!( + !items.iter().any(|i| i.label == "greeting"), + "stale AST must not contribute local suggestions" + ); + assert!( + items.iter().any(|i| i.label == "fn"), + "keywords should still be offered while mid-edit" ); } } diff --git a/ndc_lsp/src/features/definition.rs b/ndc_lsp/src/features/definition.rs new file mode 100644 index 00000000..b01f8385 --- /dev/null +++ b/ndc_lsp/src/features/definition.rs @@ -0,0 +1,186 @@ +use ndc_core::StaticType; +use ndc_lexer::{SourceId, Span}; +use ndc_parser::Expression; +use tower_lsp::lsp_types::{Location, Position, Url}; + +use crate::scope_resolve::{Decl, collect_declarations, file_scope, is_visible, scope_len}; +use crate::state::DocumentState; +use crate::visitor::{AstVisitor, node_at_offset, walk_ast}; + +/// Resolve the declaration of the identifier under the cursor. +/// +/// Gathers every declaration with the scope region in which it is visible, keeps +/// only those visible at the cursor (enclosing scope + declared-before-use, with +/// functions hoisted), then picks the innermost-scoped one. +/// +/// NOTE: this is name-only — it does not disambiguate function overloads, so a +/// call to one of several same-named functions resolves to whichever visible +/// declaration is innermost/latest rather than the overload the analyser actually +/// selected. The follow-up that exposes the analyser's resolution fixes this. +pub fn goto_definition(state: &DocumentState, position: Position, uri: Url) -> Option { + let offset = state.line_index.offset(&state.source, position)?; + let (name, source_id) = identifier_at(&state.ast, offset)?; + + let scope = file_scope(source_id, state.source.len()); + let decls = collect_declarations(&state.ast, scope); + + let best = decls + .iter() + .filter(|d| d.name == name && is_visible(d, offset)) + .reduce(|a, b| if better(b, a) { b } else { a })?; + + Some(Location { + uri, + range: state.line_index.range(&state.source, best.name_span), + }) +} + +/// The identifier name the cursor is on, plus the document's `SourceId`. +/// +/// Handles both expression uses (`x`) and assignment targets (`x = 2`). A +/// reassignment target is an [`ndc_parser::Lvalue`], not an expression node, so +/// `node_at_offset` returns the enclosing assignment for it; we then look up the +/// lvalue identifier directly. (Declaration/parameter/loop-variable lvalues are +/// found too, but they aren't visible to themselves, so they resolve to nothing.) +fn identifier_at( + ast: &[ndc_parser::ExpressionLocation], + offset: usize, +) -> Option<(String, SourceId)> { + if let Some(node) = node_at_offset(ast, offset) + && let Expression::Identifier { name, .. } = &node.expression + { + return Some((name.clone(), node.span.source_id())); + } + let mut finder = LvalueIdentFinder { + offset, + found: None, + }; + walk_ast(&mut finder, ast); + finder.found +} + +/// Finds the lvalue identifier whose span contains the cursor. Lvalue +/// identifiers don't overlap, so the one containing the offset is unambiguous. +struct LvalueIdentFinder { + offset: usize, + found: Option<(String, SourceId)>, +} + +impl AstVisitor for LvalueIdentFinder { + fn on_declaration( + &mut self, + identifier: &str, + _inferred_type: Option<&StaticType>, + _has_annotation: bool, + span: Span, + ) { + if self.offset >= span.offset() && self.offset < span.end() { + self.found = Some((identifier.to_string(), span.source_id())); + } + } +} + +/// Among declarations already known to be visible at the use, is `candidate` a +/// better match than `current`? Innermost scope wins; ties go to the most recent +/// declaration (handles shadowing within one scope). +fn better(candidate: &Decl, current: &Decl) -> bool { + let (cand_len, cur_len) = ( + scope_len(candidate.scope_span), + scope_len(current.scope_span), + ); + if cand_len != cur_len { + return cand_len < cur_len; + } + candidate.name_span.offset() > current.name_span.offset() +} + +#[cfg(test)] +mod tests { + use super::*; + use ndc_interpreter::Interpreter; + + fn analyse(source: &str) -> DocumentState { + let mut interpreter = Interpreter::capturing(); + interpreter.configure(ndc_stdlib::register); + let (ast, analysis) = interpreter.analyse_str(source).expect("analysis succeeds"); + DocumentState::from_analysis(source.to_string(), ast, analysis) + } + + fn uri() -> Url { + Url::parse("file:///test.ndc").unwrap() + } + + fn start_offset(state: &DocumentState, location: &Location) -> usize { + state + .line_index + .offset(&state.source, location.range.start) + .unwrap() + } + + /// Resolve at the byte offset of the `needle`th occurrence of `name`. + fn def_at(state: &DocumentState, byte: usize) -> Option { + let pos = state.line_index.position(&state.source, byte); + goto_definition(state, pos, uri()) + } + + #[test] + fn jump_from_usage_to_let_declaration() { + let src = "let total = 5;\ntotal + 1;"; + let state = analyse(src); + let loc = def_at(&state, src.rfind("total").unwrap()).expect("definition found"); + assert_eq!(start_offset(&state, &loc), 4); // `total` decl at byte 4 + } + + #[test] + fn jump_to_function_parameter() { + let src = "fn square(n) { n * n }"; + let state = analyse(src); + let loc = def_at(&state, src.rfind("n *").unwrap()).expect("definition found"); + assert_eq!(start_offset(&state, &loc), 10); // `n` parameter at byte 10 + } + + #[test] + fn innermost_scope_shadows_outer() { + let src = "let x = 1;\nfn f() { let x = 2; x }"; + let state = analyse(src); + let inner_decl = src.rfind("x = 2").unwrap(); + let loc = def_at(&state, src.rfind('x').unwrap()).expect("definition found"); + assert_eq!(start_offset(&state, &loc), inner_decl); + } + + #[test] + fn use_before_inner_shadow_resolves_to_outer() { + // Codex regression: the `x` use precedes the inner `let x = 2`, so it must + // resolve to the outer `x = 1`, not the not-yet-visible inner declaration. + let src = "let x = 1; { x; let x = 2; }"; + let state = analyse(src); + let outer_decl = src.find("x = 1").unwrap(); + // The first `x` after the `{` is the use. + let use_offset = src.find("{ x").unwrap() + 2; + let loc = def_at(&state, use_offset).expect("definition found"); + assert_eq!(start_offset(&state, &loc), outer_decl); + } + + #[test] + fn let_binding_invisible_in_its_own_initializer() { + // The RHS `x` must resolve to the outer (first) `x`, not the binding + // currently being declared — the analyser binds after the initializer. + let src = "let x = 1;\nlet x = x;"; + let state = analyse(src); + let outer_decl = src.find("x").unwrap(); // first `x` at byte 4 + let rhs_use = src.rfind('x').unwrap(); // the `x` on the RHS of line 2 + let loc = def_at(&state, rhs_use).expect("definition found"); + assert_eq!(start_offset(&state, &loc), outer_decl); + } + + #[test] + fn jump_from_reassignment_target_to_declaration() { + // The `x` in `x = 2` is a use in write position (an lvalue, not an + // expression node), but should still jump to its declaration. + let src = "let x = 1;\nx = 2;"; + let state = analyse(src); + let target = src.rfind("x").unwrap(); // `x` in `x = 2` on line 2 + let loc = def_at(&state, target).expect("definition found"); + assert_eq!(start_offset(&state, &loc), 4); // `let x` at byte 4 + } +} diff --git a/ndc_lsp/src/features/hover.rs b/ndc_lsp/src/features/hover.rs new file mode 100644 index 00000000..c922ae96 --- /dev/null +++ b/ndc_lsp/src/features/hover.rs @@ -0,0 +1,215 @@ +use ndc_core::StaticType; +use ndc_lexer::Span; +use ndc_parser::{Binding, Expression, ResolvedVar}; +use tower_lsp::lsp_types::{Hover, HoverContents, MarkupContent, MarkupKind, Position}; + +use crate::features::completion::FunctionInfo; +use crate::state::DocumentState; +use crate::visitor::{AstVisitor, node_at_offset, walk_ast}; + +/// Build hover information for the cursor position. +/// +/// When the cursor is on a declaration's name, shows the declared variable's +/// type. Otherwise resolves the innermost expression under the cursor and shows +/// its inferred type. An identifier is only shown as a built-in function when the +/// analyser resolved it to a global — so a local that shadows a built-in (e.g. +/// `let len = 1; len`) shows the local's type, not the built-in's docs. +pub fn hover( + state: &DocumentState, + position: Position, + functions: &[FunctionInfo], +) -> Option { + let offset = state.line_index.offset(&state.source, position)?; + + // A declaration's name is an lvalue, not an expression node, so check those + // first — they are more specific than the enclosing declaration expression. + if let Some((span, typ)) = declaration_at(state, offset) { + return Some(markup(state, span, format!("```ndc\n{typ}\n```"))); + } + + let node = node_at_offset(&state.ast, offset)?; + let markdown = match &node.expression { + Expression::Identifier { name, resolved } if resolves_to_global(resolved) => { + function_hover(name, functions).or_else(|| type_hover(state, node.id)) + } + _ => type_hover(state, node.id), + }?; + + Some(markup(state, node.span, markdown)) +} + +/// Did the analyser resolve this identifier to a global (a native function)? +/// Locals and parameters that shadow a built-in resolve to `Local`/`Upvalue`. +fn resolves_to_global(binding: &Binding) -> bool { + let is_global = |v: ResolvedVar| matches!(v, ResolvedVar::Global { .. }); + match binding { + Binding::Resolved(candidate) => is_global(candidate.var()), + // Dynamic dispatch is a global native only if every candidate is global. + Binding::Dynamic(candidates) => { + !candidates.is_empty() && candidates.iter().all(|c| is_global(c.var())) + } + Binding::None => false, + } +} + +fn markup(state: &DocumentState, span: Span, value: String) -> Hover { + Hover { + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value, + }), + range: Some(state.line_index.range(&state.source, span)), + } +} + +/// Find the declaration identifier whose span contains `offset`, returning its +/// span and inferred type. +fn declaration_at(state: &DocumentState, offset: usize) -> Option<(Span, StaticType)> { + let mut finder = DeclFinder { + offset, + found: None, + }; + walk_ast(&mut finder, &state.ast); + finder.found +} + +struct DeclFinder { + offset: usize, + found: Option<(Span, StaticType)>, +} + +impl AstVisitor for DeclFinder { + fn on_declaration( + &mut self, + _identifier: &str, + inferred_type: Option<&StaticType>, + _has_annotation: bool, + span: Span, + ) { + if self.offset >= span.offset() + && self.offset < span.end() + && let Some(typ) = inferred_type + { + self.found = Some((span, typ.clone())); + } + } +} + +/// Markdown for an identifier that names a built-in function: a fenced +/// signature followed by its documentation. +fn function_hover(name: &str, functions: &[FunctionInfo]) -> Option { + let fun = functions.iter().find(|f| f.name == name)?; + let mut out = format!( + "```ndc\n{}\n```", + format_signature(&fun.name, &fun.static_type) + ); + if let Some(doc) = &fun.documentation { + out.push_str("\n\n"); + out.push_str(doc); + } + Some(out) +} + +/// Markdown showing the inferred type of an expression. +fn type_hover(state: &DocumentState, id: ndc_parser::NodeId) -> Option { + let typ = state.analysis.expr_types.get(&id)?; + Some(format!("```ndc\n{typ}\n```")) +} + +/// Render a function name plus its [`StaticType`] as `name(p1, p2) -> ret`. +fn format_signature(name: &str, typ: &StaticType) -> String { + match typ { + StaticType::Function { + parameters: Some(params), + return_type, + } => { + let ps = params + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + format!("{name}({ps}) -> {return_type}") + } + StaticType::Function { + parameters: None, + return_type, + } => format!("{name}(...) -> {return_type}"), + other => format!("{name}: {other}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ndc_interpreter::Interpreter; + + fn analyse(source: &str) -> (DocumentState, Vec) { + let mut interpreter = Interpreter::capturing(); + interpreter.configure(ndc_stdlib::register); + let functions = FunctionInfo::collect(&interpreter); + let (ast, analysis) = interpreter.analyse_str(source).expect("analysis succeeds"); + let state = DocumentState::from_analysis(source.to_string(), ast, analysis); + (state, functions) + } + + #[test] + fn hover_on_variable_shows_type() { + let (state, functions) = analyse("let count = 41 + 1;"); + // Cursor on `count` (character 4..9 on line 0). + let hover = hover(&state, Position::new(0, 6), &functions).expect("hover present"); + let HoverContents::Markup(content) = hover.contents else { + panic!("expected markup"); + }; + assert!( + content.value.contains("Int"), + "expected Int in hover, got: {}", + content.value + ); + } + + #[test] + fn hover_on_builtin_function_shows_signature_and_docs() { + let (state, functions) = analyse("let n = abs(-3);"); + // `abs` starts at byte 8 in "let n = abs(-3);". + let hover = hover(&state, Position::new(0, 9), &functions).expect("hover present"); + let HoverContents::Markup(content) = hover.contents else { + panic!("expected markup"); + }; + assert!( + content.value.contains("abs("), + "expected a signature for abs, got: {}", + content.value + ); + } + + #[test] + fn hover_outside_any_node_is_none() { + let (state, functions) = analyse("let n = 1;"); + // Way past the end of the document. + assert!(hover(&state, Position::new(5, 0), &functions).is_none()); + } + + #[test] + fn local_shadowing_builtin_shows_local_type_not_signature() { + // `len` is a built-in, but here it's shadowed by a local Int. Hover on the + // *use* of `len` must show the local's type, not the built-in's signature. + let src = "let len = 1;\nlen;"; + let (state, functions) = analyse(src); + let use_offset = src.rfind("len").unwrap(); + let pos = state.line_index.position(&state.source, use_offset); + let hover = hover(&state, pos, &functions).expect("hover present"); + let HoverContents::Markup(content) = hover.contents else { + panic!("expected markup"); + }; + assert!( + content.value.contains("Int"), + "expected the local Int type, got: {}", + content.value + ); + assert!( + !content.value.contains("len("), + "must not show the built-in signature for a shadowing local, got: {}", + content.value + ); + } +} diff --git a/ndc_lsp/src/features/inlay_hints.rs b/ndc_lsp/src/features/inlay_hints.rs index a01883ba..e1b2db7d 100644 --- a/ndc_lsp/src/features/inlay_hints.rs +++ b/ndc_lsp/src/features/inlay_hints.rs @@ -1,81 +1,60 @@ -use std::collections::HashMap; - use ndc_core::StaticType; use ndc_interpreter::AnalysisResult; use ndc_lexer::Span; use ndc_parser::{ExpressionLocation, NodeId}; use tower_lsp::lsp_types::{InlayHint, InlayHintKind, InlayHintLabel}; -use crate::util::position_from_offset; +use crate::util::LineIndex; use crate::visitor::{AstVisitor, walk_ast}; -/// Results of collecting information from an analysed AST. -pub struct AnalysisInfo { - pub hints: Vec, - pub variable_types: HashMap, - /// Maps expression end offset -> inferred type for dot-completion on - /// arbitrary expressions (e.g. `read_file("foo").`). - pub expression_types: HashMap, -} - -/// Collect inlay hints, variable types, and expression types from an analysed AST. +/// Collect inlay type hints from an analysed AST. +/// +/// Hints are derived on demand (not cached) so they always reflect the stored +/// AST. Only unannotated declarations and inferred return types get a hint. pub fn collect( expressions: &[ExpressionLocation], analysis_result: &AnalysisResult, text: &str, -) -> AnalysisInfo { + line_index: &LineIndex, +) -> Vec { let mut collector = HintCollector { text, + line_index, analysis_result, hints: Vec::new(), - variable_types: HashMap::new(), - expression_types: HashMap::new(), }; walk_ast(&mut collector, expressions); - AnalysisInfo { - hints: collector.hints, - variable_types: collector.variable_types, - expression_types: collector.expression_types, - } + collector.hints } struct HintCollector<'a> { text: &'a str, + line_index: &'a LineIndex, analysis_result: &'a AnalysisResult, hints: Vec, - variable_types: HashMap, - expression_types: HashMap, } impl AstVisitor for HintCollector<'_> { - fn on_expression(&mut self, expr: &ExpressionLocation) { - if let Some(typ) = self.analysis_result.expr_types.get(&expr.id) { - self.expression_types.insert(expr.span.end(), typ.clone()); - } - } - fn on_declaration( &mut self, - identifier: &str, + _identifier: &str, inferred_type: Option<&StaticType>, has_annotation: bool, span: Span, ) { - if let Some(typ) = inferred_type { - if !has_annotation { - self.hints.push(InlayHint { - position: position_from_offset(self.text, span.end()), - label: InlayHintLabel::String(format!(": {typ}")), - kind: Some(InlayHintKind::TYPE), - text_edits: None, - tooltip: None, - padding_left: None, - padding_right: Some(true), - data: None, - }); - } - self.variable_types - .insert(identifier.to_string(), typ.clone()); + if let Some(typ) = inferred_type + && !has_annotation + { + self.hints.push(InlayHint { + position: self.line_index.position(self.text, span.end()), + label: InlayHintLabel::String(format!(": {typ}")), + kind: Some(InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: Some(true), + data: None, + }); } } @@ -87,19 +66,19 @@ impl AstVisitor for HintCollector<'_> { ) { // return_type is Some only when explicitly annotated by the user — skip the hint. // Inferred return types are stored in the side table. - if return_type.is_none() { - if let Some(rt) = self.analysis_result.inferred_return_types.get(&node_id) { - self.hints.push(InlayHint { - position: position_from_offset(self.text, parameters_span.end()), - label: InlayHintLabel::String(format!(" -> {rt}")), - kind: Some(InlayHintKind::TYPE), - text_edits: None, - tooltip: None, - padding_left: None, - padding_right: None, - data: None, - }); - } + if return_type.is_none() + && let Some(rt) = self.analysis_result.inferred_return_types.get(&node_id) + { + self.hints.push(InlayHint { + position: self.line_index.position(self.text, parameters_span.end()), + label: InlayHintLabel::String(format!(" -> {rt}")), + kind: Some(InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: None, + padding_right: None, + data: None, + }); } } } @@ -109,85 +88,67 @@ mod tests { use super::*; use ndc_interpreter::Interpreter; - fn collect_hints(source: &str) -> AnalysisInfo { + fn collect_hints(source: &str) -> Vec { let mut interpreter = Interpreter::capturing(); interpreter.configure(ndc_stdlib::register); let (expressions, analysis_result) = interpreter .analyse_str(source) .expect("analysis should succeed"); - collect(&expressions, &analysis_result, source) + let line_index = LineIndex::new(source); + collect(&expressions, &analysis_result, source, &line_index) + } + + fn has_label(hints: &[InlayHint], pred: impl Fn(&str) -> bool) -> bool { + hints + .iter() + .any(|hint| matches!(&hint.label, InlayHintLabel::String(label) if pred(label))) } #[test] fn inferred_let_binding_gets_type_inlay() { - let info = collect_hints("let value = 1;"); - assert!( - info.hints.iter().any( - |hint| matches!(&hint.label, InlayHintLabel::String(label) if label == ": Int") - ) - ); + let hints = collect_hints("let value = 1;"); + assert!(has_label(&hints, |l| l == ": Int")); } #[test] fn annotated_let_binding_skips_type_inlay() { - let info = collect_hints("let value: Int = 1;"); - assert!( - !info.hints.iter().any( - |hint| matches!(&hint.label, InlayHintLabel::String(label) if label == ": Int") - ) - ); - assert_eq!(info.variable_types.get("value"), Some(&StaticType::Int)); + let hints = collect_hints("let value: Int = 1;"); + assert!(!has_label(&hints, |l| l == ": Int")); } #[test] fn annotated_return_type_skips_inlay() { - let info = collect_hints("fn foo(x: Int) -> Int { x + 1 }"); - assert!(!info.hints.iter().any( - |hint| matches!(&hint.label, InlayHintLabel::String(label) if label.contains("->")) - )); + let hints = collect_hints("fn foo(x: Int) -> Int { x + 1 }"); + assert!(!has_label(&hints, |l| l.contains("->"))); } #[test] fn inferred_return_type_gets_inlay() { - let info = collect_hints("fn foo() { 42 }"); - assert!(info.hints.iter().any( - |hint| matches!(&hint.label, InlayHintLabel::String(label) if label == " -> Int") - )); + let hints = collect_hints("fn foo() { 42 }"); + assert!(has_label(&hints, |l| l == " -> Int")); } #[test] fn annotated_param_skips_inlay() { - let info = collect_hints("fn foo(x: Int) { x }"); - assert!( - !info.hints.iter().any( - |hint| matches!(&hint.label, InlayHintLabel::String(label) if label == ": Int") - ) - ); + let hints = collect_hints("fn foo(x: Int) { x }"); + assert!(!has_label(&hints, |l| l == ": Int")); } #[test] fn unannotated_param_gets_inlay() { - let info = collect_hints("fn foo(x) { x }"); - assert!( - info.hints.iter().any( - |hint| matches!(&hint.label, InlayHintLabel::String(label) if label == ": Any") - ) - ); + let hints = collect_hints("fn foo(x) { x }"); + assert!(has_label(&hints, |l| l == ": Any")); } #[test] fn lambda_inside_call_gets_return_type_inlay() { - let info = collect_hints("[1,2,3].map(fn(y) => y / 2.0);"); - assert!(info.hints.iter().any( - |hint| matches!(&hint.label, InlayHintLabel::String(label) if label.starts_with(" -> ")) - )); + let hints = collect_hints("[1,2,3].map(fn(y) => y / 2.0);"); + assert!(has_label(&hints, |l| l.starts_with(" -> "))); } #[test] fn lambda_inside_list_literal_gets_return_type_inlay() { - let info = collect_hints("let fns = [fn(x) => x + 1];"); - assert!(info.hints.iter().any( - |hint| matches!(&hint.label, InlayHintLabel::String(label) if label.starts_with(" -> ")) - )); + let hints = collect_hints("let fns = [fn(x) => x + 1];"); + assert!(has_label(&hints, |l| l.starts_with(" -> "))); } } diff --git a/ndc_lsp/src/features/mod.rs b/ndc_lsp/src/features/mod.rs index 2e7f9beb..06fbf689 100644 --- a/ndc_lsp/src/features/mod.rs +++ b/ndc_lsp/src/features/mod.rs @@ -1,2 +1,5 @@ pub mod completion; +pub mod definition; +pub mod hover; pub mod inlay_hints; +pub mod symbols; diff --git a/ndc_lsp/src/features/symbols.rs b/ndc_lsp/src/features/symbols.rs new file mode 100644 index 00000000..dbc7c62a --- /dev/null +++ b/ndc_lsp/src/features/symbols.rs @@ -0,0 +1,199 @@ +use ndc_core::StaticType; +use ndc_lexer::Span; +use ndc_parser::{Expression, ExpressionLocation, FunctionParameter, Lvalue}; +use tower_lsp::lsp_types::{DocumentSymbol, SymbolKind}; + +use crate::util::LineIndex; + +/// Build the document outline (functions and variable declarations) from the +/// analysed AST. Declarations nested inside a function body become children of +/// that function's symbol. +pub fn document_symbols( + ast: &[ExpressionLocation], + text: &str, + line_index: &LineIndex, +) -> Vec { + let mut symbols = Vec::new(); + for expr in ast { + collect_symbol(expr, text, line_index, &mut symbols); + } + symbols +} + +fn collect_symbol( + expr: &ExpressionLocation, + text: &str, + line_index: &LineIndex, + out: &mut Vec, +) { + match &expr.expression { + Expression::Statement(inner) | Expression::Grouping(inner) => { + collect_symbol(inner, text, line_index, out); + } + Expression::FunctionDeclaration { + name: Some(name), + parameters, + return_type, + body, + .. + } => { + let mut children = Vec::new(); + collect_children(body, text, line_index, &mut children); + out.push(make_symbol( + name.clone(), + Some(signature(parameters, return_type.as_ref())), + SymbolKind::FUNCTION, + expr.span, + expr.span, + text, + line_index, + children, + )); + } + Expression::VariableDeclaration { l_value, value, .. } => { + push_lvalue_symbols(l_value, expr.span, text, line_index, out); + // A lambda bound to a variable should still appear in the outline. + collect_symbol(value, text, line_index, out); + } + _ => {} + } +} + +/// Walk a function body collecting nested function/variable declarations. +fn collect_children( + body: &ExpressionLocation, + text: &str, + line_index: &LineIndex, + out: &mut Vec, +) { + match &body.expression { + Expression::Block { statements } => { + for s in statements { + collect_symbol(s, text, line_index, out); + } + } + Expression::Statement(inner) | Expression::Grouping(inner) => { + collect_children(inner, text, line_index, out); + } + _ => collect_symbol(body, text, line_index, out), + } +} + +fn push_lvalue_symbols( + lvalue: &Lvalue, + decl_span: Span, + text: &str, + line_index: &LineIndex, + out: &mut Vec, +) { + match lvalue { + Lvalue::Identifier { + identifier, + span, + inferred_type, + .. + } => { + out.push(make_symbol( + identifier.clone(), + inferred_type.as_ref().map(ToString::to_string), + SymbolKind::VARIABLE, + decl_span, + *span, + text, + line_index, + Vec::new(), + )); + } + Lvalue::Sequence(lvalues) => { + for lv in lvalues { + push_lvalue_symbols(lv, decl_span, text, line_index, out); + } + } + Lvalue::Index { .. } => {} + } +} + +/// Render a function signature like `(a, b) -> Int` for the symbol detail. +fn signature(parameters: &[FunctionParameter], return_type: Option<&StaticType>) -> String { + let params = parameters + .iter() + .map(|p| match &p.lvalue { + Lvalue::Identifier { identifier, .. } => identifier.clone(), + _ => "_".to_string(), + }) + .collect::>() + .join(", "); + match return_type { + Some(rt) => format!("({params}) -> {rt}"), + None => format!("({params})"), + } +} + +#[allow(clippy::too_many_arguments)] +fn make_symbol( + name: String, + detail: Option, + kind: SymbolKind, + range_span: Span, + selection_span: Span, + text: &str, + line_index: &LineIndex, + children: Vec, +) -> DocumentSymbol { + #[allow(deprecated)] + DocumentSymbol { + name, + detail, + kind, + tags: None, + deprecated: None, + range: line_index.range(text, range_span), + selection_range: line_index.range(text, selection_span), + children: if children.is_empty() { + None + } else { + Some(children) + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::DocumentState; + use ndc_interpreter::Interpreter; + + fn symbols(source: &str) -> Vec { + let mut interpreter = Interpreter::capturing(); + interpreter.configure(ndc_stdlib::register); + let (ast, analysis) = interpreter.analyse_str(source).expect("analysis succeeds"); + let state = DocumentState::from_analysis(source.to_string(), ast, analysis); + document_symbols(&state.ast, &state.source, &state.line_index) + } + + #[test] + fn top_level_function_and_variable_are_listed() { + let syms = symbols("let answer = 42;\nfn greet(name) { name }\n"); + assert!( + syms.iter() + .any(|s| s.name == "answer" && s.kind == SymbolKind::VARIABLE), + "expected variable `answer` in {syms:?}" + ); + assert!( + syms.iter() + .any(|s| s.name == "greet" && s.kind == SymbolKind::FUNCTION), + "expected function `greet` in {syms:?}" + ); + } + + #[test] + fn function_signature_in_detail() { + let syms = symbols("fn add(a, b) -> Int { a + b }"); + let func = syms + .iter() + .find(|s| s.name == "add") + .expect("add symbol present"); + let detail = func.detail.as_deref().unwrap_or_default(); + assert!(detail.contains("a, b"), "got detail: {detail}"); + } +} diff --git a/ndc_lsp/src/lib.rs b/ndc_lsp/src/lib.rs index 02644eae..299e2792 100644 --- a/ndc_lsp/src/lib.rs +++ b/ndc_lsp/src/lib.rs @@ -1,6 +1,7 @@ mod backend; mod diagnostics; mod features; +mod scope_resolve; mod state; mod util; mod visitor; diff --git a/ndc_lsp/src/scope_resolve.rs b/ndc_lsp/src/scope_resolve.rs new file mode 100644 index 00000000..b17aef24 --- /dev/null +++ b/ndc_lsp/src/scope_resolve.rs @@ -0,0 +1,230 @@ +//! Lexical scope resolution over the analysed AST. +//! +//! Both go-to-definition and in-scope completion need the same structural +//! question answered: *which declarations are visible at a given source offset?* +//! This module walks the AST once, recording each declaration together with the +//! source region in which it is visible, and exposes a visibility predicate. +//! +//! Resolution is done structurally (not via the analyser's resolved slots) +//! because local slot numbers are stack-relative — they reset to 0 per function, +//! so they are not unique across the document and can't key a global map. The +//! follow-up that exposes the analyser's resolution will replace go-to-definition +//! here; completion's visibility enumeration is a pure structural query that +//! stays. + +use ndc_lexer::{SourceId, Span}; +use ndc_parser::{Expression, ExpressionLocation, ForBody, ForIteration, Lvalue}; + +/// A declaration discovered while walking the AST. +pub struct Decl { + pub name: String, + /// Span of the declared name — the go-to-definition target. + pub name_span: Span, + /// Region of source in which this binding is visible. + pub scope_span: Span, + /// Byte offset from which the binding is in scope. For `let`/for-loop + /// bindings this is the END of the initializer (RHS / sequence), so the + /// binding is not visible inside its own initializer — matching the analyser, + /// which resolves the initializer before creating the binding (`let x = x` + /// must resolve the RHS to an outer `x`). Ignored for hoisted functions. + pub visible_from: usize, + /// Functions are hoisted: visible throughout their scope regardless of + /// whether the use textually precedes the declaration. + pub is_function: bool, +} + +/// The outermost (document) scope. Its end is one past the source length so a +/// cursor at end-of-file still counts as inside the file scope. +pub fn file_scope(source_id: SourceId, source_len: usize) -> Span { + Span::new(source_id, 0, source_len + 1) +} + +/// Collect every declaration in `ast`, scoped to the region it is visible in. +/// `file_scope` is the outermost scope (the whole document). +pub fn collect_declarations(ast: &[ExpressionLocation], file_scope: Span) -> Vec { + let mut out = Vec::new(); + for expr in ast { + collect(expr, file_scope, &mut out); + } + out +} + +/// Is `decl` visible at `offset`? It must be in an enclosing scope, and — unless +/// it is a hoisted function — the use must come at or after `visible_from`. +pub fn is_visible(decl: &Decl, offset: usize) -> bool { + contains(decl.scope_span, offset) && (decl.is_function || decl.visible_from <= offset) +} + +pub fn contains(span: Span, offset: usize) -> bool { + offset >= span.offset() && offset < span.end() +} + +pub fn scope_len(span: Span) -> usize { + span.end().saturating_sub(span.offset()) +} + +fn collect(expr: &ExpressionLocation, scope: Span, out: &mut Vec) { + match &expr.expression { + Expression::VariableDeclaration { l_value, value, .. } => { + // Visible only after the initializer, so `let x = x` resolves the RHS + // to an outer binding rather than itself. + push_lvalue(l_value, scope, value.span.end(), out); + collect(value, scope, out); + } + Expression::FunctionDeclaration { + name, + parameters, + body, + .. + } => { + if let Some(name) = name { + // No dedicated name span exists yet (the parser keeps only the + // whole-declaration span); the follow-up adds `name_span`. + out.push(Decl { + name: name.clone(), + name_span: expr.span, + scope_span: scope, + visible_from: expr.span.offset(), + is_function: true, + }); + } + // Parameters and body locals are scoped to the body; parameters are + // visible throughout it (the body follows the parameter list). + let body_scope = body.span; + for p in parameters { + push_lvalue(&p.lvalue, body_scope, p.span.offset(), out); + } + collect(body, body_scope, out); + } + Expression::Block { statements } => { + for s in statements { + collect(s, expr.span, out); + } + } + Expression::Statement(inner) | Expression::Grouping(inner) => collect(inner, scope, out), + Expression::If { + condition, + on_true, + on_false, + } => { + collect(condition, scope, out); + collect(on_true, scope, out); + if let Some(f) = on_false { + collect(f, scope, out); + } + } + Expression::While { + expression, + loop_body, + } => { + collect(expression, scope, out); + collect(loop_body, scope, out); + } + Expression::For { iterations, body } => { + // For-loop bindings are visible across the loop expression, but only + // after their own sequence (so `for x in x` resolves the sequence to + // an outer `x`; later iterations/guards/body still see the binding). + let loop_scope = expr.span; + for iteration in iterations { + match iteration { + ForIteration::Iteration { l_value, sequence } => { + push_lvalue(l_value, loop_scope, sequence.span.end(), out); + collect(sequence, scope, out); + } + ForIteration::Guard(e) => collect(e, loop_scope, out), + } + } + match body.as_ref() { + ForBody::Block(e) | ForBody::List { expr: e, .. } => collect(e, loop_scope, out), + ForBody::Map { + key, + value, + default, + .. + } => { + collect(key, loop_scope, out); + if let Some(v) = value { + collect(v, loop_scope, out); + } + if let Some(d) = default { + collect(d, loop_scope, out); + } + } + } + } + Expression::Return { value } => collect(value, scope, out), + Expression::Logical { left, right, .. } => { + collect(left, scope, out); + collect(right, scope, out); + } + Expression::Assignment { r_value, .. } | Expression::OpAssignment { r_value, .. } => { + collect(r_value, scope, out); + } + Expression::Call { + function, + arguments, + } + | Expression::OperatorCall { + function, + arguments, + } => { + collect(function, scope, out); + for arg in arguments { + collect(arg, scope, out); + } + } + Expression::Tuple { values } | Expression::List { values } => { + for v in values { + collect(v, scope, out); + } + } + Expression::Map { values, default } => { + for (key, value) in values { + collect(key, scope, out); + if let Some(v) = value { + collect(v, scope, out); + } + } + if let Some(d) = default { + collect(d, scope, out); + } + } + Expression::RangeInclusive { start, end } | Expression::RangeExclusive { start, end } => { + if let Some(s) = start { + collect(s, scope, out); + } + if let Some(e) = end { + collect(e, scope, out); + } + } + Expression::Identifier { .. } + | Expression::BoolLiteral(_) + | Expression::StringLiteral(_) + | Expression::Int64Literal(_) + | Expression::Float64Literal(_) + | Expression::BigIntLiteral(_) + | Expression::ComplexLiteral(_) + | Expression::Break + | Expression::Continue => {} + } +} + +fn push_lvalue(lvalue: &Lvalue, scope: Span, visible_from: usize, out: &mut Vec) { + match lvalue { + Lvalue::Identifier { + identifier, span, .. + } => out.push(Decl { + name: identifier.clone(), + name_span: *span, + scope_span: scope, + visible_from, + is_function: false, + }), + Lvalue::Sequence(lvalues) => { + for lv in lvalues { + push_lvalue(lv, scope, visible_from, out); + } + } + Lvalue::Index { .. } => {} + } +} diff --git a/ndc_lsp/src/state.rs b/ndc_lsp/src/state.rs index 7164aa61..dcbb3be9 100644 --- a/ndc_lsp/src/state.rs +++ b/ndc_lsp/src/state.rs @@ -1,15 +1,141 @@ -use std::collections::HashMap; +use ahash::AHashMap; use ndc_core::StaticType; -use tower_lsp::lsp_types::InlayHint; +use ndc_interpreter::AnalysisResult; +use ndc_lexer::Span; +use ndc_parser::ExpressionLocation; + +use crate::util::LineIndex; +use crate::visitor::{AstVisitor, walk_ast}; /// Per-document analysis state cached between edits. +/// +/// The analysed AST plus its [`AnalysisResult`] side tables are the source of +/// truth for position-based features (hover, document symbols, go-to-definition). +/// They reflect the last *successful* analysis. pub struct DocumentState { - pub hints: Vec, pub source: String, - /// Variable name -> inferred type, collected from analysed AST declarations. - pub variable_types: HashMap, - /// Expression end offset -> inferred type, for looking up the receiver type - /// of dot-completion on arbitrary expressions (e.g. `read_file("foo").`). - pub expression_types: HashMap, + pub line_index: LineIndex, + /// Monotonic document version from the client (LSP `didChange`). Used to + /// discard the result of a `validate` that an overlapping later edit has + /// already superseded, so a slow analysis can't roll the buffer back. + pub version: i32, + /// Whether `ast`/`analysis` were produced from the current `source`. False + /// while the buffer is mid-edit and the last parse failed: the AST and its + /// spans are then stale relative to `source`, so AST-backed features (hover, + /// go-to-definition, document symbols, inlay hints) must not run against it — + /// they would map old spans onto edited text. The completion caches + /// (`variable_types`/`expression_types`) stay usable regardless. + pub analysis_matches_source: bool, + /// Analysed AST from the last successful analysis. + pub ast: Vec, + /// Side tables (per-expression types, declaration spans, ...) from the last + /// successful analysis. + pub analysis: AnalysisResult, + /// Variable name -> inferred type. Derived from `ast`, but kept as a flat map + /// so that simple `ident.` dot-completion keeps working while the user is + /// mid-edit and the buffer doesn't parse (the AST is stale then, but the + /// variable's name and type are not). + pub variable_types: AHashMap, + /// Expression end offset -> inferred type, for dot-completion on arbitrary + /// receiver expressions (e.g. `read_file("foo").`). Same resilience rationale. + pub expression_types: AHashMap, +} + +impl DocumentState { + /// Create a state holding only the source text (no analysis yet). + pub fn from_source(source: String) -> Self { + let line_index = LineIndex::new(&source); + Self { + source, + line_index, + version: 0, + analysis_matches_source: false, + ast: Vec::new(), + analysis: AnalysisResult::default(), + variable_types: AHashMap::new(), + expression_types: AHashMap::new(), + } + } + + /// Build a state from a successful analysis. Test-only convenience; the + /// server commits analysis in place via [`set_analysis`]. + #[cfg(test)] + pub fn from_analysis( + source: String, + ast: Vec, + analysis: AnalysisResult, + ) -> Self { + let mut state = Self::from_source(source); + state.set_analysis(ast, analysis); + state + } + + /// Replace the analysis (AST + side tables + derived completion caches) in + /// place, leaving `source`/`line_index`/`version` untouched, and mark the + /// analysis as matching the current source. Callers must ensure `ast` was + /// produced from the current `source`. + pub fn set_analysis(&mut self, ast: Vec, analysis: AnalysisResult) { + let mut collector = MapCollector { + analysis: &analysis, + variable_types: AHashMap::new(), + expression_types: AHashMap::new(), + }; + walk_ast(&mut collector, &ast); + self.variable_types = collector.variable_types; + self.expression_types = collector.expression_types; + self.ast = ast; + self.analysis = analysis; + self.analysis_matches_source = true; + } +} + +/// Collects the flat lookup maps used by dot-completion from an analysed AST. +struct MapCollector<'a> { + analysis: &'a AnalysisResult, + variable_types: AHashMap, + expression_types: AHashMap, +} + +impl AstVisitor for MapCollector<'_> { + fn on_expression(&mut self, expr: &ExpressionLocation) { + if let Some(typ) = self.analysis.expr_types.get(&expr.id) { + self.expression_types.insert(expr.span.end(), typ.clone()); + } + } + + fn on_declaration( + &mut self, + identifier: &str, + inferred_type: Option<&StaticType>, + _has_annotation: bool, + _span: Span, + ) { + if let Some(typ) = inferred_type { + self.variable_types + .insert(identifier.to_string(), typ.clone()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ndc_interpreter::Interpreter; + + #[test] + fn from_source_is_not_fresh_until_analysed() { + let state = DocumentState::from_source("let x = 1;".to_string()); + assert!(!state.analysis_matches_source); + } + + #[test] + fn from_analysis_is_fresh() { + let mut interpreter = Interpreter::capturing(); + interpreter.configure(ndc_stdlib::register); + let source = "let x = 1;"; + let (ast, analysis) = interpreter.analyse_str(source).expect("analysis succeeds"); + let state = DocumentState::from_analysis(source.to_string(), ast, analysis); + assert!(state.analysis_matches_source); + } } diff --git a/ndc_lsp/src/util/mod.rs b/ndc_lsp/src/util/mod.rs index 13b715bd..2b4b1915 100644 --- a/ndc_lsp/src/util/mod.rs +++ b/ndc_lsp/src/util/mod.rs @@ -1,3 +1,3 @@ mod position; -pub use position::{offset_from_position, position_from_offset, span_to_range}; +pub use position::{LineIndex, span_to_range}; diff --git a/ndc_lsp/src/util/position.rs b/ndc_lsp/src/util/position.rs index eeed3dae..0433cc7d 100644 --- a/ndc_lsp/src/util/position.rs +++ b/ndc_lsp/src/util/position.rs @@ -1,62 +1,88 @@ use ndc_lexer::Span; use tower_lsp::lsp_types::{Position, Range}; -pub fn span_to_range(text: &str, span: Span) -> Range { - Range { - start: position_from_offset(text, span.offset()), - end: position_from_offset(text, span.end()), - } +/// Precomputed line-start byte offsets for a document, enabling O(log n) +/// offset<->position conversion instead of rescanning the text from the start +/// on every call. +/// +/// Character columns are counted in UTF-16 code units, which is the default LSP +/// position encoding (we advertise no alternative). A BMP scalar is one unit; an +/// astral scalar (e.g. an emoji) is two. Counting scalar values instead would +/// resolve positions after such characters to the wrong byte offset. +#[derive(Debug, Clone, Default)] +pub struct LineIndex { + /// Byte offset of the first character of each line. Always starts with `0`. + line_starts: Vec, } -pub fn position_from_offset(text: &str, offset: usize) -> Position { - let mut line = 0; - let mut col = 0; - let mut byte_count = 0; - - for c in text.chars() { - let char_len = c.len_utf8(); - if byte_count >= offset { - break; +impl LineIndex { + pub fn new(text: &str) -> Self { + let mut line_starts = vec![0]; + for (i, b) in text.bytes().enumerate() { + if b == b'\n' { + line_starts.push(i + 1); + } } + Self { line_starts } + } - if c == '\n' { - line += 1; - col = 0; - } else { - col += 1; + /// Convert a byte `offset` into an LSP [`Position`]. Offsets past the end of + /// the text clamp to the end. + pub fn position(&self, text: &str, offset: usize) -> Position { + let offset = offset.min(text.len()); + // Largest line whose start offset is <= `offset`. + let line = self.line_starts.partition_point(|&start| start <= offset) - 1; + let line_start = self.line_starts[line]; + let character: usize = text[line_start..offset].chars().map(char::len_utf16).sum(); + Position { + line: line as u32, + character: character as u32, } - - byte_count += char_len; } - Position { - line, - character: col, - } -} + /// Convert an LSP [`Position`] into a byte offset, or `None` if the line lies + /// beyond the end of the text. A character column past the end of its line + /// clamps to the line's terminating newline (or the end of the text). + pub fn offset(&self, text: &str, pos: Position) -> Option { + let line = pos.line as usize; + let line_start = *self.line_starts.get(line)?; + let line_end = self + .line_starts + .get(line + 1) + .map_or(text.len(), |&next| next); -/// Convert an LSP `Position` (line, character) to a byte offset in the source text. -pub fn offset_from_position(text: &str, pos: Position) -> Option { - let mut line = 0u32; - let mut col = 0u32; - for (i, c) in text.char_indices() { - if line == pos.line && col == pos.character { - return Some(i); - } - if c == '\n' { - if line == pos.line { - return Some(i); + // `col` accumulates UTF-16 code units, matching `position`. A column that + // lands inside an astral character's surrogate pair clamps to that + // character's start boundary. + let mut offset = line_start; + let mut col = 0u32; + for c in text[line_start..line_end].chars() { + if c == '\n' || col >= pos.character { + return Some(offset); + } + let width = c.len_utf16() as u32; + if col + width > pos.character { + // Target splits this character's surrogate pair → clamp to its start. + return Some(offset); } - line += 1; - col = 0; - } else { - col += 1; + offset += c.len_utf8(); + col += width; } + Some(offset) } - if line == pos.line && col == pos.character { - return Some(text.len()); + + pub fn range(&self, text: &str, span: Span) -> Range { + Range { + start: self.position(text, span.offset()), + end: self.position(text, span.end()), + } } - None +} + +/// Convert a [`Span`] to an LSP [`Range`]. Builds a transient [`LineIndex`]; +/// prefer a cached `LineIndex` when converting repeatedly. +pub fn span_to_range(text: &str, span: Span) -> Range { + LineIndex::new(text).range(text, span) } #[cfg(test)] @@ -66,28 +92,31 @@ mod tests { #[test] fn offset_to_position_single_line() { let text = "hello"; - assert_eq!(position_from_offset(text, 0), Position::new(0, 0)); - assert_eq!(position_from_offset(text, 3), Position::new(0, 3)); - assert_eq!(position_from_offset(text, 5), Position::new(0, 5)); + let index = LineIndex::new(text); + assert_eq!(index.position(text, 0), Position::new(0, 0)); + assert_eq!(index.position(text, 3), Position::new(0, 3)); + assert_eq!(index.position(text, 5), Position::new(0, 5)); } #[test] fn offset_to_position_multiline() { let text = "ab\ncd\nef"; - assert_eq!(position_from_offset(text, 0), Position::new(0, 0)); - assert_eq!(position_from_offset(text, 2), Position::new(0, 2)); + let index = LineIndex::new(text); + assert_eq!(index.position(text, 0), Position::new(0, 0)); + assert_eq!(index.position(text, 2), Position::new(0, 2)); // offset 3 is start of second line - assert_eq!(position_from_offset(text, 3), Position::new(1, 0)); - assert_eq!(position_from_offset(text, 4), Position::new(1, 1)); - assert_eq!(position_from_offset(text, 6), Position::new(2, 0)); + assert_eq!(index.position(text, 3), Position::new(1, 0)); + assert_eq!(index.position(text, 4), Position::new(1, 1)); + assert_eq!(index.position(text, 6), Position::new(2, 0)); } #[test] fn position_to_offset_roundtrip() { let text = "let x = 5\nx."; + let index = LineIndex::new(text); for offset in 0..=text.len() { - let pos = position_from_offset(text, offset); - let back = offset_from_position(text, pos); + let pos = index.position(text, offset); + let back = index.offset(text, pos); assert_eq!(back, Some(offset), "roundtrip failed for offset {offset}"); } } @@ -95,15 +124,53 @@ mod tests { #[test] fn position_to_offset_past_end_of_line() { let text = "ab\ncd"; + let index = LineIndex::new(text); // Position past end of first line should clamp to the newline - let offset = offset_from_position(text, Position::new(0, 5)); - assert_eq!(offset, Some(2)); + assert_eq!(index.offset(text, Position::new(0, 5)), Some(2)); } #[test] fn position_to_offset_past_end_of_text() { let text = "ab"; - assert_eq!(offset_from_position(text, Position::new(0, 2)), Some(2)); - assert_eq!(offset_from_position(text, Position::new(1, 0)), None); + let index = LineIndex::new(text); + assert_eq!(index.offset(text, Position::new(0, 2)), Some(2)); + assert_eq!(index.offset(text, Position::new(1, 0)), None); + } + + #[test] + fn bmp_char_is_one_utf16_unit() { + // "héllo" — é is two UTF-8 bytes but a single UTF-16 unit (BMP). + let text = "héllo"; + let index = LineIndex::new(text); + // byte offset 3 is just after "hé" (1 + 2 bytes) → column 2. + assert_eq!(index.position(text, 3), Position::new(0, 2)); + assert_eq!(index.offset(text, Position::new(0, 2)), Some(3)); + } + + #[test] + fn astral_char_is_two_utf16_units() { + // 😀 is U+1F600: 4 UTF-8 bytes, 2 UTF-16 units (a surrogate pair). + let text = "a😀b"; + let index = LineIndex::new(text); + // `b` is at byte 5; in UTF-16 columns that is a(1) + 😀(2) = 3. + assert_eq!(index.position(text, 5), Position::new(0, 3)); + assert_eq!(index.offset(text, Position::new(0, 3)), Some(5)); + // A column landing inside the surrogate pair clamps to the char boundary. + assert_eq!(index.offset(text, Position::new(0, 2)), Some(1)); + } + + #[test] + fn position_to_offset_roundtrip_with_astral() { + // Round-trip every char-boundary offset through UTF-16 columns. + let text = "x = 😀\ny"; + let index = LineIndex::new(text); + for (offset, _) in text.char_indices().chain([(text.len(), ' ')]) { + let pos = index.position(text, offset); + assert_eq!( + index.offset(text, pos), + Some(offset), + "roundtrip failed for offset {offset}" + ); + } } } diff --git a/ndc_lsp/src/visitor.rs b/ndc_lsp/src/visitor.rs index 8037d1a6..52359191 100644 --- a/ndc_lsp/src/visitor.rs +++ b/ndc_lsp/src/visitor.rs @@ -38,6 +38,178 @@ pub fn walk_ast(visitor: &mut impl AstVisitor, expressions: &[ExpressionLocation } } +/// Find the innermost expression whose span contains `offset`. +/// +/// Used for position-based features (hover, go-to-definition): given a cursor +/// byte offset, return the most specific expression node under it. +pub fn node_at_offset( + expressions: &[ExpressionLocation], + offset: usize, +) -> Option<&ExpressionLocation> { + let mut best: Option<&ExpressionLocation> = None; + for expr in expressions { + find_node_at(expr, offset, &mut best); + } + best +} + +fn span_len(span: Span) -> usize { + span.end().saturating_sub(span.offset()) +} + +fn find_node_at<'a>( + expr: &'a ExpressionLocation, + offset: usize, + best: &mut Option<&'a ExpressionLocation>, +) { + let span = expr.span; + if offset < span.offset() || offset >= span.end() { + return; + } + // This node contains the offset; keep it if it's at least as specific + // (smaller span) as the best candidate so far. + if best.is_none_or(|b| span_len(span) <= span_len(b.span)) { + *best = Some(expr); + } + for child in child_expressions(expr) { + find_node_at(child, offset, best); + } +} + +/// The expression-typed children of a node. Mirrors the structure walked by +/// [`walk_expression`], but returns references so callers can search for a node +/// rather than visiting via the [`AstVisitor`] trait. Lvalue (declaration) +/// positions are not expression children and are intentionally omitted, except +/// for the expression operands inside an `Lvalue::Index`. +fn child_expressions(expr: &ExpressionLocation) -> Vec<&ExpressionLocation> { + let mut out: Vec<&ExpressionLocation> = Vec::new(); + match &expr.expression { + Expression::VariableDeclaration { l_value, value, .. } => { + push_lvalue_index(l_value, &mut out); + out.push(value); + } + Expression::FunctionDeclaration { body, .. } => out.push(body), + Expression::Statement(inner) | Expression::Grouping(inner) => out.push(inner), + Expression::Block { statements } => out.extend(statements.iter()), + Expression::If { + condition, + on_true, + on_false, + } => { + out.push(condition); + out.push(on_true); + if let Some(f) = on_false { + out.push(f); + } + } + Expression::While { + expression, + loop_body, + } => { + out.push(expression); + out.push(loop_body); + } + Expression::For { iterations, body } => { + for iteration in iterations { + match iteration { + ForIteration::Iteration { l_value, sequence } => { + push_lvalue_index(l_value, &mut out); + out.push(sequence); + } + ForIteration::Guard(e) => out.push(e), + } + } + match body.as_ref() { + ForBody::Block(e) | ForBody::List { expr: e, .. } => out.push(e), + ForBody::Map { + key, + value, + default, + .. + } => { + out.push(key); + if let Some(v) = value { + out.push(v); + } + if let Some(d) = default { + out.push(d); + } + } + } + } + Expression::Return { value } => out.push(value), + Expression::Logical { left, right, .. } => { + out.push(left); + out.push(right); + } + Expression::Assignment { l_value, r_value } + | Expression::OpAssignment { + l_value, r_value, .. + } => { + push_lvalue_index(l_value, &mut out); + out.push(r_value); + } + Expression::Call { + function, + arguments, + } + | Expression::OperatorCall { + function, + arguments, + } => { + out.push(function); + out.extend(arguments.iter()); + } + Expression::Tuple { values } | Expression::List { values } => out.extend(values.iter()), + Expression::Map { values, default } => { + for (key, value) in values { + out.push(key); + if let Some(v) = value { + out.push(v); + } + } + if let Some(d) = default { + out.push(d); + } + } + Expression::RangeInclusive { start, end } | Expression::RangeExclusive { start, end } => { + if let Some(s) = start { + out.push(s); + } + if let Some(e) = end { + out.push(e); + } + } + Expression::Identifier { .. } + | Expression::BoolLiteral(_) + | Expression::StringLiteral(_) + | Expression::Int64Literal(_) + | Expression::Float64Literal(_) + | Expression::BigIntLiteral(_) + | Expression::ComplexLiteral(_) + | Expression::Break + | Expression::Continue => {} + } + out +} + +/// Push the expression operands of an `Lvalue::Index` (the indexed value and +/// the index expression) so they participate in position lookup. +fn push_lvalue_index<'a>(lvalue: &'a Lvalue, out: &mut Vec<&'a ExpressionLocation>) { + match lvalue { + Lvalue::Index { value, index, .. } => { + out.push(value); + out.push(index); + } + Lvalue::Sequence(lvalues) => { + for lv in lvalues { + push_lvalue_index(lv, out); + } + } + Lvalue::Identifier { .. } => {} + } +} + fn walk_expression(visitor: &mut impl AstVisitor, expr: &ExpressionLocation) { visitor.on_expression(expr); match &expr.expression {