- Replace sequential indexing with stable ID allocation for persistent entities - Add StableIdRegistry to maintain consistent IDs across builds - Implement incremental compilation with dependency tracking - Add IncrementalBuildState for tracking file dependencies and dirty propagation - Update anonymous class generation to use stable call site IDs - Modify type ID allocation to start from 1 and avoid renumbering - Add literal string ID allocation using stable registry - Implement global variable usage recording and tracking - Add dependency symbols for classes, functions, constants, and globals - Update property and function index allocation to use stable IDs - Add cache site ID allocation based on call site context - Implement compilation statistics merging and delta calculation - Add file writing with content comparison to avoid unnecessary writes - Implement incremental build cache clearing functionality - Add metadata restoration for incremental compilation - Create dependency graph building and dirty file propagation logic - Implement translation unit emission tracking for incremental buildsmaster
parent
7955d1613a
commit
4d2bdea924
20 changed files with 2044 additions and 144 deletions
@ -0,0 +1,112 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Tests\Build; |
||||
|
||||
use PHPUnit\Framework\TestCase; |
||||
use TypePhp\Build\IncrementalBuildState; |
||||
|
||||
final class IncrementalBuildStateTest extends TestCase |
||||
{ |
||||
private string $directory; |
||||
private string $stateFile; |
||||
|
||||
protected function setUp(): void |
||||
{ |
||||
$this->directory = sys_get_temp_dir() . '/typephp_build_graph_' . bin2hex(random_bytes(8)); |
||||
mkdir($this->directory, 0777, true); |
||||
$this->stateFile = $this->directory . '/build-state.json'; |
||||
} |
||||
|
||||
protected function tearDown(): void |
||||
{ |
||||
if (is_file($this->stateFile)) { |
||||
unlink($this->stateFile); |
||||
} |
||||
rmdir($this->directory); |
||||
} |
||||
|
||||
public function testChangedDependencyPropagatesThroughCyclesAndConsumers(): void |
||||
{ |
||||
$this->saveBaseline(); |
||||
$state = new IncrementalBuildState($this->stateFile); |
||||
|
||||
$dirty = $state->dirtyFiles( |
||||
['A.php' => 'a', 'B.php' => 'b2', 'C.php' => 'c', 'D.php' => 'd'], |
||||
[ |
||||
'A.php' => ['B.php'], |
||||
'B.php' => ['A.php'], |
||||
'C.php' => ['A.php'], |
||||
'D.php' => [], |
||||
], |
||||
'generator', |
||||
static fn(string $_source, ?array $_metadata): bool => true, |
||||
); |
||||
|
||||
self::assertSame(['A.php', 'B.php', 'C.php'], array_keys($dirty)); |
||||
} |
||||
|
||||
public function testRemovedDependencyInvalidatesItsPreviousConsumers(): void |
||||
{ |
||||
$this->saveBaseline(); |
||||
$state = new IncrementalBuildState($this->stateFile); |
||||
|
||||
$dirty = $state->dirtyFiles( |
||||
['B.php' => 'b', 'C.php' => 'c', 'D.php' => 'd'], |
||||
['B.php' => [], 'C.php' => [], 'D.php' => []], |
||||
'generator', |
||||
static fn(string $_source, ?array $_metadata): bool => true, |
||||
); |
||||
|
||||
self::assertSame(['B.php', 'C.php'], array_keys($dirty)); |
||||
} |
||||
|
||||
public function testMissingArtifactAndGeneratorChangeInvalidateExpectedFiles(): void |
||||
{ |
||||
$this->saveBaseline(); |
||||
$state = new IncrementalBuildState($this->stateFile); |
||||
$hashes = ['A.php' => 'a', 'B.php' => 'b', 'C.php' => 'c', 'D.php' => 'd']; |
||||
$dependencies = [ |
||||
'A.php' => ['B.php'], |
||||
'B.php' => ['A.php'], |
||||
'C.php' => ['A.php'], |
||||
'D.php' => [], |
||||
]; |
||||
|
||||
$dirty = $state->dirtyFiles( |
||||
$hashes, |
||||
$dependencies, |
||||
'generator', |
||||
static fn(string $source, ?array $_metadata): bool => $source !== 'A.php', |
||||
); |
||||
self::assertSame(['A.php', 'B.php', 'C.php'], array_keys($dirty)); |
||||
|
||||
$dirty = $state->dirtyFiles( |
||||
$hashes, |
||||
$dependencies, |
||||
'new-generator', |
||||
static fn(string $_source, ?array $_metadata): bool => true, |
||||
); |
||||
self::assertSame(array_keys($hashes), array_keys($dirty)); |
||||
} |
||||
|
||||
private function saveBaseline(): void |
||||
{ |
||||
$state = new IncrementalBuildState($this->stateFile); |
||||
$state->save('generator', [ |
||||
'A.php' => $this->metadata('a', ['B.php']), |
||||
'B.php' => $this->metadata('b', ['A.php']), |
||||
'C.php' => $this->metadata('c', ['A.php']), |
||||
'D.php' => $this->metadata('d', []), |
||||
]); |
||||
} |
||||
|
||||
/** @param list<string> $dependencies @return array<string, mixed> */ |
||||
private function metadata(string $hash, array $dependencies): array |
||||
{ |
||||
return [ |
||||
'hash' => $hash, |
||||
'dependencies' => $dependencies, |
||||
'emitsTranslationUnit' => true, |
||||
]; |
||||
} |
||||
} |
||||
@ -0,0 +1,290 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Tests\Build; |
||||
|
||||
use PHPUnit\Framework\TestCase; |
||||
use ReflectionClass; |
||||
use TypePhp\CompilerTest; |
||||
|
||||
final class IncrementalDeclarationTest extends TestCase |
||||
{ |
||||
private string $directory; |
||||
private string $buildDirectory; |
||||
private string $provider; |
||||
private string $consumer; |
||||
private string $independent; |
||||
|
||||
protected function setUp(): void |
||||
{ |
||||
$this->directory = sys_get_temp_dir() . '/typephp_incremental_' . bin2hex(random_bytes(8)); |
||||
$this->buildDirectory = $this->directory . '/build'; |
||||
mkdir($this->directory, 0777, true); |
||||
$this->provider = $this->directory . '/provider.php'; |
||||
$this->consumer = $this->directory . '/consumer.php'; |
||||
$this->independent = $this->directory . '/independent.php'; |
||||
file_put_contents($this->provider, <<<'PHP' |
||||
<?php |
||||
namespace Incremental; |
||||
|
||||
const LIMIT = 42; |
||||
|
||||
function answer(): int |
||||
{ |
||||
global $shared; |
||||
$shared = LIMIT; |
||||
return LIMIT; |
||||
} |
||||
PHP); |
||||
file_put_contents($this->independent, <<<'PHP' |
||||
<?php |
||||
|
||||
function independent(): string |
||||
{ |
||||
return 'stable literal'; |
||||
} |
||||
PHP); |
||||
file_put_contents($this->consumer, <<<'PHP' |
||||
<?php |
||||
|
||||
function main(): int |
||||
{ |
||||
global $shared; |
||||
return \Incremental\answer() + \Incremental\LIMIT; |
||||
} |
||||
PHP); |
||||
} |
||||
|
||||
protected function tearDown(): void |
||||
{ |
||||
$this->removeDirectory($this->directory); |
||||
} |
||||
|
||||
public function testDeclarationsAreSplitBySourceAndDependenciesAreIncluded(): void |
||||
{ |
||||
$compiler = $this->convertProject(); |
||||
$providerHeader = $compiler->getDeclarationHeaderFile($this->provider); |
||||
$consumerHeader = $compiler->getDeclarationHeaderFile($this->consumer); |
||||
$consumerCpp = $this->invoke($compiler, 'getCppFile', $this->consumer); |
||||
|
||||
self::assertFileExists($providerHeader); |
||||
self::assertFileExists($consumerHeader); |
||||
self::assertFileExists($this->buildDirectory . '/include/php_incremental_runtime_decl.h'); |
||||
self::assertFileExists($this->buildDirectory . '/include/php_incremental_all_decl.h'); |
||||
self::assertFileDoesNotExist($this->buildDirectory . '/include/php_incremental_func_decl.h'); |
||||
self::assertFileDoesNotExist($this->buildDirectory . '/include/php_incremental_data_decl.h'); |
||||
|
||||
$providerDeclarations = (string) file_get_contents($providerHeader); |
||||
$consumerDeclarations = (string) file_get_contents($consumerHeader); |
||||
$runtimeDeclarations = (string) file_get_contents( |
||||
$this->buildDirectory . '/include/php_incremental_runtime_decl.h', |
||||
); |
||||
$consumerCode = (string) file_get_contents($consumerCpp); |
||||
self::assertStringContainsString('php_incremental__answer(', $providerDeclarations); |
||||
self::assertStringContainsString('_const_var_Incremental__LIMIT', $providerDeclarations); |
||||
self::assertStringContainsString('_global_var_shared', $providerDeclarations); |
||||
self::assertStringNotContainsString('_global_var_shared', $consumerDeclarations); |
||||
self::assertStringNotContainsString('_global_var_shared', $runtimeDeclarations); |
||||
self::assertStringNotContainsString('php_main(', $providerDeclarations); |
||||
self::assertStringContainsString('php_main(', $consumerDeclarations); |
||||
self::assertStringContainsString( |
||||
'#include <' . basename($providerHeader) . '>', |
||||
$consumerDeclarations, |
||||
); |
||||
self::assertStringContainsString( |
||||
'#include <' . basename($consumerHeader) . '>', |
||||
$consumerCode, |
||||
); |
||||
|
||||
$symbols = $this->property($compiler, 'symbolDeclInFile'); |
||||
self::assertSame($this->provider, $symbols['function:incremental\\answer']); |
||||
self::assertSame($this->provider, $symbols['constant:Incremental\\LIMIT']); |
||||
} |
||||
|
||||
public function testUnchangedGeneratedCppKeepsItsTimestamp(): void |
||||
{ |
||||
$first = $this->convertProject(); |
||||
$consumerCpp = $this->invoke($first, 'getCppFile', $this->consumer); |
||||
$oldTimestamp = 1_600_000_000; |
||||
touch($consumerCpp, $oldTimestamp); |
||||
clearstatcache(true, $consumerCpp); |
||||
|
||||
$this->convertProject(); |
||||
|
||||
clearstatcache(true, $consumerCpp); |
||||
self::assertSame($oldTimestamp, filemtime($consumerCpp)); |
||||
self::assertStringContainsString( |
||||
'get_str(uint32_t index)', |
||||
(string) file_get_contents($this->buildDirectory . '/include/php_incremental_runtime_decl.h'), |
||||
); |
||||
} |
||||
|
||||
public function testChangedDeclarationRegeneratesItsTransitiveConsumersOnly(): void |
||||
{ |
||||
$first = $this->convertProject(); |
||||
$providerCpp = $this->invoke($first, 'getCppFile', $this->provider); |
||||
$consumerCpp = $this->invoke($first, 'getCppFile', $this->consumer); |
||||
$independentCpp = $this->invoke($first, 'getCppFile', $this->independent); |
||||
$oldTimestamp = 1_600_000_000; |
||||
foreach ([$providerCpp, $consumerCpp, $independentCpp] as $cppFile) { |
||||
touch($cppFile, $oldTimestamp); |
||||
} |
||||
file_put_contents($this->provider, "\n// changed provider\n", FILE_APPEND); |
||||
clearstatcache(); |
||||
|
||||
$this->convertProject(); |
||||
|
||||
clearstatcache(); |
||||
self::assertGreaterThan($oldTimestamp, filemtime($providerCpp)); |
||||
self::assertGreaterThan($oldTimestamp, filemtime($consumerCpp)); |
||||
self::assertSame($oldTimestamp, filemtime($independentCpp)); |
||||
} |
||||
|
||||
public function testGeneratedObjectCacheRequiresAnObjectNotOlderThanItsCppSource(): void |
||||
{ |
||||
$compiler = $this->convertProject(); |
||||
$consumerCpp = $this->invoke($compiler, 'getCppFile', $this->consumer); |
||||
$consumerObject = $compiler->getObjectFile($consumerCpp); |
||||
$sourceTimestamp = 1_600_000_000; |
||||
$objectTimestamp = $sourceTimestamp + 10; |
||||
|
||||
file_put_contents($consumerObject, 'object'); |
||||
touch($consumerCpp, $sourceTimestamp); |
||||
touch($consumerObject, $objectTimestamp); |
||||
clearstatcache(); |
||||
$this->invoke($compiler, 'writeGeneratedObjectCacheMetadata', $consumerCpp, $consumerObject); |
||||
|
||||
self::assertTrue( |
||||
$this->invoke($compiler, 'hasGeneratedObjectFileCache', $consumerCpp, $consumerObject), |
||||
); |
||||
|
||||
touch($consumerCpp, $objectTimestamp); |
||||
clearstatcache(); |
||||
self::assertTrue( |
||||
$this->invoke($compiler, 'hasGeneratedObjectFileCache', $consumerCpp, $consumerObject), |
||||
); |
||||
|
||||
touch($consumerCpp, $objectTimestamp + 1); |
||||
clearstatcache(); |
||||
self::assertFalse( |
||||
$this->invoke($compiler, 'hasGeneratedObjectFileCache', $consumerCpp, $consumerObject), |
||||
); |
||||
|
||||
touch($consumerCpp, $sourceTimestamp); |
||||
unlink($consumerObject); |
||||
clearstatcache(); |
||||
self::assertFalse( |
||||
$this->invoke($compiler, 'hasGeneratedObjectFileCache', $consumerCpp, $consumerObject), |
||||
); |
||||
|
||||
file_put_contents($consumerObject, 'object'); |
||||
touch($consumerObject, $objectTimestamp); |
||||
unlink($consumerCpp); |
||||
clearstatcache(); |
||||
self::assertFalse( |
||||
$this->invoke($compiler, 'hasGeneratedObjectFileCache', $consumerCpp, $consumerObject), |
||||
); |
||||
} |
||||
|
||||
public function testLinkCacheRequiresEveryObjectAndAnUpToDateTarget(): void |
||||
{ |
||||
$compiler = $this->newCompiler(); |
||||
$target = $this->directory . '/incremental-bin'; |
||||
$firstObject = $this->directory . '/first.o'; |
||||
$secondObject = $this->directory . '/second.o'; |
||||
$timestamp = 1_600_000_000; |
||||
foreach ([$target, $firstObject, $secondObject] as $file) { |
||||
file_put_contents($file, 'artifact'); |
||||
touch($file, $timestamp); |
||||
} |
||||
clearstatcache(); |
||||
|
||||
$objects = [$firstObject, $secondObject]; |
||||
$this->invoke($compiler, 'writeLinkCache', $objects, $target); |
||||
self::assertTrue($this->invoke($compiler, 'hasLinkCache', $objects, $target)); |
||||
|
||||
touch($secondObject, $timestamp + 1); |
||||
clearstatcache(); |
||||
self::assertFalse($this->invoke($compiler, 'hasLinkCache', $objects, $target)); |
||||
|
||||
unlink($secondObject); |
||||
clearstatcache(); |
||||
self::assertFalse($this->invoke($compiler, 'hasLinkCache', $objects, $target)); |
||||
} |
||||
|
||||
public function testForceCacheClearRemovesAstAndTargetIncrementalState(): void |
||||
{ |
||||
$compiler = $this->newCompiler(); |
||||
$astDirectory = $this->buildDirectory . '/cache/ast'; |
||||
$incrementalDirectory = $this->buildDirectory . '/cache/incremental/incremental'; |
||||
mkdir($astDirectory, 0777, true); |
||||
mkdir($incrementalDirectory, 0777, true); |
||||
file_put_contents($astDirectory . '/entry.ast', 'ast'); |
||||
file_put_contents($incrementalDirectory . '/stable-ids.json', '{}'); |
||||
|
||||
$this->invoke($compiler, 'clearIncrementalBuildCache'); |
||||
|
||||
self::assertDirectoryDoesNotExist($astDirectory); |
||||
self::assertDirectoryDoesNotExist($incrementalDirectory); |
||||
} |
||||
|
||||
private function convertProject(): CompilerTest |
||||
{ |
||||
global $translator; |
||||
|
||||
$compiler = $this->newCompiler(); |
||||
$translator = $compiler; |
||||
$files = [$this->provider, $this->consumer, $this->independent]; |
||||
$compiler->addFiles([$this->directory]); |
||||
foreach ($files as $file) { |
||||
$compiler->prepareFile($file); |
||||
} |
||||
$files = $compiler->getSortedFiles($files); |
||||
$this->invoke($compiler, 'initializeIncrementalCompilation', $files); |
||||
$compiler->convert($files); |
||||
return $compiler; |
||||
} |
||||
|
||||
private function newCompiler(): CompilerTest |
||||
{ |
||||
$compiler = CompilerTest::create($this->directory); |
||||
// Exercise the same on-disk stable-ID registry used by real builds. |
||||
$reflection = new ReflectionClass($compiler); |
||||
$forTest = $reflection->getProperty('forTest'); |
||||
$forTest->setAccessible(true); |
||||
$forTest->setValue($compiler, false); |
||||
$compiler->setTargetName('incremental'); |
||||
$this->invoke($compiler, 'setBuildDir', $this->buildDirectory); |
||||
return $compiler; |
||||
} |
||||
|
||||
private function invoke(CompilerTest $compiler, string $method, mixed ...$arguments): mixed |
||||
{ |
||||
$reflection = new ReflectionClass($compiler); |
||||
$methodReflection = $reflection->getMethod($method); |
||||
$methodReflection->setAccessible(true); |
||||
return $methodReflection->invoke($compiler, ...$arguments); |
||||
} |
||||
|
||||
private function property(CompilerTest $compiler, string $name): mixed |
||||
{ |
||||
$reflection = new ReflectionClass($compiler); |
||||
$property = $reflection->getProperty($name); |
||||
$property->setAccessible(true); |
||||
return $property->getValue($compiler); |
||||
} |
||||
|
||||
private function removeDirectory(string $directory): void |
||||
{ |
||||
if (!is_dir($directory)) { |
||||
return; |
||||
} |
||||
$iterator = new \RecursiveIteratorIterator( |
||||
new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), |
||||
\RecursiveIteratorIterator::CHILD_FIRST, |
||||
); |
||||
foreach ($iterator as $entry) { |
||||
$entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); |
||||
} |
||||
rmdir($directory); |
||||
} |
||||
} |
||||
@ -0,0 +1,65 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Tests\Build; |
||||
|
||||
use PHPUnit\Framework\TestCase; |
||||
use TypePhp\Build\StableIdRegistry; |
||||
|
||||
final class StableIdRegistryTest extends TestCase |
||||
{ |
||||
private string $directory; |
||||
private string $cacheFile; |
||||
|
||||
protected function setUp(): void |
||||
{ |
||||
$this->directory = sys_get_temp_dir() . '/typephp_stable_ids_' . bin2hex(random_bytes(8)); |
||||
$this->cacheFile = $this->directory . '/stable-ids.json'; |
||||
} |
||||
|
||||
protected function tearDown(): void |
||||
{ |
||||
if (is_file($this->cacheFile)) { |
||||
unlink($this->cacheFile); |
||||
} |
||||
if (is_dir($this->directory)) { |
||||
rmdir($this->directory); |
||||
} |
||||
} |
||||
|
||||
public function testIdsRemainStableAcrossCompilerProcesses(): void |
||||
{ |
||||
$first = new StableIdRegistry($this->cacheFile); |
||||
self::assertSame(0, $first->allocate('literal', "first\0value")); |
||||
self::assertSame(1, $first->allocate('literal', 'second')); |
||||
self::assertSame(0, $first->allocate('class', 'Example')); |
||||
$first->flush(); |
||||
|
||||
$second = new StableIdRegistry($this->cacheFile); |
||||
self::assertSame(1, $second->allocate('literal', 'second')); |
||||
self::assertSame(0, $second->allocate('literal', "first\0value")); |
||||
self::assertSame(2, $second->allocate('literal', 'third')); |
||||
self::assertSame(3, $second->capacity('literal')); |
||||
self::assertSame([ |
||||
"first\0value" => 0, |
||||
'second' => 1, |
||||
'third' => 2, |
||||
], $second->entries('literal')); |
||||
} |
||||
|
||||
public function testInvalidPersistedCursorCannotReuseAnExistingId(): void |
||||
{ |
||||
mkdir($this->directory, 0777, true); |
||||
file_put_contents($this->cacheFile, json_encode([ |
||||
'schema' => 2, |
||||
'domains' => [ |
||||
'function' => ['b:' . base64_encode('old') => 7], |
||||
], |
||||
'nextIds' => ['function' => 1], |
||||
], JSON_THROW_ON_ERROR)); |
||||
|
||||
$registry = new StableIdRegistry($this->cacheFile); |
||||
|
||||
self::assertSame(7, $registry->allocate('function', 'old')); |
||||
self::assertSame(8, $registry->allocate('function', 'new')); |
||||
} |
||||
} |
||||
@ -0,0 +1,189 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
/** |
||||
* Persistent Make-style dependency graph for generated PHP translation units. |
||||
* |
||||
* Edges point from a PHP source to the PHP sources whose declarations it uses. |
||||
* Dirty propagation therefore walks the reverse graph. The walk is deliberately |
||||
* iterative and visited-based so mutually dependent files form an ordinary |
||||
* strongly-connected component instead of a recursion problem. |
||||
*/ |
||||
final class IncrementalBuildState |
||||
{ |
||||
private const int SCHEMA_VERSION = 1; |
||||
|
||||
/** @var array<string, array<string, mixed>> */ |
||||
private array $files = []; |
||||
private string $generatorFingerprint = ''; |
||||
|
||||
public function __construct(private readonly string $file) |
||||
{ |
||||
$this->restore(); |
||||
} |
||||
|
||||
/** @return null|array<string, mixed> */ |
||||
public function metadata(string $source): ?array |
||||
{ |
||||
return $this->files[$source] ?? null; |
||||
} |
||||
|
||||
/** |
||||
* @param array<string, string> $sourceHashes |
||||
* @param array<string, list<string>> $dependencies |
||||
* @param callable(string, ?array<string, mixed>): bool $artifactsExist |
||||
* @return array<string, true> |
||||
*/ |
||||
public function dirtyFiles( |
||||
array $sourceHashes, |
||||
array $dependencies, |
||||
string $generatorFingerprint, |
||||
callable $artifactsExist, |
||||
bool $force = false, |
||||
): array { |
||||
$currentFiles = array_fill_keys(array_keys($sourceHashes), true); |
||||
if ($force || $this->generatorFingerprint !== $generatorFingerprint) { |
||||
return $currentFiles; |
||||
} |
||||
|
||||
$dirty = []; |
||||
foreach ($sourceHashes as $source => $hash) { |
||||
$metadata = $this->files[$source] ?? null; |
||||
$previousDependencies = is_array($metadata['dependencies'] ?? null) |
||||
? array_values(array_filter($metadata['dependencies'], 'is_string')) |
||||
: []; |
||||
$currentDependencies = $dependencies[$source] ?? []; |
||||
sort($previousDependencies, SORT_STRING); |
||||
sort($currentDependencies, SORT_STRING); |
||||
if (!is_array($metadata) |
||||
|| ($metadata['hash'] ?? null) !== $hash |
||||
|| $previousDependencies !== $currentDependencies |
||||
|| !$artifactsExist($source, $metadata)) { |
||||
$dirty[$source] = true; |
||||
} |
||||
} |
||||
|
||||
// A removed source is a dirty dependency node as well. Consumers found |
||||
// through the previous graph must be regenerated so a removed/moved |
||||
// declaration cannot remain embedded in an old translation unit. |
||||
foreach ($this->files as $source => $_metadata) { |
||||
if (!isset($currentFiles[$source])) { |
||||
$dirty[$source] = true; |
||||
} |
||||
} |
||||
|
||||
$reverse = []; |
||||
foreach ([$this->previousDependencies(), $dependencies] as $graph) { |
||||
foreach ($graph as $consumer => $requirements) { |
||||
foreach ($requirements as $requirement) { |
||||
$reverse[$requirement][$consumer] = true; |
||||
} |
||||
} |
||||
} |
||||
|
||||
$queue = array_keys($dirty); |
||||
for ($offset = 0; isset($queue[$offset]); ++$offset) { |
||||
$dependency = $queue[$offset]; |
||||
foreach ($reverse[$dependency] ?? [] as $consumer => $_) { |
||||
if (isset($dirty[$consumer])) { |
||||
continue; |
||||
} |
||||
$dirty[$consumer] = true; |
||||
$queue[] = $consumer; |
||||
} |
||||
} |
||||
|
||||
$dirty = array_intersect_key($dirty, $currentFiles); |
||||
ksort($dirty, SORT_STRING); |
||||
return $dirty; |
||||
} |
||||
|
||||
/** |
||||
* @param array<string, array<string, mixed>> $files |
||||
*/ |
||||
public function save(string $generatorFingerprint, array $files): void |
||||
{ |
||||
if ($this->file === '') { |
||||
$this->generatorFingerprint = $generatorFingerprint; |
||||
$this->files = $files; |
||||
return; |
||||
} |
||||
ksort($files, SORT_STRING); |
||||
$contents = json_encode([ |
||||
'schema' => self::SCHEMA_VERSION, |
||||
'generatorFingerprint' => $generatorFingerprint, |
||||
'files' => $files, |
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL; |
||||
$directory = dirname($this->file); |
||||
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { |
||||
throw new \RuntimeException('Cannot create incremental build state directory: ' . $directory); |
||||
} |
||||
$temporary = tempnam($directory, '.graph-'); |
||||
if ($temporary === false) { |
||||
throw new \RuntimeException('Cannot create incremental build state temporary file'); |
||||
} |
||||
try { |
||||
if (file_put_contents($temporary, $contents, LOCK_EX) === false) { |
||||
throw new \RuntimeException('Cannot write incremental build state: ' . $this->file); |
||||
} |
||||
if (!@rename($temporary, $this->file)) { |
||||
@unlink($this->file); |
||||
if (!@rename($temporary, $this->file)) { |
||||
throw new \RuntimeException('Cannot write incremental build state: ' . $this->file); |
||||
} |
||||
} |
||||
} finally { |
||||
if (is_file($temporary)) { |
||||
@unlink($temporary); |
||||
} |
||||
} |
||||
$this->generatorFingerprint = $generatorFingerprint; |
||||
$this->files = $files; |
||||
} |
||||
|
||||
/** @return array<string, list<string>> */ |
||||
private function previousDependencies(): array |
||||
{ |
||||
$dependencies = []; |
||||
foreach ($this->files as $source => $metadata) { |
||||
$values = is_array($metadata['dependencies'] ?? null) |
||||
? array_values(array_filter($metadata['dependencies'], 'is_string')) |
||||
: []; |
||||
$dependencies[$source] = $values; |
||||
} |
||||
return $dependencies; |
||||
} |
||||
|
||||
private function restore(): void |
||||
{ |
||||
if ($this->file === '' || !is_file($this->file)) { |
||||
return; |
||||
} |
||||
try { |
||||
$contents = file_get_contents($this->file); |
||||
$payload = is_string($contents) |
||||
? json_decode($contents, true, flags: JSON_THROW_ON_ERROR) |
||||
: null; |
||||
if (!is_array($payload) |
||||
|| ($payload['schema'] ?? null) !== self::SCHEMA_VERSION |
||||
|| !is_string($payload['generatorFingerprint'] ?? null) |
||||
|| !is_array($payload['files'] ?? null)) { |
||||
return; |
||||
} |
||||
foreach ($payload['files'] as $source => $metadata) { |
||||
if (!is_string($source) |
||||
|| !is_array($metadata) |
||||
|| !is_string($metadata['hash'] ?? null) |
||||
|| !is_array($metadata['dependencies'] ?? null)) { |
||||
return; |
||||
} |
||||
} |
||||
$this->generatorFingerprint = $payload['generatorFingerprint']; |
||||
$this->files = $payload['files']; |
||||
} catch (\Throwable) { |
||||
$this->files = []; |
||||
$this->generatorFingerprint = ''; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,357 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
/** Project-level incremental planning kept separate from the language parser. */ |
||||
trait IncrementalCompilationTrait |
||||
{ |
||||
private ?IncrementalBuildState $incrementalBuildState = null; |
||||
private string $incrementalBuildStateFile = ''; |
||||
/** @var array<string, string> */ |
||||
private array $incrementalSourceHashes = []; |
||||
/** @var array<string, list<string>> */ |
||||
private array $incrementalDependencies = []; |
||||
/** @var array<string, true> */ |
||||
private array $incrementalDirtyFiles = []; |
||||
/** @var array<string, array<string, mixed>> */ |
||||
private array $incrementalFileMetadata = []; |
||||
/** @var array<string, bool> */ |
||||
private array $incrementalTranslationUnits = []; |
||||
private string $incrementalGeneratorFingerprint = ''; |
||||
private bool $incrementalPlanInitialized = false; |
||||
|
||||
/** @param list<string> $files */ |
||||
protected function initializeIncrementalCompilation(array $files): void |
||||
{ |
||||
$this->incrementalPlanInitialized = false; |
||||
$this->incrementalFileMetadata = []; |
||||
$this->incrementalTranslationUnits = []; |
||||
$this->incrementalDirtyFiles = []; |
||||
|
||||
$phpFiles = []; |
||||
foreach ($files as $file) { |
||||
if (!FileScanner::isPhpFile($file)) { |
||||
continue; |
||||
} |
||||
$path = realpath($file); |
||||
if ($path !== false) { |
||||
$phpFiles[] = $path; |
||||
} |
||||
} |
||||
sort($phpFiles, SORT_STRING); |
||||
|
||||
$this->incrementalSourceHashes = []; |
||||
foreach ($phpFiles as $file) { |
||||
$hash = hash_file('sha256', $file); |
||||
if (!is_string($hash)) { |
||||
throw new \RuntimeException('Cannot hash PHP source: ' . $file); |
||||
} |
||||
$this->incrementalSourceHashes[$file] = $hash; |
||||
} |
||||
$state = $this->getIncrementalBuildState(); |
||||
$this->restoreGlobalDependencyOwnersForPlanning($phpFiles, $state); |
||||
$this->incrementalDependencies = $this->buildPhpFileDependencyGraph($phpFiles); |
||||
$this->incrementalGeneratorFingerprint = $this->getIncrementalGeneratorFingerprint(); |
||||
$this->incrementalDirtyFiles = $state->dirtyFiles( |
||||
$this->incrementalSourceHashes, |
||||
$this->incrementalDependencies, |
||||
$this->incrementalGeneratorFingerprint, |
||||
function (string $source, ?array $metadata): bool { |
||||
if (!is_array($metadata) |
||||
|| !is_file($this->getDeclarationHeaderFile($source)) |
||||
|| !is_file($this->getArgInfoHeaderFile($source))) { |
||||
return false; |
||||
} |
||||
$emits = $metadata['emitsTranslationUnit'] ?? null; |
||||
if (!is_bool($emits)) { |
||||
return false; |
||||
} |
||||
return !$emits || is_file($this->getCppFile($source)); |
||||
}, |
||||
$this->climate->arguments->defined('force'), |
||||
); |
||||
$this->incrementalPlanInitialized = true; |
||||
} |
||||
|
||||
protected function shouldRegeneratePhpFile(string $file): bool |
||||
{ |
||||
if (!$this->incrementalPlanInitialized) { |
||||
return true; |
||||
} |
||||
$path = realpath($file) ?: $file; |
||||
return isset($this->incrementalDirtyFiles[$path]); |
||||
} |
||||
|
||||
protected function incrementalTranslationUnitWasEmitted(string $file): bool |
||||
{ |
||||
$path = realpath($file) ?: $file; |
||||
return $this->incrementalTranslationUnits[$path] |
||||
?? (bool) ($this->getIncrementalBuildState()->metadata($path)['emitsTranslationUnit'] ?? false); |
||||
} |
||||
|
||||
protected function registerGeneratedProjectSource(string $source): void |
||||
{ |
||||
$this->generatedProjectSources[$source] = true; |
||||
} |
||||
|
||||
/** |
||||
* Restore conversion outputs which are consumed by whole-program generators. |
||||
* |
||||
* @param list<string> $files |
||||
*/ |
||||
protected function restoreCleanIncrementalMetadata(array $files): void |
||||
{ |
||||
if (!$this->incrementalPlanInitialized) { |
||||
return; |
||||
} |
||||
foreach ($files as $file) { |
||||
if (!FileScanner::isPhpFile($file)) { |
||||
continue; |
||||
} |
||||
$file = realpath($file) ?: $file; |
||||
if ($this->shouldRegeneratePhpFile($file)) { |
||||
continue; |
||||
} |
||||
$metadata = $this->getIncrementalBuildState()->metadata($file); |
||||
if (!is_array($metadata)) { |
||||
continue; |
||||
} |
||||
$this->incrementalFileMetadata[$file] = $metadata; |
||||
$this->incrementalTranslationUnits[$file] = (bool) ($metadata['emitsTranslationUnit'] ?? false); |
||||
$statistics = $metadata['statistics'] ?? []; |
||||
if (is_array($statistics)) { |
||||
$this->compilationStatistics->merge($statistics); |
||||
} |
||||
$globals = $metadata['globals'] ?? []; |
||||
if (is_array($globals)) { |
||||
foreach ($globals as $name => $type) { |
||||
if (is_string($name) && is_string($type)) { |
||||
$this->globalVarsInFile[$file][$name] = $type; |
||||
} |
||||
} |
||||
} |
||||
$initializers = $metadata['nativeStaticInitializers'] ?? []; |
||||
if (is_array($initializers)) { |
||||
foreach ($initializers as $name) { |
||||
if (is_string($name)) { |
||||
$this->nativeStaticInitializersInFile[$file][$name] = true; |
||||
} |
||||
} |
||||
} |
||||
$this->registerExistingArgInfoHeader($file); |
||||
} |
||||
$this->rebuildIncrementalGlobalState(); |
||||
} |
||||
|
||||
/** @param array<string, array<string, int>> $statistics */ |
||||
protected function recordIncrementalConversion( |
||||
string $file, |
||||
bool $emitsTranslationUnit, |
||||
array $statistics, |
||||
): void { |
||||
$path = realpath($file) ?: $file; |
||||
$this->incrementalTranslationUnits[$path] = $emitsTranslationUnit; |
||||
$this->incrementalFileMetadata[$path]['statistics'] = $statistics; |
||||
} |
||||
|
||||
protected function rebuildIncrementalGlobalState(): void |
||||
{ |
||||
foreach ($this->globalVarDeclInFile as $name => $_file) { |
||||
unset($this->symbolDeclInFile[$this->getGlobalDependencySymbol($name)]); |
||||
} |
||||
$this->globalVarDeclInFile = []; |
||||
$this->nativeStaticInitializerDeclInFile = []; |
||||
foreach ($this->globalVarsInFile as $file => $globals) { |
||||
ksort($globals, SORT_STRING); |
||||
foreach ($globals as $name => $type) { |
||||
if (!isset($this->nativeGlobalObjects[$name])) { |
||||
$this->globalVars[$name] = $type; |
||||
} |
||||
if (!isset($this->globalVarDeclInFile[$name])) { |
||||
$this->globalVarDeclInFile[$name] = $file; |
||||
$this->symbolDeclInFile[$this->getGlobalDependencySymbol($name)] = $file; |
||||
} |
||||
$this->symbolCallInFile[$file][] = $this->getGlobalDependencySymbol($name); |
||||
} |
||||
} |
||||
foreach ($this->nativeStaticInitializersInFile as $file => $initializers) { |
||||
foreach ($initializers as $name => $_) { |
||||
$this->nativeStaticInitializers[$name] = true; |
||||
$this->nativeStaticInitializerDeclInFile[$name] = $file; |
||||
} |
||||
} |
||||
} |
||||
|
||||
/** @param list<string> $files */ |
||||
protected function finalizeIncrementalConversionMetadata(array $files): void |
||||
{ |
||||
if (!$this->incrementalPlanInitialized) { |
||||
return; |
||||
} |
||||
$this->rebuildIncrementalGlobalState(); |
||||
$phpFiles = []; |
||||
foreach ($files as $file) { |
||||
if (!FileScanner::isPhpFile($file)) { |
||||
continue; |
||||
} |
||||
$path = realpath($file); |
||||
if ($path !== false) { |
||||
$phpFiles[] = $path; |
||||
} |
||||
} |
||||
$this->incrementalDependencies = $this->buildPhpFileDependencyGraph($phpFiles); |
||||
} |
||||
|
||||
/** @param list<string> $files */ |
||||
protected function saveIncrementalCompilationState(array $files): void |
||||
{ |
||||
if (!$this->incrementalPlanInitialized) { |
||||
return; |
||||
} |
||||
$this->rebuildIncrementalGlobalState(); |
||||
$stateFiles = []; |
||||
foreach ($files as $file) { |
||||
if (!FileScanner::isPhpFile($file)) { |
||||
continue; |
||||
} |
||||
$path = realpath($file); |
||||
if ($path === false || !isset($this->incrementalSourceHashes[$path])) { |
||||
continue; |
||||
} |
||||
$previous = $this->incrementalFileMetadata[$path] |
||||
?? $this->getIncrementalBuildState()->metadata($path) |
||||
?? []; |
||||
$declared = []; |
||||
foreach ($this->symbolDeclInFile as $symbol => $declaringFile) { |
||||
if ($declaringFile === $path) { |
||||
$declared[] = $symbol; |
||||
} |
||||
} |
||||
sort($declared, SORT_STRING); |
||||
$used = array_values(array_unique($this->symbolCallInFile[$path] ?? [])); |
||||
sort($used, SORT_STRING); |
||||
$dependencies = $this->incrementalDependencies[$path] ?? []; |
||||
sort($dependencies, SORT_STRING); |
||||
$initializers = array_keys($this->nativeStaticInitializersInFile[$path] ?? []); |
||||
sort($initializers, SORT_STRING); |
||||
$stateFiles[$path] = [ |
||||
'hash' => $this->incrementalSourceHashes[$path], |
||||
'dependencies' => $dependencies, |
||||
'symbolsDeclared' => $declared, |
||||
'symbolsUsed' => $used, |
||||
'emitsTranslationUnit' => $this->incrementalTranslationUnits[$path] |
||||
?? (bool) ($previous['emitsTranslationUnit'] ?? false), |
||||
'header' => $this->getDeclarationHeaderFile($path), |
||||
'cpp' => $this->getCppFile($path), |
||||
'statistics' => $this->incrementalFileMetadata[$path]['statistics'] |
||||
?? ($previous['statistics'] ?? []), |
||||
'globals' => $this->globalVarsInFile[$path] ?? [], |
||||
'nativeStaticInitializers' => $initializers, |
||||
]; |
||||
} |
||||
$this->getIncrementalBuildState()->save( |
||||
$this->incrementalGeneratorFingerprint, |
||||
$stateFiles, |
||||
); |
||||
} |
||||
|
||||
/** @param list<string> $files @return array<string, list<string>> */ |
||||
private function buildPhpFileDependencyGraph(array $files): array |
||||
{ |
||||
$known = array_fill_keys($files, true); |
||||
$dependencies = []; |
||||
foreach ($files as $file) { |
||||
$requirements = []; |
||||
foreach ($this->symbolCallInFile[$file] ?? [] as $symbol) { |
||||
$dependency = $this->symbolDeclInFile[$symbol] ?? null; |
||||
if (is_string($dependency) |
||||
&& $dependency !== $file |
||||
&& isset($known[$dependency])) { |
||||
$requirements[$dependency] = true; |
||||
} |
||||
} |
||||
$list = array_keys($requirements); |
||||
sort($list, SORT_STRING); |
||||
$dependencies[$file] = $list; |
||||
} |
||||
return $dependencies; |
||||
} |
||||
|
||||
private function getIncrementalBuildState(): IncrementalBuildState |
||||
{ |
||||
$file = $this->getBuildDir() . '/cache/incremental/' . $this->targetName . '/build-state.json'; |
||||
if ($this->incrementalBuildState === null || $this->incrementalBuildStateFile !== $file) { |
||||
$this->incrementalBuildState = new IncrementalBuildState($file); |
||||
$this->incrementalBuildStateFile = $file; |
||||
} |
||||
return $this->incrementalBuildState; |
||||
} |
||||
|
||||
/** @param list<string> $files */ |
||||
private function restoreGlobalDependencyOwnersForPlanning( |
||||
array $files, |
||||
IncrementalBuildState $state, |
||||
): void { |
||||
$knownFiles = array_fill_keys($files, true); |
||||
foreach ($files as $file) { |
||||
$metadata = $state->metadata($file); |
||||
if (!is_array($metadata)) { |
||||
continue; |
||||
} |
||||
foreach ($metadata['symbolsDeclared'] ?? [] as $symbol) { |
||||
if (is_string($symbol) && str_starts_with($symbol, 'global:')) { |
||||
$this->symbolDeclInFile[$symbol] = $file; |
||||
} |
||||
} |
||||
} |
||||
foreach ($files as $file) { |
||||
foreach ($this->symbolCallInFile[$file] ?? [] as $symbol) { |
||||
if (!str_starts_with($symbol, 'global:') |
||||
|| isset($this->symbolDeclInFile[$symbol])) { |
||||
continue; |
||||
} |
||||
// A newly introduced global has no previous owner. Assign one |
||||
// deterministically for this planning pass; conversion will |
||||
// record the actual first declaration owner in sorted order. |
||||
$this->symbolDeclInFile[$symbol] = $file; |
||||
} |
||||
} |
||||
foreach ($this->symbolDeclInFile as $symbol => $file) { |
||||
if (str_starts_with($symbol, 'global:') && !isset($knownFiles[$file])) { |
||||
unset($this->symbolDeclInFile[$symbol]); |
||||
} |
||||
} |
||||
} |
||||
|
||||
private function getIncrementalGeneratorFingerprint(): string |
||||
{ |
||||
$context = hash_init('sha256'); |
||||
hash_update($context, serialize([ |
||||
'translator' => self::VERSION, |
||||
'php' => $this->phpVersion, |
||||
'mode' => $this->buildMode, |
||||
'nano' => $this->isNanoMode(), |
||||
'nanoPolicy' => $this->isNanoPolicyMode(), |
||||
'wasi' => $this->isWasiTarget(), |
||||
'platform' => $this->targetPlatform, |
||||
'literalStrings' => !$this->noLiteralStrings, |
||||
'debug' => $this->debug, |
||||
])); |
||||
$sourceDirectory = dirname(__DIR__); |
||||
$iterator = new \RecursiveIteratorIterator( |
||||
new \RecursiveDirectoryIterator($sourceDirectory, \FilesystemIterator::SKIP_DOTS), |
||||
); |
||||
$sources = []; |
||||
foreach ($iterator as $entry) { |
||||
if ($entry->isFile() && $entry->getExtension() === 'php') { |
||||
$sources[] = $entry->getPathname(); |
||||
} |
||||
} |
||||
sort($sources, SORT_STRING); |
||||
foreach ($sources as $source) { |
||||
hash_update($context, str_replace('\\', '/', $source) . "\0"); |
||||
hash_update_file($context, $source); |
||||
} |
||||
return hash_final($context); |
||||
} |
||||
} |
||||
@ -0,0 +1,158 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
/** |
||||
* Append-only IDs used by generated translation units. |
||||
* |
||||
* IDs are deliberately never recycled. An object file from an earlier |
||||
* incremental build can therefore keep referring to its old slot while other |
||||
* source files are regenerated independently. |
||||
*/ |
||||
final class StableIdRegistry |
||||
{ |
||||
private const int SCHEMA_VERSION = 2; |
||||
|
||||
/** @var array<string, array<string, int>> */ |
||||
private array $domains = []; |
||||
/** @var array<string, int> */ |
||||
private array $nextIds = []; |
||||
private bool $dirty = false; |
||||
|
||||
public function __construct(private readonly string $file) |
||||
{ |
||||
$this->restore(); |
||||
} |
||||
|
||||
public function allocate(string $domain, string $key): int |
||||
{ |
||||
// Prefix prevents a base64 result made only of digits from being cast |
||||
// to an integer array key by PHP. |
||||
$encoded = 'b:' . base64_encode($key); |
||||
if (isset($this->domains[$domain][$encoded])) { |
||||
return $this->domains[$domain][$encoded]; |
||||
} |
||||
$id = $this->nextIds[$domain] ?? 0; |
||||
$this->domains[$domain][$encoded] = $id; |
||||
$this->nextIds[$domain] = $id + 1; |
||||
$this->dirty = true; |
||||
return $id; |
||||
} |
||||
|
||||
public function capacity(string $domain): int |
||||
{ |
||||
return $this->nextIds[$domain] ?? 0; |
||||
} |
||||
|
||||
/** @return array<string, int> */ |
||||
public function entries(string $domain): array |
||||
{ |
||||
$entries = []; |
||||
foreach ($this->domains[$domain] ?? [] as $key => $id) { |
||||
$decoded = str_starts_with($key, 'b:') |
||||
? base64_decode(substr($key, 2), true) |
||||
: false; |
||||
if ($decoded !== false) { |
||||
$entries[$decoded] = $id; |
||||
} |
||||
} |
||||
asort($entries, SORT_NUMERIC); |
||||
return $entries; |
||||
} |
||||
|
||||
public function flush(): void |
||||
{ |
||||
if (!$this->dirty || $this->file === '') { |
||||
return; |
||||
} |
||||
$directory = dirname($this->file); |
||||
if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { |
||||
throw new \RuntimeException('Cannot create stable ID cache directory: ' . $directory); |
||||
} |
||||
$contents = json_encode([ |
||||
'schema' => self::SCHEMA_VERSION, |
||||
'domains' => $this->domains, |
||||
'nextIds' => $this->nextIds, |
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL; |
||||
$temporary = tempnam($directory, '.ids-'); |
||||
if ($temporary === false) { |
||||
throw new \RuntimeException('Cannot create stable ID cache temporary file'); |
||||
} |
||||
try { |
||||
if (file_put_contents($temporary, $contents, LOCK_EX) === false) { |
||||
throw new \RuntimeException('Cannot write stable ID cache: ' . $this->file); |
||||
} |
||||
if (!@rename($temporary, $this->file)) { |
||||
@unlink($this->file); |
||||
if (!@rename($temporary, $this->file)) { |
||||
throw new \RuntimeException('Cannot replace stable ID cache: ' . $this->file); |
||||
} |
||||
} |
||||
} finally { |
||||
if (is_file($temporary)) { |
||||
@unlink($temporary); |
||||
} |
||||
} |
||||
$this->dirty = false; |
||||
} |
||||
|
||||
private function restore(): void |
||||
{ |
||||
if ($this->file === '' || !is_file($this->file)) { |
||||
return; |
||||
} |
||||
try { |
||||
$contents = file_get_contents($this->file); |
||||
$payload = is_string($contents) |
||||
? json_decode($contents, true, flags: JSON_THROW_ON_ERROR) |
||||
: null; |
||||
if (!is_array($payload) |
||||
|| ($payload['schema'] ?? null) !== self::SCHEMA_VERSION |
||||
|| !is_array($payload['domains'] ?? null) |
||||
|| !is_array($payload['nextIds'] ?? null) |
||||
) { |
||||
return; |
||||
} |
||||
$domains = []; |
||||
$nextIds = []; |
||||
foreach ($payload['domains'] as $domain => $entries) { |
||||
if (!is_string($domain) || !is_array($entries)) { |
||||
return; |
||||
} |
||||
$usedIds = []; |
||||
foreach ($entries as $key => $id) { |
||||
if (!is_string($key) |
||||
|| !str_starts_with($key, 'b:') |
||||
|| base64_decode(substr($key, 2), true) === false |
||||
|| !is_int($id) |
||||
|| $id < 0 |
||||
|| isset($usedIds[$id])) { |
||||
return; |
||||
} |
||||
$domains[$domain][$key] = $id; |
||||
$usedIds[$id] = true; |
||||
} |
||||
} |
||||
foreach ($payload['nextIds'] as $domain => $nextId) { |
||||
if (is_string($domain) && is_int($nextId) && $nextId >= 0) { |
||||
$nextIds[$domain] = $nextId; |
||||
} |
||||
} |
||||
// Never trust a persisted cursor to be smaller than an existing |
||||
// entry. A truncated/manual cache edit must produce holes at worst, |
||||
// never duplicate an ID already embedded in an object file. |
||||
foreach ($domains as $domain => $entries) { |
||||
$minimumNextId = max($entries) + 1; |
||||
$nextIds[$domain] = max( |
||||
$nextIds[$domain] ?? 0, |
||||
$minimumNextId, |
||||
); |
||||
} |
||||
$this->domains = $domains; |
||||
$this->nextIds = $nextIds; |
||||
} catch (\Throwable) { |
||||
$this->domains = []; |
||||
$this->nextIds = []; |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue