Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
95 / 95
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
PhpDocCommand
100.00% covered (success)
100.00%
95 / 95
100.00% covered (success)
100.00%
4 / 4
14
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 configure
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
46 / 46
100.00% covered (success)
100.00%
1 / 1
10
 ensureDocHeaderExists
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Fast Forward Development Tools for PHP projects.
7 *
8 * This file is part of fast-forward/dev-tools project.
9 *
10 * @author   Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
11 * @license  https://opensource.org/licenses/MIT MIT License
12 *
13 * @see      https://github.com/php-fast-forward/
14 * @see      https://github.com/php-fast-forward/dev-tools
15 * @see      https://github.com/php-fast-forward/dev-tools/issues
16 * @see      https://php-fast-forward.github.io/dev-tools/
17 * @see      https://datatracker.ietf.org/doc/html/rfc2119
18 */
19
20namespace FastForward\DevTools\Console\Command;
21
22use FastForward\DevTools\Console\Command\Traits\LogsCommandResults;
23use FastForward\DevTools\Composer\Json\ComposerJsonInterface;
24use FastForward\DevTools\Console\Input\HasJsonOption;
25use FastForward\DevTools\Filesystem\FilesystemInterface;
26use FastForward\DevTools\Process\ProcessBuilderInterface;
27use FastForward\DevTools\Process\ProcessQueueInterface;
28use FastForward\DevTools\Path\ManagedWorkspace;
29use Psr\Clock\ClockInterface;
30use Psr\Log\LoggerInterface;
31use Twig\Environment;
32use Composer\Command\BaseCommand;
33use Throwable;
34use FastForward\DevTools\Rector\AddMissingMethodPhpDocRector;
35use Symfony\Component\Config\FileLocatorInterface;
36use Symfony\Component\Console\Attribute\AsCommand;
37use Symfony\Component\Console\Input\InputInterface;
38use Symfony\Component\Console\Input\InputOption;
39use Symfony\Component\Console\Output\BufferedOutput;
40use Symfony\Component\Console\Output\OutputInterface;
41
42/**
43 * Provides operations to inspect, lint, and repair PHPDoc comments across the project.
44 * The class MUST NOT be extended and SHALL coordinate tools like PHP-CS-Fixer and Rector.
45 */
46#[AsCommand(
47    name: 'phpdoc',
48    description: 'Checks and fixes PHPDocs.',
49    help: 'This command checks and fixes PHPDocs in your PHP files.',
50)]
51 class PhpDocCommand extends BaseCommand implements LoggerAwareCommandInterface
52{
53    use HasJsonOption;
54    use LogsCommandResults;
55
56    /**
57     * @var string determines the template filename for docheaders
58     */
59    public const string FILENAME = '.docheader';
60
61    /**
62     * @var string stores the underlying configuration file for PHP-CS-Fixer
63     */
64    public const string CONFIG = '.php-cs-fixer.dist.php';
65
66    /**
67     * @var string defines the cache file name for PHP-CS-Fixer results
68     */
69    public const string CACHE_FILE = '.php-cs-fixer.cache';
70
71    /**
72     * Creates a new PhpDocCommand instance.
73     *
74     * @param ProcessBuilderInterface
75     * @param FileLocatorInterface $fileLocator the locator for template resources
76     * @param FilesystemInterface $filesystem the filesystem component
77     * @param ProcessBuilderInterface $processBuilder
78     * @param ProcessQueueInterface $processQueue
79     * @param ComposerJsonInterface $composer
80     * @param Environment $renderer
81     * @param ClockInterface $clock
82     * @param LoggerInterface $logger the output-aware logger
83     */
84    public function __construct(
85        private  ProcessBuilderInterface $processBuilder,
86        private  ProcessQueueInterface $processQueue,
87        private  ComposerJsonInterface $composer,
88        private  FileLocatorInterface $fileLocator,
89        private  FilesystemInterface $filesystem,
90        private  Environment $renderer,
91        private  ClockInterface $clock,
92        private  LoggerInterface $logger,
93    ) {
94        parent::__construct();
95    }
96
97    /**
98     * Configures the PHPDoc command.
99     *
100     * This method MUST securely configure the expected inputs, such as the `--fix` option.
101     *
102     * @return void
103     */
104    protected function configure(): void
105    {
106        $this->addJsonOption()
107            ->addArgument(
108                name: 'path',
109                mode: InputOption::VALUE_OPTIONAL,
110                description: 'Path to the file or directory to check.',
111                default: ['.'],
112            )
113            ->addOption(
114                name: 'progress',
115                mode: InputOption::VALUE_NONE,
116                description: 'Whether to enable progress output from PHPDoc tooling.',
117            )
118            ->addOption(
119                name: 'fix',
120                shortcut: 'f',
121                mode: InputOption::VALUE_NONE,
122                description: 'Whether to fix the PHPDoc issues automatically.',
123            )
124            ->addOption(
125                name: 'cache-dir',
126                mode: InputOption::VALUE_OPTIONAL,
127                description: 'Path to the cache directory for PHP-CS-Fixer.',
128                default: ManagedWorkspace::getCacheDirectory(ManagedWorkspace::PHP_CS_FIXER),
129            );
130    }
131
132    /**
133     * Executes the PHPDoc checks and rectifications.
134     *
135     * The method MUST ensure the `.docheader` template is present. It SHALL then invoke
136     * PHP-CS-Fixer and Rector. If both succeed, it MUST return `self::SUCCESS`.
137     *
138     * @param InputInterface $input the command input parameters
139     * @param OutputInterface $output the system output handler
140     *
141     * @return int the success or failure state
142     */
143    protected function execute(InputInterface $input, OutputInterface $output): int
144    {
145        $jsonOutput = $this->isJsonOutput($input);
146        $processOutput = $jsonOutput ? new BufferedOutput() : $output;
147        $fix = (bool) $input->getOption('fix');
148        $progress = ! $jsonOutput && (bool) $input->getOption('progress');
149
150        $this->logger->info('Checking and fixing PHPDocs...', [
151            'input' => $input,
152        ]);
153
154        $this->ensureDocHeaderExists();
155
156        $processBuilder = $this->processBuilder
157            ->withArgument('--ansi')
158            ->withArgument('--diff')
159            ->withArgument('--config', $this->fileLocator->locate(self::CONFIG))
160            ->withArgument(
161                '--cache-file',
162                $this->filesystem->getAbsolutePath(self::CACHE_FILE, $input->getOption('cache-dir'))
163            );
164
165        if (! $progress) {
166            $processBuilder = $processBuilder->withArgument('--show-progress=none');
167        }
168
169        if ($jsonOutput) {
170            $processBuilder = $processBuilder
171                ->withArgument('--format=json');
172        }
173
174        if (! $fix) {
175            $processBuilder = $processBuilder->withArgument('--dry-run');
176        }
177
178        $phpCsFixer = $processBuilder->build('vendor/bin/php-cs-fixer fix');
179
180        $processBuilder = $this->processBuilder
181            ->withArgument('--config', $this->fileLocator->locate(RefactorCommand::CONFIG))
182            ->withArgument('--autoload-file', 'vendor/autoload.php')
183            ->withArgument('--only', AddMissingMethodPhpDocRector::class);
184
185        if (! $progress) {
186            $processBuilder = $processBuilder->withArgument('--no-progress-bar');
187        }
188
189        if ($jsonOutput) {
190            $processBuilder = $processBuilder
191                ->withArgument('--output-format', 'json');
192        }
193
194        if (! $fix) {
195            $processBuilder = $processBuilder->withArgument('--dry-run');
196        }
197
198        $rector = $processBuilder->build('vendor/bin/rector process');
199
200        $this->processQueue->add($phpCsFixer);
201        $this->processQueue->add($rector);
202
203        $result = $this->processQueue->run($processOutput);
204
205        if (self::SUCCESS === $result) {
206            return $this->success('PHPDoc checks completed successfully.', $input, [
207                'output' => $processOutput,
208            ]);
209        }
210
211        return $this->failure('PHPDoc checks failed.', $input, [
212            'output' => $processOutput,
213        ]);
214    }
215
216    /**
217     * Creates the missing document header configuration file if needed.
218     *
219     * The method MUST query the local filesystem. If the file is missing, it SHOULD copy
220     * the tool template into the root folder.
221     *
222     * @return void
223     */
224    private function ensureDocHeaderExists(): void
225    {
226        $support = $this->composer->getSupport();
227
228        $links = array_unique(array_filter([
229            'homepage' => $this->composer->getHomepage(),
230            'source' => $support->getSource(),
231            'issues' => $support->getIssues(),
232            'docs' => $support->getDocs() ?? $support->getWiki(),
233            'rfc2119' => 'https://datatracker.ietf.org/doc/html/rfc2119',
234        ]));
235
236        $docHeader = $this->renderer->render('docblock/.docheader', [
237            'package' => $this->composer->getName(),
238            'description' => rtrim($this->composer->getDescription(), '.'),
239            'year' => $this->clock->now()
240                ->format('Y'),
241            'copyright_holder' => (string) $this->composer->getAuthors(true),
242            'license' => $this->composer->getLicense(),
243            'links' => $links,
244        ]);
245
246        try {
247            $this->filesystem->dumpFile(self::FILENAME, $docHeader);
248        } catch (Throwable) {
249            $this->logger->warning(
250                'Skipping .docheader creation because the destination file could not be written.'
251            );
252
253            return;
254        }
255
256        $this->logger->info('Created .docheader from repository template.');
257    }
258}