Skip to content

Generate the declarative half of the native classes from their PHP twins - #6491

Open
ondrejmirtes wants to merge 12 commits into
2.3.xfrom
turbo-native-codegen
Open

ondrejmirtes wants to merge 12 commits into
2.3.xfrom
turbo-native-codegen

Conversation

@ondrejmirtes

@ondrejmirtes ondrejmirtes commented Sep 20, 2026

Copy link
Copy Markdown
Member

Derives the declarative half of every native class from its PHP twin, and trims the boilerplate the hand-written registrations carried.

Generation

turbo-ext/bin/generate-declarations.php (backed by the new build/PHPStan/Build/TurboDeclarationGenerator.php) writes turbo-ext/src/generated/<Stem>.h from the twin:

  • class and property declarationsdeclareClass(cls) (final/abstract, parent, directly implemented interfaces) and declareProperties(cls) (the twin's own properties, exactly), called first in the registration function instead of being spelled out by hand. A class whose native properties deliberately differ keeps declaring them itself.
  • property slots — the slot:: constants of the instance properties, so the native code stops hardcoding OBJ_PROP_NUM indices. A twin that gains a property used to shift every slot silently.
  • method signatures — the registrations name a generated signature instead of repeating arginfo.
  • trait registrar wiring — the pt_type_trait_* entries a class pulls in.

side-by-side.php fails while a generated header is stale, so a twin that changes shape breaks the build rather than the analysis.

Hygiene

  • Single-statement guard clauses go on one line; the repeated file-header boilerplate is trimmed.
  • The parameter-parsing glue of the registrations is generated rather than written out.
  • Engine-call arguments are packed with zv::Args.
  • Native results carrying a pending exception are [[nodiscard]], so a dropped result is a compile error.
  • side-by-side.php now checks every PT_LC("...") lowercase identifier against the real methods (trait aliases included), properties, constants, internal functions and members, type keywords and magic methods. A misspelt name used to fail only when its code path first ran.

Also

turbo-ext/poc/ goes away. It held a patch recording three measured changes to the vendored ondrejmirtes/better-reflection that could not be committed because vendor/ is git-ignored. Nothing applies the file, so it only goes stale as the vendored code moves — those changes belong in a pull request against that repository.

Verification

  • generate-declarations.php is a no-op on the committed headers (13 headers (0 written, 0 removed)), at every commit that touches the generator.
  • bin/phpstan analyse over src/Type src/Analyser src/Reflection: output identical with the extension on and off (separate tmpDir per leg, activation verified per leg).
  • smoke.php, signature-parity.php (79 methods), side-by-side.php including the new name check: OK.
  • make phpstan, make lint, make cs: clean.
  • make tests with the extension loaded and active: 22016 tests, 97656 assertions, OK (65 skipped).

🤖 Generated with Claude Code

https://claude.ai/code/session_017MvPby652L7wUqGAHEiEcN

ondrejmirtes and others added 12 commits September 20, 2026 22:53
A guard whose body is only a return, RETURN_THROWS(), break or continue is
written `if (cond) return x;` — mostly the propagation of a pending
exception after each engine call. Guards that would grow wider than 160
columns keep their braces; macro bodies and else chains are untouched.
Token-identical, so every object compiles byte-identical.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy
A method that only parses its parameters and delegates them in order to a
handle member is registered as cls.method<&Handle::member, zp::K...>(...):
reg::detail::Bound generates the handler (zv::Val, void, or bool with a
trailing bool & out parameter). Every other ZEND_PARSE_PARAMETERS block of
the supported kinds is written zp::parse<K...>(execute_data, ...). Both
expand to the engine's own ZPP macros per slot, so the handlers compile to
the hand-written glue's code (a generated handler differs only in how its
error paths are outlined). The 46 parameter blocks of the existing native
sources parse through zp::parse; the ports to come register their
delegating methods in the bound form, which side-by-side.php accepts.
PT_RETURN_VAL, the zv::Val-into-return_value tail the generated handlers
share with the hand-written ones, lives in reg.h.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy
The `zval args[N]` + one ZVAL_* line per slot of an engine call is a
zv::Args pack: the type of each value picks the ZVAL_* macro, nothing is
addref'ed, and the pack converts to the zval * the call takes. Everything
is inline and fills the same zvals the macros did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy
Drop the sentences a header repeats from README.md and the registration
code below it: the handle class mirroring the twin with the registration
as ABI glue, a bare "declared as ... at activation" restating
cls.final()/implements(). File-specific notes stay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy
zv::Val, zv::Arr and zv::Str are [[nodiscard]] types (an UNDEF value is a
pending exception), and so are the functions documented to signal one
through their bool / -1 / NULL result. No call site dropped such a
result.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2Qsp9sqyymJYoqgoMkgLT
side-by-side.php now verifies every PT_LC("...") lowercase identifier the
native code passes — the lowercased method names of by-name calls,
$this-dispatch and function-table lookups — against the methods (trait
aliases included), properties and constants of src/ and the vendored
libraries, the internal functions and members, the type keywords, PHP's
magic methods and the methods the extension registers itself. Lookup-table
entries and plain string data passed to zend_string_init() and the like
are not names. A misspelt name used to fail only when its path first ran.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2Qsp9sqyymJYoqgoMkgLT
turbo-ext/bin/generate-declarations.php derives the declarative half of
every shadowing class from its PHP twin (PHPStan\Build\TurboDeclarationGenerator)
into turbo-ext/src/generated/<Stem>.h: declareClass() with the final /
abstract flags, the parent and the directly implemented interfaces,
declareProperties() with the twin's own property declarations, and the
OBJ_PROP_NUM slot of each instance property. The 13 registrations of the
shadowing classes call declareClass() instead of spelling cls.final() /
parent() / implements() out; the 6 classes whose native properties equal
the twin's call declareProperties() instead of their builder calls (the
rest deliberately declare different state). A reflection dump of every
native class — modifiers, parent, interfaces, constants, properties with
types and defaults, method signatures — is identical before and after.
side-by-side.php fails while a generated header is stale.

reg.h can declare every property shape a twin spells: reg::Class::property()
takes the visibility flags, the kind and the type, and the kinds cover a
typed property with no default and one defaulting to null, [], false, a
bool or an int, class-typed ones and unions of classes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2Qsp9sqyymJYoqgoMkgLT
The numeric #define PT_*_PROP_* slot macros of the native classes become
the generated slot constants (ptdecl::<Stem>::slot, aliased as slots::) —
mapped by index, so every object compiles byte-identical. A slot number
now follows the twin's declaration instead of being counted by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2Qsp9sqyymJYoqgoMkgLT
The declaration generator also emits each method's signature (name, flags,
required count, arginfo, return type) into the class's generated header,
and a header per trait the shadowed classes use. Registrations take them —
cls.method(sigs::accepts, handler), cls.method<&Handle::accepts, zp::Obj,
zp::Bool>(sigs::accepts), cls.traitMethod(sigs::isArray, handler) —
instead of spelling names, flags, parameter descriptors and return types
out; a bound registration whose parameter kinds no longer match its
signature refuses to load the module. The generated signatures are
constexpr, and so are the reg::Arg builders they call. Methods of classes
without a PHP twin, and those whose native signature deliberately differs
from the twin's, keep their hand-written arginfo. The reflection dump of
every native class is identical before and after; side-by-side.php
resolves sigs:: names through the headers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2Qsp9sqyymJYoqgoMkgLT
The generator also renders ptdecl::<Stem>::registerTraits(cls) for a class
whose twin uses traits: the pt_type_trait_*() registrars of those traits,
in the twin's order. A class that runs exactly the registrars its twin
uses calls it instead of listing them one by one; one that deliberately
runs a different set, or runs them in another order, keeps its own calls.
No native class uses a trait yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P2Qsp9sqyymJYoqgoMkgLT
The patch recorded three measured changes to ondrejmirtes/better-reflection
(the getName() memo, the attribute-less short-circuit in
getAttributesByName(), the cached-member checks hoisted in ReflectionClass)
because vendor/ is git-ignored and they could not be committed. Nothing
applies the file, so it only goes stale as the vendored code moves - those
changes belong in a pull request against that repository.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017MvPby652L7wUqGAHEiEcN
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.

1 participant