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