diff --git a/.gitignore b/.gitignore index 275a2ff2..cb18e9bf 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ composer.lock .php-version composer.phar .phpunit.cache/ -.vscode \ No newline at end of file +.vscode +.phparkitect.cache \ No newline at end of file diff --git a/src/Analyzer/CachedFileParser.php b/src/Analyzer/CachedFileParser.php new file mode 100644 index 00000000..9ef61320 --- /dev/null +++ b/src/Analyzer/CachedFileParser.php @@ -0,0 +1,71 @@ + */ + private array $entries = []; + + private bool $dirty = false; + + private string $filePath; + + private Parser $innerParser; + + public function __construct(Parser $innerParser, string $cacheFilePath) + { + $this->filePath = $cacheFilePath; + $this->innerParser = $innerParser; + + if (file_exists($cacheFilePath)) { + $data = unserialize((string) file_get_contents($cacheFilePath)); + if (\is_array($data)) { + $this->entries = $data; + } + } + } + + public function __destruct() + { + if ($this->dirty) { + file_put_contents($this->filePath, serialize($this->entries)); + } + } + + public function parse(string $fileContent, string $filename): ParserResult + { + $cachedResult = $this->get($filename, md5($fileContent)); + + if (null !== $cachedResult) { + return $cachedResult; + } + + $result = $this->innerParser->parse($fileContent, $filename); + + $this->set($filename, md5($fileContent), $result); + + return $result; + } + + public function get(string $filename, string $contentHash): ?ParserResult + { + if (!isset($this->entries[$filename])) { + return null; + } + + if ($this->entries[$filename]['hash'] !== $contentHash) { + return null; + } + + return $this->entries[$filename]['result']; + } + + public function set(string $filename, string $contentHash, ParserResult $result): void + { + $this->entries[$filename] = ['hash' => $contentHash, 'result' => $result]; + $this->dirty = true; + } +} diff --git a/src/Analyzer/FileParserFactory.php b/src/Analyzer/FileParserFactory.php index 5c11da1f..c3c05399 100644 --- a/src/Analyzer/FileParserFactory.php +++ b/src/Analyzer/FileParserFactory.php @@ -10,19 +10,32 @@ class FileParserFactory { - public static function createFileParser(TargetPhpVersion $targetPhpVersion, bool $parseCustomAnnotations = true): FileParser - { - return new FileParser( + public static function createFileParser( + TargetPhpVersion $targetPhpVersion, + bool $parseCustomAnnotations = true, + ?string $cacheFilePath = null, + ): Parser { + $fp = new FileParser( new NodeTraverser(), new FileVisitor(new ClassDescriptionBuilder()), new NameResolver(), new DocblockTypesResolver($parseCustomAnnotations), $targetPhpVersion ); + + if (null !== $cacheFilePath) { + $fp = new CachedFileParser($fp, $cacheFilePath); + } + + return $fp; } - public static function forPhpVersion(string $targetPhpVersion): FileParser + public static function forPhpVersion(string $targetPhpVersion): Parser { - return self::createFileParser(TargetPhpVersion::create($targetPhpVersion), true); + return self::createFileParser( + TargetPhpVersion::create($targetPhpVersion), + true, + null + ); } } diff --git a/src/Analyzer/FilesToParse.php b/src/Analyzer/FilesToParse.php new file mode 100644 index 00000000..34b0fdd3 --- /dev/null +++ b/src/Analyzer/FilesToParse.php @@ -0,0 +1,26 @@ + + */ +class FilesToParse implements \IteratorAggregate +{ + /** @var array */ + private array $files = []; + + public function add(SplFileInfo $file): void + { + $this->files[] = $file; + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->files); + } +} diff --git a/src/Analyzer/ParsedFiles.php b/src/Analyzer/ParsedFiles.php new file mode 100644 index 00000000..abf96d18 --- /dev/null +++ b/src/Analyzer/ParsedFiles.php @@ -0,0 +1,20 @@ +data[$relativeFilePath] = $result; + } + + public function get(string $relativeFilePath): ?ParserResult + { + return $this->data[$relativeFilePath] ?? null; + } +} diff --git a/src/CLI/Command/Check.php b/src/CLI/Command/Check.php index 67fac7fe..fc17c167 100644 --- a/src/CLI/Command/Check.php +++ b/src/CLI/Command/Check.php @@ -30,6 +30,7 @@ class Check extends Command private const IGNORE_BASELINE_LINENUMBERS_PARAM = 'ignore-baseline-linenumbers'; private const FORMAT_PARAM = 'format'; private const AUTOLOAD_PARAM = 'autoload'; + private const NO_CACHE_PARAM = 'no-cache'; private const GENERATE_BASELINE_PARAM = 'generate-baseline'; private const DEFAULT_RULES_FILENAME = 'phparkitect.php'; @@ -105,6 +106,12 @@ protected function configure(): void 'a', InputOption::VALUE_REQUIRED, 'Specify an autoload file to use', + ) + ->addOption( + self::NO_CACHE_PARAM, + 'o', + InputOption::VALUE_NONE, + 'Disable cache' ); } @@ -124,6 +131,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $generateBaseline = $input->getOption(self::GENERATE_BASELINE_PARAM); $phpVersion = $input->getOption('target-php-version'); $format = $input->getOption(self::FORMAT_PARAM); + $noCache = (bool) $input->getOption(self::NO_CACHE_PARAM); // we write everything on STDERR apart from the list of violations which goes on STDOUT // this allows to pipe the output of this command to a file while showing output on the terminal @@ -139,7 +147,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int ->baselineFilePath(Baseline::resolveFilePath($useBaseline, self::DEFAULT_BASELINE_FILENAME)) ->ignoreBaselineLinenumbers($ignoreBaselineLinenumbers) ->skipBaseline($skipBaseline) - ->format($format); + ->format($format) + ->noCache($noCache); $this->requireAutoload($output, $config->getAutoloadFilePath()); $printer = $this->createPrinter($output, $config->getFormat()); diff --git a/src/CLI/Command/DebugExpression.php b/src/CLI/Command/DebugExpression.php index c9ddf15f..6fa582a3 100644 --- a/src/CLI/Command/DebugExpression.php +++ b/src/CLI/Command/DebugExpression.php @@ -92,11 +92,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** * @throws \Arkitect\Exceptions\PhpVersionNotValidException */ - private function getParser(InputInterface $input): \Arkitect\Analyzer\FileParser + private function getParser(InputInterface $input): \Arkitect\Analyzer\Parser { $phpVersion = $input->getOption('target-php-version'); $targetPhpVersion = TargetPhpVersion::create($phpVersion); - $fileParser = FileParserFactory::createFileParser($targetPhpVersion); + $fileParser = FileParserFactory::createFileParser($targetPhpVersion, true, null); return $fileParser; } diff --git a/src/CLI/Config.php b/src/CLI/Config.php index 361a16aa..df16ffab 100644 --- a/src/CLI/Config.php +++ b/src/CLI/Config.php @@ -31,6 +31,8 @@ class Config private TargetPhpVersion $targetPhpVersion; + private ?string $cacheFilePath = null; + public function __construct() { $this->classSetRules = []; @@ -43,6 +45,7 @@ public function __construct() $this->format = PrinterFactory::default(); $this->autoloadFilePath = null; $this->targetPhpVersion = TargetPhpVersion::latest(); + $this->cacheFilePath = '.phparkitect.cache'; } public function add(ClassSet $classSet, ArchRule ...$rules): self @@ -167,4 +170,18 @@ public function getAutoloadFilePath(): ?string { return $this->autoloadFilePath; } + + public function noCache(bool $noCache): self + { + if ($noCache) { + $this->cacheFilePath = null; + } + + return $this; + } + + public function getCacheFilePath(): ?string + { + return $this->cacheFilePath; + } } diff --git a/src/CLI/Runner.php b/src/CLI/Runner.php index 910f0ca7..c6c0bd16 100644 --- a/src/CLI/Runner.php +++ b/src/CLI/Runner.php @@ -6,6 +6,8 @@ use Arkitect\Analyzer\ClassDescription; use Arkitect\Analyzer\FileParserFactory; +use Arkitect\Analyzer\FilesToParse; +use Arkitect\Analyzer\ParsedFiles; use Arkitect\Analyzer\Parser; use Arkitect\Analyzer\ParsingErrors; use Arkitect\ClassSetRules; @@ -45,17 +47,46 @@ public function check( Violations $violations, ParsingErrors $parsingErrors, bool $stopOnFailure, + ): void { + // first step: collect all files to parse + $filesToParse = $this->collectFilesToParse($classSetRule); + + // second step: parse all files and collect results + $parsedFiles = $this->collectParsedFiles( + $filesToParse, + $fileParser, + $progress + ); + + // third step: check all rules on all files + $this->checkRulesOnParsedFiles( + $classSetRule, + $parsedFiles, + $violations, + $parsingErrors, + $stopOnFailure + ); + } + + public function checkRulesOnParsedFiles( + ClassSetRules $classSetRule, + ParsedFiles $parsedFiles, + Violations $violations, + ParsingErrors $parsingErrors, + bool $stopOnFailure, ): void { /** @var SplFileInfo $file */ foreach ($classSetRule->getClassSet() as $file) { - $fileViolations = new Violations(); - - $progress->startParsingFile($file->getRelativePathname()); + $result = $parsedFiles->get($file->getRelativePathname()); - $result = $fileParser->parse($file->getContents(), $file->getRelativePathname()); + if (null === $result) { + continue; // this should not happen + } $parsingErrors->merge($result->parsingErrors()); + $fileViolations = new Violations(); + /** @var ClassDescription $classDescription */ foreach ($result->classDescriptions() as $classDescription) { foreach ($classSetRule->getRules() as $rule) { @@ -70,9 +101,37 @@ public function check( } $violations->merge($fileViolations); + } + } + + protected function collectFilesToParse(ClassSetRules $classSetRule): FilesToParse + { + $filesToParse = new FilesToParse(); + + /** @var SplFileInfo $file */ + foreach ($classSetRule->getClassSet() as $file) { + $filesToParse->add($file); + } + + return $filesToParse; + } + + protected function collectParsedFiles(FilesToParse $filesToParse, Parser $fileParser, Progress $progress): ParsedFiles + { + $parsedFiles = new ParsedFiles(); + + /** @var SplFileInfo $file */ + foreach ($filesToParse as $file) { + $progress->startParsingFile($file->getRelativePathname()); + + $result = $fileParser->parse($file->getContents(), $file->getRelativePathname()); + + $parsedFiles->add($file->getRelativePathname(), $result); $progress->endParsingFile($file->getRelativePathname()); } + + return $parsedFiles; } protected function doRun(Config $config, Progress $progress): array @@ -82,7 +141,8 @@ protected function doRun(Config $config, Progress $progress): array $fileParser = FileParserFactory::createFileParser( $config->getTargetPhpVersion(), - $config->isParseCustomAnnotationsEnabled() + $config->isParseCustomAnnotationsEnabled(), + $config->getCacheFilePath() ); /** @var ClassSetRules $classSetRule */ @@ -90,7 +150,14 @@ protected function doRun(Config $config, Progress $progress): array $progress->startFileSetAnalysis($classSetRule->getClassSet()); try { - $this->check($classSetRule, $progress, $fileParser, $violations, $parsingErrors, $config->isStopOnFailure()); + $this->check( + $classSetRule, + $progress, + $fileParser, + $violations, + $parsingErrors, + $config->isStopOnFailure() + ); } catch (FailOnFirstViolationException $e) { break; } finally { diff --git a/src/PHPUnit/ArchRuleCheckerConstraintAdapter.php b/src/PHPUnit/ArchRuleCheckerConstraintAdapter.php index 641509d6..6de962af 100644 --- a/src/PHPUnit/ArchRuleCheckerConstraintAdapter.php +++ b/src/PHPUnit/ArchRuleCheckerConstraintAdapter.php @@ -4,8 +4,8 @@ namespace Arkitect\PHPUnit; -use Arkitect\Analyzer\FileParser; use Arkitect\Analyzer\FileParserFactory; +use Arkitect\Analyzer\Parser; use Arkitect\Analyzer\ParsingErrors; use Arkitect\ClassSet; use Arkitect\ClassSetRules; @@ -32,7 +32,7 @@ class ArchRuleCheckerConstraintAdapter extends Constraint private Runner $runner; - private FileParser $fileparser; + private Parser $fileparser; private ParsingErrors $parsingErrors; @@ -42,7 +42,7 @@ public function __construct(ClassSet $classSet) { $targetPhpVersion = TargetPhpVersion::create(null); $this->runner = new Runner(); - $this->fileparser = FileParserFactory::createFileParser($targetPhpVersion); + $this->fileparser = FileParserFactory::createFileParser($targetPhpVersion, true, null); $this->classSet = $classSet; $this->violations = new Violations(); $this->parsingErrors = new ParsingErrors(); diff --git a/tests/E2E/Smoke/RunArkitectBinTest.php b/tests/E2E/Smoke/RunArkitectBinTest.php index eeb10da0..428f0025 100644 --- a/tests/E2E/Smoke/RunArkitectBinTest.php +++ b/tests/E2E/Smoke/RunArkitectBinTest.php @@ -113,7 +113,7 @@ public function test_only_violations_are_printed_on_stdout(): void protected function runArkitectPassingConfigFilePath($configFilePath): Process { - $process = new Process([$this->phparkitect, 'check', '--config='.$configFilePath], __DIR__); + $process = new Process([$this->phparkitect, 'check', '--no-cache', '--config='.$configFilePath], __DIR__); $process->run(); return $process; @@ -121,7 +121,7 @@ protected function runArkitectPassingConfigFilePath($configFilePath): Process protected function runArkitect(): Process { - $process = new Process([$this->phparkitect, 'check'], __DIR__); + $process = new Process([$this->phparkitect, 'check', '--no-cache'], __DIR__); $process->run(); return $process; diff --git a/tests/Unit/Analyzer/FileParser/CanParseDocblocksTest.php b/tests/Unit/Analyzer/FileParser/CanParseDocblocksTest.php index f4634317..ce7e5f4c 100644 --- a/tests/Unit/Analyzer/FileParser/CanParseDocblocksTest.php +++ b/tests/Unit/Analyzer/FileParser/CanParseDocblocksTest.php @@ -418,8 +418,10 @@ class ApplicationLevelDto $fp = FileParserFactory::createFileParser( TargetPhpVersion::create(TargetPhpVersion::PHP_8_1), - false + false, + null ); + $result = $fp->parse($code, 'relativePathName'); $cd = $result->classDescriptions(); diff --git a/tests/Utils/TestRunner.php b/tests/Utils/TestRunner.php index d09a5277..cc6603a2 100644 --- a/tests/Utils/TestRunner.php +++ b/tests/Utils/TestRunner.php @@ -29,7 +29,11 @@ private function __construct(?string $version = null) { $this->violations = new Violations(); $this->parsingErrors = new ParsingErrors(); - $this->fileParser = FileParserFactory::createFileParser(TargetPhpVersion::create($version)); + $this->fileParser = FileParserFactory::createFileParser( + TargetPhpVersion::create($version), + true, + null + ); } public static function create(?string $version = null): self