Skip to content

Commit ba16be9

Browse files
committed
Suppress unknown class diagnostic for class_index lazy loads
1 parent aebbf8f commit ba16be9

4 files changed

Lines changed: 88 additions & 0 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3838
- **False-positive type errors on generic class methods resolved through return types.** When a method returns a generic class (e.g. `@return HasMany<Translation, Tag>`), calling a method on the result whose parameter references a class-level template parameter (e.g. `@param TRelatedModel $model`) no longer produces a spurious "expects TRelatedModel, got Translation" diagnostic. The return type's generic arguments are now preserved and used for template substitution. Covers instance methods, static methods, standalone functions, inherited methods, trait methods, and nullable generic return types.
3939
- **Variables prefixed with `$this` receiving stale type information.** Variables like `$thisItem` or `$thisValue` no longer receive stale type information from `$this`-related narrowing.
4040
- **False-positive type errors on generic class methods and templated static methods.** Class-level `@template` parameters (e.g. `Collection<User>`) are now substituted into method parameter types before checking argument compatibility. Method-level `@template` parameters (e.g. PHPUnit's `assertSame`) are resolved from call-site argument types, covering literals, variables, enum cases, property accesses, and method call return types. Argument type checking correctly rejects `float` passed where an int-range type like `positive-int` is expected.
41+
- **False "class not found" for global-namespace classes loaded via Composer's `files` autoloading.** Classes like `Mockery` that live in the global namespace and are autoloaded through Composer's `files` directive (rather than PSR-4 or classmap) are now resolved correctly. The class index populated during initialization was never consulted by the class resolver, causing false diagnostics when such classes were imported with `use Mockery;` in namespaced files.
4142
- **Unbound template parameters resolved to their declared bounds.** When a template parameter cannot be inferred from call-site arguments (e.g. `new Collection()` without a generic annotation, `collect()` with no arguments, or a method-level `@template` with no matching parameter), the parameter is now replaced with its declared upper bound (`@template T of Foo` resolves to `Foo`) or `mixed` when no bound exists. The same resolution now applies to interface-level template parameters: when a class implements a generic interface without providing `@implements` generics, the interface's template params are substituted with their bounds instead of leaking as raw names into inherited method signatures. Previously the raw template name leaked through as a type (e.g. `TValue`, `TReduceReturnType`, `TKey`), causing false "expects TValue, got string" diagnostics on every call site that used the unresolved generic.
4243
- **Foreach key variable type from static properties and scalar keys.** Iterating over a static property with a generic array annotation (e.g. `self::$map` typed as `array<string, string>`) now correctly resolves the foreach key variable's type. Static property access (`self::$prop`, `static::$prop`, `ClassName::$prop`) is resolved through the class's property type hints. Scalar key types (`string`, `int`, `array-key`) are now extracted from generic iterables for key variable resolution, where previously only class-typed keys were handled.
4344
- **Foreach loop prescan no longer leaks reassigned types into same-statement RHS.** When a foreach key variable is reassigned inside the loop body (e.g. `$type = DeviationType::from($type)`), the `$type` argument in `from()` now correctly resolves to the foreach key type (`string`) instead of the reassigned type (`DeviationType`). The loop-body prescan results are deferred until after the normal body walk completes.

docs/todo/bugs.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,3 +267,27 @@ function test(): void {
267267
$store->products()->filter()->add($product);
268268
}
269269
```
270+
271+
## B12 — Hover cross-file property docblock cache invalidation fails after edits
272+
273+
**Root cause:** When a class is loaded from a cross-file source
274+
(PSR-4 or classmap) and its docblock is later edited, hover
275+
continues to show the stale docblock content instead of the updated
276+
version. The parsed `ClassInfo` cached in `ast_map` and/or
277+
`fqn_index` is not invalidated when the dependency file changes.
278+
279+
Six integration tests document the failure:
280+
281+
- `hover_cross_file_property_docblock_cache_invalidation_psr4_then_edit`
282+
- `hover_cross_file_property_docblock_cache_invalidation_dependent_class`
283+
- `hover_cross_file_property_docblock_cache_invalidation_via_var_annotation`
284+
- `hover_cross_file_property_docblock_cache_invalidation_dependent_class_with_model`
285+
- `hover_cross_file_property_docblock_cache_invalidation_via_method_chain`
286+
- `hover_cross_file_property_docblock_cache_warm_then_invalidate`
287+
288+
**Where to fix:** The cache layer that stores cross-file
289+
`ClassInfo` results must be invalidated (or re-parsed) when
290+
`didChange` or `didSave` fires for the dependency file. The
291+
`resolved_class_cache` and/or `fqn_index` entries for the changed
292+
URI must be evicted so that the next hover request re-parses the
293+
file and picks up the new docblock content.

src/diagnostics/unknown_classes.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1089,4 +1089,47 @@ mod tests {
10891089
diags.iter().map(|d| &d.message).collect::<Vec<_>>()
10901090
);
10911091
}
1092+
1093+
#[test]
1094+
fn no_diagnostic_for_global_class_via_class_index_lazy_load() {
1095+
// A global-namespace class (like Mockery) that is discovered by
1096+
// `scan_autoload_files` and placed in class_index — but NOT yet
1097+
// parsed into ast_map — should be lazily loaded via Phase 0 of
1098+
// find_or_load_class and suppress the diagnostic.
1099+
let dir = tempfile::tempdir().expect("failed to create temp dir");
1100+
let dep_path = dir.path().join("Mockery.php");
1101+
std::fs::write(&dep_path, "<?php\nclass Mockery {}\n").expect("failed to write temp file");
1102+
let dep_uri = crate::util::path_to_uri(&dep_path);
1103+
1104+
let backend = Backend::new_test();
1105+
1106+
// Only populate class_index (simulating scan_autoload_files).
1107+
// Do NOT call update_ast for the dependency — it must be lazily
1108+
// parsed by find_or_load_class Phase 0.
1109+
{
1110+
let mut idx = backend.class_index.write();
1111+
idx.insert("Mockery".to_string(), dep_uri);
1112+
}
1113+
1114+
let uri = "file:///test.php";
1115+
let content = concat!(
1116+
"<?php\n",
1117+
"namespace Tests\\Feature;\n",
1118+
"\n",
1119+
"use Mockery;\n",
1120+
"\n",
1121+
"class ApiTest {\n",
1122+
" public function test(): void {\n",
1123+
" Mockery::mock();\n",
1124+
" }\n",
1125+
"}\n",
1126+
);
1127+
1128+
let diags = collect(&backend, uri, content);
1129+
assert!(
1130+
!diags.iter().any(|d| d.message.contains("Mockery")),
1131+
"should not flag Mockery resolved via class_index lazy load, got: {:?}",
1132+
diags.iter().map(|d| &d.message).collect::<Vec<_>>()
1133+
);
1134+
}
10921135
}

src/resolution.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ use std::sync::Arc;
3737

3838
use std::path::Path;
3939

40+
use tower_lsp::lsp_types::Url;
41+
4042
use crate::Backend;
4143
use crate::composer;
4244
use crate::php_type::PhpType;
@@ -90,6 +92,24 @@ impl Backend {
9092
return None;
9193
}
9294

95+
// ── Phase 0: Try the class_index (FQN → URI) ──
96+
// The class_index is populated by `scan_autoload_files` (Composer
97+
// `autoload_files.php` entries and their `require_once` chains),
98+
// by `update_ast` for every opened/changed file, and by the
99+
// workspace full-scan for non-Composer projects. It covers
100+
// classes that don't follow PSR-4 conventions and aren't in the
101+
// Composer classmap — e.g. global-namespace classes like `Mockery`
102+
// that are loaded via Composer's `files` autoloading.
103+
if let Some(file_uri) = self.class_index.read().get(class_name).cloned()
104+
&& let Some(file_path) = Url::parse(&file_uri)
105+
.ok()
106+
.and_then(|u| u.to_file_path().ok())
107+
&& let Some(classes) = self.parse_and_cache_file(&file_path)
108+
&& let Some(cls) = classes.iter().find(|c| c.name == last_segment)
109+
{
110+
return Some(Arc::clone(cls));
111+
}
112+
93113
// ── Phase 1: Search all already-parsed files in the ast_map ──
94114
// Checks short name + namespace to avoid false positives (e.g.
95115
// "Demo\\PDO" won't match the global "PDO" stub).

0 commit comments

Comments
 (0)