Skip to content

refactor(type-engine): preserve scalar literal types in expression resolution - #304

Merged
AJenbo merged 2 commits into
PHPantom-dev:mainfrom
snowyukitty:refactor/preserve-literal-types
Aug 1, 2026
Merged

AJenbo merged 2 commits into
PHPantom-dev:mainfrom
snowyukitty:refactor/preserve-literal-types

Conversation

@snowyukitty

Copy link
Copy Markdown
Contributor

Closes #293.

What this changes

Scalar literals now survive ordinary expression flow instead of being widened to string/int/float at resolution time.

Given:

function test(bool $flag): void {
    $scalar = 'draft';
    $choice = $flag ? 'asc' : 'desc';
    $collection = [$scalar, $choice];
}

hovering $scalar used to show $scalar = string. It now shows $scalar = 'draft', and $choice shows 'asc'|'desc'. $collection still shows list<string>, because storing into a mutable collection is where the distinction stops being meaningful.

Why

resolve_rhs_expression widened literals immediately, so every consumer that wanted precision had to rebuild it. The diagnostic path did that with narrow_literal_type(), which walked the expression tree a second time and handled a fixed list of compound forms — ternary, null-coalesce, match, and parenthesised wrappers. Anything outside that list, and anything reached by hover, completion, or inferred PHPDoc return types, got no literal precision at all.

As #293 puts it, the narrowing pass is architecturally fragile because it enumerates expression forms rather than getting precision for free from the resolver.

How

  • Resolution returns literal_string_raw / literal_int / literal_float for scalar literals and preserves them through parenthesised and signed expressions, ternaries, match arms, coalescing, branch merges, guards, increments and decrements, by-reference updates, and return inference.
  • A runtime-value join, distinct from coercive parameter subtyping, keeps int|float a real runtime union while letting broad scalar domains absorb literal subsets.
  • Semantic literal equality, hashing, and union deduplication are deterministic without conflating case-sensitive string payloads, escaped quote semantics, malformed numerics, aliases, or the Number class.
  • A shallow widening operation deliberately leaves nested generic, shape, and callable payloads intact, and is applied at the real mutable boundaries: array literals, shapes, keyed writes, appends, spreads, iterator conversion, and collection return paths.
  • Array keys are normalised across literal and broad scalar domains, nullable and union keys, explicit numeric shape keys, escaped keys, object shapes, and unresolved class-constant keys, following PHP's own coercion rules.
  • PHP 8 numeric-string recognition and the scalar aliases numeric, the lowercase pseudo-type number, the class Number, and real are corrected.
  • narrow_literal_type() and the closure, assignment, and by-reference collection workarounds are removed, since the type engine now carries the information they existed to recover.

Tests

New coverage spans literals, structured unions, malformed and overflow numerics, PHP numeric strings, class metadata, branch joins, nullable and coalesce flow, keyed and appended collections, array-key coercion, iterator conversion, typed and variadic by-reference parameters, and large literal return unions.

hover_preserves_scalar_literals_but_widens_collections fails on an unmodified checkout of the base commit with $scalar = string, and passes with this change.

Validation

Run on Windows against base 88ba719:

Check Result
cargo fmt --all -- --check passed
cargo clippy --all-targets --all-features -- -D warnings passed, no warnings
cargo nextest run --no-fail-fast 11,504 run, 11,467 passed, 37 failed, 2 skipped
Assertion runner 54 passed
Fixture runner 332 passed
Doc tests 1 passed, 10 ignored
git diff --check passed
cargo bench --bench completion completed, 28 scenarios

The same suite on the unmodified base commit, measured in the same session with no local changes at all, gives 11,463 run, 11,426 passed, 37 failed, 2 skipped. The two failure sets are identical by name — nothing introduced, nothing fixed — and are 9 library, 27 integration, and 1 unit case, all Windows path, symlink, and URI-escaping behaviour unrelated to this change.

Performance

Because performance matters here, base and candidate were compared by building both benchmark harnesses first and then running them alternately for three rounds each, so background load affects both sides equally. Medians:

Scenario Base Candidate Delta
completion_with_narrowing 88,396 ns 90,031 ns +1.8%
hover_method_call 219,615 ns 206,661 ns −5.9%
update_ast_parse_time/100_lines 464,692 ns 454,989 ns −2.1%

Repeating an identical build varied by ±4% to ±12% between rounds, so none of these is resolvable as a real difference, including on update_ast_parse_time, which this change cannot affect and which is included as a control. No regression is detectable at this machine's resolution, and no improvement is claimed.

Not verified locally

php -l examples/demo.php, php -d zend.assertions=1 examples/demo.php, and php -l examples/laravel/app/Demo.php need a PHP CLI that was not available on the machine used, and coverage via cargo llvm-cov and the Linux-musl target were likewise unavailable. Those are left to CI.

Checklist

If applicable:

  • I have updated CHANGELOG.md
  • I have updated the documentation (README.md, docs/, examples/)
  • I have updated the config schema (config-schema.json)
  • I have added/updated tests to cover my changes
  • I fully understand the code that I am submitting (what it does,
    how it works, how it's organized), including any code drafted by an LLM.
  • For any prose generated by an LLM, I have proof-read and copy-edited with
    an eye towards deleting anything that is irrelevant, clarifying anything
    that is confusing, and adding details that are relevant. This includes,
    for example, commit descriptions, PR descriptions, and code comments.

@codecov-commenter

codecov-commenter commented Aug 1, 2026 •

Copy link
Copy Markdown

snowyukitty and others added 2 commits August 1, 2026 09:46
…solution

The expression resolver widened every scalar literal to its base type at
the point of resolution, so compound expressions such as ternaries, match
arms, and null-coalescing lost literal precision before any consumer saw
them. The diagnostic path compensated with `narrow_literal_type()`, a
second walk that re-derived literals for argument checking by enumerating
every compound expression form it knew about. Precision was therefore
available only where somebody had remembered to reconstruct it, and only
for the expression shapes that function already listed.

Resolution now returns literal string, int, and float types directly and
keeps them through parentheses, unary signs, ternaries, match arms,
coalescing, branch merges, guards, by-reference updates, and return
inference. Widening happens where PHP itself stops distinguishing the
value: entries stored into mutable arrays, lists, and shapes, values
crossing a native type-hint boundary, and operations that change the
value rather than observe it. Array keys follow PHP's own coercion rules
across literal and broad scalar domains.

Because the resolver is now precise on its own, `narrow_literal_type()`
and the closure, assignment, and by-reference workarounds that existed to
recover the same information are removed rather than reimplemented.

Closes #293
@AJenbo
AJenbo force-pushed the refactor/preserve-literal-types branch from 46f11e3 to b8680a1 Compare August 1, 2026 08:38
@AJenbo
AJenbo merged commit a2e7012 into PHPantom-dev:main Aug 1, 2026
7 checks passed
@AJenbo

AJenbo commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Thanks, this starts down a wobbly road of getting the type engine to be even more strict, but now is the right time and this was a good first step.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Preserve literal types in the expression resolver (PHPStan-style)

3 participants