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