Skip to content

feat(enums): accept an enum case as a declared property default - #1026

Merged
nahime0 merged 3 commits into
mainfrom
fix/566-enum-case-property-default
Sep 18, 2026
Merged

nahime0 merged 3 commits into
mainfrom
fix/566-enum-case-property-default

Conversation

@Guikingone

Copy link
Copy Markdown
Collaborator

Closes #566.

enum Level { case Low; }

class Config {
    public Level $level = Level::Low;   // PHP accepts this; elephc did not
}

Two layers refused it, and the issue predicted the second.

The checker

validate_schema_declared_default_type ran while class schemas were being built, where enum cases do not exist yet — so infer_expr_type_syntactic answered Str for Level::Low and the declared Object("Level") slot rejected it with "expects Object("Level"), got Str".

Parameters already had the answer. validate_schema_parameter_default_type deferred a ScopedConstantAccess default to schema::defaults, which revalidates it once the schemas are complete and can resolve the constant semantically (PR #565). Constructor promotion worked for exactly that reason — its default is a parameter default. The deferral now lives in the declared-default validator, so properties and static properties get it too, and validate_class_property_defaults routes through the same validate_deferred_default the signature pass uses.

Nothing is waved through: it changes when the default is judged, not what counts as compatible. Both negatives still report, from the pass that can tell the difference:

public Level $level = Level::Missing; Undefined enum case: Level::Missing
public Level $level = Holder::NAME; expects Object("Level"), got Str

The backend

Past the checker, the property-initialization path had no form for it either — the gap the issue names:

unsupported EIR backend feature: object_new for default value of property $level with PHP type Object("Level")

LiteralDefaultValue::EnumCase is that form. It is recognized on shape in literal_default_value, which has no module, and settled where the module is available: emit_property_default and emit_static_property_default_value both verify the receiver really names an enum and the constant one of its cases, reporting unsupported otherwise rather than emitting a symbol reference that would fail at assembly time.

The value is loaded through emit_lazy_case_load_unguarded, not read out of the case slot. Cases are materialized lazily, so a default written before the case's first use anywhere else would otherwise store the still-null slot — and every $obj->prop === Level::Low after it would be false, which is the failure mode that looks like it works until it doesn't.

Measured

Static, declared and promoted forms in one program, byte-identical to the host PHP 8.5.10:

bool(true)   Config::$shared === Level::High     static property
bool(true)   $c->level === Level::Low            declared property
bool(false)  $c->level === Level::High
bool(true)   $c->promoted === Level::High        promoted property
bool(false)  $c->level === $c->promoted          the two forms are distinct cases
string(3) "Low"                                  ->name
string(1) "a"                                    backed ->value
bool(true)   after $c->level = Level::High
bool(true)   after Config::$shared = Level::Low

=== is the assertion that matters: a default that allocated a fresh object, or stored the unmaterialized slot, would still print the right ->name.

Acceptance criteria

  • Lower enum case defaults through the EIR path used for directly declared property initializers
  • Preserve the enum case's object type and singleton identity
  • Keep ownership and cleanup correct — the singleton is a borrowed global, so the slot stores it without a retain, exactly as a case read elsewhere does
  • Continue to reject missing enum cases and incompatible scalar class constants
  • Positive end-to-end regression for the non-promoted form (plus static and promoted, asserted to agree)
  • Focused negative coverage for invalid scoped constants
  • test_error_plain_property_enum_case_default_remains_unsupported replaced by test_plain_property_enum_case_default_is_accepted
  • Every target — the change is in shared lowering and the emit_lazy_case_load_unguarded helper both architectures already use

Docs in docs/php/classes.md (a new "Enum cases as defaults" section covering all four positions), and the examples/enums example extended with the three property forms.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr

@github-actions github-actions Bot added area:codegen Touches target-aware assembly or backend lowering. area:types Touches type checking, inference, or compatibility. size:s Small pull request. type:feature Introduces new user-visible behavior or capabilities. labels Sep 15, 2026
@greptile-apps

greptile-apps Bot commented Sep 15, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds PHP-compatible enum-case defaults for directly declared instance and static properties while preserving semantic validation, lazy singleton materialization, and ownership.

  • Defers scoped-constant default validation until enum cases and class constants can be resolved semantically.
  • Introduces an enum-case literal-default representation and validates the receiver and case during native lowering.
  • Retains canonical enum singletons when property slots acquire ownership.
  • Adds positive, negative, singleton-identity, and reassignment/ownership regression coverage.
  • Updates the enum example and PHP class documentation.

Confidence Score: 5/5

The PR appears safe to merge; both previous findings are fully addressed and no new actionable issue was introduced.

The current lowering retains enum singletons before transferring them into owned property slots, deferred validation still rejects missing or incompatible constants, and focused regressions cover identity and reassignment behavior. The stale module documentation was also corrected.

Important Files Changed
Filename Overview
src/types/checker/type_compat/declarations.rs Defers scoped-constant declared defaults so they can be validated after schemas are complete.
src/types/checker/schema/defaults.rs Reuses semantic deferred-default validation for parameters and directly declared properties.
src/codegen/literal_defaults.rs Represents object-typed scoped constant defaults as enum-case candidates for later metadata validation.
src/codegen/lower_inst/objects/property_defaults.rs Validates, lazily loads, retains, and stores canonical enum cases in instance property slots.
src/codegen/block_emit.rs Adds equivalent validated and retained enum-case initialization for static property slots.
tests/codegen/types/enums.rs Covers all property forms, backed cases, singleton identity, lazy initialization, and ownership across reassignment.
tests/error_tests/type_system.rs Verifies accepted enum-case defaults while preserving missing-case and incompatible-constant diagnostics.
docs/php/classes.md Documents supported enum-case default positions, singleton identity, and invalid-default behavior.
examples/enums/main.php Demonstrates declared, static, and promoted enum-case property defaults.
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart LR
    PHP["Property default: Level::Low"] --> Schema["Schema construction"]
    Schema --> Deferred["Defer scoped-constant validation"]
    Deferred --> Semantic["Resolve enum and case semantically"]
    Semantic --> EIR["LiteralDefaultValue::EnumCase"]
    EIR --> Validate["Validate enum metadata"]
    Validate --> Load["Lazily materialize canonical case"]
    Load --> Retain["Retain for property-slot ownership"]
    Retain --> Store["Store singleton in instance/static slot"]
Loading

Reviews (4): Last reviewed commit: "fix(enums): retain the singleton an enum..." | Re-trigger Greptile

Comment thread src/codegen/lower_inst/objects/property_defaults.rs
Comment thread src/types/checker/schema/defaults.rs
@Guikingone Guikingone self-assigned this Sep 15, 2026
@Guikingone
Guikingone requested a review from nahime0 September 15, 2026 19:17
Closes #566.

    enum Level { case Low; }
    class Config {
        public Level $level = Level::Low;   // PHP accepts this; elephc did not
    }

Two layers refused it, and the issue predicted the second.

## The checker

`validate_schema_declared_default_type` ran while class schemas were being
built, where enum cases do not exist yet -- so `infer_expr_type_syntactic`
answered `Str` for `Level::Low` and the declared `Object("Level")` slot rejected
it with "expects Object(\"Level\"), got Str".

PARAMETERS already had the answer. `validate_schema_parameter_default_type`
deferred a `ScopedConstantAccess` default to `schema::defaults`, which revalidates
it once the schemas are complete and can resolve the constant semantically (PR
#565). Constructor promotion worked for exactly that reason: its default is a
parameter default. The deferral now lives in the declared-default validator, so
properties and static properties get it too, and
`validate_class_property_defaults` routes through the same
`validate_deferred_default` the signature pass uses.

Nothing is waved through -- it changes WHEN the default is judged, not what
counts as compatible. Both negatives still report, from the pass that can tell
the difference:

    public Level $level = Level::Missing;   Undefined enum case: Level::Missing
    public Level $level = Holder::NAME;     expects Object("Level"), got Str

## The backend

Past the checker, the property-initialization path had no form for it either:

    unsupported EIR backend feature: object_new for default value of property
    $level with PHP type Object("Level")

`LiteralDefaultValue::EnumCase` is that form. It is recognized on SHAPE in
`literal_default_value`, which has no module, and settled where the module is
available: `emit_property_default` and `emit_static_property_default_value` both
verify the receiver really names an enum and the constant one of its cases, and
report unsupported otherwise rather than emitting a symbol reference that would
fail at assembly time.

The value is loaded through `emit_lazy_case_load_unguarded`, not read out of the
case slot. Cases are materialized lazily, so a default written before the case's
first use anywhere else would otherwise store the still-null slot and every
`$obj->prop === Level::Low` after it would be false.

## Measured

Static, declared and promoted forms in one program, byte-identical to the host
PHP 8.5.10 -- including `===` against the case (the assertion that separates the
singleton from a fresh object), a backed enum's `->value`, cross-form identity,
and reassignment afterwards.

`test_error_plain_property_enum_case_default_remains_unsupported` is replaced by
`test_plain_property_enum_case_default_is_accepted` plus two negatives, as the
issue's acceptance criteria ask.

Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
CI caught what the local run did not: `examples/enums/main.php` gained the
enum-case property section, and `test_example_enums_compiles_and_runs` asserts
that file's WHOLE stdout, so extending the example without extending the
expectation fails the pin. It failed identically on all three architectures,
which is the right shape for a stale expectation rather than a codegen problem:

    left:  "...\nDESC\nLow High High same"
    right: "...\nDESC"

The expectation now carries the new row, and the doc comment says what it is
worth: the `=== Level::Low` at the end is the assertion that separates the
singleton from a fresh object, because `->name` prints the same either way.

Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
Review follow-up, and a real one: the default paths stored the enum global's
BORROWED singleton without an incref, so the slot became a second owner of a
reference it never took.

That is not a leak but its opposite. The first thing that releases the slot -- a
property reassignment, an object's cleanup, a web worker's static teardown --
consumes the global's only reference and frees a case that is still reachable by
name. Lazy materialization then hands the freed block to the NEXT case, so two
cases end up sharing one object. It is the same under-retention #349 fixed for
an ordinary `Enum::Case` read, reached through the default paths instead.

Measured, 500 iterations of `$c = new Config(); $c->level = Level::High;`:

    Level::Low->name        string(0) ""      <- freed and reused
    Level::Low === Level::Low   bool(true)    <- identity survives a dangling
                                                 pointer, which is why `->name`
                                                 is the assertion that catches it

Both store sites now retain, exactly as `lower_inst::scoped_constants` does:
the instance path in `emit_property_default` and the static one in
`emit_static_property_default_value`. `object_reg` is preserved across
`__rt_incref` the way the boxed-literal arms already preserve it; the
materializer needs no such care, because it promises to preserve every
caller-saved integer register.

After the fix the same program matches the host PHP 8.5.10 byte for byte, and
`--gc-stats` reports `allocs=504 frees=502` -- the two retained blocks are the
two singletons, which are process-lifetime by design, so nothing accumulates per
iteration.

The module preamble of `schema/defaults.rs` still said plain property
scoped-constant defaults stay outside that pass, which this PR ended; it now says
what the pass covers and repeats that it changes WHEN a default is judged, not
what counts as compatible.

Claude-Session: https://claude.ai/code/session_01KSAAWPyNBq6dP2b5puN3wr
@nahime0

nahime0 commented Sep 18, 2026

Copy link
Copy Markdown
Member

Follow-ups from the Grok review leftovers (not merge blockers for this PR):

@nahime0

nahime0 commented Sep 18, 2026

Copy link
Copy Markdown
Member

i've opened 5 follow-ups. the rest is good for me

@nahime0
nahime0 merged commit 976acda into main Sep 18, 2026
149 checks passed
@nahime0
nahime0 deleted the fix/566-enum-case-property-default branch September 18, 2026 10:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:codegen Touches target-aware assembly or backend lowering. area:types Touches type checking, inference, or compatibility. size:s Small pull request. type:feature Introduces new user-visible behavior or capabilities.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support enum case defaults for typed properties

2 participants