Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.20% covered (success)
97.20%
104 / 107
83.33% covered (warning)
83.33%
5 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
DocsCommand
97.20% covered (success)
97.20%
104 / 107
83.33% covered (warning)
83.33%
5 / 6
18
0.00% covered (danger)
0.00%
0 / 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%
33 / 33
100.00% covered (success)
100.00%
1 / 1
1
 execute
94.00% covered (success)
94.00%
47 / 50
0.00% covered (danger)
0.00%
0 / 1
11.03
 createPhpDocumentorConfig
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
2
 isDefaultGuideSource
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 normalizeProjectRelativePath
100.00% covered (success)
100.00%
4 / 4
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 Twig\Environment;
27use FastForward\DevTools\Filesystem\FilesystemInterface;
28use FastForward\DevTools\Path\DevToolsPathResolver;
29use FastForward\DevTools\Process\ProcessBuilderInterface;
30use FastForward\DevTools\Process\ProcessQueueInterface;
31use FastForward\DevTools\Path\ManagedWorkspace;
32use FastForward\DevTools\Project\ProjectCapabilities;
33use FastForward\DevTools\Project\ProjectCapabilitiesResolverInterface;
34use Psr\Log\LogLevel;
35use Symfony\Component\Console\Attribute\AsCommand;
36use Symfony\Component\Console\Command\Command;
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
42use function Safe\getcwd;
43
44/**
45 * Generates the package API documentation through phpDocumentor.
46 *
47 * The command prepares a temporary phpDocumentor configuration from the
48 * current package metadata, then delegates execution to the shared process
49 * queue so logging and grouped output stay consistent with the rest of the
50 * command surface.
51 */
52#[AsCommand(
53    name: 'reports:docs',
54    description: 'Generates API documentation.',
55    aliases: ['reports:phpdoc', 'phpDocumentor', 'docs'],
56)]
57 class DocsCommand extends Command
58{
59    use HasCacheOption;
60    use HasJsonOption;
61    use LogsCommandResults;
62
63    /**
64     * @var string the default phpDocumentor template path relative to the consumer project
65     */
66    private const string DEFAULT_TEMPLATE = 'vendor/fast-forward/phpdoc-bootstrap-template';
67
68    /**
69     * Creates a new DocsCommand instance.
70     *
71     * @param ProcessBuilderInterface $processBuilder the process builder for executing phpDocumentor
72     * @param ProcessQueueInterface $processQueue the process queue for managing execution
73     * @param Environment $renderer renders phpDocumentor configuration templates
74     * @param FilesystemInterface $filesystem the filesystem for handling file operations
75     * @param ComposerJsonInterface $composer the composer.json handler for accessing project metadata
76     * @param ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver the project capability resolver
77     */
78    public function __construct(
79        private  ProcessBuilderInterface $processBuilder,
80        private  ProcessQueueInterface $processQueue,
81        private  Environment $renderer,
82        private  FilesystemInterface $filesystem,
83        private  ComposerJsonInterface $composer,
84        private  ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver,
85    ) {
86        parent::__construct();
87    }
88
89    /**
90     * Configures the command options used to generate API documentation.
91     */
92    protected function configure(): void
93    {
94        $this->setHelp('This command generates API documentation using phpDocumentor.');
95        $this
96            ->addJsonOption()
97            ->addCacheOption('Whether to enable phpDocumentor caching.')
98            ->addCacheDirOption(
99                description: 'Path to the cache directory for phpDocumentor.',
100                default: ManagedWorkspace::getCacheDirectory(ManagedWorkspace::PHPDOC),
101            )
102            ->addOption(
103                name: 'progress',
104                mode: InputOption::VALUE_NONE,
105                description: 'Whether to enable progress output from phpDocumentor.',
106            )
107            ->addOption(
108                name: 'target',
109                shortcut: 't',
110                mode: InputOption::VALUE_OPTIONAL,
111                description: 'Path to the output directory for the generated HTML documentation.',
112                default: ManagedWorkspace::getOutputDirectory(),
113            )
114            ->addOption(
115                name: 'source',
116                shortcut: 's',
117                mode: InputOption::VALUE_OPTIONAL,
118                description: 'Path to the source directory for the generated HTML documentation.',
119                default: ProjectCapabilitiesResolverInterface::DEFAULT_GUIDE_DIRECTORY,
120            )
121            ->addOption(
122                name: 'template',
123                mode: InputOption::VALUE_OPTIONAL,
124                description: 'Path to the template directory for the generated HTML documentation.',
125                default: self::DEFAULT_TEMPLATE,
126            );
127    }
128
129    /**
130     * Generates API documentation for the configured project surface.
131     *
132     * @param InputInterface $input
133     * @param OutputInterface $output
134     */
135    protected function execute(InputInterface $input, OutputInterface $output): int
136    {
137        $jsonOutput = $this->isJsonOutput($input);
138        $processOutput = $jsonOutput ? new BufferedOutput() : $output;
139        $progress = ! $jsonOutput && (bool) $input->getOption('progress');
140        $cacheEnabled = $this->isCacheEnabled($input);
141
142        $sourceOption = (string) $input->getOption('source');
143        $source = $this->filesystem->getAbsolutePath($sourceOption);
144        $target = $this->filesystem->getAbsolutePath($input->getOption('target'));
145        $cacheDir = $this->filesystem->getAbsolutePath($input->getOption('cache-dir'));
146        $template = (string) $input->getOption('template');
147        $projectCapabilities = $this->projectCapabilitiesResolver->resolve(guideDirectory: $sourceOption);
148
149        if (self::DEFAULT_TEMPLATE === $template) {
150            $template = DevToolsPathResolver::getPreferredVendorPath(self::DEFAULT_TEMPLATE);
151        }
152
153        $this->log('Generating API documentation...', $input);
154
155        if (
156            ! $projectCapabilities->hasGuideDirectory()
157            && ! $this->isDefaultGuideSource($sourceOption)
158        ) {
159            return $this->failure('Source directory not found: {source}', $input, [
160                'source' => $source,
161            ]);
162        }
163
164        if (! $projectCapabilities->canGenerateDocs()) {
165            return $this->success(
166                'Skipping API documentation generation because no guide source or autoloaded PHP API directories were detected.',
167                $input,
168                [],
169                LogLevel::WARNING,
170            );
171        }
172
173        $config = $this->createPhpDocumentorConfig(
174            source: $source,
175            target: $target,
176            template: $template,
177            cacheDir: $cacheEnabled ? $cacheDir : sys_get_temp_dir(),
178            projectCapabilities: $projectCapabilities,
179        );
180
181        $processBuilder = $this->processBuilder
182            ->withArgument('--config', $config)
183            ->withArgument('--ansi')
184            ->withArgument('--markers', 'TODO,FIXME,BUG,HACK');
185
186        if ($cacheEnabled) {
187            $processBuilder = $processBuilder->withArgument('--cache-folder', $cacheDir);
188        }
189
190        if (! $progress) {
191            $processBuilder = $processBuilder->withArgument('--no-progress');
192        }
193
194        $phpdoc = $processBuilder->build([DevToolsPathResolver::getPreferredToolBinaryPath('phpdoc')]);
195
196        $this->processQueue->add(process: $phpdoc, label: 'Generating API Docs with phpDocumentor');
197
198        $result = $this->processQueue->run($processOutput);
199
200        if (self::SUCCESS === $result) {
201            return $this->success('API documentation generated successfully.', $input, [
202                'output' => $processOutput,
203            ]);
204        }
205
206        return $this->failure('API documentation generation failed.', $input, [
207            'output' => $processOutput,
208        ]);
209    }
210
211    /**
212     * Creates a temporary phpDocumentor configuration for the current project.
213     *
214     * @param string $source the source directory for the generated documentation
215     * @param string $target the output directory for the generated documentation
216     * @param string $template the phpDocumentor template name or path
217     * @param string $cacheDir the cache directory for phpDocumentor
218     * @param ProjectCapabilities $projectCapabilities the resolved project capability snapshot
219     *
220     * @return string the absolute path to the generated configuration
221     */
222    private function createPhpDocumentorConfig(
223        string $source,
224        string $target,
225        string $template,
226        string $cacheDir,
227        ProjectCapabilities $projectCapabilities,
228    ): string {
229        $workingDirectory = getcwd();
230        $guidePath = $projectCapabilities->hasGuideDirectory()
231            ? $this->filesystem->makePathRelative($source)
232            : null;
233
234        $content = $this->renderer->render('phpdocumentor.xml', [
235            'title' => $this->composer->getName(),
236            'template' => $template,
237            'target' => $target,
238            'cacheDir' => $cacheDir,
239            'workingDirectory' => $workingDirectory,
240            'apiDirectories' => $projectCapabilities->getApiDirectories(),
241            'guidePath' => $guidePath,
242            'defaultPackageName' => $projectCapabilities->getDefaultPackageName(),
243        ]);
244
245        $this->filesystem->dumpFile(filename: 'phpdocumentor.xml', content: $content, path: $cacheDir);
246
247        return $this->filesystem->getAbsolutePath('phpdocumentor.xml', $cacheDir);
248    }
249
250    /**
251     * Detects whether a source option still points at the default guide directory.
252     *
253     * @param string $sourceOption the guide source option received from the CLI
254     *
255     * @return bool true when the provided path is equivalent to the default guide directory
256     */
257    private function isDefaultGuideSource(string $sourceOption): bool
258    {
259        return $this->normalizeProjectRelativePath($sourceOption) === $this->normalizeProjectRelativePath(
260            ProjectCapabilitiesResolverInterface::DEFAULT_GUIDE_DIRECTORY
261        );
262    }
263
264    /**
265     * Normalizes a project-relative path for resilient default-option comparisons.
266     *
267     * @param string $path the project-relative path to normalize
268     *
269     * @return string the normalized project-relative path
270     */
271    private function normalizeProjectRelativePath(string $path): string
272    {
273        $normalizedPath = str_replace('\\', '/', $path);
274
275        while (str_starts_with($normalizedPath, './')) {
276            $normalizedPath = substr($normalizedPath, 2);
277        }
278
279        return rtrim($normalizedPath, '/');
280    }
281}