feat(build): support stub files in incremental compilation

- Add getDeclarationInputFiles method to include stub files in header generation
- Modify incremental compilation to process stub files alongside regular PHP files
- Update build state tracking to handle stub files differently (no translation units)
- Add test coverage for stub declaration generation and dependency invalidation
- Implement MSVC /bigobj flag for large object format support in generated commands
- Generate wrapper callbacks for stub functions and methods bridging to native implementations
- Update source pipeline to handle native/import stub metadata in module entry
- Modify declaration header generation to include stub callback registrations
- Add comprehensive test suite for stub functionality
master
韩天峰 4 weeks ago
parent 4aba3023cc
commit 222911cbde
  1. 2
      .github/smoke/linux-arm64/.gitignore
  2. 11
      phpunit/src/Backend/BackendTest.php
  3. 106
      phpunit/src/Build/IncrementalDeclarationTest.php
  4. 3
      src/Backend/Msvc.php
  5. 30
      src/Build/IncrementalCompilationTrait.php
  6. 10
      src/Build/SourcePipelineTrait.php
  7. 46
      src/Translator.php

@ -0,0 +1,2 @@
/build
/linux_arm64_smoke

@ -117,6 +117,17 @@ class BackendTest extends TestCase
$this->assertStringNotContainsString('/std:', $cmd);
}
public function testMsvcLargeObjectFormatAppliesToGeneratedAndNativeCommands(): void
{
$compiler = new Msvc(new Windows());
foreach ([false, true] as $debug) {
$options = ['debug' => $debug];
$this->assertStringContainsString('/bigobj', $compiler->buildCompileCommand('generated.cc', 'generated.obj', $options));
$this->assertStringContainsString('/bigobj', $compiler->buildNativeCompileCommand('native.c', 'native.obj', $options, 'c'));
}
$this->assertStringNotContainsString('/bigobj', $compiler->buildLinkOptions());
}
public function testMsvcDebugPdbOptionsApplyToCppAndCCommands(): void
{
$compiler = new Msvc(new Windows());

@ -316,6 +316,112 @@ PHP);
self::assertDirectoryDoesNotExist($incrementalDirectory);
}
private function prepareNativeStub(): void
{
$stub = $this->directory . '/provider.stub.php';
rename($this->provider, $stub);
$this->provider = $stub;
file_put_contents($stub, '<?php namespace Incremental; function answer(int $value = 42): int {}');
file_put_contents($this->consumer, '<?php function main(): int { return \\Incremental\\answer(); }');
}
public function testStubDeclarationsAreGeneratedWithoutConvertingStubBodies(): void
{
$this->prepareNativeStub();
$first = $this->convertProject();
$header = $first->getDeclarationHeaderFile($this->provider);
self::assertFileExists($header);
self::assertStringContainsString('php_incremental__answer(', file_get_contents($header));
self::assertStringContainsString('#include <' . basename($header) . '>',
file_get_contents($first->getDeclarationHeaderFile($this->consumer)));
self::assertFileDoesNotExist($this->invoke($first, 'getCppFile', $this->provider));
self::assertFileExists($first->getArgInfoHeaderFile($this->provider));
$extension = $this->buildDirectory . '/extension-incremental.cc';
$extensionCode = file_get_contents($extension);
self::assertStringContainsString('ZEND_FUNCTION(incremental_answer)', $extensionCode);
self::assertStringContainsString('#include <' . basename($first->getArgInfoHeaderFile($this->provider)) . '>', $extensionCode);
$state = json_decode(file_get_contents($this->buildDirectory . '/cache/incremental/incremental/build-state.json'), true);
self::assertFalse($state['files'][$this->provider]['emitsTranslationUnit']);
self::assertContains($this->provider, $state['files'][$this->consumer]['dependencies']);
$second = $this->convertProject();
self::assertFalse($this->invoke($second, 'shouldRegeneratePhpFile', $this->provider));
self::assertFalse($this->invoke($second, 'shouldRegeneratePhpFile', $this->consumer));
self::assertSame($extensionCode, file_get_contents($extension));
}
public function testStubChangePreservingMtimeInvalidatesTheConsumer(): void
{
$this->prepareNativeStub();
$this->convertProject();
$extension = $this->buildDirectory . '/extension-incremental.cc';
$oldCode = file_get_contents($extension);
$mtime = filemtime($this->provider);
file_put_contents($this->provider, str_replace('42', '43', file_get_contents($this->provider)));
touch($this->provider, $mtime);
clearstatcache();
$second = $this->convertProject();
self::assertTrue($this->invoke($second, 'shouldRegeneratePhpFile', $this->provider));
self::assertTrue($this->invoke($second, 'shouldRegeneratePhpFile', $this->consumer));
self::assertNotSame($oldCode, file_get_contents($extension));
self::assertStringContainsString('43', file_get_contents($extension));
}
public function testMissingStubHeaderIsRegeneratedAndInvalidatesConsumers(): void
{
$this->prepareNativeStub();
$first = $this->convertProject();
unlink($first->getDeclarationHeaderFile($this->provider));
$second = $this->convertProject();
self::assertFileExists($second->getDeclarationHeaderFile($this->provider));
self::assertTrue($this->invoke($second, 'shouldRegeneratePhpFile', $this->consumer));
}
public function testMissingStubArginfoIsRegeneratedAndInvalidatesConsumers(): void
{
$this->prepareNativeStub();
$first = $this->convertProject();
unlink($first->getArgInfoHeaderFile($this->provider));
$second = $this->convertProject();
self::assertFileExists($second->getArgInfoHeaderFile($this->provider));
self::assertTrue($this->invoke($second, 'shouldRegeneratePhpFile', $this->consumer));
}
public function testImportedClassMetadataAndCallbacksStayInTheExtension(): void
{
$this->prepareNativeStub();
file_put_contents($this->provider, <<<'PHP'
<?php
/** @import-library */
namespace Incremental;
function answer(int $value = 42): int {}
final class Counter
{
public int $value = 0;
public function add(int $delta): int {}
}
PHP);
file_put_contents($this->consumer, <<<'PHP'
<?php
function main(): int
{
$counter = new \Incremental\Counter();
return $counter->add(1) + \Incremental\answer();
}
PHP);
$first = $this->convertProject();
$arginfo = $first->getArgInfoHeaderFile($this->provider);
$extension = $this->buildDirectory . '/extension-incremental.cc';
$extensionCode = file_get_contents($extension);
self::assertStringContainsString('php_register_class_Incremental_Counter', file_get_contents($arginfo));
self::assertStringContainsString('ZEND_METHOD(Incremental_Counter, add)', $extensionCode);
self::assertStringContainsString('php_incremental__counter__add(this_, arg_delta)', $extensionCode);
self::assertStringContainsString('#include <' . basename($arginfo) . '>', $extensionCode);
self::assertStringNotContainsString(basename($arginfo), file_get_contents($this->invoke($first, 'getCppFile', $this->consumer)));
self::assertFileDoesNotExist($this->invoke($first, 'getCppFile', $this->provider));
$this->convertProject();
self::assertSame($extensionCode, file_get_contents($extension));
}
private function convertProject(): CompilerTest
{
global $translator;

@ -36,7 +36,8 @@ class Msvc extends CompilerBackend
private function buildCommonCompileFlags(array $config, bool $includeCppOptions = true): string
{
$cmd = '';
// Generated code/templates can exceed ordinary COFF section limits.
$cmd = ' /bigobj';
$cmd .= ' /utf-8 /DZEND_WIN32 /DPHP_WIN32 /DZEND_DEBUG=0 /DENABLE_INTSAFE_SIGNED_FUNCTIONS';

@ -20,6 +20,26 @@ trait IncrementalCompilationTrait
private string $incrementalGeneratorFingerprint = '';
private bool $incrementalPlanInitialized = false;
/**
* Stubs provide declarations without emitting a body translation unit.
* Include them in header generation and dependency invalidation as well.
* @param list<string> $files
* @return list<string>
*/
private function getDeclarationInputFiles(array $files): array
{
$inputs = [];
foreach ($files as $file) {
$inputs[realpath($file) ?: $file] = true;
}
foreach ($this->preparedFileAsts as $file => $_ast) {
if ($this->isStubFile($file)) {
$inputs[$file] = true;
}
}
return array_keys($inputs);
}
/** @param list<string> $files */
protected function initializeIncrementalCompilation(array $files): void
{
@ -29,7 +49,7 @@ trait IncrementalCompilationTrait
$this->incrementalDirtyFiles = [];
$phpFiles = [];
foreach ($files as $file) {
foreach ($this->getDeclarationInputFiles($files) as $file) {
if (!FileScanner::isPhpFile($file)) {
continue;
}
@ -112,7 +132,7 @@ trait IncrementalCompilationTrait
if (!$this->incrementalPlanInitialized) {
return;
}
foreach ($files as $file) {
foreach ($this->getDeclarationInputFiles($files) as $file) {
if (!FileScanner::isPhpFile($file)) {
continue;
}
@ -198,7 +218,7 @@ trait IncrementalCompilationTrait
}
$this->rebuildIncrementalGlobalState();
$phpFiles = [];
foreach ($files as $file) {
foreach ($this->getDeclarationInputFiles($files) as $file) {
if (!FileScanner::isPhpFile($file)) {
continue;
}
@ -218,7 +238,7 @@ trait IncrementalCompilationTrait
}
$this->rebuildIncrementalGlobalState();
$stateFiles = [];
foreach ($files as $file) {
foreach ($this->getDeclarationInputFiles($files) as $file) {
if (!FileScanner::isPhpFile($file)) {
continue;
}
@ -248,7 +268,7 @@ trait IncrementalCompilationTrait
'symbolsDeclared' => $declared,
'symbolsUsed' => $used,
'emitsTranslationUnit' => $this->incrementalTranslationUnits[$path]
?? (bool) ($previous['emitsTranslationUnit'] ?? false),
?? (!$this->isStubFile($path) && (bool) ($previous['emitsTranslationUnit'] ?? false)),
'header' => $this->getDeclarationHeaderFile($path),
'cpp' => $this->getCppFile($path),
'splitTranslationUnits' => $this->getSplitTranslationUnits($path),

@ -368,7 +368,7 @@ trait SourcePipelineTrait
// All declarations are now known. Lower declaration constant
// expressions before translating any function body so cache IDs
// are assigned exclusively in the convert phase.
$this->finalizeDeclarationExpressions($files);
$this->finalizeDeclarationExpressions($this->getDeclarationInputFiles($files));
// Whole-program extension generation must not depend on conversion
// side effects from dirty files. Clean incremental files are not
// converted, but their non-empty property defaults still require a
@ -377,6 +377,14 @@ trait SourcePipelineTrait
$this->initializeDeclarationHeaderFiles($files);
$this->restoreCleanIncrementalMetadata($files);
// Native/import stubs are declaration inputs, not ordinary PHP
// bodies. Their Zend metadata still belongs to the module entry.
foreach ($this->getDeclarationInputFiles($files) as $file) {
if ($this->isStubFile($file) && $this->shouldRegeneratePhpFile($file)) {
$this->genStubFile($file);
}
}
$sourceFiles = [];
$validSourceCount = 0;
// Generate the C++ files

@ -1193,6 +1193,10 @@ class Translator extends Preprocessor
sort($this->registerSymbols, SORT_STRING);
sort($this->releaseAstConstantFns, SORT_STRING);
$this->localHeaders = $this->argInfoHeaderFiles;
// Stubs have native implementations, but still need local Zend
// callbacks. Generate these before sizing caches/rendering literals:
// parameter validation may allocate additional stable cache IDs.
$stubWrapperCode = $this->genStubWrappers();
// genExtension() is also a public code-generation entry used directly
// by tooling and tests, outside SourcePipelineTrait::convert(). Keep the
// whole-program property-default invariant local to the consumer too.
@ -1395,6 +1399,9 @@ CODE;
$code .= '} // namespace ' . $projectNamespace . PHP_EOL . PHP_EOL;
$code .= "// native/import stub callbacks \n";
$code .= $stubWrapperCode;
$code .= "// default argument values \n";
$code .= $this->genDefaultArgumentHelperDefinitions();
@ -2770,7 +2777,7 @@ CODE;
private function initializeDeclarationHeaderFiles(array $files): void
{
$this->declarationHeaderFiles = [];
foreach ($files as $file) {
foreach ($this->getDeclarationInputFiles($files) as $file) {
if (FileScanner::isPhpFile($file)) {
$this->declarationHeaderFiles[$file] = $this->getDeclarationHeaderFile($file, true);
}
@ -2831,17 +2838,14 @@ CODE;
$this->writeFile($runtimeHeader, '#pragma once' . PHP_EOL . PHP_EOL
. $this->renderDataDeclarations(null, true)
. $this->genNativeObjectForwardDeclarations());
foreach ($files as $file) {
if (!isset($this->declarationHeaderFiles[$file])) {
continue;
}
foreach ($this->declarationHeaderFiles as $file => $header) {
if (!$this->shouldRegeneratePhpFile($file)) {
continue;
}
$code = $this->renderFunctionDeclarations($file);
$code .= $this->renderDataDeclarations($file);
$this->writeFile(
$this->getIncludeDir() . '/' . $this->declarationHeaderFiles[$file],
$this->getIncludeDir() . '/' . $header,
$code,
$this->shouldRegeneratePhpFile($file),
);
@ -3060,6 +3064,13 @@ CODE;
];
} else {
$declarationHeaders = [$this->getRuntimeDeclarationHeaderName()];
// The callbacks emitted here bridge stub declarations to native
// C++ implementations. Arginfo remains included only by this TU.
foreach ($this->declarationHeaderFiles as $source => $header) {
if ($this->isStubFile($source)) {
$declarationHeaders[] = $header;
}
}
// Generated arginfo registration helpers call compile-time
// attribute factories directly to materialize lazy values such as
// enum cases. Include only the declaration owners of those helper
@ -8410,6 +8421,29 @@ CODE;
return $cppCode;
}
private function genStubWrappers(): string
{
$code = '';
foreach ($this->symbols->classes() as $classDef) {
if (!$this->isStubFile($classDef->sourceFile) || $classDef->nativeObject || $classDef->trait !== null) {
continue;
}
foreach ($classDef->methods as $methodDef) {
if (!$methodDef->functionDef->abstractMethod
&& !$this->functionUsesNativeObject($methodDef->functionDef)) {
$code .= $this->genMethodWrapper($classDef, $methodDef);
}
}
}
foreach ($this->symbols->functions() as $functionDef) {
if ($functionDef->stub && !$functionDef->method && !$functionDef->attributeFactory
&& !$this->functionUsesNativeObject($functionDef)) {
$code .= $this->genFunctionWrapper($functionDef);
}
}
return $code;
}
/** Return the generated C++ symbol for a hidden runtime-attribute factory. */
public function getRuntimeAttributeFactoryNativeName(string $fullName): string
{

Loading…
Cancel
Save