- Implemented AstCache class with schema versioning and file-based storage - Added cache validation using source hash, PHP version, and parser version - Integrated atomic file operations to ensure cache consistency - Updated CompilerBase to use lazy-initialized AST cache instance - Replaced direct parser calls with cache-aware load operations in preprocessor - Modified translator to utilize cached AST instead of reparsing - Added comprehensive test coverage including cache hit, invalidmaster
parent
7623d367ed
commit
58fe7b5d3c
5 changed files with 387 additions and 5 deletions
@ -0,0 +1,193 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Tests\Build; |
||||
|
||||
use PhpParser\ErrorHandler; |
||||
use PhpParser\Parser; |
||||
use PhpParser\ParserFactory; |
||||
use PHPUnit\Framework\TestCase; |
||||
use TypePhp\Build\AstCache; |
||||
use TypePhp\CompilerTest; |
||||
|
||||
final class CountingParser implements Parser |
||||
{ |
||||
public int $parseCount = 0; |
||||
|
||||
public function __construct(private readonly Parser $delegate) |
||||
{ |
||||
} |
||||
|
||||
public function parse(string $code, ?ErrorHandler $errorHandler = null): ?array |
||||
{ |
||||
++$this->parseCount; |
||||
return $this->delegate->parse($code, $errorHandler); |
||||
} |
||||
|
||||
public function getTokens(): array |
||||
{ |
||||
return $this->delegate->getTokens(); |
||||
} |
||||
} |
||||
|
||||
final class AstCacheTest extends TestCase |
||||
{ |
||||
private string $directory; |
||||
private string $sourceFile; |
||||
private string $buildDirectory; |
||||
|
||||
protected function setUp(): void |
||||
{ |
||||
$this->directory = sys_get_temp_dir() . '/typephp_ast_cache_' . bin2hex(random_bytes(8)); |
||||
$this->buildDirectory = $this->directory . '/custom-build'; |
||||
mkdir($this->buildDirectory, 0777, true); |
||||
$this->sourceFile = $this->directory . '/program.php'; |
||||
} |
||||
|
||||
protected function tearDown(): void |
||||
{ |
||||
$this->removeDirectory($this->directory); |
||||
} |
||||
|
||||
public function testCacheHitSkipsParserAndReturnsAnIndependentAst(): void |
||||
{ |
||||
$source = "<?php function main(): void { echo 'ok'; }\n";
|
||||
file_put_contents($this->sourceFile, $source); |
||||
$parser = $this->createCountingParser(); |
||||
$cache = new AstCache($parser, $this->buildDirectory, '8.4.0', 'test-parser'); |
||||
|
||||
$prepareAst = $cache->load($this->sourceFile, $source); |
||||
$prepareAst[0]->setAttribute('typephpPhaseMutation', true); |
||||
$convertAst = $cache->load($this->sourceFile, $source); |
||||
|
||||
self::assertSame(1, $parser->parseCount); |
||||
self::assertFalse($convertAst[0]->getAttribute('typephpPhaseMutation', false)); |
||||
self::assertDirectoryExists($this->buildDirectory . '/cache/ast'); |
||||
self::assertCount(1, glob($this->buildDirectory . '/cache/ast/*.ast')); |
||||
} |
||||
|
||||
public function testPersistentCacheIsReusedByAnotherCompilerProcess(): void |
||||
{ |
||||
$source = "<?php function cached(): int { return 42; }\n";
|
||||
file_put_contents($this->sourceFile, $source); |
||||
$firstParser = $this->createCountingParser(); |
||||
(new AstCache($firstParser, $this->buildDirectory, '8.4.0', 'test-parser')) |
||||
->load($this->sourceFile, $source); |
||||
|
||||
$secondParser = $this->createCountingParser(); |
||||
$ast = (new AstCache($secondParser, $this->buildDirectory, '8.4.0', 'test-parser')) |
||||
->load($this->sourceFile, $source); |
||||
|
||||
self::assertSame(1, $firstParser->parseCount); |
||||
self::assertSame(0, $secondParser->parseCount); |
||||
self::assertNotEmpty($ast); |
||||
} |
||||
|
||||
public function testNewerOrChangedSourceInvalidatesCache(): void |
||||
{ |
||||
$source = "<?php function value(): int { return 1; }\n";
|
||||
file_put_contents($this->sourceFile, $source); |
||||
$parser = $this->createCountingParser(); |
||||
$cache = new AstCache($parser, $this->buildDirectory, '8.4.0', 'test-parser'); |
||||
$cache->load($this->sourceFile, $source); |
||||
$cacheFile = glob($this->buildDirectory . '/cache/ast/*.ast')[0]; |
||||
|
||||
$changed = "<?php function value(): int { return 2; }\n";
|
||||
file_put_contents($this->sourceFile, $changed); |
||||
touch($this->sourceFile, filemtime($cacheFile) + 2); |
||||
$cache->load($this->sourceFile, $changed); |
||||
|
||||
self::assertSame(2, $parser->parseCount); |
||||
} |
||||
|
||||
public function testContentHashAndMetadataRejectStaleOrCorruptCache(): void |
||||
{ |
||||
$source = "<?php function first(): int { return 1; }\n";
|
||||
file_put_contents($this->sourceFile, $source); |
||||
$parser = $this->createCountingParser(); |
||||
$cache = new AstCache($parser, $this->buildDirectory, '8.4.0', 'test-parser'); |
||||
$cache->load($this->sourceFile, $source); |
||||
$cacheFile = glob($this->buildDirectory . '/cache/ast/*.ast')[0]; |
||||
$cacheMtime = filemtime($cacheFile); |
||||
|
||||
$sameSizeChange = "<?php function other(): int { return 2; }\n";
|
||||
self::assertSame(strlen($source), strlen($sameSizeChange)); |
||||
file_put_contents($this->sourceFile, $sameSizeChange); |
||||
touch($this->sourceFile, $cacheMtime); |
||||
$cache->load($this->sourceFile, $sameSizeChange); |
||||
self::assertSame(2, $parser->parseCount); |
||||
|
||||
file_put_contents($cacheFile, 'broken-cache'); |
||||
touch($cacheFile, time() + 2); |
||||
$cache->load($this->sourceFile, $sameSizeChange); |
||||
self::assertSame(3, $parser->parseCount); |
||||
} |
||||
|
||||
public function testParserVersionChangeInvalidatesCache(): void |
||||
{ |
||||
$source = "<?php function cached(): int { return 42; }\n";
|
||||
file_put_contents($this->sourceFile, $source); |
||||
$firstParser = $this->createCountingParser(); |
||||
(new AstCache($firstParser, $this->buildDirectory, '8.4.0', 'parser-v1')) |
||||
->load($this->sourceFile, $source); |
||||
|
||||
$secondParser = $this->createCountingParser(); |
||||
(new AstCache($secondParser, $this->buildDirectory, '8.4.0', 'parser-v2')) |
||||
->load($this->sourceFile, $source); |
||||
|
||||
self::assertSame(1, $firstParser->parseCount); |
||||
self::assertSame(1, $secondParser->parseCount); |
||||
} |
||||
|
||||
public function testPrepareAndConvertShareOneParseAndRespectBuildDirectory(): void |
||||
{ |
||||
global $translator; |
||||
|
||||
$source = "<?php function main(): void { echo 'cached'; }\n";
|
||||
file_put_contents($this->sourceFile, $source); |
||||
$compiler = CompilerTest::create($this->directory); |
||||
$translator = $compiler; |
||||
$reflection = new \ReflectionClass($compiler); |
||||
$setBuildDir = $reflection->getMethod('setBuildDir'); |
||||
$setBuildDir->setAccessible(true); |
||||
$setBuildDir->invoke($compiler, $this->buildDirectory); |
||||
|
||||
$parser = $this->createCountingParser(); |
||||
$parserProperty = $reflection->getProperty('parser'); |
||||
$parserProperty->setAccessible(true); |
||||
$parserProperty->setValue($compiler, $parser); |
||||
$astCacheProperty = $reflection->getProperty('astCache'); |
||||
$astCacheProperty->setAccessible(true); |
||||
$astCacheProperty->setValue($compiler, null); |
||||
|
||||
$compiler->addFiles([$this->sourceFile]); |
||||
$compiler->prepareFile($this->sourceFile); |
||||
$generated = $compiler->convertFile($this->sourceFile); |
||||
|
||||
self::assertSame(1, $parser->parseCount); |
||||
self::assertNotNull($generated); |
||||
self::assertFileExists($generated); |
||||
self::assertCount(1, glob($this->buildDirectory . '/cache/ast/*.ast')); |
||||
} |
||||
|
||||
private function createCountingParser(): CountingParser |
||||
{ |
||||
return new CountingParser((new ParserFactory())->createForVersion( |
||||
\PhpParser\PhpVersion::fromString('8.4'), |
||||
)); |
||||
} |
||||
|
||||
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,180 @@ |
||||
<?php |
||||
|
||||
namespace TypePhp\Build; |
||||
|
||||
use Composer\InstalledVersions; |
||||
use PhpParser\Node; |
||||
use PhpParser\Parser; |
||||
|
||||
/** |
||||
* Persistent cache of pristine php-parser output. |
||||
* |
||||
* Every load returns an independent AST. The cached tree is serialized before |
||||
* any TypePHP visitor can mutate it, so prepare and convert retain their |
||||
* existing phase-local lowering semantics without parsing the source twice. |
||||
*/ |
||||
final class AstCache |
||||
{ |
||||
private const int SCHEMA_VERSION = 1; |
||||
|
||||
private readonly string $cacheDirectory; |
||||
private readonly string $parserVersion; |
||||
|
||||
public function __construct( |
||||
private readonly Parser $parser, |
||||
string $buildDirectory, |
||||
private readonly string $phpVersion, |
||||
?string $parserVersion = null, |
||||
) { |
||||
$this->cacheDirectory = rtrim($buildDirectory, '/\\') |
||||
. DIRECTORY_SEPARATOR . 'cache' |
||||
. DIRECTORY_SEPARATOR . 'ast'; |
||||
$this->parserVersion = $parserVersion ?? self::detectParserVersion(); |
||||
} |
||||
|
||||
/** @return list<Node\Stmt> */ |
||||
public function load(string $file, string $source): array |
||||
{ |
||||
$cacheFile = $this->getCacheFile($file); |
||||
$sourceHash = hash('sha256', $source); |
||||
$cached = $this->restore($file, $cacheFile, $source, $sourceHash); |
||||
if ($cached !== null) { |
||||
return $cached; |
||||
} |
||||
|
||||
$ast = $this->parser->parse($source); |
||||
if ($ast === null) { |
||||
throw new \LogicException('php-parser returned no AST for: ' . $file); |
||||
} |
||||
$ast = $this->requireStatementList($ast); |
||||
$this->store($cacheFile, $source, $sourceHash, $ast); |
||||
return $ast; |
||||
} |
||||
|
||||
/** @return list<Node\Stmt>|null */ |
||||
private function restore(string $file, string $cacheFile, string $source, string $sourceHash): ?array |
||||
{ |
||||
if (!is_file($cacheFile)) { |
||||
return null; |
||||
} |
||||
|
||||
clearstatcache(true, $file); |
||||
clearstatcache(true, $cacheFile); |
||||
$sourceMtime = filemtime($file); |
||||
$cacheMtime = filemtime($cacheFile); |
||||
if ($sourceMtime === false || $cacheMtime === false || $sourceMtime > $cacheMtime) { |
||||
return null; |
||||
} |
||||
|
||||
$contents = file_get_contents($cacheFile); |
||||
if (!is_string($contents) || $contents === '') { |
||||
return null; |
||||
} |
||||
|
||||
try { |
||||
$payload = @unserialize($contents, ['allowed_classes' => true]); |
||||
} catch (\Throwable) { |
||||
return null; |
||||
} |
||||
if (!is_array($payload) |
||||
|| ($payload['schema'] ?? null) !== self::SCHEMA_VERSION |
||||
|| ($payload['phpVersion'] ?? null) !== $this->phpVersion |
||||
|| ($payload['parserVersion'] ?? null) !== $this->parserVersion |
||||
|| ($payload['sourceSize'] ?? null) !== strlen($source) |
||||
|| ($payload['sourceHash'] ?? null) !== $sourceHash |
||||
|| !isset($payload['ast']) |
||||
|| !is_array($payload['ast']) |
||||
|| !array_is_list($payload['ast']) |
||||
) { |
||||
return null; |
||||
} |
||||
|
||||
try { |
||||
return $this->requireStatementList($payload['ast']); |
||||
} catch (\UnexpectedValueException) { |
||||
return null; |
||||
} |
||||
} |
||||
|
||||
/** @param list<Node\Stmt> $ast */ |
||||
private function store(string $cacheFile, string $source, string $sourceHash, array $ast): void |
||||
{ |
||||
try { |
||||
if (!is_dir($this->cacheDirectory) |
||||
&& !mkdir($this->cacheDirectory, 0777, true) |
||||
&& !is_dir($this->cacheDirectory) |
||||
) { |
||||
return; |
||||
} |
||||
|
||||
$contents = serialize([ |
||||
'schema' => self::SCHEMA_VERSION, |
||||
'phpVersion' => $this->phpVersion, |
||||
'parserVersion' => $this->parserVersion, |
||||
'sourceSize' => strlen($source), |
||||
'sourceHash' => $sourceHash, |
||||
'ast' => $ast, |
||||
]); |
||||
$temporary = tempnam($this->cacheDirectory, '.ast-'); |
||||
if ($temporary === false) { |
||||
return; |
||||
} |
||||
try { |
||||
if (file_put_contents($temporary, $contents, LOCK_EX) === false) { |
||||
return; |
||||
} |
||||
if (!@rename($temporary, $cacheFile)) { |
||||
// Windows cannot atomically replace an existing file. A |
||||
// concurrent reader may safely treat the short gap as a |
||||
// cache miss and rebuild the disposable snapshot. |
||||
@unlink($cacheFile); |
||||
@rename($temporary, $cacheFile); |
||||
} |
||||
} finally { |
||||
if (is_file($temporary)) { |
||||
@unlink($temporary); |
||||
} |
||||
} |
||||
} catch (\Throwable) { |
||||
// AST caching is an optimization. Serialization or filesystem |
||||
// failures must never prevent an otherwise valid compilation. |
||||
} |
||||
} |
||||
|
||||
private function getCacheFile(string $file): string |
||||
{ |
||||
$normalized = str_replace('\\', '/', $file); |
||||
$cacheIdentity = implode("\0", [ |
||||
(string) self::SCHEMA_VERSION, |
||||
$this->phpVersion, |
||||
$this->parserVersion, |
||||
$normalized, |
||||
]); |
||||
return $this->cacheDirectory . DIRECTORY_SEPARATOR |
||||
. hash('sha256', $cacheIdentity) . '.ast'; |
||||
} |
||||
|
||||
/** @param array<mixed> $ast @return list<Node\Stmt> */ |
||||
private function requireStatementList(array $ast): array |
||||
{ |
||||
foreach ($ast as $node) { |
||||
if (!$node instanceof Node\Stmt) { |
||||
throw new \UnexpectedValueException('AST cache contains a non-statement root node'); |
||||
} |
||||
} |
||||
return $ast; |
||||
} |
||||
|
||||
private static function detectParserVersion(): string |
||||
{ |
||||
if (class_exists(InstalledVersions::class) |
||||
&& InstalledVersions::isInstalled('nikic/php-parser') |
||||
) { |
||||
return InstalledVersions::getPrettyVersion('nikic/php-parser') |
||||
?? InstalledVersions::getVersion('nikic/php-parser') |
||||
?? InstalledVersions::getReference('nikic/php-parser') |
||||
?? 'unknown'; |
||||
} |
||||
return 'unknown'; |
||||
} |
||||
} |
||||
Loading…
Reference in new issue