Skip to content

Commit 06022f0

Browse files
timfennisclaude
andcommitted
fix(lsp): correct hover/go-to-def shadowing and scope-aware locals 🩹
Addresses PR review (Codex + Tim). All issues shared one cause: the LSP re-derived name resolution by name only and ignored scope/binding info. - New scope_resolve.rs: one scope-aware declaration walk with correct visibility (enclosing scope + declared-before-use; functions hoisted). Shared by go-to-definition and completion. - go-to-def: a use before a later inner shadow now resolves to the outer binding (was: inner scope won by size before the visibility check). - completion: in-scope locals only — a local from one function is no longer suggested inside another. - hover: only show a built-in's signature when the identifier resolved to a global; a local shadowing a built-in (e.g. `let len = 1; len`) shows its type. - Use AHashMap throughout ndc_lsp, per project convention. Known gap: go-to-def is still name-only and does not disambiguate function overloads. The follow-up that exposes the analyser's resolution closes this. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1 parent 45b0434 commit 06022f0

9 files changed

Lines changed: 376 additions & 288 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.

‎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: 3 additions & 3 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};
@@ -22,7 +22,7 @@ use crate::state::DocumentState;
2222

2323
pub struct Backend {
2424
pub client: Client,
25-
documents: RwLock<HashMap<Url, DocumentState>>,
25+
documents: RwLock<AHashMap<Url, DocumentState>>,
2626
configure: fn(&mut FunctionRegistry<Rc<NativeFunction>>),
2727
/// Native-function metadata, snapshotted once at startup. The set of native
2828
/// functions never changes, so completion and hover read this instead of
@@ -39,7 +39,7 @@ impl Backend {
3939
};
4040
Self {
4141
client,
42-
documents: RwLock::new(HashMap::new()),
42+
documents: RwLock::new(AHashMap::new()),
4343
configure,
4444
functions,
4545
}

‎ndc_lsp/src/features/completion.rs‎

Lines changed: 50 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,13 @@
1-
use std::collections::HashMap;
2-
1+
use ahash::AHashMap;
32
use ndc_core::StaticType;
43
use ndc_interpreter::Interpreter;
5-
use ndc_lexer::Span;
64
use tower_lsp::lsp_types::{
75
CompletionItem, CompletionItemKind, CompletionItemLabelDetails, CompletionResponse,
86
Documentation, MarkupContent, MarkupKind, Position,
97
};
108

9+
use crate::scope_resolve::{collect_declarations, file_scope, is_visible};
1110
use crate::state::DocumentState;
12-
use crate::visitor::{AstVisitor, walk_ast};
1311

1412
/// A `Send` snapshot of a registered native function, built once at startup so
1513
/// completion (and hover) never have to rebuild the interpreter per request.
@@ -143,20 +141,29 @@ fn keyword_completions() -> impl Iterator<Item = CompletionItem> {
143141
})
144142
}
145143

146-
/// Collect in-scope local variables (declarations whose span ends at or before
147-
/// the cursor) from the last successfully analysed AST.
144+
/// Collect in-scope local variables from the last successfully analysed AST.
145+
/// Uses lexical-scope visibility (enclosing scope + declared-before-use), so a
146+
/// local declared in one function is not offered inside another.
148147
fn local_completions(state: &DocumentState, position: Position) -> Vec<CompletionItem> {
149148
let Some(offset) = state.line_index.offset(&state.source, position) else {
150149
return Vec::new();
151150
};
152-
let mut collector = LocalsCollector {
153-
cursor: offset,
154-
locals: HashMap::new(),
151+
let Some(source_id) = state.ast.first().map(|e| e.span.source_id()) else {
152+
return Vec::new();
155153
};
156-
walk_ast(&mut collector, &state.ast);
154+
let scope = file_scope(source_id, state.source.len());
155+
156+
let mut names: AHashMap<String, Option<StaticType>> = AHashMap::new();
157+
for decl in collect_declarations(&state.ast, scope) {
158+
if is_visible(&decl, offset) {
159+
// Type is a best-effort hint from the name-keyed map (a shadowed name
160+
// may show the wrong type until the analyser resolution is exposed).
161+
let typ = state.variable_types.get(&decl.name).cloned();
162+
names.insert(decl.name, typ);
163+
}
164+
}
157165

158-
collector
159-
.locals
166+
names
160167
.into_iter()
161168
.map(|(name, typ)| CompletionItem {
162169
label: name,
@@ -170,29 +177,6 @@ fn local_completions(state: &DocumentState, position: Position) -> Vec<Completio
170177
.collect()
171178
}
172179

173-
/// Gathers declared variable names visible at the cursor. Approximate: it does
174-
/// not model block scoping, only "declared earlier in the file", which is enough
175-
/// for a useful completion list.
176-
struct LocalsCollector {
177-
cursor: usize,
178-
locals: HashMap<String, Option<StaticType>>,
179-
}
180-
181-
impl AstVisitor for LocalsCollector {
182-
fn on_declaration(
183-
&mut self,
184-
identifier: &str,
185-
inferred_type: Option<&StaticType>,
186-
_has_annotation: bool,
187-
span: Span,
188-
) {
189-
if span.end() <= self.cursor {
190-
self.locals
191-
.insert(identifier.to_string(), inferred_type.cloned());
192-
}
193-
}
194-
}
195-
196180
fn is_normal_ident(input: &str) -> bool {
197181
input
198182
.chars()
@@ -252,8 +236,8 @@ mod tests {
252236
/// directly, simulating the cached-after-analysis state used by completion.
253237
fn state_with(
254238
source: &str,
255-
variable_types: HashMap<String, StaticType>,
256-
expression_types: HashMap<usize, StaticType>,
239+
variable_types: AHashMap<String, StaticType>,
240+
expression_types: AHashMap<usize, StaticType>,
257241
) -> DocumentState {
258242
let mut state = DocumentState::from_source(source.to_string());
259243
state.variable_types = variable_types;
@@ -310,8 +294,8 @@ mod tests {
310294
// Simulate: user typed `let x = [1,2,3]` then `x.`
311295
let state = state_with(
312296
"let x = [1,2,3]\nx.",
313-
HashMap::from([("x".to_string(), StaticType::List(Box::new(StaticType::Int)))]),
314-
HashMap::new(),
297+
AHashMap::from([("x".to_string(), StaticType::List(Box::new(StaticType::Int)))]),
298+
AHashMap::new(),
315299
);
316300

317301
// Cursor is after the dot: line 1, character 2
@@ -339,8 +323,8 @@ mod tests {
339323
// preserved from a previous successful analysis.
340324
let state = state_with(
341325
"let x = 42\nx.",
342-
HashMap::from([("x".to_string(), StaticType::Int)]),
343-
HashMap::new(),
326+
AHashMap::from([("x".to_string(), StaticType::Int)]),
327+
AHashMap::new(),
344328
);
345329

346330
let response = complete(Some(&state), Position::new(1, 2), &functions());
@@ -366,8 +350,8 @@ mod tests {
366350
// The call expression spans bytes 0..16, so its end offset is 16.
367351
let state = state_with(
368352
source,
369-
HashMap::new(),
370-
HashMap::from([(16, StaticType::String)]),
353+
AHashMap::new(),
354+
AHashMap::from([(16, StaticType::String)]),
371355
);
372356

373357
// Cursor is at end: line 0, character 17 (after the dot)
@@ -390,8 +374,8 @@ mod tests {
390374
fn general_completion_includes_keywords() {
391375
let state = state_with(
392376
"let x = 42\n",
393-
HashMap::from([("x".to_string(), StaticType::Int)]),
394-
HashMap::new(),
377+
AHashMap::from([("x".to_string(), StaticType::Int)]),
378+
AHashMap::new(),
395379
);
396380

397381
// No dot — general completion
@@ -431,4 +415,25 @@ mod tests {
431415
"general completion should include the in-scope local `greeting`"
432416
);
433417
}
418+
419+
#[test]
420+
fn locals_do_not_leak_across_functions() {
421+
let mut interpreter = Interpreter::capturing();
422+
interpreter.configure(ndc_stdlib::register);
423+
// `foo` is local to `a`; completing inside `b` must not offer it.
424+
let source = "fn a() { let foo = 1; }\nfn b() {\n\n}\n";
425+
let (ast, analysis) = interpreter.analyse_str(source).expect("analysis succeeds");
426+
let state = DocumentState::from_analysis(source.to_string(), ast, analysis);
427+
428+
// The blank line 2 is inside b's body.
429+
let response = complete(Some(&state), Position::new(2, 0), &functions());
430+
let CompletionResponse::Array(items) = response else {
431+
panic!("expected Array response");
432+
};
433+
434+
assert!(
435+
!items.iter().any(|i| i.label == "foo"),
436+
"a local from another function must not be suggested"
437+
);
438+
}
434439
}

0 commit comments

Comments
 (0)