Skip to content

Commit 3804765

Browse files
committed
Faster diagnostics on method/function calls that resolve to no concrete
class
1 parent ae8c211 commit 3804765

5 files changed

Lines changed: 31 additions & 51 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5656
- **Faster project startup.** The parts of startup that still ran on a single core now use all of them. Every autoload directory in the project, your own and each vendor package's, is walked in one pass that shares work between cores at the directory level, so a single very large dependency no longer holds up the rest of the scan, and the ignore rules above those directories are compiled once for the whole project instead of once per package. A bundled tool archive such as PHPStan's `.phar` is now read through a memory map with only its file index retained, rather than copied into memory whole. On a large Laravel project this cuts the indexing phase by roughly a quarter and lowers peak memory by around 25 MB. Discovered files are now sorted, so when two files declare the same class name the one that wins is the same on every run instead of depending on the order the filesystem happened to return.
5757
- **Class origin classification no longer re-scans the whole classmap after the fact.** Startup used to look up each discovered class's completion-ranking origin (project, explicit dependency, transitive dependency, core stub) by re-reading and re-parsing `installed.json` a second time and prefix-matching every class's file path against the package list on a single thread. The origin is now attached to a class the moment it is discovered during the already-parallel vendor scan, the same way it already worked for functions and constants, removing both the duplicate parse and the serial pass.
5858
- **Faster `assert()`/type-guard narrowing during the forward walk.** Every statement used to build a fresh resolution context (including a scope clone) for each in-scope variable to check whether it was an `assert()` or `@phpstan-assert`/`@psalm-assert` call, even for statements that could never be one. Non-call statements now skip that work entirely, cutting a measurable share of the walk on methods with many locals and many statements.
59+
- **Faster diagnostics on method/function calls that resolve to no concrete class.** Checking whether such a call's result was actually a bare `object`/`?object` (still valid for member access) used to re-resolve the callee's whole receiver chain and method signature a second time from scratch. That check now reuses the resolution already performed, roughly halving diagnostics time on files with many unresolved or missing-method call chains.
5960

6061
### Removed
6162

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,6 @@ unlikely to move the needle for most users.
201201
| P16 | [Pre-parsed stub format (eliminate raw PHP embedding)](todo/performance.md#p16-pre-parsed-stub-format-eliminate-raw-php-embedding) | High | Medium-High |
202202
| P25 | [`type_mismatch_argument` / `argument_count_mismatch` slow on large single files](todo/performance.md#p25-type_mismatch_argument-argument_count_mismatch-slow-on-large-single-files) | Medium | Medium |
203203
| P22 | [Signature change re-queues slow diagnostics for every open file](todo/performance.md#p22-signature-change-re-queues-slow-diagnostics-for-every-open-file) | Medium-High | Medium |
204-
| P27 | [`object`/`?object` call-return check re-resolves the subject a second time](todo/performance.md#p27-objectobject-call-return-check-re-resolves-the-subject-a-second-time) | Medium | Low |
205204
| P11 | [Uncached base-resolution in `build_scope_methods_for_builder`](todo/performance.md#p11-uncached-base-resolution-in-build_scope_methods_for_builder) | Low-Medium | Low |
206205
| P3 | Parallel pre-filter in `find_implementors` | Low-Medium | Medium |
207206
| P6 | O(n²) transitive eviction in `evict_fqn` | Low | Low |

docs/todo/performance.md

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -628,36 +628,6 @@ this item describes is still unaddressed.
628628

629629
---
630630

631-
## P27. `object`/`?object` call-return check re-resolves the subject a second time
632-
633-
**Impact: Medium · Effort: Low**
634-
635-
`resolve_subject_outcome` resolves the whole subject up front (the
636-
`resolve_target_classes(subject, …)` call at the top). When that
637-
yields no concrete class, the `object`/`?object` escape-hatch branch
638-
then calls `resolve_call_raw_return_type(callee, "", ctx)` purely to
639-
peek at `is_object()`, and that helper re-resolves the callee's base
640-
chain from scratch. So every method/function call whose result has
641-
no concrete class pays a second full return-type resolution.
642-
643-
This is exactly the shape of the `diagnostics/fixture/lots_of_missing_methods`
644-
benchmark (many calls whose results have missing members), and it
645-
regressed that fixture by ~29% (54ms → 70ms) when the branch was
646-
added, with a matching drift on hover and go-to-definition, which
647-
share this path.
648-
649-
### Fix
650-
651-
Fold the `object` detection into the primary resolution pass instead
652-
of running a separate re-resolution: when `resolve_target_classes`
653-
is about to return empty for a call subject, check whether the raw
654-
return type was `object`/`?object` at that point (the base chain is
655-
already resolved there) and emit the synthetic `stdClass` from the
656-
same pass. Do not pass `""` for the argument text on a second call;
657-
reuse the resolution already performed.
658-
659-
---
660-
661631
## P46. `mago-phpdoc-syntax` cannot parse `@method static (…) name()`
662632

663633
**Impact: Low · Effort: Low (upstream)**

src/type_engine/resolver/mod.rs

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -376,10 +376,20 @@ fn resolve_target_classes_expr_inner(
376376
// Use the raw return type hint only when at least one
377377
// resolved class has template parameters — non-generic
378378
// classes don't benefit from it.
379-
if let Some(h) = hint
380-
&& classes.iter().any(|c| !c.template_params.is_empty())
381-
{
382-
return ResolvedType::from_classes_with_hint(classes, h);
379+
if let Some(h) = hint {
380+
if classes.iter().any(|c| !c.template_params.is_empty()) {
381+
return ResolvedType::from_classes_with_hint(classes, h);
382+
}
383+
// `object`/`?object` is the "any object" escape hatch: no
384+
// concrete class matches it, but the base chain and method
385+
// lookup above have already determined that. Surface it as
386+
// a type-string-only candidate instead of dropping it, so
387+
// callers that need to detect a bare `object` return (e.g.
388+
// `resolve_subject_outcome`'s stdClass synthesis) don't have
389+
// to re-resolve the callee's base chain a second time.
390+
if classes.is_empty() && h.is_object() {
391+
return vec![ResolvedType::from_type_string(h)];
392+
}
383393
}
384394

385395
classes.into_iter().map(ResolvedType::from_arc).collect()
@@ -861,22 +871,10 @@ pub(crate) fn resolve_subject_outcome(
861871
if let Some(scalar) = resolve_call_scalar_return(callee, access_kind, ctx) {
862872
return SubjectOutcome::Scalar(scalar);
863873
}
864-
// A call returning `object` (or `?object`) yields no concrete
865-
// class, but `object` is the "any object" escape hatch: member
866-
// access is always valid at runtime. Resolve it to a synthetic
867-
// `stdClass` so downstream verification treats it like the plain
868-
// `object` property/parameter case, instead of reporting the
869-
// subject type as unresolved. `is_object()` unwraps nullability,
870-
// so `?object` is handled here too.
871-
if let Some(raw_type) = resolve_call_raw_return_type(callee, "", ctx)
872-
&& raw_type.is_object()
873-
{
874-
let synthetic = Arc::new(ClassInfo {
875-
name: crate::atom::atom("stdClass"),
876-
..ClassInfo::default()
877-
});
878-
return SubjectOutcome::Resolved(vec![synthetic]);
879-
}
874+
// Note: a call returning `object`/`?object` is already caught by
875+
// the "stdClass / object" check above — `resolve_target_classes`
876+
// surfaces it as a type-string-only candidate instead of an empty
877+
// result, so `resolved` would not have been empty in that case.
880878
// Try unresolvable class detection for function calls.
881879
if let SubjectExpr::FunctionCall(fn_name) = callee.as_ref()
882880
&& let Some(fl) = ctx.function_loader

src/type_engine/variable/forward_walk/assignment.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2478,6 +2478,18 @@ pub(crate) fn process_assert_narrowing<'b>(
24782478
scope: &mut ScopeState,
24792479
ctx: &ForwardWalkCtx<'_>,
24802480
) {
2481+
// Every narrowing path below only fires for a (possibly parenthesized)
2482+
// call expression, so a non-call statement can never be an assert() /
2483+
// custom type-guard call. Bail out before the scope clone below, which
2484+
// otherwise runs once per in-scope variable for every statement.
2485+
let unwrapped = match expr {
2486+
Expression::Parenthesized(inner) => inner.expression,
2487+
other => other,
2488+
};
2489+
if !matches!(unwrapped, Expression::Call(_)) {
2490+
return;
2491+
}
2492+
24812493
// ── Handle assert($x instanceof Foo) for variables NOT yet in scope ──
24822494
// When a foreach binds a variable but the iterable element type is
24832495
// unknown, the variable won't be in the scope map. A subsequent

0 commit comments

Comments
 (0)