diff --git a/bin/scaffold b/bin/scaffolder similarity index 63% rename from bin/scaffold rename to bin/scaffolder index 5803dd1cde1ae7183c636bc3dccd2e11cf0420c5..211b7a5869e2bd19c2c793826c1eb6cb5f08160a 100755 --- a/bin/scaffold +++ b/bin/scaffolder @@ -3,7 +3,8 @@ declare(strict_types = 1); -use Grifart\ClassScaffolder\Console\GenerateClassCommand; +use Grifart\ClassScaffolder\Console\CheckCommand; +use Grifart\ClassScaffolder\Console\ScaffoldCommand; use Symfony\Component\Console\Application; $autoloadPaths = [ @@ -24,7 +25,8 @@ if ( ! $autoloadFound) { throw new RuntimeException('Unable to find "vendor/autoload.php"'); } -$application = new Application('class-scaffolder'); -$application->add($command = new GenerateClassCommand()); -$application->setDefaultCommand($command->getName(), true); +$application = new Application('grifart/scaffolder'); +$application->add($scaffoldCommand = new ScaffoldCommand()); +$application->add(new CheckCommand()); +$application->setDefaultCommand($scaffoldCommand->getName(), false); exit($application->run()); diff --git a/composer.json b/composer.json index 97da51c4ae85fa031810aca4247222465634cc11..adaf098873f8ab0d2d8cd0da0ac4eae3045ed02e 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,7 @@ "nikic/php-parser": "To able to use KeepMethodDecorator." }, "bin": [ - "bin/scaffold" + "bin/scaffolder" ], "autoload": { "psr-4": { diff --git a/readme.md b/readme.md index 6d052606ddcd623e24ada5b6060b1f9ca0cf1d22..8044deeec9494f6aa060119d278ec0cbe31f94e7 100644 --- a/readme.md +++ b/readme.md @@ -47,16 +47,16 @@ composer require grifart/scaffolder The recommended way is to run the pre-packaged Composer binary: ```sh - composer run scaffold .definition.php + composer exec scaffolder scaffold .definition.php ``` <details> <summary>Alternative way: Register scaffolder as a Symfony command into you app.</summary> - Alternatively, you can register the `Grifart\ClassScaffolder\Console\GenerateClassCommand` into your application's DI container and run scaffolder through *symfony/console*. This makes it easier to access your project's services and environment in definition files. *This is considered advanced usage and is not necessary in most cases.* + Alternatively, you can register the `Grifart\ClassScaffolder\Console\ScaffoldCommand` into your application's DI container and run scaffolder through *symfony/console*. This makes it easier to access your project's services and environment in definition files. *This is considered advanced usage and is not necessary in most cases.* ```sh - php bin/console grifart:scaffold .definition.php + php bin/console grifart:scaffolder:scaffold .definition.php ``` </details> @@ -122,6 +122,8 @@ composer require grifart/scaffolder 5. **Use static analysis tool** such as PHPStan or Psalm to make sure that everything still works fine if you've changed any definition file. +6. **Make sure that you haven't accidentally changed any generated file** by adding `composer exec scaffolder check .definition.php` to your CI workflow. The command fails if any generated class differs from its definition, and thus running the scaffolder would result in losing your changes. + ## Definition files diff --git a/src/Console/CheckCommand.php b/src/Console/CheckCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..94ba8f454bf3202416ace12f84c70299581f2290 --- /dev/null +++ b/src/Console/CheckCommand.php @@ -0,0 +1,93 @@ +<?php + +declare(strict_types=1); + +namespace Grifart\ClassScaffolder\Console; + +use Grifart\ClassScaffolder\Definition\ClassDefinition; +use Grifart\ClassScaffolder\DefinitionFile; +use Grifart\ClassScaffolder\DefinitionResult; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; + +final class CheckCommand extends ScaffolderCommand +{ + protected function configure(): void + { + parent::configure(); + + $this->setName('check') + ->setDescription('Checks that all generated classes match given definitions.') + ->setAliases(['grifart:scaffolder:check']); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + $definitionFiles = $this->locateDefinitionFiles($input); + } catch (\Throwable $error) { + $this->printError($error, $output); + return 1; + } + + $results = []; + $isSuccess = true; + + $processedFiles = 0; + $total = \count($definitionFiles); + + $output->writeln(\sprintf('Checking %d definition file%s:%s', $total, $total !== 1 ? 's' : '', \PHP_EOL)); + + foreach ($definitionFiles as $definitionFile) { + $results[] = $result = $this->processFile($definitionFile, $input); + + if ($result->isSuccessful()) { + $output->write('.'); + } else { + $output->write('<error>F</error>'); + $isSuccess = false; + } + + if (++$processedFiles % 40 === 0) { + $output->writeln(''); + } + } + + $output->writeln(\PHP_EOL); + + $this->printResults($results, $output); + + return (int) ! $isSuccess; + } + + protected function processDefinition( + ClassDefinition $definition, + DefinitionFile $definitionFile, + InputInterface $input, + ): DefinitionResult + { + try { + $generatedFile = $this->classGenerator->generateClass($definition); + } catch (\Throwable $error) { + return DefinitionResult::error($definition, $error); + } + + $code = (string) $generatedFile; + $targetPath = $definitionFile->resolveTargetFileFor($definition); + + if ( ! \file_exists($targetPath)) { + return DefinitionResult::error($definition, new \RuntimeException('There is no generated file for given definition.')); + } + + $contents = \file_get_contents($targetPath); + if ($contents === false) { + return DefinitionResult::error($definition, new \RuntimeException('Failed to read file.')); + } + + if ($contents !== $code) { + return DefinitionResult::error($definition, new \RuntimeException('The generated file contains changes that will be lost if you generate it again.')); + } + + return DefinitionResult::success($definition); + } +} diff --git a/src/Console/GenerateClassCommand.php b/src/Console/GenerateClassCommand.php deleted file mode 100644 index 0d0628c6d0e9fa9bd25f08f8ddb8c9e6a4474d0c..0000000000000000000000000000000000000000 --- a/src/Console/GenerateClassCommand.php +++ /dev/null @@ -1,401 +0,0 @@ -<?php - -declare(strict_types = 1); - -namespace Grifart\ClassScaffolder\Console; - -use Grifart\ClassScaffolder\ClassGenerator; -use Grifart\ClassScaffolder\Definition\ClassDefinition; -use Nette\Utils\Finder; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; -use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Style\SymfonyStyle; -use Webmozart\PathUtil\Path; - - -final class GenerateClassCommand extends Command -{ - private ClassGenerator $classGenerator; - private SymfonyStyle $style; - - public function __construct() - { - parent::__construct(); - $this->classGenerator = new ClassGenerator(); - } - - protected function configure(): void - { - $this->setName('grifart:scaffold') - ->setDescription('Generate classes from given definitions.') - ->addArgument('definition', InputArgument::OPTIONAL, 'Definition file or directory containing definitions.', \getcwd()) - ->addOption('search-pattern', NULL, InputArgument::OPTIONAL, 'Search pattern for your definition files.', '*.definition.php') - ->addOption('no-readonly', NULL, InputOption::VALUE_NONE, 'Generated files are marked as read only by default (using chmod). Use this option to turn off this behaviour.') - ->addOption('dry-run', NULL, InputOption::VALUE_NONE, 'Only print the generated file to output instead of saving it.') - ->setAliases(['grifart:scaffolder:generateClass']); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $this->style = new SymfonyStyle($input, $output); - - $definitionPath = $input->getArgument('definition'); - $cwd = \getcwd(); - \assert(\is_string($definitionPath) && \is_string($cwd)); - $definitionPath = Path::makeAbsolute($definitionPath, $cwd); - - $searchPattern = $input->getOption('search-pattern'); - \assert(\is_string($searchPattern)); - - // 1. find files to process - $filesToProcess = []; - if (is_dir($definitionPath)) { - foreach(Finder::find($searchPattern)->from($definitionPath) as $definitionFile) { - $filesToProcess[] = (string) $definitionFile; - } - } elseif (is_file($definitionPath)) { - $filesToProcess[] = $definitionPath; - } else { - $output->writeln('<error>Given path is nor a file or directory.</error>'); - return 1; - } - - // 2. process them - - /** @var FileResult[] */ - $results = []; - $isSuccess = true; - - $processedFiles = 0; - $total = count($filesToProcess); - - $output->writeln(\sprintf('Processing %d definition file%s:%s', $total, $total !== 1 ? 's' : '', \PHP_EOL)); - - foreach ($filesToProcess as $fileToProcess) { - $results[] = $result = $this->processFile($fileToProcess, $input); - - if ($result->isSuccessful()) { - $output->write('.'); - } else { - $output->write('<error>F</error>'); - $isSuccess = false; - } - - if (++$processedFiles % 40 === 0) { - $output->writeln(''); - } - } - - $output->writeln(\PHP_EOL); - - $this->printResults($results, $input, $output); - - return (int) ! $isSuccess; - } - - private function processFile( - string $definitionFile, - InputInterface $input, - ): FileResult - { - try { - $definitions = $this->loadDefinitions($definitionFile); - } catch (\Throwable $error) { - return new FileResult($definitionFile, $error); - } - - $result = new FileResult($definitionFile, null); - foreach ($definitions as $definition) { - $definitionResult = $this->generateClass($definition, $definitionFile, $input); - $result->addDefinition($definitionResult); - } - - return $result; - } - - private function generateClass( - ClassDefinition $definition, - string $definitionFile, - InputInterface $input, - ): DefinitionResult - { - try { - $generatedFile = $this->classGenerator->generateClass($definition); - } catch (\Throwable $error) { - return DefinitionResult::error($definition, $error); - } - - $code = (string) $generatedFile; - - $targetPath = Path::join( - Path::getDirectory($definitionFile), - $definition->getClassName() . '.php' - ); - - if ($input->getOption('dry-run')) { - return DefinitionResult::success($definition, $code); - } - - if (\file_exists($targetPath)) { - \chmod($targetPath, 0664); // some users accessing files using group permissions - } - - if (\file_put_contents($targetPath, $code) === false) { - throw new \RuntimeException('Failed to write file.'); - } - - if ( ! $input->getOption('no-readonly')) { - \chmod($targetPath, 0444); // read-only -- assumes single user system - } - - return DefinitionResult::success($definition, $code); - } - - /** - * @return ClassDefinition[] - */ - private function loadDefinitions(string $definitionFile): iterable - { - $definitionFile = Path::canonicalize($definitionFile); - if ( ! \file_exists($definitionFile)) { - throw new \InvalidArgumentException(\sprintf( - 'Definition file not found at %s', - $definitionFile - )); - } - - $definitions = require $definitionFile; - if ($definitions instanceof ClassDefinition) { - $definitions = [$definitions]; - } - - if ( ! \is_iterable($definitions)) { - throw new \InvalidArgumentException(\sprintf( - 'Definition file must return an iterable of ClassDefinition, %s received.', - \get_debug_type($definitions), - )); - } - - $count = 0; - foreach ($definitions as $definition) { - if ( ! ($definition instanceof ClassDefinition)) { - throw new \InvalidArgumentException(\sprintf( - 'Definition file must return instanceof ClassDefinition, %s received.', - \get_debug_type($definition), - )); - } - - $count++; - } - - if ($count === 0) { - throw new \InvalidArgumentException('Definition file must return at least one ClassDefinition, empty list received.'); - } - - return $definitions; - } - - /** - * @param FileResult[] $results - */ - private function printResults( - array $results, - InputInterface $input, - OutputInterface $output, - ): void - { - $isDryRun = (bool) $input->getOption('dry-run'); - foreach ($results as $result) { - if ( ! $isDryRun && $result->isSuccessful() && ! $output->isVerbose()) { - continue; - } - - $cwd = \getcwd(); - \assert(\is_string($cwd)); - $definitionFilePath = Path::makeRelative($result->getDefinitionFile(), $cwd); - $definitions = $result->getDefinitions(); - - $definitionFileError = $result->getError(); - if ($definitionFileError !== null) { - $this->style->section(\sprintf( - '%s: <error>error loading definitions</error>', - $definitionFilePath, - )); - - $this->printError($definitionFileError, $output); - - continue; - } - - $errors = \array_filter($definitions, static fn(DefinitionResult $result) => ! $result->isSuccessful()); - $this->style->section(\sprintf( - '%s: %d definition%s, %s', - $definitionFilePath, - \count($definitions), - \count($definitions) !== 1 ? 's' : '', - \count($errors) === 0 ? '<info>OK</info>' : \sprintf( - '<error>%d error%s</error>', - \count($errors), - \count($errors) > 1 ? 's' : '', - ), - )); - - foreach ($definitions as $definition) { - if ( ! $isDryRun && $definition->isSuccessful() && ! $output->isVeryVerbose()) { - continue; - } - - $output->writeln(\sprintf( - '%s %s%s', - $definition->isSuccessful() ? '<info> OK </info>' : '<error>FAIL</error>', - $definition->getClassName(), - \PHP_EOL, - )); - - if ( ! $definition->isSuccessful()) { - $error = $definition->getError(); - \assert($error !== null); - - $this->printError($error, $output); - $output->writeln(''); - - } elseif ($isDryRun) { - $code = $definition->getCode(); - \assert($code !== null); - - $output->writeln(''); - $output->writeln($code . \PHP_EOL); - } - } - } - } - - private function printError(\Throwable $error, OutputInterface $output): void - { - $this->style->error([ - \sprintf( - '%s: %s', - \get_class($error), - $error->getMessage(), - ), - \sprintf( - 'in %s:%d', - $error->getFile(), - $error->getLine(), - ), - ]); - - if ( ! \class_exists(\Tracy\Debugger::class)) { - return; - } - - try { - $exceptionFile = \Tracy\Debugger::log($error); - } catch (\Throwable) { - return; - } - - if (\is_string($exceptionFile)) { - $cwd = \getcwd(); - \assert(\is_string($cwd)); - - $output->writeln(\sprintf( - 'Error was logged in %s', - Path::makeRelative($exceptionFile, $cwd), - )); - } - } -} - -/** - * @internal - */ -final class FileResult -{ - /** @var DefinitionResult[] */ - private array $definitions = []; - - public function __construct( - private string $definitionFile, - private \Throwable|null $error, - ) {} - - public function getDefinitionFile(): string - { - return $this->definitionFile; - } - - public function getError(): \Throwable|null - { - return $this->error; - } - - public function isSuccessful(): bool - { - foreach ($this->definitions as $result) { - if ( ! $result->isSuccessful()) { - return false; - } - } - - return $this->error === null; - } - - public function addDefinition(DefinitionResult $definitionResult): void - { - $this->definitions[] = $definitionResult; - } - - /** - * @return DefinitionResult[] - */ - public function getDefinitions(): array - { - return $this->definitions; - } -} - -/** - * @internal - */ -final class DefinitionResult -{ - private function __construct( - private ClassDefinition $definition, - private \Throwable|null $error, - private string|null $code, - ) {} - - public static function success(ClassDefinition $definition, string $code): self - { - return new self($definition, null, $code); - } - - public static function error(ClassDefinition $definition, \Throwable $error): self - { - return new self($definition, $error, null); - } - - public function getClassName(): string - { - return $this->definition->getFullyQualifiedName(); - } - - public function isSuccessful(): bool - { - return $this->error === null; - } - - public function getError(): \Throwable|null - { - return $this->error; - } - - public function getCode(): string|null - { - return $this->code; - } -} diff --git a/src/Console/ScaffoldCommand.php b/src/Console/ScaffoldCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..207a1f6b07bb9c93b3f1637993db5651a21cbb4d --- /dev/null +++ b/src/Console/ScaffoldCommand.php @@ -0,0 +1,96 @@ +<?php + +declare(strict_types = 1); + +namespace Grifart\ClassScaffolder\Console; + +use Grifart\ClassScaffolder\Definition\ClassDefinition; +use Grifart\ClassScaffolder\DefinitionFile; +use Grifart\ClassScaffolder\DefinitionResult; +use Grifart\ClassScaffolder\FileResult; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +final class ScaffoldCommand extends ScaffolderCommand +{ + protected function configure(): void + { + parent::configure(); + + $this->setName('scaffold') + ->setDescription('Generate classes from given definitions.') + ->addOption('no-readonly', NULL, InputOption::VALUE_NONE, 'Generated files are marked as read only by default (using chmod). Use this option to turn off this behaviour.') + ->setAliases(['grifart:scaffolder:scaffold', 'grifart:scaffolder:generateClass']); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + try { + $definitionFiles = $this->locateDefinitionFiles($input); + } catch (\Throwable $error) { + $this->printError($error, $output); + return 1; + } + + /** @var FileResult[] */ + $results = []; + $isSuccess = true; + + $processedFiles = 0; + $total = count($definitionFiles); + + $output->writeln(\sprintf('Processing %d definition file%s:%s', $total, $total !== 1 ? 's' : '', \PHP_EOL)); + + foreach ($definitionFiles as $definitionFile) { + $results[] = $result = $this->processFile($definitionFile, $input); + + if ($result->isSuccessful()) { + $output->write('.'); + } else { + $output->write('<error>F</error>'); + $isSuccess = false; + } + + if (++$processedFiles % 40 === 0) { + $output->writeln(''); + } + } + + $output->writeln(\PHP_EOL); + + $this->printResults($results, $output); + + return (int) ! $isSuccess; + } + + protected function processDefinition( + ClassDefinition $definition, + DefinitionFile $definitionFile, + InputInterface $input, + ): DefinitionResult + { + try { + $generatedFile = $this->classGenerator->generateClass($definition); + } catch (\Throwable $error) { + return DefinitionResult::error($definition, $error); + } + + $code = (string) $generatedFile; + $targetPath = $definitionFile->resolveTargetFileFor($definition); + + if (\file_exists($targetPath)) { + \chmod($targetPath, 0664); // some users accessing files using group permissions + } + + if (\file_put_contents($targetPath, $code) === false) { + return DefinitionResult::error($definition, new \RuntimeException('Failed to write file.')); + } + + if ( ! $input->getOption('no-readonly')) { + \chmod($targetPath, 0444); // read-only -- assumes single user system + } + + return DefinitionResult::success($definition); + } +} diff --git a/src/Console/ScaffolderCommand.php b/src/Console/ScaffolderCommand.php new file mode 100644 index 0000000000000000000000000000000000000000..38fcca146694d5717c755716c337f438889f8e3f --- /dev/null +++ b/src/Console/ScaffolderCommand.php @@ -0,0 +1,200 @@ +<?php + +declare(strict_types=1); + +namespace Grifart\ClassScaffolder\Console; + +use Grifart\ClassScaffolder\ClassGenerator; +use Grifart\ClassScaffolder\Definition\ClassDefinition; +use Grifart\ClassScaffolder\DefinitionFile; +use Grifart\ClassScaffolder\DefinitionFilesLocator; +use Grifart\ClassScaffolder\DefinitionResult; +use Grifart\ClassScaffolder\FileProcessor; +use Grifart\ClassScaffolder\FileResult; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Webmozart\PathUtil\Path; + +abstract class ScaffolderCommand extends Command +{ + private const ARGUMENT_DEFINITION_PATH = 'definition'; + private const OPTION_SEARCH_PATTERN = 'search-pattern'; + + protected ClassGenerator $classGenerator; + private DefinitionFilesLocator $definitionFilesLocator; + private FileProcessor $fileProcessor; + protected SymfonyStyle $style; + + public function __construct() + { + parent::__construct(); + $this->classGenerator = new ClassGenerator(); + $this->definitionFilesLocator = new DefinitionFilesLocator(); + $this->fileProcessor = new FileProcessor(); + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + $this->style = new SymfonyStyle($input, $output); + } + + protected function configure(): void + { + $this->addArgument(self::ARGUMENT_DEFINITION_PATH, InputArgument::OPTIONAL, 'Definition file or directory containing definitions.', \getcwd()); + $this->addOption(self::OPTION_SEARCH_PATTERN, null, InputOption::VALUE_REQUIRED, 'Search pattern for your definition files.', '*.definition.php'); + } + + /** + * @return DefinitionFile[] + */ + protected function locateDefinitionFiles( + InputInterface $input, + ): array + { + $path = $this->getDefinitionPath($input); + $searchPattern = $this->getSearchPattern($input); + + return $this->definitionFilesLocator->locateDefinitionFiles($path, $searchPattern); + } + + protected function processFile( + DefinitionFile $definitionFile, + InputInterface $input, + ): FileResult + { + return $this->fileProcessor->processFile( + $definitionFile, + fn(ClassDefinition $definition) => $this->processDefinition($definition, $definitionFile, $input), + ); + } + + abstract protected function processDefinition( + ClassDefinition $definition, + DefinitionFile $definitionFile, + InputInterface $input, + ): DefinitionResult; + + protected function printError(\Throwable $error, OutputInterface $output): void + { + $this->style->error([ + \sprintf( + '%s: %s', + \get_class($error), + $error->getMessage(), + ), + \sprintf( + 'in %s:%d', + $error->getFile(), + $error->getLine(), + ), + ]); + + if ( ! \class_exists(\Tracy\Debugger::class)) { + return; + } + + try { + $exceptionFile = \Tracy\Debugger::log($error); + } catch (\Throwable) { + return; + } + + if (\is_string($exceptionFile)) { + $cwd = \getcwd(); + \assert(\is_string($cwd)); + + $output->writeln(\sprintf( + 'Error was logged in %s', + Path::makeRelative($exceptionFile, $cwd), + )); + } + } + + /** + * @param FileResult[] $results + */ + protected function printResults( + array $results, + OutputInterface $output, + ): void + { + foreach ($results as $result) { + if ($result->isSuccessful() && ! $output->isVerbose()) { + continue; + } + + $cwd = \getcwd(); + \assert(\is_string($cwd)); + $definitionFilePath = Path::makeRelative($result->getDefinitionFile(), $cwd); + $definitions = $result->getDefinitions(); + + $definitionFileError = $result->getError(); + if ($definitionFileError !== null) { + $this->style->section(\sprintf( + '%s: <error>error loading definitions</error>', + $definitionFilePath, + )); + + $this->printError($definitionFileError, $output); + + continue; + } + + $errors = \array_filter($definitions, static fn(DefinitionResult $result) => ! $result->isSuccessful()); + $this->style->section(\sprintf( + '%s: %d definition%s, %s', + $definitionFilePath, + \count($definitions), + \count($definitions) !== 1 ? 's' : '', + \count($errors) === 0 ? '<info>OK</info>' : \sprintf( + '<error>%d error%s</error>', + \count($errors), + \count($errors) > 1 ? 's' : '', + ), + )); + + foreach ($definitions as $definition) { + if ($definition->isSuccessful() && ! $output->isVeryVerbose()) { + continue; + } + + $output->writeln(\sprintf( + '%s %s%s', + $definition->isSuccessful() ? '<info> OK </info>' : '<error>FAIL</error>', + $definition->getClassName(), + \PHP_EOL, + )); + + if ( ! $definition->isSuccessful()) { + $error = $definition->getError(); + \assert($error !== null); + + $this->printError($error, $output); + $output->writeln(''); + } + } + } + } + + private function getDefinitionPath(InputInterface $input): string + { + $cwd = \getcwd(); + + $definitionPath = $input->getArgument(self::ARGUMENT_DEFINITION_PATH); + \assert(\is_string($definitionPath) && \is_string($cwd)); + + return Path::makeAbsolute($definitionPath, $cwd); + } + + private function getSearchPattern(InputInterface $input): string + { + $searchPattern = $input->getOption(self::OPTION_SEARCH_PATTERN); + \assert(\is_string($searchPattern)); + + return $searchPattern; + } +} diff --git a/src/DefinitionFile.php b/src/DefinitionFile.php new file mode 100644 index 0000000000000000000000000000000000000000..133be9f20c0d32b672edac79b0863ea4899ba227 --- /dev/null +++ b/src/DefinitionFile.php @@ -0,0 +1,81 @@ +<?php + +declare(strict_types=1); + +namespace Grifart\ClassScaffolder; + +use Grifart\ClassScaffolder\Definition\ClassDefinition; +use Webmozart\PathUtil\Path; + +/** + * @internal + */ +final class DefinitionFile +{ + private function __construct( + private string $path, + ) {} + + public static function from(string $path): self + { + return new self( + Path::canonicalize($path), + ); + } + + public function getPath(): string + { + return $this->path; + } + + /** + * @return iterable<ClassDefinition> + */ + public function load(): iterable + { + if ( ! \file_exists($this->path) || ! \is_readable($this->path)) { + throw new \RuntimeException(\sprintf( + 'Definition file not found or not readable at %s', + $this->path, + )); + } + + $definitions = require $this->path; + if ($definitions instanceof ClassDefinition) { + $definitions = [$definitions]; + } + + if ( ! \is_iterable($definitions)) { + throw new \RuntimeException(\sprintf( + 'Definition file must return an iterable of ClassDefinition, %s received.', + \get_debug_type($definitions), + )); + } + + $count = 0; + foreach ($definitions as $definition) { + if ( ! ($definition instanceof ClassDefinition)) { + throw new \RuntimeException(\sprintf( + 'Definition file must return instanceof ClassDefinition, %s received.', + \get_debug_type($definition), + )); + } + + $count++; + } + + if ($count === 0) { + throw new \RuntimeException('Definition file must return at least one ClassDefinition, empty list received.'); + } + + return $definitions; + } + + public function resolveTargetFileFor(ClassDefinition $definition): string + { + return Path::join( + Path::getDirectory($this->path), + $definition->getClassName() . '.php', + ); + } +} diff --git a/src/DefinitionFilesLocator.php b/src/DefinitionFilesLocator.php new file mode 100644 index 0000000000000000000000000000000000000000..36f209966e19c729ec6f5420a0072888c61f0e57 --- /dev/null +++ b/src/DefinitionFilesLocator.php @@ -0,0 +1,34 @@ +<?php + +declare(strict_types=1); + +namespace Grifart\ClassScaffolder; + +use Nette\Utils\Finder; + +final class DefinitionFilesLocator +{ + /** + * @return DefinitionFile[] + */ + public function locateDefinitionFiles( + string $path, + string $searchPattern, + ): array + { + $result = []; + + if (\is_dir($path)) { + $files = Finder::findFiles($searchPattern)->from($path); + foreach ($files as $file) { + $result[] = DefinitionFile::from($file->getPathname()); + } + } elseif (\is_file($path)) { + $result[] = DefinitionFile::from($path); + } else { + throw new \RuntimeException('Given path is neither a file nor a directory.'); + } + + return $result; + } +} diff --git a/src/DefinitionResult.php b/src/DefinitionResult.php new file mode 100644 index 0000000000000000000000000000000000000000..0394dc7bfa655b7708afbb0734dcececf4e315bb --- /dev/null +++ b/src/DefinitionResult.php @@ -0,0 +1,43 @@ +<?php + +declare(strict_types=1); + +namespace Grifart\ClassScaffolder; + +use Grifart\ClassScaffolder\Definition\ClassDefinition; + +/** + * @internal + */ +final class DefinitionResult +{ + private function __construct( + private ClassDefinition $definition, + private \Throwable|null $error, + ) {} + + public static function success(ClassDefinition $definition): self + { + return new self($definition, null); + } + + public static function error(ClassDefinition $definition, \Throwable $error): self + { + return new self($definition, $error); + } + + public function getClassName(): string + { + return $this->definition->getFullyQualifiedName(); + } + + public function isSuccessful(): bool + { + return $this->error === null; + } + + public function getError(): \Throwable|null + { + return $this->error; + } +} diff --git a/src/FileProcessor.php b/src/FileProcessor.php new file mode 100644 index 0000000000000000000000000000000000000000..bb8393894f5c98336a6b28068d49c3816285dc2d --- /dev/null +++ b/src/FileProcessor.php @@ -0,0 +1,35 @@ +<?php + +declare(strict_types=1); + +namespace Grifart\ClassScaffolder; + +final class FileProcessor +{ + /** + * @param (\Closure(Definition\ClassDefinition): DefinitionResult) $processDefinition + */ + public function processFile( + DefinitionFile $definitionFile, + \Closure $processDefinition, + ): FileResult + { + try { + $definitions = $definitionFile->load(); + } catch (\Throwable $error) { + return new FileResult($definitionFile, $error); + } + + $result = new FileResult($definitionFile, null); + foreach ($definitions as $definition) { + try { + $definitionResult = $processDefinition($definition); + $result->addDefinition($definitionResult); + } catch (\Throwable $error) { + $result->addDefinition(DefinitionResult::error($definition, $error)); + } + } + + return $result; + } +} diff --git a/src/FileResult.php b/src/FileResult.php new file mode 100644 index 0000000000000000000000000000000000000000..b23616f82fd7bb837081c1298fc6c6e88653f0e8 --- /dev/null +++ b/src/FileResult.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +namespace Grifart\ClassScaffolder; + +/** + * @internal + */ +final class FileResult +{ + /** @var DefinitionResult[] */ + private array $definitions = []; + + public function __construct( + private DefinitionFile $definitionFile, + private \Throwable|null $error, + ) {} + + public function getDefinitionFile(): string + { + return $this->definitionFile->getPath(); + } + + public function getError(): \Throwable|null + { + return $this->error; + } + + public function isSuccessful(): bool + { + foreach ($this->definitions as $result) { + if ( ! $result->isSuccessful()) { + return false; + } + } + + return $this->error === null; + } + + public function addDefinition(DefinitionResult $definitionResult): void + { + $this->definitions[] = $definitionResult; + } + + /** + * @return DefinitionResult[] + */ + public function getDefinitions(): array + { + return $this->definitions; + } +} diff --git a/tests/Console/CheckCommandTest.expected.txt b/tests/Console/CheckCommandTest.expected.txt new file mode 100644 index 0000000000000000000000000000000000000000..678ab13067df1197b22fb993f4afb7b4804b467a --- /dev/null +++ b/tests/Console/CheckCommandTest.expected.txt @@ -0,0 +1,21 @@ +Checking 1 definition file: + +F + + +check/.definition.php: 3 definitions, 2 errors +---------------------------------------------- + +FAIL ModifiedClass + + [ERROR] RuntimeException: The generated file contains changes that will be lost + if you generate it again. + + in %A% + + +FAIL MissingClass + + [ERROR] RuntimeException: There is no generated file for given definition. + + in %A% diff --git a/tests/Console/CheckCommandTest.phpt b/tests/Console/CheckCommandTest.phpt new file mode 100644 index 0000000000000000000000000000000000000000..808e2b3db417aa4e3b5cf09464ac0cfcbb440cdf --- /dev/null +++ b/tests/Console/CheckCommandTest.phpt @@ -0,0 +1,37 @@ +<?php + +declare(strict_types=1); + +namespace Grifart\ClassScaffolder\Test\Console; + +use Grifart\ClassScaffolder\Console\CheckCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\BufferedOutput; +use Tester\Assert; + +require __DIR__ . '/../bootstrap.php'; + +/** + * @testCase + */ +final class CheckCommandTest extends CommandTestCase +{ + public function testCheck(): void + { + $input = new ArrayInput(['check', 'definition' => __DIR__ . '/check']); + $output = new BufferedOutput(); + + $exitCode = $this->runCommand($input, $output); + + Assert::same(1, $exitCode); + Assert::matchFile(__DIR__ . '/CheckCommandTest.expected.txt', $output->fetch()); + } + + protected function createCommand(): Command + { + return new CheckCommand(); + } +} + +(new CheckCommandTest())->run(); diff --git a/tests/Console/CommandTestCase.php b/tests/Console/CommandTestCase.php new file mode 100644 index 0000000000000000000000000000000000000000..f61d1e145f3a0e9b0670553dea810f02da8dc95d --- /dev/null +++ b/tests/Console/CommandTestCase.php @@ -0,0 +1,30 @@ +<?php + +declare(strict_types=1); + +namespace Grifart\ClassScaffolder\Test\Console; + +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Tester\TestCase; + +abstract class CommandTestCase extends TestCase +{ + abstract protected function createCommand(): Command; + + protected function runCommand( + InputInterface $input, + OutputInterface $output, + ): int + { + $command = $this->createCommand(); + + $application = new Application(); + $application->setAutoExit(false); + $application->add($command); + + return $application->run($input, $output); + } +} diff --git a/tests/Console/ScaffoldCommandTest.expected.all.txt b/tests/Console/ScaffoldCommandTest.expected.all.txt new file mode 100644 index 0000000000000000000000000000000000000000..02976aad7874a3227b2a774de327f1fc641eb4ef --- /dev/null +++ b/tests/Console/ScaffoldCommandTest.expected.all.txt @@ -0,0 +1,21 @@ +Processing 3 definition files: + +F.F + + +definitions/invalid.definition.php: error loading definitions +------------------------------------------------------------- + + [ERROR] RuntimeException: Definition file must return an iterable of + ClassDefinition, int received. + + in %A% + +definitions/failing.definition.php: 2 definitions, 1 error +---------------------------------------------------------- + +FAIL FailingClass + + [ERROR] RuntimeException: Oh no, I failed :( + + in %A% diff --git a/tests/Console/ScaffoldCommandTest.expected.failing.txt b/tests/Console/ScaffoldCommandTest.expected.failing.txt new file mode 100644 index 0000000000000000000000000000000000000000..bd03979bc87f3650f6e8b31b7c594ba4bc53f2a1 --- /dev/null +++ b/tests/Console/ScaffoldCommandTest.expected.failing.txt @@ -0,0 +1,13 @@ +Processing 1 definition file: + +F + + +definitions/failing.definition.php: 2 definitions, 1 error +---------------------------------------------------------- + +FAIL FailingClass + + [ERROR] RuntimeException: Oh no, I failed :( + + in %A% diff --git a/tests/Console/ScaffoldCommandTest.expected.invalid.txt b/tests/Console/ScaffoldCommandTest.expected.invalid.txt new file mode 100644 index 0000000000000000000000000000000000000000..02d166c5adf79cba2dadf8098c1f39afca126a12 --- /dev/null +++ b/tests/Console/ScaffoldCommandTest.expected.invalid.txt @@ -0,0 +1,12 @@ +Processing 1 definition file: + +F + + +definitions/invalid.definition.php: error loading definitions +------------------------------------------------------------- + + [ERROR] RuntimeException: Definition file must return an iterable of + ClassDefinition, int received. + + in %A% diff --git a/tests/Console/ScaffoldCommandTest.expected.success.txt b/tests/Console/ScaffoldCommandTest.expected.success.txt new file mode 100644 index 0000000000000000000000000000000000000000..9d34f1cb278cf5e020011be8560b4c0d576a379d --- /dev/null +++ b/tests/Console/ScaffoldCommandTest.expected.success.txt @@ -0,0 +1,3 @@ +Processing 1 definition file: + +. diff --git a/tests/Console/ScaffoldCommandTest.expected.verbose.txt b/tests/Console/ScaffoldCommandTest.expected.verbose.txt new file mode 100644 index 0000000000000000000000000000000000000000..17c69a83302861e05a55ed27dccabcd886b13e3b --- /dev/null +++ b/tests/Console/ScaffoldCommandTest.expected.verbose.txt @@ -0,0 +1,7 @@ +Processing 1 definition file: + +. + + +definitions/good.definition.php: 2 definitions, OK +-------------------------------------------------- diff --git a/tests/Console/ScaffoldCommandTest.expected.veryVerbose.txt b/tests/Console/ScaffoldCommandTest.expected.veryVerbose.txt new file mode 100644 index 0000000000000000000000000000000000000000..7b3b2e9d59fdbc6bc46289e3ed1b221f1edfb39e --- /dev/null +++ b/tests/Console/ScaffoldCommandTest.expected.veryVerbose.txt @@ -0,0 +1,11 @@ +Processing 1 definition file: + +. + + +definitions/good.definition.php: 2 definitions, OK +-------------------------------------------------- + + OK DataClass + + OK AnotherClass diff --git a/tests/Console/ScaffoldCommandTest.phpt b/tests/Console/ScaffoldCommandTest.phpt new file mode 100644 index 0000000000000000000000000000000000000000..2205f55efbde9fc99d10ad30075b305b9f27686c --- /dev/null +++ b/tests/Console/ScaffoldCommandTest.phpt @@ -0,0 +1,82 @@ +<?php + +declare(strict_types=1); + +namespace Grifart\ClassScaffolder\Test\Console; + +use Grifart\ClassScaffolder\Console\ScaffoldCommand; +use Nette\Utils\Finder; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\BufferedOutput; +use Tester\Assert; + +require __DIR__ . '/../bootstrap.php'; + +/** + * @testCase + */ +final class ScaffoldCommandTest extends CommandTestCase +{ + public function testSuccess(): void + { + $input = new ArrayInput(['scaffold', 'definition' => __DIR__ . '/definitions/good.definition.php']); + $output = new BufferedOutput(); + + $exitCode = $this->runCommand($input, $output); + + Assert::same(0, $exitCode); + Assert::matchFile(__DIR__ . '/ScaffoldCommandTest.expected.success.txt', $output->fetch()); + } + + public function testVerbose(): void + { + $input = new ArrayInput(['scaffold', 'definition' => __DIR__ . '/definitions/good.definition.php']); + $output = new BufferedOutput(BufferedOutput::VERBOSITY_VERBOSE); + + $exitCode = $this->runCommand($input, $output); + + Assert::same(0, $exitCode); + Assert::matchFile(__DIR__ . '/ScaffoldCommandTest.expected.verbose.txt', $output->fetch()); + } + + public function testVeryVerbose(): void + { + $input = new ArrayInput(['scaffold', 'definition' => __DIR__ . '/definitions/good.definition.php']); + $output = new BufferedOutput(BufferedOutput::VERBOSITY_VERY_VERBOSE); + + $exitCode = $this->runCommand($input, $output); + + Assert::same(0, $exitCode); + Assert::matchFile(__DIR__ . '/ScaffoldCommandTest.expected.veryVerbose.txt', $output->fetch()); + } + + public function testFailure(): void + { + $input = new ArrayInput(['scaffold', 'definition' => __DIR__ . '/definitions/failing.definition.php']); + $output = new BufferedOutput(); + + $exitCode = $this->runCommand($input, $output); + + Assert::same(1, $exitCode); + Assert::matchFile(__DIR__ . '/ScaffoldCommandTest.expected.failing.txt', $output->fetch()); + } + + public function testInvalid(): void + { + $input = new ArrayInput(['scaffold', 'definition' => __DIR__ . '/definitions/invalid.definition.php']); + $output = new BufferedOutput(); + + $exitCode = $this->runCommand($input, $output); + + Assert::same(1, $exitCode); + Assert::matchFile(__DIR__ . '/ScaffoldCommandTest.expected.invalid.txt', $output->fetch()); + } + + protected function createCommand(): Command + { + return new ScaffoldCommand(); + } +} + +(new ScaffoldCommandTest())->run(); diff --git a/tests/Console/check/.definition.php b/tests/Console/check/.definition.php new file mode 100644 index 0000000000000000000000000000000000000000..e0ae4226abb7bcd385617c40bb219a613a331d47 --- /dev/null +++ b/tests/Console/check/.definition.php @@ -0,0 +1,18 @@ +<?php + +use function Grifart\ClassScaffolder\Capabilities\constructorWithPromotedProperties; +use function Grifart\ClassScaffolder\Definition\definitionOf; + +return [ + definitionOf(UnmodifiedClass::class) + ->withField('field', 'string') + ->with(constructorWithPromotedProperties()), + + definitionOf(ModifiedClass::class) + ->withField('field', 'string') + ->with(constructorWithPromotedProperties()), + + definitionOf(MissingClass::class) + ->withField('field', 'string') + ->with(constructorWithPromotedProperties()), +]; diff --git a/tests/Console/check/ModifiedClass.php b/tests/Console/check/ModifiedClass.php new file mode 100644 index 0000000000000000000000000000000000000000..6791c5cdd919a6717e6bd136b66feafe1058f417 --- /dev/null +++ b/tests/Console/check/ModifiedClass.php @@ -0,0 +1,14 @@ +<?php + +/** + * Do not edit. This is generated file. Modify definition file instead. + */ + +declare(strict_types=1); + +final class ModifiedClass +{ + public function __construct(private int $field) + { + } +} diff --git a/tests/Console/check/UnmodifiedClass.php b/tests/Console/check/UnmodifiedClass.php new file mode 100644 index 0000000000000000000000000000000000000000..8676cb14dda172bf99be0ba10c805bb8604914ec --- /dev/null +++ b/tests/Console/check/UnmodifiedClass.php @@ -0,0 +1,14 @@ +<?php + +/** + * Do not edit. This is generated file. Modify definition file instead. + */ + +declare(strict_types=1); + +final class UnmodifiedClass +{ + public function __construct(private string $field) + { + } +} diff --git a/tests/Console/definitions/.gitignore b/tests/Console/definitions/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..b6590d3daa765c7898ca0e26f32a8fa5a39c8582 --- /dev/null +++ b/tests/Console/definitions/.gitignore @@ -0,0 +1,2 @@ +*.php +!*.definition.php diff --git a/tests/Console/definitions/failing.definition.php b/tests/Console/definitions/failing.definition.php new file mode 100644 index 0000000000000000000000000000000000000000..cef21d916be601d89dff1d01caded0d867c006b0 --- /dev/null +++ b/tests/Console/definitions/failing.definition.php @@ -0,0 +1,20 @@ +<?php + +use Grifart\ClassScaffolder\Capabilities\Capability; +use Grifart\ClassScaffolder\ClassInNamespace; +use Grifart\ClassScaffolder\Definition\ClassDefinition; +use function Grifart\ClassScaffolder\Definition\definitionOf; + +return [ + definitionOf(SuccessClass::class) + ->withField('field', 'string'), + + definitionOf(FailingClass::class) + ->withField('field', 'string') + ->with(new class implements Capability { + public function applyTo(ClassDefinition $definition, ClassInNamespace $draft, ?ClassInNamespace $current,): void + { + throw new RuntimeException('Oh no, I failed :('); + } + }), +]; diff --git a/tests/Console/definitions/good.definition.php b/tests/Console/definitions/good.definition.php new file mode 100644 index 0000000000000000000000000000000000000000..d97713ccf6c2468de43ae956681bcd6b911d87ca --- /dev/null +++ b/tests/Console/definitions/good.definition.php @@ -0,0 +1,21 @@ +<?php + +use Grifart\ClassScaffolder\Capabilities; +use Grifart\ClassScaffolder\Definition\ClassDefinition; +use Grifart\ClassScaffolder\Definition\Types; +use function Grifart\ClassScaffolder\Definition\definitionOf; + +function valueObject(string $className): ClassDefinition { + return definitionOf($className)->with( + Capabilities\constructorWithPromotedProperties(), + Capabilities\readonlyProperties(), + ); +} + +return [ + $dataClass = definitionOf(valueObject(DataClass::class)) + ->withField('field', 'string'), + + definitionOf(valueObject(AnotherClass::class)) + ->withField('data', Types\listOf($dataClass)), +]; diff --git a/tests/Console/definitions/invalid.definition.php b/tests/Console/definitions/invalid.definition.php new file mode 100644 index 0000000000000000000000000000000000000000..188daf9ed0e7d0bc40f2fd8b19ab52b2e3cd2510 --- /dev/null +++ b/tests/Console/definitions/invalid.definition.php @@ -0,0 +1,3 @@ +<?php + +return 42;