Skip to content

Commit c1a74ff

Browse files
committed
Even more refactoring
1 parent e7cbe8c commit c1a74ff

23 files changed

Lines changed: 3500 additions & 3234 deletions

docs/todo.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ unlikely to move the needle for most users.
8686
| T29 | [Definite vs possible variable existence tracking](todo/type-inference.md#t29-definite-vs-possible-variable-existence-tracking) | Medium | Medium |
8787
| T3 | [Property hooks (PHP 8.4)](todo/type-inference.md#t3-property-hooks-php-84) | Medium | Medium |
8888
| T25 | [Call-site template argument inference for callable parameters](todo/type-inference.md#t25-call-site-template-argument-inference-for-callable-parameters) (partially done) | Medium | Medium |
89+
| T32 | [Audit `is_type_compatible`'s MAYBE escape hatches for core-engine gaps](todo/type-inference.md#t32-audit-is_type_compatibles-maybe-escape-hatches-for-core-engine-gaps) | Medium | Medium |
8990
| T30 | [Literal type collapse limit](todo/type-inference.md#t30-literal-type-collapse-limit) | Low-Medium | Low |
9091
| T24 | [`stdClass` dynamic property access](todo/type-inference.md#t24-stdclass-dynamic-property-access) | Low-Medium | Low |
9192
| T6 | `Closure::bind()` / `Closure::fromCallable()` return type preservation | Low-Medium | Low-Medium |

docs/todo/refactor.md

Lines changed: 64 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -214,100 +214,17 @@ Each item must include:
214214

215215
# Outstanding items
216216

217-
Recommended order: do tasks 2 → 3 → 4 in that order (4 renames fields
218-
across the whole crate, so it goes last to avoid churning code that 2
219-
and 3 are about to move).
217+
Recommended order: 3 → 4 → 5. Tasks 4 and 5 both churn the whole crate
218+
(4 renames `Backend` fields, 5 moves the type-engine modules), so they
219+
go last to avoid re-touching code that 3 moves; do 4 before 5 so 5's
220+
module moves don't collide with 4's field renames.
220221

221-
All three tasks are pure internal refactors. None of them gets a
222+
All three remaining tasks are pure internal refactors. None gets a
222223
changelog entry unless the work uncovers and fixes a concrete
223224
user-visible bug. Run the Rust CI checks (`cargo nextest run`, `cargo
224225
clippy -- -D warnings`, `cargo clippy --tests -- -D warnings`, `cargo
225226
fmt`) after each task.
226227

227-
## 2. Split `diagnostics/mod.rs` around its thin orchestrator
228-
229-
**What to do.** The actual orchestrator (`collect_slow_diagnostics`,
230-
at ~line 240, ~85 lines) is fine, but it is buried in a 3,391-line
231-
`src/diagnostics/mod.rs`. This is pure code motion plus one shared
232-
context struct — no behavior changes. Rust allows `impl Backend`
233-
blocks in any file of the crate, so moving a method means moving it
234-
into a new `impl Backend` block in the new file; nothing needs to
235-
become free functions.
236-
237-
**Step 1 — external-tool pipelines → `diagnostics/external/`.**
238-
Create `src/diagnostics/external/{mod,phpstan,phpcs,mago}.rs` and move
239-
the four subprocess spawn/debounce/parse pipelines out of `mod.rs`:
240-
`schedule_phpstan` (~line 1299), `schedule_phpcs` (~1474),
241-
`schedule_mago_lint` (~1632), and `schedule_mago_analyze` (~1764,
242-
both mago pipelines go in `mago.rs`) — roughly 730 lines — each with
243-
the private helpers only it uses. Helpers shared by two or more
244-
pipelines go in `external/mod.rs`. Do **not** move
245-
`schedule_diagnostics`, `schedule_external_diagnostics`, or
246-
`schedule_diagnostics_for_open_files` (~1077–1298): those are the
247-
native debounce orchestration and stay in `mod.rs`.
248-
249-
**Step 2 — staleness reconciliation → `diagnostics/stale.rs`.**
250-
Move `is_stale_phpstan_diagnostic` (~line 467) plus every helper only
251-
it uses (~335 lines reconciling cached external diagnostics with
252-
edits).
253-
254-
**Step 3 — post-processing policy → `diagnostics/suppression.rs`.**
255-
Move `filter_suppressed` (~line 2034), `suppress_imprecise_overlaps`
256-
(~2071), and `is_full_line_range` (~2159) — ~230 lines.
257-
258-
**Step 4 — shared `FileDiagnosticContext`.** Seven symbol-span
259-
collectors (`unknown_classes.rs`, `unknown_functions.rs`,
260-
`deprecated.rs`, `implementation_errors.rs`, `invalid_class_kind.rs`,
261-
`unknown_members/mod.rs`, `unused_imports.rs`) open with variations of
262-
the same lock-gathering preamble. Define, next to the existing
263-
`diagnostics/helpers.rs` utilities, a struct holding the per-file
264-
snapshot they gather: `symbol_map: Arc<SymbolMap>`,
265-
`resolved_names: Option<Arc<OwnedResolvedNames>>`, the file's use map
266-
(from `file_imports`), its `Vec<NamespaceSpan>` (from
267-
`file_namespaces`), and its local classes (from `uri_classes_index`).
268-
Give it a constructor like `FileDiagnosticContext::gather(&Backend,
269-
uri) -> Option<Self>` (returning `None` when no symbol map exists,
270-
which is every collector's current early-out). Build it **once** in
271-
`collect_slow_diagnostics` and pass `&FileDiagnosticContext` to the
272-
seven collectors; collectors that need only some fields just ignore
273-
the rest. Before changing collector signatures, grep for callers
274-
outside `collect_slow_diagnostics` (the `analyse/` CLI path, code
275-
actions, and tests call some collectors directly); any such caller
276-
builds its own context via `gather`. Besides deduplication, the shared
277-
snapshot guarantees all collectors observe consistent state instead of
278-
re-reading locks that a concurrent parse may update between
279-
collectors.
280-
281-
**Step 5 — split `type_errors.rs`.** Convert
282-
`src/diagnostics/type_errors.rs` (1,538 lines) into a directory and
283-
move `is_type_compatible` (starts at ~line 76, ~730 lines) plus the
284-
predicates only it uses (`is_bare_array`, `is_refined_scalar_pair`,
285-
etc.) to `type_errors/compatibility.rs`. It is the diagnostic-policy
286-
layer over `crate::class_lookup::is_subtype_of_typed`.
287-
288-
**Step 6 — file the MAYBE audit, do not act on it.** While moving
289-
`is_type_compatible`, list its "MAYBE escape hatches" (IntRange,
290-
union handling, iterable/Traversable, bare Closure/callable, bare
291-
array, nullable → non-nullable) and assess which ones describe real
292-
subtype relationships that belong in
293-
`class_lookup::is_subtype_of_typed` where all callers would benefit,
294-
versus diagnostic-only leniency that must stay in the policy layer.
295-
Do **not** move any semantics in this task — file the candidates with
296-
reasoning in `docs/todo/type-inference.md`.
297-
298-
**Acceptance.** `cargo nextest run` passes with no test edits (except
299-
mechanical import-path updates); `mod.rs` shrinks to the orchestrator,
300-
scheduling, and publishing logic; behavior is unchanged.
301-
302-
**Why it matters.** Diagnostics is the most actively developed area;
303-
new collectors keep landing in a module whose entry file is 75%
304-
unrelated scheduling and filtering code. The `external/` split also
305-
directly de-risks the scheduled D10 task (PHPMD proxy — a fifth
306-
external-tool pipeline that gets a formulaic home instead of growing
307-
`mod.rs` further).
308-
309-
---
310-
311228
## 3. Move workspace init/indexing out of `server.rs` and `references/mod.rs`
312229

313230
**What to do.** `src/server.rs` (4,007 lines) ends with an
@@ -413,3 +330,62 @@ the rest makes the Backend's state graph legible and shrinks the
413330
constructors.
414331

415332
---
333+
334+
## 5. Extract the shared type engine out of `completion/`
335+
336+
**What to do.** Despite the name, `src/completion/` houses the
337+
project's single type-resolution engine — the code that answers "what
338+
is the type of this expression here?" — consumed by diagnostics, hover,
339+
go-to-definition, and signature help, not just completion. The name
340+
misleads every new contributor and buries the most load-bearing
341+
subsystem inside a feature module. Move the engine into a top-level
342+
module (proposed `src/type_engine/`; name negotiable but **not**
343+
`resolve`/`resolution`, which collide with the existing `resolution.rs`
344+
class/function *name* lookup), leaving `completion/` with only
345+
completion-specific code. This is pure code motion — `impl Backend`
346+
blocks can live in any file of the crate, so methods move without
347+
becoming free functions.
348+
349+
**Engine subtrees to move** (whole directories): `completion/resolver/`,
350+
`completion/call_resolution/`, `completion/types/`, and the
351+
type-resolution half of `completion/variable/``resolution.rs`,
352+
`forward_walk/`, `rhs_resolution/`, `foreach_resolution.rs`,
353+
`closure_resolution.rs`, `class_string_resolution.rs`,
354+
`raw_type_inference.rs`. The root-level `subject_expr.rs`,
355+
`subject_extraction.rs`, and `subject_resolution.rs` are part of the
356+
engine too — fold them in.
357+
358+
**Stays in `completion/`:** `handler/`, `context/`, `phpdoc/`,
359+
`builder.rs`, `target.rs`, `array_shape.rs`, `array_callable.rs`,
360+
`named_args.rs`, `use_edit.rs`, `eloquent_string.rs`,
361+
`laravel_route_controller.rs`, `laravel_string_keys.rs`, and the one
362+
non-clean split: `completion/variable/completion.rs` is variable *name*
363+
completion (scope collection for the completion list), not type
364+
resolution, so it stays while its sibling type-resolution files move.
365+
366+
**Steps.**
367+
1. Create the new module and move the engine subtrees into it, updating
368+
`mod`/`use` declarations.
369+
2. Split `completion/variable/`: keep `completion.rs` (and any
370+
scope-collection helpers only it uses) under `completion/`; move the
371+
rest under `<engine>/variable/`.
372+
3. Update imports crate-wide (`crate::completion::resolver::…`
373+
`crate::type_engine::resolver::…`, etc.). Add thin `pub use`
374+
re-exports in `completion/mod.rs` only if they meaningfully shrink
375+
the diff; otherwise fix call sites directly.
376+
4. Update the module map and the "shared type engine lives under
377+
`completion/`" note in `docs/ARCHITECTURE.md`, and the Project
378+
Structure landmark in `AGENTS.md`, to point at the new module.
379+
380+
**Acceptance.** `cargo nextest run` passes with only mechanical
381+
import-path updates; behavior is unchanged; `completion/` contains only
382+
completion features; the type engine has one obvious top-level home.
383+
384+
**Why it matters.** Anti-pattern #6 in `AGENTS.md` ("do not build a
385+
second type-resolution path") depends on the engine being
386+
discoverable. While it hides under `completion/`, contributors reach
387+
for a "simpler" local resolver instead of finding the shared one. This
388+
is the largest churn of the set (crate-wide import moves), so it goes
389+
last.
390+
391+
---

docs/todo/type-inference.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,3 +716,109 @@ call-site inference work in T25.
716716
- PHPStan: closure return type inference in
717717
`ClosureReturnStatementsNode` / `TypeSpecifyingExtension` flow.
718718
- Psalm: `ClosureAnalyzer` return-type widening.
719+
720+
---
721+
722+
## T32. Audit `is_type_compatible`'s MAYBE escape hatches for core-engine gaps
723+
724+
**Impact: Medium · Effort: Medium**
725+
726+
`is_type_compatible` (`src/diagnostics/type_errors/compatibility.rs`,
727+
moved there from `type_errors.rs` as part of a refactor split) is the
728+
diagnostic-policy layer over `class_lookup::is_subtype_of_typed`. It
729+
mixes two kinds of checks: sound subtype facts that happen to be
730+
implemented here instead of in the core engine, and genuine "MAYBE"
731+
leniency — cases where the types *might* be compatible at runtime but
732+
cannot be proven so statically, so the diagnostic stays silent to
733+
avoid false positives.
734+
735+
The sound facts are candidates to move into
736+
`class_lookup::is_subtype_of_typed`, where every caller benefits
737+
(completion filtering, hover, other diagnostics), not just argument
738+
type mismatch. The MAYBE leniency must stay here — moving it into the
739+
core engine would make `is_subtype_of_typed` unsound for every other
740+
consumer. This item is the audit only; no semantics changed while
741+
filing it. Verify each candidate against `is_subtype_of_typed`'s
742+
current implementation before moving anything (it may already cover
743+
some of these).
744+
745+
### Candidates to move (sound, currently diagnostic-only)
746+
747+
- **`param.is_object() && arg.base_name().is_some()` → YES.** Every
748+
class/interface/enum instance is an `object`. Universally true, not
749+
diagnostic-specific.
750+
- **`iterable`/`Traversable` generic params accept array-like or
751+
`Traversable`-subtype args.** `iterable` is `array|Traversable` in
752+
PHP's type system; an array-like value or a `Traversable` subclass
753+
always satisfies it. Not tagged MAYBE in the source — the code
754+
already treats it as sound, just not in the core engine.
755+
- **Closure/callable-string args → `callable` param.** The
756+
`arg_type.is_closure() || arg_type.is_callable()` half of the
757+
"bare Closure/callable → callable" check is a direct fact. (The
758+
"object-like might implement `__invoke`" half of the same check is
759+
genuine MAYBE leniency — see below — so this rule needs to be split
760+
before moving.)
761+
- **Stringable objects → `string` param.** When the class provably
762+
implements `\Stringable` or declares `__toString()`, PHP performs
763+
the conversion; this is a real, checkable fact via
764+
`class_lookup::is_subtype_of(&cls, "Stringable", …)` plus a
765+
`__toString()` method-existence check, not a guess.
766+
- **Non-nullable arg → nullable param: `X` satisfies `?X`.** Basic
767+
nullable subtyping, always true.
768+
- **`array<K,V>``array<V>` (2-param to 1-param).** `array<V>` means
769+
`array<mixed,V>`; a 2-param array's value type always fits when the
770+
value types are individually compatible. Structural, not MAYBE.
771+
- **`array<int,X>`/generic array → `X[]` (array slice).** The value
772+
type of the generic form is always at least as specific as the
773+
slice form.
774+
- **`list<X>``X[]`.** A list is a stricter array (sequential int
775+
keys from 0); it always satisfies the weaker slice constraint.
776+
777+
### Must stay (genuine diagnostic-only leniency)
778+
779+
Everything else tagged `MAYBE` in the source, notably:
780+
781+
- Refined-scalar-pair leniency (`string``non-empty-string`, `int`
782+
`positive-int`, etc.) and the `int``int<min..max>` case — the
783+
base type is not actually a subtype of the refinement; we simply
784+
cannot disprove it holds at runtime.
785+
- Union-argument "any member compatible" handling — intentionally
786+
unsound (documented as such: "the developer may have narrowed the
787+
type in a way we can't see"). A sound union-argument rule requires
788+
*every* member to be compatible.
789+
- Bare `array` ↔ typed array/shape (both directions), `X[]`
790+
`array<K,V>` reverse direction, `X[]``list<X>` reverse direction,
791+
and array-shape superset/subset acceptance — these accept without
792+
checking value-type compatibility in several cases and are
793+
explicitly approximate ("arrays are open by convention").
794+
- The reverse-direction class hierarchy MAYBE (accepting a broader
795+
declared type where a narrower one might hold at runtime unless the
796+
arg's class is `final`) — an intentional PHPStan-incompatible
797+
leniency for resolver under-narrowing, documented at length in the
798+
source. This is PHPantom-specific diagnostic policy, not a type
799+
fact.
800+
- Arrayable/`ArrayAccess` arg → bare `array` param — structural
801+
substitutability, not actual PHP array subtyping.
802+
- `strict_types`-conditional PHP type-juggling rules (int/float →
803+
string, numeric-string → int/float, `numeric` → int/float) — these
804+
depend on a per-file `declare(strict_types=…)` setting that
805+
`is_subtype_of_typed` has no parameter for today. Could be
806+
revisited if the core engine ever gains strict-types awareness, but
807+
that is a larger structural change out of scope here.
808+
- `model-property<Model>` string-literal validation — Larastan/Laravel
809+
convention, framework-specific, not general PHP typing.
810+
- Same-base generic covariance and the union/intersection-parameter
811+
recursive checks — these recurse into `is_type_compatible` itself
812+
(not `is_subtype_of_typed`) specifically so nested positions get the
813+
MAYBE rules too; they are structural glue, not a separable fact.
814+
815+
### Needs deeper look before deciding
816+
817+
- Array-shape-superset-accepts-subset and `ArrayShape` ↔ typed-array
818+
rules currently return `true` without checking that shared/known
819+
value types are actually compatible (e.g. `array{id: string}` would
820+
be accepted for `array<string,int>` unconditionally). Even if kept
821+
as diagnostic-only leniency, tightening these to check per-key/
822+
per-value compatibility instead of blanket-accepting would reduce
823+
false negatives without introducing false positives — a smaller,
824+
separate follow-up from the move-to-core question above.

src/analyse/run.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,10 @@ pub async fn run(options: AnalyseOptions) -> i32 {
460460
// Use original_content (not virtual PHP) because
461461
// diagnostic line numbers have already been translated
462462
// back to original file coordinates.
463-
crate::diagnostics::filter_ignored_by_comment(&mut raw, original_content);
463+
crate::diagnostics::suppression::filter_ignored_by_comment(
464+
&mut raw,
465+
original_content,
466+
);
464467

465468
// ── Apply [[diagnostics.ignore]] config rules ──────
466469
if !ignore_rules.is_empty() {

src/backend/file_access.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,17 @@ impl Backend {
245245
.unwrap_or_default()
246246
}
247247

248+
/// Look up the precomputed [`SymbolMap`](crate::symbol_map::SymbolMap)
249+
/// for a file, without triggering any parsing.
250+
///
251+
/// Returns `None` when the file has never been parsed (no `did_open`
252+
/// / `update_ast` yet). Centralises the read-lock-and-clone
253+
/// boilerplate that used to be duplicated at the top of every
254+
/// diagnostic collector.
255+
pub(crate) fn symbol_map_for(&self, uri: &str) -> Option<Arc<crate::symbol_map::SymbolMap>> {
256+
self.symbol_maps.read().get(uri).cloned()
257+
}
258+
248259
/// Remove a file's entries from `uri_classes_index`, `use_map`, and `namespace_map`.
249260
///
250261
/// This is the mirror of [`file_context`](Self::file_context): where that

0 commit comments

Comments
 (0)