- 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