1- use std:: collections:: HashMap ;
2-
1+ use ahash:: AHashMap ;
32use ndc_core:: StaticType ;
43use ndc_interpreter:: Interpreter ;
5- use ndc_lexer:: Span ;
64use 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} ;
1110use 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.
148147fn 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-
196180fn 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]\n x." ,
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\n x." ,
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; }\n fn 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