- Add loadPristineAst method to CompilerBase for independent AST loading - Introduce PreparedProjectCacheTrait with conservative whole-project caching mechanism - Implement translation unit splitting for large C++ files with splitLargeTranslationUnit - Add preprocessing warning count tracking in CompilerDiagnosticTrait - Enhance gen_stub.php to use cached AST when available instead of reparsing - Improve incremental compilation checks with splitTranslationUnits validation - Add comprehensive test coverage for AST caching, prepared project cache, and translation unit splitting - Optimize precompiled header manager with dependency digest caching and improved invalidation - Implement file digest memoization for faster native compilation input processing - Add support for persistent AST cache reuse across compiler processesmaster
parent
05c5b591aa
commit
4aba3023cc
13 changed files with 610 additions and 16 deletions
@ -0,0 +1,92 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace TypePhp\Tests\Build; |
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase; |
||||||
|
use TypePhp\CompilerBase; |
||||||
|
use TypePhp\CompilerTest; |
||||||
|
|
||||||
|
final class PreparedProjectCacheTest extends TestCase |
||||||
|
{ |
||||||
|
private string $directory; |
||||||
|
|
||||||
|
protected function setUp(): void |
||||||
|
{ |
||||||
|
$this->directory = sys_get_temp_dir() . '/typephp_prepared_' . bin2hex(random_bytes(8)); |
||||||
|
mkdir($this->directory); |
||||||
|
file_put_contents($this->directory . '/source.php', <<<'PHP' |
||||||
|
<?php |
||||||
|
namespace Prepared; |
||||||
|
trait Helper { public function value(): int { return 42; } } |
||||||
|
class ParentClass { public const ITEMS = ['ready']; } |
||||||
|
class Child extends ParentClass { use Helper; public array $items = ['ready']; } |
||||||
|
PHP); |
||||||
|
} |
||||||
|
|
||||||
|
protected function tearDown(): void |
||||||
|
{ |
||||||
|
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator( |
||||||
|
$this->directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); |
||||||
|
foreach ($iterator as $entry) { |
||||||
|
$entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); |
||||||
|
} |
||||||
|
rmdir($this->directory); |
||||||
|
} |
||||||
|
|
||||||
|
private function compiler(): CompilerTest |
||||||
|
{ |
||||||
|
global $translator; |
||||||
|
$compiler = CompilerTest::create(dirname(__DIR__, 3)); |
||||||
|
$compiler->setTargetName('prepared'); |
||||||
|
$compiler->setBuildMode(CompilerBase::BUILD_MODE_EXT); |
||||||
|
$this->invoke($compiler, 'setBuildDir', $this->directory . '/build'); |
||||||
|
$translator = $compiler; |
||||||
|
return $compiler; |
||||||
|
} |
||||||
|
|
||||||
|
private function invoke(CompilerTest $compiler, string $method, mixed ...$arguments): mixed |
||||||
|
{ |
||||||
|
return (new \ReflectionMethod($compiler, $method))->invoke($compiler, ...$arguments); |
||||||
|
} |
||||||
|
|
||||||
|
public function testPreparedSymbolsTraitsAndDefaultsRoundTrip(): void |
||||||
|
{ |
||||||
|
$file = $this->directory . '/source.php'; |
||||||
|
$first = $this->compiler(); |
||||||
|
$key = $this->invoke($first, 'preparedProjectKey', [$file]); |
||||||
|
$first->prepareFile($file); |
||||||
|
$this->invoke($first, 'composeTraitDeclarations', [$file]); |
||||||
|
$this->invoke($first, 'discoverNativeGlobalObjects', [$file]); |
||||||
|
$this->invoke($first, 'storePreparedProject', $key); |
||||||
|
$first->convert([$file]); |
||||||
|
$extension = file_get_contents($this->directory . '/build/extension-prepared.cc'); |
||||||
|
$second = $this->compiler(); |
||||||
|
self::assertTrue($this->invoke($second, 'restorePreparedProject', $key)); |
||||||
|
self::assertTrue($this->invoke($second, 'hasClass', 'Prepared\\Child')); |
||||||
|
$second->convert([$file]); |
||||||
|
self::assertSame($extension, file_get_contents($this->directory . '/build/extension-prepared.cc')); |
||||||
|
self::assertFalse($this->invoke($second, 'restorePreparedProject', $key)); |
||||||
|
} |
||||||
|
|
||||||
|
public function testSourceChangesInvalidateEvenWithTheSameMtime(): void |
||||||
|
{ |
||||||
|
$file = $this->directory . '/source.php'; |
||||||
|
$compiler = $this->compiler(); |
||||||
|
$key = $this->invoke($compiler, 'preparedProjectKey', [$file]); |
||||||
|
$mtime = filemtime($file); |
||||||
|
file_put_contents($file, str_replace('42', '43', file_get_contents($file))); |
||||||
|
touch($file, $mtime); |
||||||
|
self::assertNotSame($key, $this->invoke($compiler, 'preparedProjectKey', [$file])); |
||||||
|
} |
||||||
|
|
||||||
|
public function testCorruptSnapshotFallsBackWithoutInstallingSymbols(): void |
||||||
|
{ |
||||||
|
$compiler = $this->compiler(); |
||||||
|
$file = $this->invoke($compiler, 'preparedProjectCacheFile'); |
||||||
|
mkdir(dirname($file), 0777, true); |
||||||
|
$key = str_repeat('a', 64); |
||||||
|
file_put_contents($file, $key . "\ncorrupt"); |
||||||
|
self::assertFalse($this->invoke($compiler, 'restorePreparedProject', $key)); |
||||||
|
self::assertFalse($this->invoke($compiler, 'hasClass', 'Prepared\\Child')); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,110 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace TypePhp\Tests\Build; |
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase; |
||||||
|
use TypePhp\CompilerTest; |
||||||
|
|
||||||
|
final class TranslationUnitSplitTest extends TestCase |
||||||
|
{ |
||||||
|
private string $directory; |
||||||
|
private CompilerTest $compiler; |
||||||
|
private string $source; |
||||||
|
|
||||||
|
protected function setUp(): void |
||||||
|
{ |
||||||
|
global $translator; |
||||||
|
$this->directory = sys_get_temp_dir() . '/typephp_split_' . bin2hex(random_bytes(8)); |
||||||
|
mkdir($this->directory); |
||||||
|
$this->source = $this->directory . '/source.php'; |
||||||
|
file_put_contents($this->source, '<?php function main(): void {}'); |
||||||
|
$this->compiler = CompilerTest::create($this->directory); |
||||||
|
$translator = $this->compiler; |
||||||
|
$this->compiler->setTargetName('split'); |
||||||
|
$this->compiler->prepareFile($this->source); |
||||||
|
} |
||||||
|
|
||||||
|
protected function tearDown(): void |
||||||
|
{ |
||||||
|
$iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator( |
||||||
|
$this->directory, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST); |
||||||
|
foreach ($iterator as $entry) { |
||||||
|
$entry->isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); |
||||||
|
} |
||||||
|
rmdir($this->directory); |
||||||
|
} |
||||||
|
|
||||||
|
private function invoke(string $method, mixed ...$arguments): mixed |
||||||
|
{ |
||||||
|
return (new \ReflectionMethod($this->compiler, $method))->invoke($this->compiler, ...$arguments); |
||||||
|
} |
||||||
|
|
||||||
|
private function set(string $property, mixed $value): void |
||||||
|
{ |
||||||
|
$reflection = new \ReflectionClass($this->compiler); |
||||||
|
while (!$reflection->hasProperty($property)) { |
||||||
|
$reflection = $reflection->getParentClass(); |
||||||
|
} |
||||||
|
$reflection->getProperty($property)->setValue($this->compiler, $value); |
||||||
|
} |
||||||
|
|
||||||
|
public function testCompleteEntitiesKeepTheirLocalDataAndStalePartsAreRemoved(): void |
||||||
|
{ |
||||||
|
$primary = $this->invoke('getCppFile', $this->source); |
||||||
|
$body = "void large_method() {\n" . str_repeat("// body\n", 2000) . "use(blob);\n}\n"; |
||||||
|
$small = "void small_helper() {}\n"; |
||||||
|
$code = str_repeat("// padding\n", 200000) . $body . $small; |
||||||
|
$this->set('splitTranslationUnitsEnabled', true); |
||||||
|
$this->set('generatedMethodBodies', [$body, $small]); |
||||||
|
$this->set('constData', ['blob' => '1,2,3', 'unused_blob' => '4,5,6']); |
||||||
|
$remaining = $this->invoke('splitLargeTranslationUnit', $code, $primary, false); |
||||||
|
$parts = $this->invoke('getSplitTranslationUnits', $this->source); |
||||||
|
self::assertCount(1, $parts); |
||||||
|
self::assertStringNotContainsString($body, $remaining); |
||||||
|
self::assertStringContainsString($small, $remaining); |
||||||
|
$part = file_get_contents($parts[0]); |
||||||
|
self::assertStringContainsString($body, $part); |
||||||
|
self::assertStringContainsString('static const unsigned char blob[]', $part); |
||||||
|
self::assertStringNotContainsString('unused_blob', $part); |
||||||
|
self::assertStringNotContainsString('_arginfo.h', $part); |
||||||
|
$this->invoke('splitLargeTranslationUnit', $small, $primary, false); |
||||||
|
self::assertSame([], $this->invoke('getSplitTranslationUnits', $this->source)); |
||||||
|
self::assertFileDoesNotExist($parts[0]); |
||||||
|
} |
||||||
|
|
||||||
|
public function testStandaloneConversionIsNotSilentlySplit(): void |
||||||
|
{ |
||||||
|
$primary = $this->invoke('getCppFile', $this->source); |
||||||
|
$body = str_repeat('// body', 2000); |
||||||
|
$code = str_repeat('// prefix', 250000) . $body; |
||||||
|
$this->set('generatedMethodBodies', [$body]); |
||||||
|
self::assertSame($code, $this->invoke('splitLargeTranslationUnit', $code, $primary, false)); |
||||||
|
self::assertSame([], $this->invoke('getSplitTranslationUnits', $this->source)); |
||||||
|
} |
||||||
|
|
||||||
|
public function testSwitchingBackToStandaloneConversionRemovesOldParts(): void |
||||||
|
{ |
||||||
|
$primary = $this->invoke('getCppFile', $this->source); |
||||||
|
$body = "void large_method() {\n" . str_repeat("// body\n", 2000) . "}\n"; |
||||||
|
$code = str_repeat("// padding\n", 200000) . $body; |
||||||
|
$this->set('splitTranslationUnitsEnabled', true); |
||||||
|
$this->set('generatedMethodBodies', [$body]); |
||||||
|
$this->invoke('splitLargeTranslationUnit', $code, $primary, false); |
||||||
|
$parts = $this->invoke('getSplitTranslationUnits', $this->source); |
||||||
|
self::assertCount(1, $parts); |
||||||
|
$this->set('splitTranslationUnitsEnabled', false); |
||||||
|
self::assertSame($code, $this->invoke('splitLargeTranslationUnit', $code, $primary, false)); |
||||||
|
self::assertSame([], $this->invoke('getSplitTranslationUnits', $this->source)); |
||||||
|
self::assertFileDoesNotExist($parts[0]); |
||||||
|
} |
||||||
|
|
||||||
|
public function testManifestCannotAuthorizeAnUnrelatedPath(): void |
||||||
|
{ |
||||||
|
$primary = $this->invoke('getCppFile', $this->source); |
||||||
|
if (!is_dir(dirname($primary))) { |
||||||
|
mkdir(dirname($primary), 0777, true); |
||||||
|
} |
||||||
|
file_put_contents($primary . '.parts.json', json_encode([$this->source, $primary . '/../other.cc'])); |
||||||
|
self::assertSame([], $this->invoke('getSplitTranslationUnits', $this->source)); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,156 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace TypePhp\Build; |
||||||
|
|
||||||
|
/** Conservative whole-project cache: any input change repeats preprocessing. */ |
||||||
|
trait PreparedProjectCacheTrait |
||||||
|
{ |
||||||
|
private const array PREPARED_PROJECT_FIELDS = [ |
||||||
|
'symbols', 'preparedFileAsts', 'constants', 'symbolDeclInFile', 'symbolCallInFile', |
||||||
|
'classMethodOverride', 'classSubClasses', 'internalFunctions', 'internalConstants', |
||||||
|
'stdTypeMap', 'nativeClassDeclarations', 'nativeGlobalObjects', 'nativeGlobalTypeResolver', |
||||||
|
'globalVars', 'globalVarsInFile', 'globalVarDeclInFile', 'linkLibs', |
||||||
|
'traitDeclarationsComposed', 'declarationExpressionsFinalized', 'methodOverrideFlagsFinalized', |
||||||
|
]; |
||||||
|
|
||||||
|
private function preparedProjectKey(array $files): string |
||||||
|
{ |
||||||
|
$sources = []; |
||||||
|
foreach ($files as $file) { |
||||||
|
$sources[realpath($file) ?: $file] = hash_file('sha256', $file); |
||||||
|
} |
||||||
|
ksort($sources, SORT_STRING); |
||||||
|
$extensions = []; |
||||||
|
foreach (get_loaded_extensions() as $extension) { |
||||||
|
$extensions[$extension] = phpversion($extension); |
||||||
|
} |
||||||
|
$initialState = []; |
||||||
|
foreach (self::PREPARED_PROJECT_FIELDS as $field) { |
||||||
|
$initialState[$field] = $this->{$field}; |
||||||
|
} |
||||||
|
return hash('sha256', serialize([ |
||||||
|
2, $this->getIncrementalGeneratorFingerprint(), $sources, PHP_VERSION, |
||||||
|
$extensions, get_defined_constants(), $this->linkLibs, |
||||||
|
$this->varIntTypes, $this->decimalTypes, $this->bigintTypes, $initialState, |
||||||
|
])); |
||||||
|
} |
||||||
|
|
||||||
|
private function preparedProjectCacheFile(): string |
||||||
|
{ |
||||||
|
return $this->buildDir . '/cache/prepared/' . hash('sha256', $this->targetName) . '.bin'; |
||||||
|
} |
||||||
|
|
||||||
|
private function restorePreparedProject(string $key): bool |
||||||
|
{ |
||||||
|
if ($this->climate->arguments->defined('force') || $this->preparedFileAsts !== [] |
||||||
|
|| $this->symbols->functions() !== [] || $this->symbols->classes() !== [] |
||||||
|
|| $this->symbols->interfaces() !== []) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
$file = $this->preparedProjectCacheFile(); |
||||||
|
if (!is_file($file)) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
$stream = @fopen($file, 'rb'); |
||||||
|
if ($stream === false) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
try { |
||||||
|
// Reject changed inputs before deserializing the large AST graph. |
||||||
|
if (fgets($stream) !== $key . "\n") { |
||||||
|
return false; |
||||||
|
} |
||||||
|
$contents = stream_get_contents($stream); |
||||||
|
$state = is_string($contents) ? @unserialize($contents) : false; |
||||||
|
if (!is_array($state) || array_keys($state) !== self::PREPARED_PROJECT_FIELDS |
||||||
|
|| !($state['symbols'] instanceof \TypePhp\Symbol\SymbolRepository) |
||||||
|
|| ($state['nativeGlobalTypeResolver'] !== null |
||||||
|
&& !($state['nativeGlobalTypeResolver'] instanceof \TypePhp\NativeClass\NativeGlobalTypeResolver))) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
foreach (self::PREPARED_PROJECT_FIELDS as $field) { |
||||||
|
if (get_debug_type($state[$field]) !== get_debug_type($this->{$field}) |
||||||
|
&& $field !== 'nativeGlobalTypeResolver') { |
||||||
|
return false; |
||||||
|
} |
||||||
|
} |
||||||
|
foreach (self::PREPARED_PROJECT_FIELDS as $field) { |
||||||
|
$this->{$field} = $state[$field]; |
||||||
|
} |
||||||
|
$this->resetFile(); |
||||||
|
$this->resetNamespace(); |
||||||
|
$this->resetClass(); |
||||||
|
$this->resetMethod(); |
||||||
|
$this->resetFunction(); |
||||||
|
$this->climate->darkGray('[cached] project preprocessing'); |
||||||
|
return true; |
||||||
|
} catch (\Throwable) { |
||||||
|
return false; |
||||||
|
} finally { |
||||||
|
fclose($stream); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
private function storePreparedProject(string $key): void |
||||||
|
{ |
||||||
|
// Imported stubs can live outside the scanned input set. Do not cache |
||||||
|
// them until they are included in the snapshot's dependency manifest. |
||||||
|
if ($this->externalImportStubFiles !== []) { |
||||||
|
return; |
||||||
|
} |
||||||
|
$temporary = ''; |
||||||
|
$bodies = []; |
||||||
|
try { |
||||||
|
// Conversion always loads a fresh pristine AST. The prepared tree |
||||||
|
// is needed only for declaration expressions, not ordinary bodies. |
||||||
|
// Keep trait templates intact: a missing output may require their |
||||||
|
// bodies to be composed into a dirty consuming class on a cache hit. |
||||||
|
$stack = []; |
||||||
|
foreach ($this->preparedFileAsts as $statements) { |
||||||
|
array_push($stack, ...$statements); |
||||||
|
} |
||||||
|
while ($stack !== []) { |
||||||
|
$node = array_pop($stack); |
||||||
|
if (!($node instanceof \PhpParser\Node) || $node instanceof \PhpParser\Node\Stmt\Trait_) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (($node instanceof \PhpParser\Node\Stmt\Function_ || $node instanceof \PhpParser\Node\Stmt\ClassMethod) |
||||||
|
&& $node->stmts !== null) { |
||||||
|
$bodies[] = [$node, $node->stmts]; |
||||||
|
$node->stmts = []; |
||||||
|
} |
||||||
|
foreach ($node->getSubNodeNames() as $name) { |
||||||
|
$child = $node->{$name}; |
||||||
|
if (is_array($child)) { |
||||||
|
array_push($stack, ...$child); |
||||||
|
} elseif ($child instanceof \PhpParser\Node) { |
||||||
|
$stack[] = $child; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
$state = []; |
||||||
|
foreach (self::PREPARED_PROJECT_FIELDS as $field) { |
||||||
|
$state[$field] = $this->{$field}; |
||||||
|
} |
||||||
|
$file = $this->preparedProjectCacheFile(); |
||||||
|
if (!is_dir(dirname($file)) && !mkdir(dirname($file), 0777, true) && !is_dir(dirname($file))) { |
||||||
|
return; |
||||||
|
} |
||||||
|
$candidate = tempnam(dirname($file), '.prepared-'); |
||||||
|
if (is_string($candidate)) { |
||||||
|
$temporary = $candidate; |
||||||
|
$this->writeFile($temporary, $key . "\n" . serialize($state)); |
||||||
|
@rename($temporary, $file); |
||||||
|
} |
||||||
|
} catch (\Throwable) { |
||||||
|
// Optional cache failures must not fail a valid compilation. |
||||||
|
} finally { |
||||||
|
foreach ($bodies as [$node, $statements]) { |
||||||
|
$node->stmts = $statements; |
||||||
|
} |
||||||
|
if (is_string($temporary) && is_file($temporary)) { |
||||||
|
@unlink($temporary); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,80 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
namespace TypePhp\Build; |
||||||
|
|
||||||
|
/** Split complete code-generation entities, never lexically parse C++ bodies. */ |
||||||
|
trait TranslationUnitSplitTrait |
||||||
|
{ |
||||||
|
private bool $splitTranslationUnitsEnabled = false; |
||||||
|
/** @var list<string> */ |
||||||
|
private array $generatedMethodBodies = []; |
||||||
|
|
||||||
|
protected function getSplitTranslationUnits(string $source): array |
||||||
|
{ |
||||||
|
$primary = $this->getCppFile($source); |
||||||
|
$manifest = $primary . '.parts.json'; |
||||||
|
$parts = is_file($manifest) ? json_decode((string) file_get_contents($manifest), true) : []; |
||||||
|
if (!is_array($parts)) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
// A manifest is disposable cache data, not authority to read/delete |
||||||
|
// arbitrary paths supplied by a damaged or edited cache file. |
||||||
|
return array_values(array_filter($parts, static fn($part): bool => is_string($part) |
||||||
|
&& preg_match('/^' . preg_quote($primary, '/') . '\\.part-[0-9]+\\.cc$/D', $part) === 1)); |
||||||
|
} |
||||||
|
|
||||||
|
private function splitLargeTranslationUnit(string $code, string $primary, bool $force): string |
||||||
|
{ |
||||||
|
$oldParts = $this->getSplitTranslationUnits($this->file); |
||||||
|
$parts = []; |
||||||
|
if ($this->splitTranslationUnitsEnabled && strlen($code) >= 2 * 1024 * 1024) { |
||||||
|
$groups = []; |
||||||
|
$group = ''; |
||||||
|
foreach ($this->generatedMethodBodies as $body) { |
||||||
|
// Keep small helpers together in the primary TU, retaining |
||||||
|
// useful inlining; move only large, complete method entities. |
||||||
|
if (strlen($body) < 8192) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$position = strpos($code, $body); |
||||||
|
if ($position === false) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$code = substr_replace($code, '', $position, strlen($body)); |
||||||
|
if ($group !== '' && strlen($group) + strlen($body) > 384 * 1024) { |
||||||
|
$groups[] = $group; |
||||||
|
$group = ''; |
||||||
|
} |
||||||
|
$group .= $body . "\n"; |
||||||
|
} |
||||||
|
if ($group !== '') { |
||||||
|
$groups[] = $group; |
||||||
|
} |
||||||
|
$includes = $this->genIncludeHeaderFiles(); |
||||||
|
foreach ($groups as $index => $body) { |
||||||
|
$data = ''; |
||||||
|
foreach ($this->constData as $name => $value) { |
||||||
|
if (str_contains($body, $name)) { |
||||||
|
$data .= 'static const unsigned char ' . $name . '[] = {' . $value . "};\n"; |
||||||
|
} |
||||||
|
} |
||||||
|
$part = $primary . '.part-' . $index . '.cc'; |
||||||
|
$this->save($includes . $data . "\n" . $body, $part, $force); |
||||||
|
$this->registerGeneratedProjectSource($part); |
||||||
|
$parts[] = $part; |
||||||
|
} |
||||||
|
} |
||||||
|
if ($this->splitTranslationUnitsEnabled || $oldParts !== []) { |
||||||
|
$this->writeFile($primary . '.parts.json', json_encode($parts, JSON_THROW_ON_ERROR)); |
||||||
|
} |
||||||
|
foreach (array_diff($oldParts, $parts) as $obsolete) { |
||||||
|
foreach ([$obsolete, $this->getObjectFile($obsolete), |
||||||
|
$this->getMiscObjectCacheMetadataFile($this->getObjectFile($obsolete))] as $artifact) { |
||||||
|
if (is_file($artifact)) { |
||||||
|
unlink($artifact); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
return $code; |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue