Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
120 / 120
100.00% covered (success)
100.00%
4 / 4
CRAP
100.00% covered (success)
100.00%
1 / 1
PhpDocCommand
100.00% covered (success)
100.00%
120 / 120
100.00% covered (success)
100.00%
4 / 4
23
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%
25 / 25
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
65 / 65
100.00% covered (success)
100.00%
1 / 1
18
 ensureDocHeaderExists
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
3
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\HasCacheOption;
25use FastForward\DevTools\Console\Input\HasJsonOption;
26use FastForward\DevTools\Filesystem\FilesystemInterface;
27use FastForward\DevTools\Path\DevToolsPathResolver;
28use FastForward\DevTools\Process\ProcessBuilderInterface;
29use FastForward\DevTools\Process\ProcessQueueInterface;
30use FastForward\DevTools\Path\ManagedWorkspace;
31use Psr\Clock\ClockInterface;
32use Psr\Log\LogLevel;
33use Twig\Environment;
34use Throwable;
35use UnderflowException;
36use FastForward\DevTools\Rector\AddMissingMethodPhpDocRector;
37use Symfony\Component\Config\FileLocatorInterface;
38use Symfony\Component\Console\Attribute\AsCommand;
39use Symfony\Component\Console\Command\Command;
40use Symfony\Component\Console\Input\InputInterface;
41use Symfony\Component\Console\Input\InputArgument;
42use Symfony\Component\Console\Input\InputOption;
43use Symfony\Component\Console\Output\BufferedOutput;
44use Symfony\Component\Console\Output\OutputInterface;
45
46/**
47 * Provides operations to inspect, lint, and repair PHPDoc comments across the project.
48 * The class MUST NOT be extended and SHALL coordinate tools like PHP-CS-Fixer and Rector.
49 */
50#[AsCommand(name: 'standards:phpdoc', description: 'Checks and fixes PHPDocs.', aliases: [
51    'phpdoc',
52    'docheader',
53    'php-cs-fixer',
54])]
55 class PhpDocCommand extends Command
56{
57    use HasCacheOption;
58    use HasJsonOption;
59    use LogsCommandResults;
60
61    /**
62     * @var string determines the template filename for docheaders
63     */
64    public const string FILENAME = '.docheader';
65
66    /**
67     * @var string stores the underlying configuration file for PHP-CS-Fixer
68     */
69    public const string CONFIG = '.php-cs-fixer.dist.php';
70
71    /**
72     * @var string defines the cache file name for PHP-CS-Fixer results
73     */
74    public const string CACHE_FILE = '.php-cs-fixer.cache';
75
76    /**
77     * Creates a new PhpDocCommand instance.
78     *
79     * @param ProcessBuilderInterface
80     * @param FileLocatorInterface $fileLocator the locator for template resources
81     * @param FilesystemInterface $filesystem the filesystem component
82     * @param ProcessBuilderInterface $processBuilder
83     * @param ProcessQueueInterface $processQueue
84     * @param ComposerJsonInterface $composer
85     * @param Environment $renderer
86     * @param ClockInterface $clock
87     */
88    public function __construct(
89        private  ProcessBuilderInterface $processBuilder,
90        private  ProcessQueueInterface $processQueue,
91        private  ComposerJsonInterface $composer,
92        private  FileLocatorInterface $fileLocator,
93        private  FilesystemInterface $filesystem,
94        private  Environment $renderer,
95        private  ClockInterface $clock,
96    ) {
97        parent::__construct();
98    }
99
100    /**
101     * Configures the PHPDoc command.
102     *
103     * This method MUST securely configure the expected inputs, such as the `--fix` option.
104     *
105     * @return void
106     */
107    protected function configure(): void
108    {
109        $this->setHelp('This command checks and fixes PHPDocs in your PHP files.');
110        $this
111            ->addJsonOption()
112            ->addCacheOption('Whether to enable PHP-CS-Fixer caching.')
113            ->addCacheDirOption(
114                description: 'Path to the cache directory for PHP-CS-Fixer.',
115                default: ManagedWorkspace::getCacheDirectory(ManagedWorkspace::PHP_CS_FIXER),
116            )
117            ->addArgument(
118                name: 'path',
119                mode: InputArgument::OPTIONAL | InputArgument::IS_ARRAY,
120                description: 'Files or directories to check; omit to use configured paths.',
121                default: [],
122            )
123            ->addOption(
124                name: 'progress',
125                mode: InputOption::VALUE_NONE,
126                description: 'Whether to enable progress output from PHPDoc tooling.',
127            )
128            ->addOption(
129                name: 'fix',
130                shortcut: 'f',
131                mode: InputOption::VALUE_NONE,
132                description: 'Whether to fix the PHPDoc issues automatically.',
133            );
134    }
135
136    /**
137     * Executes the PHPDoc checks and rectifications.
138     *
139     * The method MUST ensure the `.docheader` template is present. It SHALL then invoke
140     * PHP-CS-Fixer and Rector. If both succeed, it MUST return `self::SUCCESS`.
141     *
142     * @param InputInterface $input the command input parameters
143     * @param OutputInterface $output the system output handler
144     *
145     * @return int the success or failure state
146     */
147    protected function execute(InputInterface $input, OutputInterface $output): int
148    {
149        $jsonOutput = $this->isJsonOutput($input);
150        $processOutput = $jsonOutput ? new BufferedOutput() : $output;
151        $fix = (bool) $input->getOption('fix');
152        $progress = ! $jsonOutput && (bool) $input->getOption('progress');
153        $cacheEnabled = $this->isCacheEnabled($input);
154
155        $this->log('Checking and fixing PHPDocs...', $input);
156
157        $paths = (array) $input->getArgument('path');
158
159        foreach ($paths as $path) {
160            if ('' === $path || ! $this->filesystem->exists($path)) {
161                return $this->failure('PHPDoc path not found: {path}', $input, [
162                    'path' => $path,
163                    'output' => $processOutput,
164                ]);
165            }
166        }
167
168        $this->ensureDocHeaderExists($input);
169
170        $processBuilder = $this->processBuilder
171            ->withArgument('--ansi')
172            ->withArgument('--diff')
173            ->withArgument('--config', $this->fileLocator->locate(self::CONFIG));
174
175        if ($cacheEnabled) {
176            $processBuilder = $processBuilder->withArgument('--using-cache=yes')
177                ->withArgument(
178                    '--cache-file',
179                    $this->filesystem->getAbsolutePath(self::CACHE_FILE, $input->getOption('cache-dir'))
180                );
181        } else {
182            $processBuilder = $processBuilder->withArgument('--using-cache=no');
183        }
184
185        if (! $progress) {
186            $processBuilder = $processBuilder->withArgument('--show-progress=none');
187        }
188
189        if ($jsonOutput) {
190            $processBuilder = $processBuilder
191                ->withArgument('--format=json');
192        }
193
194        if (! $fix) {
195            $processBuilder = $processBuilder->withArgument('--dry-run');
196        }
197
198        if ([] !== $paths) {
199            $processBuilder = $processBuilder->withArgument('--path-mode=intersection')
200                ->withArgument('--');
201
202            foreach ($paths as $path) {
203                $processBuilder = $processBuilder->withArgument($path);
204            }
205        }
206
207        $phpCsFixer = $processBuilder->build([DevToolsPathResolver::getPreferredToolBinaryPath('php-cs-fixer'), 'fix']);
208
209        $processBuilder = $this->processBuilder
210            ->withArgument('--ansi')
211            ->withArgument('--config', $this->fileLocator->locate(RefactorCommand::CONFIG))
212            ->withArgument('--autoload-file', 'vendor/autoload.php')
213            ->withArgument('--only', AddMissingMethodPhpDocRector::class);
214
215        if (! $progress) {
216            $processBuilder = $processBuilder->withArgument('--no-progress-bar');
217        }
218
219        if ($jsonOutput) {
220            $processBuilder = $processBuilder
221                ->withArgument('--output-format', 'json');
222        }
223
224        if (! $fix) {
225            $processBuilder = $processBuilder->withArgument('--dry-run');
226        }
227
228        if ([] !== $paths) {
229            $processBuilder = $processBuilder->withArgument('--');
230
231            foreach ($paths as $path) {
232                $processBuilder = $processBuilder->withArgument($path);
233            }
234        }
235
236        $rector = $processBuilder->build([DevToolsPathResolver::getPreferredToolBinaryPath('rector'), 'process']);
237
238        $this->processQueue->add(process: $phpCsFixer, label: 'Fixing PHPDoc File Headers with PHP-CS-Fixer');
239        $this->processQueue->add(process: $rector, label: 'Adding Missing PHPDoc with Rector');
240
241        $result = $this->processQueue->run($processOutput);
242
243        if (self::SUCCESS === $result) {
244            return $this->success('PHPDoc checks completed successfully.', $input, [
245                'output' => $processOutput,
246            ]);
247        }
248
249        return $this->failure('PHPDoc checks failed.', $input, [
250            'output' => $processOutput,
251        ]);
252    }
253
254    /**
255     * Creates the missing document header configuration file if needed.
256     *
257     * The method MUST query the local filesystem. If the file is missing, it SHOULD copy
258     * the tool template into the root folder.
259     *
260     * @param InputInterface $input the originating command input
261     *
262     * @return void
263     */
264    private function ensureDocHeaderExists(InputInterface $input): void
265    {
266        $support = $this->composer->getSupport();
267
268        $links = array_unique(array_filter([
269            'homepage' => $this->composer->getHomepage(),
270            'source' => $support->getSource(),
271            'issues' => $support->getIssues(),
272            'docs' => $support->getDocs() ?? $support->getWiki(),
273            'rfc2119' => 'https://datatracker.ietf.org/doc/html/rfc2119',
274        ]));
275
276        try {
277            $copyrightHolder = (string) $this->composer->getAuthors(true);
278        } catch (UnderflowException) {
279            $copyrightHolder = '';
280        }
281
282        $docHeader = $this->renderer->render('docblock/.docheader', [
283            'package' => $this->composer->getName(),
284            'description' => rtrim($this->composer->getDescription(), '.'),
285            'year' => $this->clock->now()
286                ->format('Y'),
287            'copyright_holder' => $copyrightHolder,
288            'license' => $this->composer->getLicense(),
289            'links' => $links,
290        ]);
291
292        try {
293            $this->filesystem->dumpFile(self::FILENAME, $docHeader);
294        } catch (Throwable) {
295            $this->log(
296                'Skipping .docheader creation because the destination file could not be written.',
297                $input,
298                logLevel: LogLevel::WARNING,
299            );
300
301            return;
302        }
303
304        $this->log('Created .docheader from repository template.', $input);
305    }
306}