Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
112 / 112 |
|
100.00% |
7 / 7 |
CRAP | |
100.00% |
1 / 1 |
| Application | |
100.00% |
112 / 112 |
|
100.00% |
7 / 7 |
34 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| is_absolute | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| configure | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| execute | |
100.00% |
25 / 25 |
|
100.00% |
1 / 1 |
9 | |||
| loadDefinition | |
100.00% |
32 / 32 |
|
100.00% |
1 / 1 |
11 | |||
| generateStubs | |
100.00% |
40 / 40 |
|
100.00% |
1 / 1 |
10 | |||
| stripDocs | |
100.00% |
3 / 3 |
|
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 | |
| 8 | namespace Automattic\Jetpack\StubGenerator; |
| 9 | |
| 10 | use Automattic\Jetpack\StubGenerator\PhpParser\PhpDocNameResolver; |
| 11 | use Automattic\Jetpack\StubGenerator\PhpParser\StripDocsNodeVisitor; |
| 12 | use Automattic\Jetpack\StubGenerator\PhpParser\StubNodeVisitor; |
| 13 | use PhpParser\NodeTraverser; |
| 14 | use PhpParser\NodeVisitor\NameResolver; |
| 15 | use PhpParser\NodeVisitor\ParentConnectingVisitor; |
| 16 | use PhpParser\ParserFactory; |
| 17 | use PhpParser\PrettyPrinter\Standard as PrettyPrinter_Standard; |
| 18 | use Symfony\Component\Console\Formatter\OutputFormatterStyle; |
| 19 | use Symfony\Component\Console\Input\InputArgument; |
| 20 | use Symfony\Component\Console\Input\InputInterface; |
| 21 | use Symfony\Component\Console\Input\InputOption; |
| 22 | use Symfony\Component\Console\Output\OutputInterface; |
| 23 | use Symfony\Component\Console\SingleCommandApplication; |
| 24 | use Throwable; |
| 25 | |
| 26 | /** |
| 27 | * User interface for the jetpack-stub-generator tool. |
| 28 | */ |
| 29 | class 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 | } |