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