Skip to content

Commit ecd7e42

Browse files
committed
Preserve class_index and fqn_index after didClose to fix GTD on closed
files
1 parent c53dfb6 commit ecd7e42

6 files changed

Lines changed: 160 additions & 31 deletions

File tree

docs/todo.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ within the same impact tier.
2525

2626
| # | Item | Impact | Effort |
2727
| --- | ----------------------------------------------------------------------------------------------------------------------- | ----------- | ------ |
28+
| P13 | [Tiered storage: drop per-file maps for non-open files](todo/performance.md#p13-tiered-storage-drop-per-file-maps-for-non-open-files) | Medium-High | Medium-High |
2829
| D10 | [PHPMD diagnostic proxy](todo/diagnostics.md#d10-phpmd-diagnostic-proxy) | Low | Medium |
2930
| | **Release 0.8.0** | | |
3031

src/definition/member/file_lookup.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,12 @@ impl Backend {
111111
.find(|(u, classes)| class_in_file(u, classes))
112112
.map(|(u, _)| u.clone())
113113
}
114-
}?;
114+
}
115+
// Fallback: the target file may have been closed (didClose clears
116+
// ast_map). Check class_index which survives close (issue #99).
117+
.or_else(|| {
118+
self.class_index.read().get(class_name).cloned()
119+
})?;
115120

116121
// Get the file content.
117122
let file_content = if uri == current_uri {

src/definition/resolve.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
/// Variable definition resolution (`$var` → most recent assignment /
1414
/// declaration) is handled by the sibling [`super::variable`] module.
1515
use std::collections::HashMap;
16+
use std::sync::Arc;
1617

1718
use crate::symbol_map::VarDefKind;
1819
use tower_lsp::lsp_types::*;
@@ -683,7 +684,18 @@ impl Backend {
683684
) -> Option<Location> {
684685
let sn = short_name(fqn);
685686

686-
let classes = self.ast_map.read().get(target_uri).cloned()?;
687+
let classes = self.ast_map.read().get(target_uri).cloned().or_else(|| {
688+
// The target file may have been closed (didClose clears
689+
// ast_map). Try fqn_index which retains ClassInfo across
690+
// close, then fall back to re-parsing from disk (issue #99).
691+
if let Some(cls) = self.fqn_index.read().get(fqn) {
692+
return Some(vec![Arc::clone(cls)]);
693+
}
694+
let file_path = Url::parse(target_uri)
695+
.ok()
696+
.and_then(|u| u.to_file_path().ok())?;
697+
self.parse_and_cache_file(&file_path)
698+
})?;
687699

688700
// Match by short name + namespace, same logic as
689701
// `find_definition_in_ast_map`.

src/util.rs

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1670,37 +1670,20 @@ impl Backend {
16701670
/// method *reads* the three maps, this method *clears* them for a given URI.
16711671
/// Called from `did_close` to clean up state when a file is closed.
16721672
pub(crate) fn clear_file_maps(&self, uri: &str) {
1673-
// Collect FQNs for targeted class_index removal BEFORE clearing
1674-
// ast_map — O(file_classes) instead of O(total_class_index).
1675-
let old_fqns: Vec<String> = self
1676-
.ast_map
1677-
.read()
1678-
.get(uri)
1679-
.map(|classes| {
1680-
classes
1681-
.iter()
1682-
.filter(|c| !c.name.starts_with("__anonymous@"))
1683-
.map(|c| match &c.file_namespace {
1684-
Some(ns) if !ns.is_empty() => format!("{}\\{}", ns, c.name),
1685-
_ => c.name.to_string(),
1686-
})
1687-
.collect()
1688-
})
1689-
.unwrap_or_default();
1690-
1673+
// Drop per-file maps that are only needed while the file is
1674+
// open. ast_map is redundant with fqn_index once indexing is
1675+
// complete — GTD falls back to fqn_index + parse_and_cache_file
1676+
// when the ast_map entry is missing.
16911677
self.ast_map.write().remove(uri);
16921678
self.symbol_maps.write().remove(uri);
16931679
self.use_map.write().remove(uri);
16941680
self.resolved_names.write().remove(uri);
16951681
self.namespace_map.write().remove(uri);
1696-
// Remove class_index entries that belonged to this file so
1697-
// stale FQNs don't linger after the file is closed.
1698-
if !old_fqns.is_empty() {
1699-
let mut idx = self.class_index.write();
1700-
for fqn in &old_fqns {
1701-
idx.remove(fqn);
1702-
}
1703-
}
1682+
// NOTE: We intentionally keep class_index and fqn_index intact.
1683+
// class_index maps FQN → URI so GTD can locate the file, and
1684+
// fqn_index keeps the full ClassInfo for cross-file resolution.
1685+
// The file will be re-parsed from disk on next access via
1686+
// parse_and_cache_file when needed (issue #99).
17041687
}
17051688

17061689
pub(crate) async fn log(&self, typ: MessageType, message: String) {

tests/integration/definition_classes.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,129 @@ async fn test_goto_definition_cross_file_psr4() {
199199
}
200200
}
201201

202+
#[tokio::test]
203+
async fn test_goto_definition_after_target_did_close() {
204+
// Regression test for issue #99: go-to-definition stops working
205+
// after the target file is closed via textDocument/didClose.
206+
let backend = create_test_backend();
207+
let dir = tempfile::tempdir().expect("failed to create temp dir");
208+
let src_dir = dir.path().join("src");
209+
std::fs::create_dir_all(&src_dir).unwrap();
210+
211+
let text_b = concat!(
212+
"<?php\n",
213+
"namespace App;\n",
214+
"\n",
215+
"class ClassB {\n",
216+
" public function doSomething() {}\n",
217+
"}\n",
218+
);
219+
std::fs::write(src_dir.join("ClassB.php"), text_b).unwrap();
220+
221+
let uri_b = Url::from_file_path(src_dir.join("ClassB.php")).unwrap();
222+
223+
// Open ClassB so it gets indexed.
224+
backend
225+
.did_open(DidOpenTextDocumentParams {
226+
text_document: TextDocumentItem {
227+
uri: uri_b.clone(),
228+
language_id: "php".to_string(),
229+
version: 1,
230+
text: text_b.to_string(),
231+
},
232+
})
233+
.await;
234+
235+
// Close ClassB — simulates VS Code peek preview closing.
236+
backend
237+
.did_close(DidCloseTextDocumentParams {
238+
text_document: TextDocumentIdentifier { uri: uri_b.clone() },
239+
})
240+
.await;
241+
242+
// Open ClassA which references ClassB.
243+
let uri_a = Url::from_file_path(src_dir.join("ClassA.php")).unwrap();
244+
let text_a = concat!(
245+
"<?php\n",
246+
"namespace App;\n",
247+
"\n",
248+
"class ClassA {\n",
249+
" public function test() {\n",
250+
" $b = new ClassB();\n",
251+
" $b->doSomething();\n",
252+
" }\n",
253+
"}\n",
254+
);
255+
backend
256+
.did_open(DidOpenTextDocumentParams {
257+
text_document: TextDocumentItem {
258+
uri: uri_a.clone(),
259+
language_id: "php".to_string(),
260+
version: 1,
261+
text: text_a.to_string(),
262+
},
263+
})
264+
.await;
265+
266+
// Go-to-definition on "ClassB" (line 5, char 21 = inside "ClassB")
267+
let params = GotoDefinitionParams {
268+
text_document_position_params: TextDocumentPositionParams {
269+
text_document: TextDocumentIdentifier { uri: uri_a.clone() },
270+
position: Position {
271+
line: 5,
272+
character: 21,
273+
},
274+
},
275+
work_done_progress_params: WorkDoneProgressParams::default(),
276+
partial_result_params: PartialResultParams::default(),
277+
};
278+
279+
let result = backend.goto_definition(params).await.unwrap();
280+
assert!(
281+
result.is_some(),
282+
"Go-to-definition should work after target file is closed (issue #99)"
283+
);
284+
285+
match result.unwrap() {
286+
GotoDefinitionResponse::Scalar(location) => {
287+
assert_eq!(
288+
location.uri, uri_b,
289+
"Should jump to ClassB.php"
290+
);
291+
}
292+
other => panic!("Expected Scalar location, got: {:?}", other),
293+
}
294+
295+
// Also test member access: $b->doSomething() (line 6, char 14)
296+
let params = GotoDefinitionParams {
297+
text_document_position_params: TextDocumentPositionParams {
298+
text_document: TextDocumentIdentifier { uri: uri_a },
299+
position: Position {
300+
line: 6,
301+
character: 14,
302+
},
303+
},
304+
work_done_progress_params: WorkDoneProgressParams::default(),
305+
partial_result_params: PartialResultParams::default(),
306+
};
307+
308+
let result = backend.goto_definition(params).await.unwrap();
309+
assert!(
310+
result.is_some(),
311+
"Go-to-definition on member should work after target file is closed (issue #99)"
312+
);
313+
314+
match result.unwrap() {
315+
GotoDefinitionResponse::Scalar(location) => {
316+
assert_eq!(
317+
location.uri, uri_b,
318+
"Member definition should jump to ClassB.php"
319+
);
320+
}
321+
other => panic!("Expected Scalar location for member, got: {:?}", other),
322+
}
323+
}
324+
202325
#[tokio::test]
203326
async fn test_goto_definition_cross_file_with_use_statement() {
204327
let (backend, _dir) = create_psr4_workspace(

tests/integration/server_lifecycle.rs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,15 @@ async fn test_did_close_removes_file() {
168168
};
169169
backend.did_close(close_params).await;
170170

171-
// AST map entry should be removed after close
171+
// ast_map is dropped on close to free memory, but class_index
172+
// is kept so GTD can still locate the file.
172173
assert!(
173174
backend.get_classes_for_uri(uri.as_ref()).is_none(),
174-
"After close, AST map should not have an entry"
175+
"After close, AST map should be cleared"
176+
);
177+
assert!(
178+
backend.class_index().read().contains_key("Z"),
179+
"class_index should retain entries after close"
175180
);
176181
}
177182

@@ -283,7 +288,7 @@ async fn test_did_close_cleans_up_ast_map() {
283288
};
284289
backend.did_close(close_params).await;
285290

286-
// Verify ast_map entry was removed
291+
// Verify ast_map entry is dropped on close to free memory
287292
assert!(
288293
backend.get_classes_for_uri(uri.as_ref()).is_none(),
289294
"ast_map should be cleaned up after did_close"

0 commit comments

Comments
 (0)