diff --git a/src/ComposerRequireChecker/Cli/CheckCommand.php b/src/ComposerRequireChecker/Cli/CheckCommand.php index be14c207..7eee6d27 100644 --- a/src/ComposerRequireChecker/Cli/CheckCommand.php +++ b/src/ComposerRequireChecker/Cli/CheckCommand.php @@ -5,6 +5,8 @@ namespace ComposerRequireChecker\Cli; use ComposerRequireChecker\ASTLocator\LocateASTFromFiles; +use ComposerRequireChecker\Cli\ResultsWriter\CliJson; +use ComposerRequireChecker\Cli\ResultsWriter\CliText; use ComposerRequireChecker\DefinedExtensionsResolver\DefinedExtensionsResolver; use ComposerRequireChecker\DefinedSymbolsLocator\LocateDefinedSymbolsFromASTRoots; use ComposerRequireChecker\DefinedSymbolsLocator\LocateDefinedSymbolsFromComposerRuntimeApi; @@ -18,25 +20,28 @@ use ComposerRequireChecker\GeneratorUtil\ComposeGenerators; use ComposerRequireChecker\JsonLoader; use ComposerRequireChecker\UsedSymbolsLocator\LocateUsedSymbolsFromASTRoots; +use DateTimeImmutable; use InvalidArgumentException; use LogicException; use PhpParser\ErrorHandler\Collecting as CollectingErrorHandler; use PhpParser\Lexer; use PhpParser\ParserFactory; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Helper\Table; 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 Webmozart\Assert\Assert; +use function array_combine; use function array_diff; +use function array_map; use function array_merge; +use function assert; use function count; use function dirname; use function gettype; -use function implode; +use function in_array; use function is_string; use function realpath; use function sprintf; @@ -68,11 +73,37 @@ protected function configure(): void InputOption::VALUE_NONE, 'this will cause ComposerRequireChecker to ignore errors when files cannot be parsed, otherwise' . ' errors will be thrown' + ) + ->addOption( + 'output', + null, + InputOption::VALUE_REQUIRED, + 'generate output either as "text" or as "json", if specified, "quiet mode" is implied' ); } + protected function initialize(InputInterface $input, OutputInterface $output): void + { + if ($input->getOption('output') === null) { + return; + } + + $optionValue = $input->getOption('output'); + assert(is_string($optionValue)); + + if (! in_array($optionValue, ['text', 'json'])) { + throw new InvalidArgumentException( + 'Option "output" must be either of value "json", "text" or omitted altogether' + ); + } + } + protected function execute(InputInterface $input, OutputInterface $output): int { + if ($input->getOption('output') !== null) { + $output->setVerbosity(OutputInterface::VERBOSITY_QUIET); + } + if (! $output->isQuiet()) { $application = $this->getApplication(); $output->writeln($application !== null ? $application->getLongVersion() : 'Unknown version'); @@ -147,26 +178,43 @@ protected function execute(InputInterface $input, OutputInterface $output): int $options->getSymbolWhitelist() ); - if (! $unknownSymbols) { - $output->writeln('There were no unknown symbols found.'); - - return 0; + switch ($input->getOption('output')) { + case 'json': + $application = $this->getApplication(); + $resultsWriter = new CliJson( + static function (string $string) use ($output): void { + $output->write($string, false, OutputInterface::VERBOSITY_QUIET | OutputInterface::OUTPUT_RAW); + }, + $application !== null ? $application->getVersion() : 'Unknown version', + static fn () => new DateTimeImmutable() + ); + break; + case 'text': + $resultsWriter = new CliText( + $output, + static function (string $string) use ($output): void { + $output->write($string, false, OutputInterface::VERBOSITY_QUIET | OutputInterface::OUTPUT_RAW); + } + ); + break; + default: + $resultsWriter = new CliText($output); } - $output->writeln('The following ' . count($unknownSymbols) . ' unknown symbols were found:'); - $table = new Table($output); - $table->setHeaders(['Unknown Symbol', 'Guessed Dependency']); $guesser = new DependencyGuesser($options); - foreach ($unknownSymbols as $unknownSymbol) { - $guessedDependencies = []; - foreach ($guesser($unknownSymbol) as $guessedDependency) { - $guessedDependencies[] = $guessedDependency; - } - - $table->addRow([$unknownSymbol, implode("\n", $guessedDependencies)]); - } - - $table->render(); + $resultsWriter->write( + array_map( + static function (string $unknownSymbol) use ($guesser): array { + $guessedDependencies = []; + foreach ($guesser($unknownSymbol) as $guessedDependency) { + $guessedDependencies[] = $guessedDependency; + } + + return $guessedDependencies; + }, + array_combine($unknownSymbols, $unknownSymbols) + ), + ); return (int) (bool) $unknownSymbols; } diff --git a/src/ComposerRequireChecker/Cli/ResultsWriter/CliJson.php b/src/ComposerRequireChecker/Cli/ResultsWriter/CliJson.php new file mode 100644 index 00000000..dc28dd80 --- /dev/null +++ b/src/ComposerRequireChecker/Cli/ResultsWriter/CliJson.php @@ -0,0 +1,55 @@ +writeCallable = $write; + $this->applicationVersion = $applicationVersion; + $this->nowCallable = $now; + } + + /** + * {@inheritdoc} + */ + public function write(array $unknownSymbols): void + { + $write = $this->writeCallable; + $now = $this->nowCallable; + + $write( + json_encode( + [ + '_meta' => [ + 'composer-require-checker' => [ + 'version' => $this->applicationVersion, + ], + 'date' => $now()->format(DateTimeImmutable::ATOM), + ], + 'unknown-symbols' => $unknownSymbols, + ], + JSON_THROW_ON_ERROR + ) + ); + } +} diff --git a/src/ComposerRequireChecker/Cli/ResultsWriter/CliText.php b/src/ComposerRequireChecker/Cli/ResultsWriter/CliText.php new file mode 100644 index 00000000..ede7c791 --- /dev/null +++ b/src/ComposerRequireChecker/Cli/ResultsWriter/CliText.php @@ -0,0 +1,57 @@ +output = $output; + if ($write === null) { + $write = static function (string $string) use ($output): void { + $output->write($string); + }; + } + + $this->writeCallable = $write; + } + + /** + * {@inheritdoc} + */ + public function write(array $unknownSymbols): void + { + if (! $unknownSymbols) { + $this->output->writeln('There were no unknown symbols found.'); + + return; + } + + $this->output->writeln('The following ' . count($unknownSymbols) . ' unknown symbols were found:'); + + $tableOutput = new BufferedOutput(); + $table = new Table($tableOutput); + $table->setHeaders(['Unknown Symbol', 'Guessed Dependency']); + foreach ($unknownSymbols as $unknownSymbol => $guessedDependencies) { + $table->addRow([$unknownSymbol, implode("\n", $guessedDependencies)]); + } + + $table->render(); + + $write = $this->writeCallable; + $write($tableOutput->fetch()); + } +} diff --git a/src/ComposerRequireChecker/Cli/ResultsWriter/ResultsWriter.php b/src/ComposerRequireChecker/Cli/ResultsWriter/ResultsWriter.php new file mode 100644 index 00000000..e45d7e78 --- /dev/null +++ b/src/ComposerRequireChecker/Cli/ResultsWriter/ResultsWriter.php @@ -0,0 +1,13 @@ +> $unknownSymbols the unknown symbols found + */ + public function write(array $unknownSymbols): void; +} diff --git a/test/ComposerRequireCheckerTest/Cli/CheckCommandTest.php b/test/ComposerRequireCheckerTest/Cli/CheckCommandTest.php index ec9a4249..15be1368 100644 --- a/test/ComposerRequireCheckerTest/Cli/CheckCommandTest.php +++ b/test/ComposerRequireCheckerTest/Cli/CheckCommandTest.php @@ -15,9 +15,11 @@ use function dirname; use function file_put_contents; +use function json_decode; use function unlink; use function version_compare; +use const JSON_THROW_ON_ERROR; use const PHP_VERSION; final class CheckCommandTest extends TestCase @@ -75,6 +77,62 @@ public function testUnknownSymbolsFound(): void $this->assertStringContainsString('libxml_clear_errors', $display); } + public function testInvalidOutputOptionValue(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Option "output" must be either of value "json", "text" or omitted altogether'); + + $this->commandTester->execute([ + 'composer-json' => dirname(__DIR__, 2) . '/fixtures/unknownSymbols/composer.json', + '--output' => '__invalid__', + ]); + } + + public function testUnknownSymbolsFoundJsonReport(): void + { + $this->commandTester->execute([ + 'composer-json' => dirname(__DIR__, 2) . '/fixtures/unknownSymbols/composer.json', + '--output' => 'json', + ]); + + $this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); + $display = $this->commandTester->getDisplay(); + + /** @var array{'unknown-symbols': array>} $actual */ + $actual = json_decode($display, true, JSON_THROW_ON_ERROR); + + $this->assertSame( + [ + 'Doctrine\Common\Collections\ArrayCollection' => [], + 'Example\Library\Dependency' => [], + 'FILTER_VALIDATE_URL' => ['ext-filter'], + 'filter_var' => ['ext-filter'], + 'Foo\Bar\Baz' => [], + 'libxml_clear_errors' => ['ext-libxml'], + ], + $actual['unknown-symbols'] + ); + } + + public function testUnknownSymbolsFoundTextReport(): void + { + $this->commandTester->execute([ + 'composer-json' => dirname(__DIR__, 2) . '/fixtures/unknownSymbols/composer.json', + '--output' => 'text', + ]); + + $this->assertSame(Command::FAILURE, $this->commandTester->getStatusCode()); + $display = $this->commandTester->getDisplay(); + + $this->assertStringNotContainsString('The following 6 unknown symbols were found:', $display); + $this->assertStringContainsString('Doctrine\Common\Collections\ArrayCollection', $display); + $this->assertStringContainsString('Example\Library\Dependency', $display); + $this->assertStringContainsString('FILTER_VALIDATE_URL', $display); + $this->assertStringContainsString('filter_var', $display); + $this->assertStringContainsString('Foo\Bar\Baz', $display); + $this->assertStringContainsString('libxml_clear_errors', $display); + } + public function testSelfCheckShowsNoErrors(): void { $this->commandTester->execute([ diff --git a/test/ComposerRequireCheckerTest/Cli/ResultsWriter/CliJsonTest.php b/test/ComposerRequireCheckerTest/Cli/ResultsWriter/CliJsonTest.php new file mode 100644 index 00000000..107a27fa --- /dev/null +++ b/test/ComposerRequireCheckerTest/Cli/ResultsWriter/CliJsonTest.php @@ -0,0 +1,58 @@ +writer = new CliJson( + function (string $string): void { + $this->output .= $string; + }, + '0.0.1', + static fn () => new DateTimeImmutable('@0') + ); + } + + public function testWriteReport(): void + { + $this->writer->write([ + 'Foo' => [], + 'opcache_get_status' => ['ext-opcache'], + 'dummy' => ['ext-dummy', 'ext-other'], + ]); + + $actual = json_decode($this->output, true, JSON_THROW_ON_ERROR); + + self::assertSame( + [ + '_meta' => [ + 'composer-require-checker' => ['version' => '0.0.1'], + 'date' => '1970-01-01T00:00:00+00:00', + ], + 'unknown-symbols' => [ + 'Foo' => [], + 'opcache_get_status' => ['ext-opcache'], + 'dummy' => ['ext-dummy', 'ext-other'], + ], + ], + $actual + ); + } +} diff --git a/test/ComposerRequireCheckerTest/Cli/ResultsWriter/CliTextTest.php b/test/ComposerRequireCheckerTest/Cli/ResultsWriter/CliTextTest.php new file mode 100644 index 00000000..1399c480 --- /dev/null +++ b/test/ComposerRequireCheckerTest/Cli/ResultsWriter/CliTextTest.php @@ -0,0 +1,90 @@ +output = new BufferedOutput(); + $this->writer = new CliText($this->output); + } + + public function testWriteReportNoUnknownSymbolsFound(): void + { + $this->writer->write([]); + + self::assertSame('There were no unknown symbols found.' . PHP_EOL, $this->output->fetch()); + } + + public function testWriteReportWithUnknownSymbols(): void + { + $this->writer->write([ + 'Foo' => [], + 'opcache_get_status' => ['ext-opcache'], + 'dummy' => ['ext-dummy', 'ext-other'], + ]); + + $buffer = $this->output->fetch(); + self::assertStringContainsString('The following 3 unknown symbols were found:', $buffer); + self::assertStringContainsString('Foo', $buffer); + self::assertStringContainsString('| opcache_get_status', $buffer); + self::assertStringContainsString('| ext-opcache', $buffer); + self::assertStringContainsString('| dummy', $buffer); + self::assertStringContainsString('| ext-dummy', $buffer); + self::assertStringContainsString('| ext-other', $buffer); + } + + public function testWriteReportQuiet(): void + { + $this->output->setVerbosity(OutputInterface::VERBOSITY_QUIET); + + $this->writer->write([ + 'Foo' => [], + 'opcache_get_status' => ['ext-opcache'], + 'dummy' => ['ext-dummy', 'ext-other'], + ]); + + $buffer = $this->output->fetch(); + self::assertSame('', $buffer); + } + + public function testWriteReportQuietWithWriteCallable(): void + { + $output = ''; + $write = static function (string $string) use (&$output): void { + $output .= $string; + }; + + $writer = new CliText($this->output, $write); + $writer->write([ + 'Foo' => [], + 'opcache_get_status' => ['ext-opcache'], + 'dummy' => ['ext-dummy', 'ext-other'], + ]); + + $buffer = $this->output->fetch(); + self::assertStringContainsString('The following 3 unknown symbols were found:', $buffer); + self::assertStringNotContainsString('Foo', $buffer); + self::assertStringContainsString('Foo', $output); + self::assertStringContainsString('| opcache_get_status', $output); + self::assertStringContainsString('| ext-opcache', $output); + self::assertStringContainsString('| dummy', $output); + self::assertStringContainsString('| ext-dummy', $output); + self::assertStringContainsString('| ext-other', $output); + } +}