Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 79 additions & 5 deletions build/PHPStan/Build/TurboAttributeCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
use ReflectionClass;
use RuntimeException;
use Throwable;
use function array_keys;
use function class_exists;
use function count;
use function file_get_contents;
Expand Down Expand Up @@ -143,7 +144,7 @@ public function __construct(string $root)
* the path) and merges the hardcoded vendored entries.
*
* @return array{
* pairs: array<string, array{string, bool, string}>,
* pairs: array<string, array{string, bool, string, list<class-string>}>,
* manifest: array<string, array{php: string, cpp: string, vendored?: bool}>,
* classMap: array<string, string>,
* referenced: array<string, string>,
Expand Down Expand Up @@ -195,6 +196,28 @@ public function collect(): array
}
}

foreach (array_keys($pairs) as $className) {
$reflection = new ReflectionClass($className);

// The stub shell extends the native class, and PHP is
// single-inheritance, so a parent of the shadowed class would
// silently disappear together with everything it declares.
$parent = $reflection->getParentClass();
if ($parent !== false) {
throw new RuntimeException(sprintf(
'%s cannot be shadowed by the turbo extension because it extends %s — the stub shell already extends the native class.',
$className,
$parent->getName(),
));
}

// Interfaces do survive, but only because they are re-declared on
// the stub: it inherits from the native class alone, so an
// interface left out here silently stops matching instanceof and
// DI type lookups.
$pairs[$className][3] = $reflection->getInterfaceNames();
}

ksort($pairs);
ksort($classMap);
ksort($referenced);
Expand All @@ -219,24 +242,74 @@ public function collect(): array
return ['pairs' => $pairs, 'manifest' => $manifest, 'classMap' => $classMap, 'referenced' => $referenced];
}

/** @param array<string, array{string, bool, string}> $pairs */
/**
* Parent interfaces first, deduplicated, keyed by name to keep the order
* stable across calls.
*
* @param class-string $interface
* @param array<string, string> $files
*/
private static function collectInterfaceFiles(string $interface, array &$files): void
{
if (isset($files[$interface])) {
return;
}

$reflection = new ReflectionClass($interface);
foreach ($reflection->getInterfaceNames() as $parent) {
self::collectInterfaceFiles($parent, $files);
}

$fileName = $reflection->getFileName();
if ($fileName === false) {
return; // an engine interface, always declared
}

$files[$interface] = $fileName;
}

/** @param array<string, array{string, bool, string, list<class-string>}> $pairs */
public function renderStubs(array $pairs): string
{
$namespaces = [];
foreach ($pairs as $className => [$turboClass, $final]) {
foreach ($pairs as $className => [$turboClass, $final, $cppFile, $interfaces]) {
$pos = strrpos($className, '\\');
if ($pos === false) {
throw new RuntimeException(sprintf('%s is not a namespaced class name', $className));
}
$implements = '';
if (count($interfaces) > 0) {
$implements = ' implements \\' . implode(', \\', $interfaces);
}
$namespaces[substr($className, 0, $pos)][] = sprintf(
"\t%sclass %s extends \\%s {}",
"\t%sclass %s extends \\%s%s {}",
$final ? 'final ' : '',
substr($className, $pos + 1),
$turboClass,
$implements,
);
}

$blocks = [];

// The stubs are declared before the Composer autoloader registers, so
// the interfaces they re-declare have to be required here — parents
// first, since an interface extending another needs it at declaration
// time. Interfaces without a file are the engine's own.
$interfaceFiles = [];
foreach ($pairs as $pair) {
foreach ($pair[3] as $interface) {
self::collectInterfaceFiles($interface, $interfaceFiles);
}
}
if (count($interfaceFiles) > 0) {
$requires = [];
foreach ($interfaceFiles as $file) {
$requires[] = sprintf("\trequire_once __DIR__ . '/../%s';", $this->relativize($file));
}
$blocks[] = sprintf("namespace {\n\n%s\n\n}", implode("\n", $requires));
}

foreach ($namespaces as $namespace => $declarations) {
$blocks[] = sprintf("namespace %s {\n\n%s\n\n}", $namespace, implode("\n", $declarations));
}
Expand All @@ -250,7 +323,8 @@ public function renderStubs(array $pairs): string
// ShadowedByTurboExtension attribute (plus the hardcoded vendored
// PhpParser\NodeTraverser) with the phpstan_turbo extension's native
// implementation. Required by PHPStan\Turbo\TurboExtensionEnabler before
// the Composer autoloader registers.
// the Composer autoloader registers — hence the interface sources required
// below, which the autoloader cannot resolve yet.

%s

Expand Down
3 changes: 2 additions & 1 deletion build/generate-turbo-stubs.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
*
* - vendor/turbo-stubs.php: one empty stub shell per shadowed class,
* extending the phpstan_turbo extension's native counterpart the attribute
* names. TurboExtensionEnabler requires the file before the Composer
* names and repeating the class's own implements clause.
* TurboExtensionEnabler requires the file before the Composer
* autoloader registers, so with the extension active every reference to
* the original class name transparently resolves to the native
* implementation.
Expand Down
7 changes: 6 additions & 1 deletion src/DependencyInjection/Configurator.php
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,12 @@ public function loadContainer(): string
is_file($attributesPhp) ? hash_file('sha256', $attributesPhp) : 'attributes-missing',
NeonAdapter::CACHE_KEY,
$this->getAllConfigFilesHashes(),
var_export(TurboExtensionEnabler::isLoaded(), true),
// the stubs, not the extension: a shadowed service class is
// reflected while the container compiles, so a container built
// against the stub shells must not be reused by a run that loaded
// the extension but left it inactive (version mismatch,
// PHPSTAN_TURBO=0) and reflects the PHP implementations instead
var_export(TurboExtensionEnabler::isActive(), true),
];

$className = $loader->load(
Expand Down
4 changes: 3 additions & 1 deletion src/Turbo/ShadowedByTurboExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@
*
* On composer dump-autoload, build/generate-turbo-stubs.php collects these
* attributes and generates vendor/turbo-stubs.php — an empty stub shell per
* class extending the native one — which TurboExtensionEnabler declares
* class extending the native one and repeating its implements clause (a
* parent class is rejected: the shell has no inheritance slot left for it)
* — which TurboExtensionEnabler declares
* before the Composer autoloader registers when the extension is active,
* plus vendor/turbo-shadowed-classes.json — the manifest of shadowed pairs
* read by the enabler, the compiler's preload builder, and the parity
Expand Down
2 changes: 1 addition & 1 deletion src/Turbo/TurboExtensionEnabler.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ final class TurboExtensionEnabler
* version is the short SHA of the last commit touching turbo-ext/src/,
* enforced by the phar.yml turbo-version job.
*/
public const EXPECTED_EXTENSION_VERSION = '2d4b432';
public const EXPECTED_EXTENSION_VERSION = 'e9ce4dc';

private static bool $typeCombinatorCacheEnabled = false;

Expand Down
9 changes: 6 additions & 3 deletions turbo-ext/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,12 @@ being ≥0.5% faster is. When the estimate is marginal, don't port.
`grep "\$this->foo"` in double quotes sends `\$` to grep and silently
matches nothing — use single quotes). Run the full test suite now, before
any native work.
2. **Check it is not a DI service** (rule 6 in README — this is fatal and
was measured at 617 broken tests). Value classes and manually-instantiated
classes are safe.
2. **Check it has no parent class** — the stub shell extends the native
class and PHP is single-inheritance, so the collector rejects it. If it is
a DI service, its native `__construct` arginfo must declare the real
parameter class names (rule 6 in README): Nette autowires by reflecting
the constructor, and erased types fail container compilation for every
shadowed service at once.
3. **Implement natively**: one class per `.cpp` in `src/`, namespace
`PHPStanTurbo`, class **non-final**, `instanceof`-style checks instead of
exact class-entry comparisons. Hot classes are registered with the raw
Expand Down
16 changes: 13 additions & 3 deletions turbo-ext/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ Every shadowed piece of PHP code follows the same three steps:
per class, `final class Foo extends \PHPStanTurbo\Foo {}` (shadowed
classes living in vendor/ cannot carry the attribute and are hardcoded in
`build/TurboAttributeCollector.php`; currently `PhpParser\NodeTraverser`).
The shell repeats the class's own `implements` clause, because it inherits
from the native class alone — an interface left off it silently stops
matching `instanceof` and DI type lookups — and `require`s the interface
sources, which the autoloader cannot yet provide at that point. A parent
class cannot survive the same way (PHP is single-inheritance), so the
collector rejects a shadowed class that has one.
When the extension
is enabled, `PHPStan\Turbo\TurboExtensionEnabler` `require`s that file
*before* the Composer autoloader registers. All PHP code keeps calling
Expand Down Expand Up @@ -336,9 +342,13 @@ Measured in the July 2026 benchmarks (callback-free absorptions gained
call is exactly the per-element boundary cost these rules forbid. (The
extension originally hosted its lifecycle in PHP-CPP; it is a plain Zend
module since the Windows port.)
6. **Never shadow a DI-service class.** Nette's `getByType()` normalizes
requested types through reflection to the real class name and breaks
containers cached in the other mode.
6. **A shadowed DI-service class has to stay autowirable.** Nette reflects
`__construct` while it compiles the container, so the native arginfo must
declare the real parameter class names — erasing them fails container
compilation for every shadowed service at once. (Aliasing a service class
is what genuinely cannot work: `getByType()` normalizes the requested type
through reflection to the real class name. The stub shells are subclasses
carrying the original name, so that does not apply to them.)
7. **Every port must prove itself**: interleaved A/B benchmark on a long run
(user CPU, result cache cleared) plus a byte-identical output diff. Ports
measuring ≤0.5% get reverted — the failure mode is silent no-gain, and
Expand Down
9 changes: 6 additions & 3 deletions turbo-ext/src/NodeTraverser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -877,21 +877,24 @@ void pt_register_node_traverser()
}
});

static const reg::Arg voidReturn = { "", MAY_BE_VOID, nullptr };
static const reg::Arg arrayReturn = reg::arrayArg("");

cls.method("addVisitor", reg::Public, 1, { reg::obj("visitor", NODE_VISITOR_CLASS) }, [](INTERNAL_FUNCTION_PARAMETERS) {
zval *visitor;
ZEND_PARSE_PARAMETERS_START(1, 1)
Z_PARAM_OBJECT(visitor)
ZEND_PARSE_PARAMETERS_END();
NodeTraverser(Z_OBJ_P(ZEND_THIS)).addVisitor(zv::Ref(visitor));
});
}, &voidReturn);

cls.method("removeVisitor", reg::Public, 1, { reg::obj("visitor", NODE_VISITOR_CLASS) }, [](INTERNAL_FUNCTION_PARAMETERS) {
zval *visitor;
ZEND_PARSE_PARAMETERS_START(1, 1)
Z_PARAM_OBJECT(visitor)
ZEND_PARSE_PARAMETERS_END();
NodeTraverser(Z_OBJ_P(ZEND_THIS)).removeVisitor(zv::Ref(visitor));
});
}, &voidReturn);

cls.method("traverse", reg::Public, 1, { reg::arrayArg("nodes") }, [](INTERNAL_FUNCTION_PARAMETERS) {
HashTable *nodes;
Expand All @@ -904,7 +907,7 @@ void pt_register_node_traverser()
RETURN_THROWS();
}
result.intoReturnValue(return_value);
});
}, &arrayReturn);

pt_ce_node_traverser = cls.register_();
}
Expand Down
10 changes: 6 additions & 4 deletions turbo-ext/src/reg.h
Original file line number Diff line number Diff line change
Expand Up @@ -116,14 +116,16 @@ class Class
* requiredArgs is ZEND_BEGIN_ARG_INFO_EX's required_num_args; args carry
* name/type/by-ref/variadic exactly as the ZEND_ARG_* macros would.
*/
Class &method(const char *methodName, uint32_t flags, uint32_t requiredArgs, std::initializer_list<Arg> args, zif_handler handler)
Class &method(const char *methodName, uint32_t flags, uint32_t requiredArgs, std::initializer_list<Arg> args, zif_handler handler, const Arg *returns = NULL)
{
/* arginfo array: slot 0 is the return-info slot carrying the
* required-args count, exactly as ZEND_BEGIN_ARG_INFO_EX emits */
* required-args count, exactly as ZEND_BEGIN_ARG_INFO_EX emits.
* A declared return type goes in the same slot's type — needed only
* where the engine enforces it (implementing a userland interface). */
auto *argInfo = (zend_internal_arg_info *) pemalloc(sizeof(zend_internal_arg_info) * (args.size() + 1), 1);
argInfo[0].name = (const char *) (uintptr_t) requiredArgs;
argInfo[0].type.ptr = NULL;
argInfo[0].type.type_mask = 0;
argInfo[0].type.ptr = returns != NULL ? (void *) returns->className : NULL;
argInfo[0].type.type_mask = returns != NULL ? returns->typeMask : 0;
argInfo[0].default_value = NULL;
size_t i = 1;
for (const Arg &arg : args) {
Expand Down
Loading