Skip to content

Commit c749daa

Browse files
committed
Argument-count and argument-type diagnostics no longer mix up calls that
share the same text but resolve differently
1 parent 52f7d45 commit c749daa

9 files changed

Lines changed: 154 additions & 90 deletions

File tree

docs/BUILDING.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,12 @@ php -l examples/laravel/app/Demo.php
6969
phpantom_lsp analyze --project-root examples/laravel --no-colour
7070
```
7171

72-
All eight must pass with zero warnings and zero failures.
72+
All eight must pass with zero warnings and zero failures, except the
73+
final `analyze` run: `app/Demo.php` intentionally calls
74+
`Artisan::call('does:not-exist')` to demonstrate the
75+
`invalid_laravel_command` diagnostic, so it must report exactly
76+
`[ERROR] Found 1 error` on that line. Any other count, or an error on
77+
a different line, is a regression.
7378

7479
### Manual LSP Testing
7580

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
120120
- **Calls to functions declared in another `namespace` block of the same file resolve their return type.** In a file that declares more than one `namespace`, a call to a function from a later block used to leave the returned value untyped unless the function carried an `@return` docblock. The call and its return type now resolve regardless, so completion, hover, and diagnostics see the value's type.
121121
- **`@template` bindings resolve correctly when a call uses named arguments.** A generic function or method called with named arguments (for example `process(class: Product::class, flag: true, function ($p) { ... })`) previously bound its template parameter from the wrong argument whenever the named arguments were out of declaration order, leaving closure parameters untyped. Named arguments now route to the parameter they actually target.
122122
- **Vendor-provided functions and constants no longer rank as project-native in completion.** With the `self`, `full`, and `composer` indexing strategies, functions and constants discovered while scanning vendor packages lost their package origin when merged into the workspace scan, so they sorted ahead of other vendor symbols as if declared in the project itself instead of by their actual dependency tier.
123+
- **Argument-count and argument-type diagnostics no longer mix up calls that share the same text but resolve differently.** A per-file cache reused the first resolved target for every call site with the same expression text, without accounting for the fact that `self::method()`, `static::method()`, and `parent::method()` resolve differently depending on which class they appear in, and `$var->method()` resolves differently depending on what type `$var` holds at that call site. Two classes each declaring their own `self::make()` with a different required argument count, or two methods each assigning a different type to a same-named variable before calling the same method name on it, could silently report the wrong argument-count or argument-type diagnostic (or miss one) on every call after the first. These forms are now always resolved fresh per call site.
123124

124125
## [0.9.0] - 2026-07-20
125126

docs/CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ php -l examples/laravel/app/Demo.php
2222
phpantom_lsp analyze --project-root examples/laravel --no-colour
2323
```
2424

25-
Note that clippy runs twice, once for library code and once including test code. The `php -l` check ensures `examples/demo.php` remains valid PHP. The `php -d zend.assertions=1` run executes `runDemoAssertions()` to verify that scaffolding stubs actually return what their docblocks claim. The final `php -l` and `phpantom_lsp analyze` runs check `examples/laravel/` for syntax errors and diagnostic regressions. The analyze run must report `[OK] No errors`.
25+
Note that clippy runs twice, once for library code and once including test code. The `php -l` check ensures `examples/demo.php` remains valid PHP. The `php -d zend.assertions=1` run executes `runDemoAssertions()` to verify that scaffolding stubs actually return what their docblocks claim. The final `php -l` and `phpantom_lsp analyze` runs check `examples/laravel/` for syntax errors and diagnostic regressions. `app/Demo.php` intentionally calls `Artisan::call('does:not-exist')` to demonstrate the `invalid_laravel_command` diagnostic, so the analyze run must report exactly `[ERROR] Found 1 error` on that line, not `[OK] No errors`; any other count, or an error on a different line, is a regression.
2626

2727
## Code Style
2828

docs/todo.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,7 @@ unlikely to move the needle for most users.
107107
| D3 | [Deprecated rendering — chain subject resolution](todo/diagnostics.md#d3-deprecated-rendering-chain-subject-resolution) | Low-Medium | Medium |
108108
| D5 | [External tool diagnostic suppression actions](todo/diagnostics.md#d5-external-tool-diagnostic-suppression-actions) | Low | Low |
109109
| D15 | [Unused parameter diagnostic](todo/diagnostics.md#d15-unused-parameter-diagnostic) | Low | Low |
110-
| | **[Bug Fixes](todo/bugs.md)** | | |
111-
| B117 | [Per-file callable-target caches are keyed by call-expression text alone](todo/bugs.md#b117-per-file-callable-target-caches-are-keyed-by-call-expression-text-alone) | Medium | Low-Medium |
112-
| B118 | [`analyze` on `examples/laravel` reports one error, but CI requires none](todo/bugs.md#b118-phpantom_lsp-analyze-on-exampleslaravel-reports-one-error-but-ci-requires-none) | Low | Low |
110+
| | **[Bug Fixes](todo/bugs.md)** — no outstanding items | | |
113111
| | **[Code Actions](todo/actions.md)** | | |
114112
| A40 | [Generate method from call](todo/actions.md#a40-generate-method-from-call) | Medium-High | Medium |
115113
| A41 | [Create class from non-existing name](todo/actions.md#a41-create-class-from-non-existing-name) | Medium | Medium |

docs/todo/bugs.md

Lines changed: 1 addition & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -7,58 +7,5 @@ pipeline so it produces correct data. Downstream consumers
77
(diagnostics, hover, completion, definition) should never need
88
to second-guess upstream output.
99

10-
## B117. Per-file callable-target caches are keyed by call-expression text alone
10+
No outstanding items.
1111

12-
**Impact: Medium · Effort: Low-Medium**
13-
14-
Both argument diagnostic passes memoize `resolve_callable_target`
15-
in a per-file `HashMap<String, Option<ResolvedCallableTarget>>`
16-
keyed only on the call expression, then resolve the entry using
17-
whichever call site happened to be reached first. Callable-target
18-
resolution is position-dependent (it picks the enclosing class via
19-
`find_class_at_offset` and resolves receiver variables against the
20-
scope at the cursor), so any expression whose meaning varies within
21-
the file gets the wrong target at every site after the first:
22-
23-
- `argument_count.rs` caches *every* expression, including
24-
variable-based calls. Two methods that both call
25-
`$parser->parse(…)` on different types share one cached
26-
signature, so the argument count is checked against the wrong
27-
method.
28-
- `type_errors/mod.rs` deliberately skips variable-based calls
29-
(`expr.starts_with('$')`), but `self::`, `static::`, and
30-
`parent::` do not start with `$` and are equally
31-
position-dependent. In a file declaring two classes that each
32-
have a zero-argument `self::make()`, the second class resolves
33-
to the first class's method.
34-
35-
Both are latent today because the wrong target usually has a
36-
compatible signature, and a wrong resolution can produce either a
37-
false positive or a silently missed diagnostic.
38-
39-
The cache key must cover what resolution actually depends on.
40-
Restricting the cache to position-independent expressions (plain
41-
function calls and `Fqn::method` where the class part is not
42-
`self`/`static`/`parent`) is the smallest sound fix; keying on
43-
`(expr, enclosing_class_start_offset)` keeps more hits for the
44-
keyword forms. Note that per-site resolution is no longer
45-
expensive now that the offset round trip is gone, so dropping the
46-
unsound entries costs little.
47-
48-
---
49-
50-
## B118. `phpantom_lsp analyze` on `examples/laravel` reports one error, but CI requires none
51-
52-
**Impact: Low · Effort: Low**
53-
54-
`docs/CONTRIBUTING.md` lists
55-
`phpantom_lsp analyze --project-root examples/laravel --no-colour`
56-
as a required check and states it "must report `[OK] No errors`",
57-
but `examples/laravel/app/Demo.php` intentionally contains
58-
`Artisan::call('does:not-exist')` to demonstrate the
59-
`invalid_laravel_command` diagnostic, so the command reports
60-
`Found 1 error`. Either the demo needs a form that shows the
61-
diagnostic without failing the check (the way `examples/demo.php`
62-
keeps its intentional diagnostics out of the analyze gate), or the
63-
documented expectation needs to name the diagnostics the Laravel
64-
example is expected to produce.

src/diagnostics/argument_count.rs

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ use crate::Backend;
3030
use crate::parser::with_parse_cache;
3131
use crate::types::ResolvedCallableTarget;
3232

33-
use super::helpers::make_diagnostic;
33+
use super::helpers::{is_position_independent_call_expression, make_diagnostic};
3434

3535
/// Diagnostic code used for argument-count diagnostics.
3636
pub(crate) const ARGUMENT_COUNT_MISMATCH_CODE: &str = "argument_count_mismatch";
@@ -246,8 +246,15 @@ impl Backend {
246246
let _parse_guard = with_parse_cache(content);
247247

248248
// Call-expression resolution cache: avoids re-resolving the
249-
// same call expression (e.g. `$purchaseFile->save`) at every
250-
// call site that uses it.
249+
// same call expression (e.g. `Fqn::method`) at every call site
250+
// that uses it.
251+
//
252+
// Only expressions that are guaranteed to resolve to the same
253+
// target everywhere in the file are cached (see
254+
// `is_position_independent_call_expression`). Variable-based
255+
// calls (`$purchaseFile->save`) and `self::`/`static::`/
256+
// `parent::` calls are resolved fresh every time because their
257+
// target depends on the call site's position.
251258
let mut call_cache: HashMap<String, Option<ResolvedCallableTarget>> = HashMap::new();
252259

253260
// ── Walk every call site ────────────────────────────────────
@@ -261,17 +268,26 @@ impl Backend {
261268
let expr = &call_site.call_expression;
262269

263270
// Look up or populate the call expression cache.
264-
let resolved = call_cache
265-
.entry(expr.clone())
266-
.or_insert_with(|| {
267-
self.resolve_callable_target_at_offset(
268-
expr,
269-
content,
270-
call_site.args_start,
271-
&file_ctx,
272-
)
273-
})
274-
.clone();
271+
let resolved = if is_position_independent_call_expression(expr) {
272+
call_cache
273+
.entry(expr.clone())
274+
.or_insert_with(|| {
275+
self.resolve_callable_target_at_offset(
276+
expr,
277+
content,
278+
call_site.args_start,
279+
&file_ctx,
280+
)
281+
})
282+
.clone()
283+
} else {
284+
self.resolve_callable_target_at_offset(
285+
expr,
286+
content,
287+
call_site.args_start,
288+
&file_ctx,
289+
)
290+
};
275291

276292
// Resolve the call expression to a callable target.
277293
let resolved = match resolved {

src/diagnostics/helpers.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -774,6 +774,33 @@ pub(crate) fn find_innermost_enclosing_class(
774774
.map(|(c, _)| c.as_ref())
775775
}
776776

777+
/// Returns `true` when a call expression's `resolve_callable_target*`
778+
/// result is guaranteed to be the same at every call site in a file, so
779+
/// it is safe to memoize by expression text alone in a per-file cache.
780+
///
781+
/// Excludes:
782+
/// - Variable-based calls (`$subject->method`) — the receiver
783+
/// variable's type comes from the assignments visible at the cursor
784+
/// and can differ between call sites that share the same text (e.g.
785+
/// two methods that each assign a different type to `$parser`).
786+
/// - `self::`, `static::`, `parent::`, `new self`, `new static`, and
787+
/// `new parent` — resolved via the enclosing class at the cursor
788+
/// offset (`find_class_at_offset`), which differs between call sites
789+
/// in different classes within the same file.
790+
///
791+
/// Plain function calls and calls through a literal class name
792+
/// (`Fqn::method`, `new Fqn`) resolve identically regardless of where
793+
/// in the file they appear, so those remain safe to cache by text.
794+
pub(crate) fn is_position_independent_call_expression(expr: &str) -> bool {
795+
if expr.starts_with('$') {
796+
return false;
797+
}
798+
if expr.starts_with("self::") || expr.starts_with("static::") || expr.starts_with("parent::") {
799+
return false;
800+
}
801+
!matches!(expr, "new self" | "new static" | "new parent")
802+
}
803+
777804
/// Build a standard diagnostic with the common fields pre-filled.
778805
///
779806
/// Most diagnostic collectors build `Diagnostic` values with `source`

src/diagnostics/type_errors/mod.rs

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ use crate::type_engine::resolver::{Loaders, VarResolutionCtx};
3333
use crate::type_engine::variable::foreach_resolution::resolve_expression_type;
3434
use crate::types::{ClassInfo, ResolvedCallableTarget};
3535

36-
use super::helpers::{find_innermost_enclosing_class, make_diagnostic};
36+
use super::helpers::{
37+
find_innermost_enclosing_class, is_position_independent_call_expression, make_diagnostic,
38+
};
3739

3840
/// Diagnostic code used for argument type mismatch diagnostics.
3941
pub(crate) const TYPE_MISMATCH_ARGUMENT_CODE: &str = "type_mismatch_argument";
@@ -487,12 +489,15 @@ impl Backend {
487489
// call site that uses it.
488490
//
489491
// Only expressions that are guaranteed to resolve to the same
490-
// target everywhere in the file are cached. Variable-based
492+
// target everywhere in the file are cached (see
493+
// `is_position_independent_call_expression`). Variable-based
491494
// calls (`$listener->handle`, `$repo->save`) are NOT cached
492495
// because the same variable name can hold different types in
493-
// different methods or after reassignment. Static calls
494-
// (`Foo::bar`) and plain function calls (`array_map`) are
495-
// safe to cache.
496+
// different methods or after reassignment, and neither are
497+
// `self::`/`static::`/`parent::` calls, whose target depends on
498+
// the enclosing class at the call site. Calls through a
499+
// literal class name (`Foo::bar`) and plain function calls
500+
// (`array_map`) are safe to cache.
496501
let mut call_cache: HashMap<String, Option<ResolvedCallableTarget>> = HashMap::new();
497502

498503
// ── Walk every call site ────────────────────────────────────
@@ -506,11 +511,11 @@ impl Backend {
506511
let expr = &call_site.call_expression;
507512

508513
// Look up or populate the call expression cache.
509-
// Variable-based calls are resolved fresh every time
510-
// because the receiver variable may hold different types
511-
// at different call sites (e.g. `$listener->handle` in
512-
// two different test methods with different assignments).
513-
let is_variable_call = expr.starts_with('$');
514+
// Variable-based and `self::`/`static::`/`parent::` calls
515+
// are resolved fresh every time because their target
516+
// depends on the call site's position (the receiver
517+
// variable's assigned type, or the enclosing class).
518+
let is_position_dependent_call = !is_position_independent_call_expression(expr);
514519

515520
// Extract the raw argument text from the source so that
516521
// method-level @template parameters can be resolved from
@@ -536,13 +541,13 @@ impl Backend {
536541
// Method-level template substitution uses the per-site
537542
// argument text extracted above.
538543
//
539-
// Variable-based calls are always resolved per-site
540-
// because the receiver variable may hold different types
541-
// at different call sites. Static/function calls are
542-
// also resolved per-site when argument text is available
543-
// (method-level template subs depend on per-site args);
544-
// only zero-arg calls can be cached.
545-
let resolved = if is_variable_call || call_args_text.is_some() {
544+
// Position-dependent calls are always resolved per-site
545+
// because their target may differ at different call sites.
546+
// Position-independent calls are also resolved per-site
547+
// when argument text is available (method-level template
548+
// subs depend on per-site args); only zero-arg calls can
549+
// be cached.
550+
let resolved = if is_position_dependent_call || call_args_text.is_some() {
546551
self.resolve_callable_target_with_args_at_offset(
547552
expr,
548553
content,

tests/integration/diagnostics_argument_count.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,4 +962,69 @@ function test(): void {
962962
"Expected both $a and $b reported missing, got: {diags:?}",
963963
);
964964
}
965+
966+
#[test]
967+
fn variable_call_target_not_shared_across_different_receiver_types() {
968+
// Two unrelated classes each expose a `parse` method with a
969+
// different required arity, and two functions each assign a
970+
// same-named local variable a different one of those classes.
971+
// Caching the resolved target by the expression text
972+
// `$parser->parse` alone (ignoring what type `$parser` holds at
973+
// each call site) would make the second call reuse the first's
974+
// resolved target and miss its own too-few-args error.
975+
let php = r#"<?php
976+
class ParserA {
977+
public function parse(int $a): void {}
978+
}
979+
class ParserB {
980+
public function parse(int $a, int $b): void {}
981+
}
982+
function testA(): void {
983+
$parser = new ParserA();
984+
$parser->parse(1);
985+
}
986+
function testB(): void {
987+
$parser = new ParserB();
988+
$parser->parse(1);
989+
}
990+
"#;
991+
let diags = collect(php);
992+
assert!(
993+
diags
994+
.iter()
995+
.any(|d| d.message.contains("Expected 2 arguments") && d.message.contains("got 1")),
996+
"Expected too-few-args diagnostic for ParserB::parse via $parser, got: {diags:?}",
997+
);
998+
}
999+
1000+
#[test]
1001+
fn self_call_target_not_shared_across_classes() {
1002+
// Two classes each declare a zero-arg-looking `self::make` call
1003+
// with a different required arity. `self::make` resolution
1004+
// depends on the enclosing class at the call site, so caching
1005+
// the target by expression text alone (ignoring which class the
1006+
// call appears in) would make the second class reuse the
1007+
// first's resolved target and miss its own too-few-args error.
1008+
let php = r#"<?php
1009+
class First {
1010+
public static function make(int $a): void {}
1011+
public function run(): void {
1012+
self::make(1);
1013+
}
1014+
}
1015+
class Second {
1016+
public static function make(int $a, int $b): void {}
1017+
public function run(): void {
1018+
self::make(1);
1019+
}
1020+
}
1021+
"#;
1022+
let diags = collect(php);
1023+
assert!(
1024+
diags
1025+
.iter()
1026+
.any(|d| d.message.contains("Expected 2 arguments") && d.message.contains("got 1")),
1027+
"Expected too-few-args diagnostic for Second::make via self::, got: {diags:?}",
1028+
);
1029+
}
9651030
}

0 commit comments

Comments
 (0)