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