Skip to content

Commit 98be07d

Browse files
timfennisclaude
andauthored
feat(lsp): hover, document symbols, go-to-definition and smarter completion 🔍 (#164)
## Context The `ndc_lsp` server offered only inlay hints, dot-completion, and diagnostics. This adds the commonly-expected LSP features and, along the way, cleans up the document model and removes a per-request hot path. Prompted by a request to improve the LSP across features, architecture, and performance. ## Changes **Architecture** - `DocumentState` now stores the analysed AST + `AnalysisResult` side tables (verified `Send + Sync`) as the source of truth for position-based features, instead of only pre-flattened offset-keyed maps. The flat `variable_types`/`expression_types` maps are kept as a documented resilience cache so dot-completion keeps working while the buffer is mid-edit and doesn't parse. - Added a `node_at_offset` resolver to the AST visitor. **Performance** - Native-function metadata is snapshotted once at startup (`FunctionInfo`); completion and hover no longer rebuild an interpreter per request. - New `LineIndex` gives O(log n) offset↔position conversion instead of rescanning from byte 0; completion holds the read lock instead of cloning document state. **Features** - **Hover** — inferred type at the cursor; signature + docs for built-in functions. - **Document symbols** — outline of top-level/nested functions and variables. - **Go-to-definition** — jump from a usage to its declaration. - **Completion** — in-scope locals and language keywords in general completion. **Docs** - New "Editor support" manual page; updated the VS Code extension README capabilities. ## Note for reviewers The original plan was to add a `HashMap<ResolvedVar, Span>` to the analyser for go-to-definition. While implementing I found that `ResolvedVar::Local` slots are stack-relative and reset to 0 per function (`new_function_scope` sets `base_offset: 0`), so such a map collides across functions and resolves to the wrong declaration. Go-to-definition is therefore implemented via lexical scope resolution over the AST — correct, and contained entirely within `ndc_lsp` with no analyser change. 🤖 --------- Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 38837bf commit 98be07d

18 files changed

Lines changed: 1743 additions & 307 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

ext/andy-cpp/README.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,18 @@
22

33
## Features
44

5-
This extension offers basic syntax highlighting for the Andy C++ programming language.
5+
This extension provides editor support for the Andy C++ programming language:
66

7-
## Known Issues
7+
- Syntax highlighting for `.ndc` files
8+
- Language server features (via the bundled `ndc` binary):
9+
- Diagnostics (lexer, parser, and type errors)
10+
- Inlay type hints
11+
- Hover (inferred types; signatures and docs for built-ins)
12+
- Completion (method-call style on `.`, plus locals and keywords)
13+
- Document symbols (outline)
14+
- Go-to-definition
15+
- "Run Script" command
816

9-
Not all the syntax of the language is highlighted correctly
17+
## Known Issues
1018

19+
Not all the syntax of the language is highlighted correctly.

manual/src/SUMMARY.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,8 @@
3434
- [Memoization](./features/memoization.md)
3535
- [Tracing](./features/tracing.md)
3636

37+
# Tooling
38+
- [Editor support](./tooling/editor-support.md)
39+
3740
# Troubleshooting
3841
- [Overload dispatch with collections](./troubleshooting/overload-dispatch-collections.md)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Editor support
2+
3+
Andy C++ ships a language server (LSP) so editors can offer rich feedback as you
4+
write `.ndc` files. The server is built into the `ndc` binary and is started with:
5+
6+
```bash
7+
ndc lsp --stdio
8+
```
9+
10+
Most users don't run this by hand — the [VS Code extension](https://open-vsx.org/)
11+
launches it automatically. Any LSP-capable editor can use it by pointing at the
12+
`ndc lsp --stdio` command for the `andy-cpp` language and the `.ndc` file extension.
13+
14+
## What the language server provides
15+
16+
- **Diagnostics** — lexer, parser, and semantic/type errors are reported inline as
17+
you type.
18+
- **Inlay type hints** — inferred types are shown after `let` bindings and function
19+
parameters, and inferred return types after function signatures. Hints are only
20+
shown where you didn't already write an annotation.
21+
- **Hover** — hovering an expression shows its inferred type; hovering a built-in
22+
function shows its signature and documentation.
23+
- **Completion** — typing `.` offers functions whose first parameter accepts the
24+
receiver's type (method-call style). General completion offers built-in functions,
25+
in-scope variables, and language keywords.
26+
- **Document symbols** — an outline of the top-level and nested functions and
27+
variable declarations in the file.
28+
- **Go-to-definition** — jump from a variable or function usage to its declaration.
29+
30+
## Notes
31+
32+
- The server uses full-document synchronisation and re-analyses on each edit.
33+
- While the buffer is mid-edit and doesn't parse, the last successful analysis is
34+
retained so hints and dot-completion keep working.

ndc_lsp/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ version.workspace = true
66

77
[dependencies]
88
tokio = { version = "1.49.0", features = ["full"] }
9+
ahash.workspace = true
910
ndc_analyser.workspace = true
1011
ndc_lexer.workspace = true
1112
ndc_interpreter.workspace = true

ndc_lsp/src/backend.rs

Lines changed: 160 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::collections::HashMap;
1+
use ahash::AHashMap;
22

33
use ndc_core::FunctionRegistry;
44
use ndc_interpreter::{Interpreter, NativeFunction};
@@ -7,28 +7,41 @@ use tokio::sync::RwLock;
77
use tower_lsp::jsonrpc::Result as JsonRPCResult;
88
use tower_lsp::lsp_types::{
99
CompletionItem, CompletionOptions, CompletionParams, CompletionResponse,
10-
DidChangeTextDocumentParams, DidOpenTextDocumentParams, InitializeParams, InitializeResult,
10+
DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
11+
DocumentSymbolParams, DocumentSymbolResponse, GotoDefinitionParams, GotoDefinitionResponse,
12+
Hover, HoverParams, HoverProviderCapability, InitializeParams, InitializeResult,
1113
InitializedParams, InlayHint, InlayHintParams, MessageType, OneOf, ServerCapabilities,
1214
TextDocumentSyncCapability, TextDocumentSyncKind, Url, WorkDoneProgressOptions,
1315
};
1416
use tower_lsp::{Client, LanguageServer};
1517

1618
use crate::diagnostics;
17-
use crate::features::{completion, inlay_hints};
19+
use crate::features::completion::FunctionInfo;
20+
use crate::features::{completion, definition, hover, inlay_hints, symbols};
1821
use crate::state::DocumentState;
1922

2023
pub struct Backend {
2124
pub client: Client,
22-
documents: RwLock<HashMap<Url, DocumentState>>,
25+
documents: RwLock<AHashMap<Url, DocumentState>>,
2326
configure: fn(&mut FunctionRegistry<Rc<NativeFunction>>),
27+
/// Native-function metadata, snapshotted once at startup. The set of native
28+
/// functions never changes, so completion and hover read this instead of
29+
/// rebuilding an interpreter per request.
30+
functions: Vec<FunctionInfo>,
2431
}
2532

2633
impl Backend {
2734
pub fn new(client: Client, configure: fn(&mut FunctionRegistry<Rc<NativeFunction>>)) -> Self {
35+
let functions = {
36+
let mut interpreter = Interpreter::capturing();
37+
interpreter.configure(configure);
38+
FunctionInfo::collect(&interpreter)
39+
};
2840
Self {
2941
client,
30-
documents: RwLock::new(HashMap::new()),
42+
documents: RwLock::new(AHashMap::new()),
3143
configure,
44+
functions,
3245
}
3346
}
3447

@@ -38,63 +51,88 @@ impl Backend {
3851
interpreter
3952
}
4053

41-
/// Update the source text immediately so concurrent requests (e.g. completion
42-
/// triggered by `.`) always see the latest document content.
43-
async fn update_source(&self, uri: &Url, text: &str) {
54+
// Staleness & concurrency model
55+
// ------------------------------
56+
// `didChange` notifications can interleave at await points, so any cached
57+
// state is tagged with the client's monotonic document `version`. Two rules
58+
// keep async edits sound:
59+
// 1. `update_source` runs synchronously under the lock and bumps `version`,
60+
// so completion (triggered by `.`) always sees the latest text.
61+
// 2. `validate` runs analysis off the lock, then commits/publishes only if
62+
// the stored `version` still equals the one it analysed — a slow run
63+
// can't roll the buffer back to older text.
64+
// AST-backed features (hover, go-to-def, symbols, inlay hints) additionally
65+
// gate on `analysis_matches_source`, so they never map stale spans onto
66+
// edited text. The completion caches (`variable_types` by name,
67+
// `expression_types` by end-offset) are intentionally resilient: dot
68+
// completion fires on a buffer that doesn't parse (`x.`), and the data it
69+
// reads sits at the cursor where offsets are stable for the appended `.`.
70+
71+
/// Update the cached source text immediately and bump the document version.
72+
async fn update_source(&self, uri: &Url, text: &str, version: i32) {
4473
let mut docs = self.documents.write().await;
45-
match docs.get_mut(uri) {
46-
Some(state) => state.source = text.to_string(),
47-
None => {
48-
docs.insert(
49-
uri.clone(),
50-
DocumentState {
51-
hints: Vec::new(),
52-
source: text.to_string(),
53-
variable_types: HashMap::new(),
54-
expression_types: HashMap::new(),
55-
},
56-
);
57-
}
74+
if let Some(state) = docs.get_mut(uri) {
75+
state.source = text.to_string();
76+
state.line_index = crate::util::LineIndex::new(text);
77+
state.version = version;
78+
// The AST now predates this edit; `validate` re-sets this to true
79+
// if the new source parses.
80+
state.analysis_matches_source = false;
81+
} else {
82+
let mut state = DocumentState::from_source(text.to_string());
83+
state.version = version;
84+
docs.insert(uri.clone(), state);
5885
}
5986
}
6087

61-
/// Run diagnostics and semantic analysis, updating cached hints and types.
62-
/// The source text must already be updated via `update_source` before calling this.
63-
async fn validate(&self, uri: &Url, text: &str) {
88+
/// Run diagnostics and semantic analysis for `version` of the document, then
89+
/// commit the analysis and publish diagnostics only if no later edit has
90+
/// superseded it. `update_source` must have run for this `version` first.
91+
async fn validate(&self, uri: &Url, text: &str, version: i32) {
6492
let (mut diagnostics, _ast) = diagnostics::lex_and_parse(text);
6593

66-
// Run full semantic analysis and collect inlay hints + variable types.
67-
// The interpreter uses Rc internally (non-Send), so it must be fully
68-
// dropped before the next await point.
69-
let analysis = {
94+
// Run full semantic analysis. The interpreter uses Rc internally
95+
// (non-Send), so it must be fully dropped before the next await point.
96+
let analysed = {
7097
let mut interpreter = self.make_interpreter();
7198
interpreter
7299
.analyse_str(text)
73100
.ok()
74101
.map(|(expressions, analysis_result)| {
75-
// Convert analysis errors to LSP diagnostics.
76102
for err in &analysis_result.errors {
77103
diagnostics.push(diagnostics::analysis_error_to_diagnostic(text, err));
78104
}
79-
inlay_hints::collect(&expressions, &analysis_result, text)
105+
(expressions, analysis_result)
80106
})
81107
};
82108

109+
// Hold the write lock across the commit AND the publish. `did_close`
110+
// also takes this lock to remove state + clear diagnostics, so holding
111+
// it here serializes the two: either close runs first (and we then see
112+
// the document gone and skip), or we publish first and close clears it
113+
// afterwards. Without this, a close could slip between an unlocked
114+
// version check and the publish, leaving stale diagnostics on a closed
115+
// file. The publish is a fire-and-forget notification, so the lock is
116+
// held only briefly.
117+
let mut docs = self.documents.write().await;
118+
let Some(state) = docs.get_mut(uri) else {
119+
return;
120+
};
121+
// A later edit already moved the buffer on — drop this stale run
122+
// rather than committing old AST or publishing old diagnostics.
123+
if state.version != version {
124+
return;
125+
}
126+
// On success, commit in place (keeps the current source/line_index).
127+
// On parse failure, keep the last good AST; `analysis_matches_source`
128+
// is already false, so AST-backed features stay disabled.
129+
if let Some((ast, analysis)) = analysed {
130+
state.set_analysis(ast, analysis);
131+
}
132+
83133
self.client
84-
.publish_diagnostics(uri.clone(), diagnostics, None)
134+
.publish_diagnostics(uri.clone(), diagnostics, Some(version))
85135
.await;
86-
87-
// Only update document state when analysis succeeds. On failure (e.g.
88-
// incomplete syntax while typing `x.`), keep the last good hints and
89-
// variable types so inlay hints stay visible and dot-completion works.
90-
if let Some(info) = analysis {
91-
let mut docs = self.documents.write().await;
92-
if let Some(state) = docs.get_mut(uri) {
93-
state.hints = info.hints;
94-
state.variable_types = info.variable_types;
95-
state.expression_types = info.expression_types;
96-
}
97-
}
98136
}
99137
}
100138

@@ -116,6 +154,9 @@ impl LanguageServer for Backend {
116154
completion_item: None,
117155
}),
118156
inlay_hint_provider: Some(OneOf::Left(true)),
157+
hover_provider: Some(HoverProviderCapability::Simple(true)),
158+
document_symbol_provider: Some(OneOf::Left(true)),
159+
definition_provider: Some(OneOf::Left(true)),
119160
..Default::default()
120161
},
121162
..Default::default()
@@ -135,46 +176,100 @@ impl LanguageServer for Backend {
135176
async fn did_open(&self, params: DidOpenTextDocumentParams) {
136177
let uri = params.text_document.uri;
137178
let text = params.text_document.text;
138-
self.update_source(&uri, &text).await;
139-
self.validate(&uri, &text).await;
179+
let version = params.text_document.version;
180+
self.update_source(&uri, &text, version).await;
181+
self.validate(&uri, &text, version).await;
140182
}
141183

142184
async fn did_change(&self, params: DidChangeTextDocumentParams) {
143185
let uri = params.text_document.uri;
144-
for change in params.content_changes {
145-
self.update_source(&uri, &change.text).await;
146-
self.validate(&uri, &change.text).await;
186+
let version = params.text_document.version;
187+
// Full-document sync: the last change carries the whole buffer, so only
188+
// the final one matters.
189+
if let Some(change) = params.content_changes.into_iter().next_back() {
190+
self.update_source(&uri, &change.text, version).await;
191+
self.validate(&uri, &change.text, version).await;
147192
}
148193
}
149194

195+
async fn did_close(&self, params: DidCloseTextDocumentParams) {
196+
let uri = params.text_document.uri;
197+
// Drop cached state so the map doesn't grow unbounded, and clear the
198+
// document's diagnostics. Hold the lock across the publish so it can't
199+
// interleave with a `validate` publish (see the note in `validate`).
200+
let mut docs = self.documents.write().await;
201+
docs.remove(&uri);
202+
self.client.publish_diagnostics(uri, Vec::new(), None).await;
203+
}
204+
150205
async fn inlay_hint(&self, params: InlayHintParams) -> JsonRPCResult<Option<Vec<InlayHint>>> {
206+
let docs = self.documents.read().await;
207+
Ok(docs.get(&params.text_document.uri).map(|state| {
208+
if !state.analysis_matches_source {
209+
return Vec::new();
210+
}
211+
inlay_hints::collect(
212+
&state.ast,
213+
&state.analysis,
214+
&state.source,
215+
&state.line_index,
216+
)
217+
}))
218+
}
219+
220+
async fn hover(&self, params: HoverParams) -> JsonRPCResult<Option<Hover>> {
221+
let docs = self.documents.read().await;
222+
let uri = &params.text_document_position_params.text_document.uri;
223+
let position = params.text_document_position_params.position;
224+
Ok(docs
225+
.get(uri)
226+
.filter(|state| state.analysis_matches_source)
227+
.and_then(|state| hover::hover(state, position, &self.functions)))
228+
}
229+
230+
async fn goto_definition(
231+
&self,
232+
params: GotoDefinitionParams,
233+
) -> JsonRPCResult<Option<GotoDefinitionResponse>> {
234+
let docs = self.documents.read().await;
235+
let uri = &params.text_document_position_params.text_document.uri;
236+
let position = params.text_document_position_params.position;
237+
Ok(docs
238+
.get(uri)
239+
.filter(|state| state.analysis_matches_source)
240+
.and_then(|state| {
241+
definition::goto_definition(state, position, uri.clone())
242+
.map(GotoDefinitionResponse::Scalar)
243+
}))
244+
}
245+
246+
async fn document_symbol(
247+
&self,
248+
params: DocumentSymbolParams,
249+
) -> JsonRPCResult<Option<DocumentSymbolResponse>> {
151250
let docs = self.documents.read().await;
152251
Ok(docs
153252
.get(&params.text_document.uri)
154-
.map(|state| state.hints.clone()))
253+
.filter(|state| state.analysis_matches_source)
254+
.map(|state| {
255+
DocumentSymbolResponse::Nested(symbols::document_symbols(
256+
&state.ast,
257+
&state.source,
258+
&state.line_index,
259+
))
260+
}))
155261
}
156262

157263
async fn completion(
158264
&self,
159265
params: CompletionParams,
160266
) -> JsonRPCResult<Option<CompletionResponse>> {
161-
let state = {
162-
let docs = self.documents.read().await;
163-
let uri = &params.text_document_position.text_document.uri;
164-
docs.get(uri).map(|s| {
165-
// Clone what completion needs so we can drop the lock.
166-
DocumentState {
167-
hints: Vec::new(), // not needed for completion
168-
source: s.source.clone(),
169-
variable_types: s.variable_types.clone(),
170-
expression_types: s.expression_types.clone(),
171-
}
172-
})
173-
};
174-
175-
let interpreter = self.make_interpreter();
267+
let docs = self.documents.read().await;
268+
let uri = &params.text_document_position.text_document.uri;
176269
let position = params.text_document_position.position;
177-
let response = completion::complete(state.as_ref(), position, &interpreter);
270+
// Completion is synchronous and never awaits, so we can hold the read
271+
// lock and borrow the state directly (no cloning).
272+
let response = completion::complete(docs.get(uri), position, &self.functions);
178273
Ok(Some(response))
179274
}
180275

0 commit comments

Comments
 (0)