Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
112 / 112
100.00% covered (success)
100.00%
7 / 7
CRAP
100.00% covered (success)
100.00%
1 / 1
Application
100.00% covered (success)
100.00%
112 / 112
100.00% covered (success)
100.00%
7 / 7
34
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 is_absolute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 configure
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
9
 loadDefinition
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
11
 generateStubs
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
10
 stripDocs
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * User interface for the jetpack-stub-generator tool.
4 *
5 * @package automattic/jetpack-stub-generator
6 */
7
8namespace Automattic\Jetpack\StubGenerator;
9
10use Automattic\Jetpack\StubGenerator\PhpParser\PhpDocNameResolver;
11use Automattic\Jetpack\StubGenerator\PhpParser\StripDocsNodeVisitor;
12use Automattic\Jetpack\StubGenerator\PhpParser\StubNodeVisitor;
13use PhpParser\NodeTraverser;
14use PhpParser\NodeVisitor\NameResolver;
15use PhpParser\NodeVisitor\ParentConnectingVisitor;
16use PhpParser\ParserFactory;
17use PhpParser\PrettyPrinter\Standard as PrettyPrinter_Standard;
18use Symfony\Component\Console\Formatter\OutputFormatterStyle;
19use Symfony\Component\Console\Input\InputArgument;
20use Symfony\Component\Console\Input\InputInterface;
21use Symfony\Component\Console\Input\InputOption;
22use Symfony\Component\Console\Output\OutputInterface;
23use Symfony\Component\Console\SingleCommandApplication;
24use Throwable;
25
26/**
27 * User interface for the jetpack-stub-generator tool.
28 */
29class Application extends SingleCommandApplication {
30
31    const VERSION = '2.2.0';
32
33    /**
34     * Exit code to use.
35     *
36     * @var int
37     */
38    private $exitCode = 0;
39
40    /**
41     * Constructor.
42     */
43    public function __construct() {
44        parent::__construct();
45        $this->setName( 'Jetpack Stub Generator' );
46        $this->setVersion( self::VERSION );
47    }
48
49    /**
50     * Test if a path is absolute.
51     *
52     * @param string $path Path.
53     * @return bool Whether it's absolute.
54     */
55    private static function is_absolute( string $path ): bool {
56        return preg_match( '#^[a-z]:|^[a-z0-9.+-]+://|^[/\\\\]#i', $path );
57    }
58
59    /**
60     * Configures the command.
61     */
62    protected function configure() {
63        $this
64            ->addArgument( 'stub-definition', InputArgument::REQUIRED, 'Stub definition file.' )
65            ->addOption( 'json', null, InputOption::VALUE_NONE, 'Definition file is a JSON file.' )
66            ->addOption( 'output', null, InputOption::VALUE_REQUIRED, 'Write output to this file rather than standard output.' )
67            ->setHelp(
68                <<<'EOF'
69                Generate stubs for specific functions/classes/etc from a codebase.
70
71                The <info>file</info> specifies which files to scan and which functions,
72                classes, and such to extract from each one. By default this should be a PHP
73                file which returns an associative array; passing <info>--json</info> will
74                instead parse it as a JSON file.
75                EOF
76            );
77    }
78
79    /**
80     * Executes the command.
81     *
82     * @param InputInterface  $input InputInterface.
83     * @param OutputInterface $output OutputInterface.
84     * @return int 0 if everything went fine, or an exit code.
85     */
86    protected function execute( InputInterface $input, OutputInterface $output ): int {
87        $output->getFormatter()->setStyle( 'warning', new OutputFormatterStyle( 'black', 'yellow' ) );
88        // @phan-suppress-next-line PhanUndeclaredMethod -- Being checked before being called. See also https://github.com/phan/phan/issues/1204.
89        $errout = is_callable( array( $output, 'getErrorOutput' ) ) ? $output->getErrorOutput() : $output;
90
91        $definition = $this->loadDefinition( $input, $errout );
92        if ( $definition === null ) {
93            return 1;
94        }
95
96        $stmts = $this->generateStubs( $definition, $errout );
97
98        if ( ! empty( $definition['strip-docs'] ) || ! empty( $definition['strip-docs-keep-tags'] ) ) {
99            $stmts = $this->stripDocs( $stmts, ! empty( $definition['strip-docs-keep-tags'] ), $errout );
100        }
101
102        $code = "<?php\n";
103        if ( ! empty( $definition['header'] ) ) {
104            $code .= $definition['header'] . "\n\n";
105        }
106        $code .= ( new PrettyPrinter_Standard() )->prettyPrint( $stmts );
107        $code .= "\n";
108        if ( ! empty( $definition['footer'] ) ) {
109            $code .= "\n" . $definition['footer'] . "\n";
110        }
111
112        $outfile = $input->getOption( 'output' );
113        if ( empty( $outfile ) ) {
114            $output->write( $code );
115        } else {
116            error_clear_last();
117            // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- We're using error_get_last() to handle it.
118            if ( @file_put_contents( $outfile, $code ) !== false ) {
119                $errout->writeln( "Output written to $outfile" );
120            } else {
121                $errmsg = error_get_last()['message'] ?? 'Unknown error';
122                $errout->writeln( "<error>Failed to write $outfile: $errmsg</>" );
123                return 1;
124            }
125        }
126
127        return $this->exitCode;
128    }
129
130    /**
131     * Load the definition file.
132     *
133     * @param InputInterface  $input InputInterface.
134     * @param OutputInterface $output OutputInterface.
135     * @return ?array Definition.
136     */
137    protected function loadDefinition( InputInterface $input, OutputInterface $output ): ?array {
138        $definitionFile = $input->getArgument( 'stub-definition' );
139        if ( ! is_readable( $definitionFile ) ) {
140            $output->writeln( "<error>File $definitionFile does not exist or is not readable</>" );
141            return null;
142        }
143
144        if ( $input->getOption( 'json' ) ) {
145            error_clear_last();
146            // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- We're using error_get_last() to handle it.
147            $contents = @file_get_contents( $definitionFile );
148            if ( $contents === false ) {
149                $errmsg = error_get_last()['message'] ?? 'Unknown error';
150                $output->writeln( "<error>Failed to read $definitionFile: $errmsg</>" );
151                return null;
152            }
153            try {
154                $definition = json_decode( $contents, true, 512, JSON_THROW_ON_ERROR );
155            } catch ( Throwable $t ) {
156                $output->writeln( "<error>Invalid JSON data in $definitionFile: {$t->getMessage()}</>" );
157                $output->writeln( $t->__toString(), OutputInterface::VERBOSITY_DEBUG );
158                return null;
159            }
160            if ( ! is_array( $definition ) ) {
161                $output->writeln( "<error>$definitionFile did not contain a JSON object</>" );
162                return null;
163            }
164        } else {
165            try {
166                $definition = require $definitionFile;
167            } catch ( Throwable $t ) {
168                $output->writeln( "<error>Exception thrown when loading $definitionFile: {$t->getMessage()}</>" );
169                $output->writeln( $t->__toString(), OutputInterface::VERBOSITY_DEBUG );
170                return null;
171            }
172            if ( ! is_array( $definition ) ) {
173                $output->writeln( "<error>$definitionFile did not return an array</>" );
174                return null;
175            }
176        }
177
178        if ( empty( $definition['basedir'] ) || ! is_string( $definition['basedir'] ) ) {
179            $definition['basedir'] = dirname( $definitionFile );
180        } elseif ( ! self::is_absolute( $definition['basedir'] ) ) {
181            $definition['basedir'] = realpath( dirname( $definitionFile ) . DIRECTORY_SEPARATOR . $definition['basedir'] );
182        }
183
184        return $definition;
185    }
186
187    /**
188     * Generate stubs from a definition file.
189     *
190     * @param array           $definition Definition file.
191     * @param OutputInterface $output OutputInterface.
192     * @return \PhpParser\Node[] Stubs.
193     */
194    protected function generateStubs( array $definition, OutputInterface $output ): array {
195        if ( empty( $definition['files'] ) ) {
196            $output->writeln( 'No files in definition', OutputInterface::VERBOSITY_VERBOSE );
197            return array();
198        }
199
200        $parser = ( new ParserFactory() )->createForHostVersion();
201
202        $traverser    = new NodeTraverser();
203        $nameResolver = new NameResolver();
204        $traverser->addVisitor( $nameResolver );
205        $traverser->addVisitor( new PhpDocNameResolver( $nameResolver->getNameContext(), $output ) );
206        $traverser->addVisitor( new ParentConnectingVisitor() );
207        $visitor = new StubNodeVisitor( $output );
208        $traverser->addVisitor( $visitor );
209
210        foreach ( $definition['files'] as $file => $defs ) {
211            if ( ! self::is_absolute( $file ) ) {
212                $file = $definition['basedir'] . DIRECTORY_SEPARATOR . $file;
213            }
214            $output->writeln( "Processing $file...", OutputInterface::VERBOSITY_VERBOSE );
215
216            if ( ! is_readable( $file ) ) {
217                $output->writeln( "<warning>File $file does not exist or is not readable</>" );
218                $this->exitCode = 2;
219                continue;
220            }
221
222            error_clear_last();
223            // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- We're using error_get_last() to handle it.
224            $contents = @file_get_contents( $file );
225            if ( $contents === false ) {
226                $errmsg = error_get_last()['message'] ?? 'Unknown error';
227                $output->writeln( "<warning>Failed to read $file: $errmsg</>" );
228                $this->exitCode = 2;
229                continue;
230            }
231
232            try {
233                $stmts = $parser->parse( $contents );
234            } catch ( Throwable $t ) {
235                $output->writeln( "<warning>Failed to parse $file: {$t->getMessage()}</>" );
236                $output->writeln( $t->__toString(), OutputInterface::VERBOSITY_DEBUG );
237                $this->exitCode = 2;
238                continue;
239            }
240
241            if ( $stmts ) {
242                $visitor->setDefs( $file, $defs );
243                $traverser->traverse( $stmts );
244            }
245        }
246
247        ksort( $visitor->namespaces );
248        $stmts = array_values( $visitor->namespaces );
249        if ( count( $stmts ) === 1 && ! $stmts[0]->name ) {
250            $stmts = $stmts[0]->stmts;
251        }
252        return $stmts;
253    }
254
255    /**
256     * Strip descriptions and unrecognized tags from doc comments.
257     *
258     * @param \PhpParser\Node[] $stmts Stubs.
259     * @param bool              $keepTags Whether to keep unrecognized tags.
260     * @param OutputInterface   $output OutputInterface.
261     * @return \PhpParser\Node[] $stmts, with attributes modified.
262     */
263    protected function stripDocs( array $stmts, bool $keepTags, OutputInterface $output ): array {
264        $traverser = new NodeTraverser();
265        $traverser->addVisitor( new StripDocsNodeVisitor( $output, $keepTags ) );
266        return $traverser->traverse( $stmts );
267    }
268}