1#!/usr/bin/env php 2<?php 3 4use dokuwiki\Extension\CLIPlugin; 5use dokuwiki\Extension\PluginController; 6use dokuwiki\plugin\dev\LangProcessor; 7use dokuwiki\plugin\dev\Skeletor; 8use dokuwiki\plugin\dev\SVGIcon; 9use splitbrain\phpcli\Exception as CliException; 10use splitbrain\phpcli\Options; 11 12/** 13 * @license GPL2 14 * @author Andreas Gohr <andi@splitbrain.org> 15 */ 16class cli_plugin_dev extends CLIPlugin 17{ 18 /** 19 * Register options and arguments on the given $options object 20 * 21 * @param Options $options 22 * @return void 23 */ 24 protected function setup(Options $options) 25 { 26 $options->useCompactHelp(); 27 $options->setHelp( 28 "CLI to help with DokuWiki plugin and template development.\n\n" . 29 "Run this script from within the extension's directory." 30 ); 31 32 $options->registerCommand('init', 'Initialize a new plugin or template in the current directory.'); 33 $options->registerCommand('addTest', 'Add the testing framework files and a test. (_test/)'); 34 $options->registerArgument('test', 'Optional name of the new test. Defaults to the general test.', false, 35 'addTest'); 36 $options->registerCommand('addConf', 'Add the configuration files. (conf/)'); 37 $options->registerCommand('addLang', 'Add the language files. (lang/)'); 38 39 $types = PluginController::PLUGIN_TYPES; 40 array_walk( 41 $types, 42 function (&$item) { 43 $item = $this->colors->wrap($item, $this->colors::C_BROWN); 44 } 45 ); 46 47 $options->registerCommand('addComponent', 'Add a new plugin component.'); 48 $options->registerArgument('type', 'Type of the component. Needs to be one of ' . join(', ', $types), true, 49 'addComponent'); 50 $options->registerArgument('name', 'Optional name of the component. Defaults to a base component.', false, 51 'addComponent'); 52 53 $options->registerCommand('deletedFiles', 'Create the list of deleted files based on the git history.'); 54 $options->registerCommand('rmObsolete', 'Delete obsolete files.'); 55 56 $prefixes = array_keys(SVGIcon::SOURCES); 57 array_walk( 58 $prefixes, 59 function (&$item) { 60 $item = $this->colors->wrap($item, $this->colors::C_BROWN); 61 } 62 ); 63 64 $options->registerCommand('downloadSvg', 'Download an SVG file from a known icon repository.'); 65 $options->registerArgument('prefix:name', 66 'Colon-prefixed name of the icon. Available prefixes: ' . join(', ', $prefixes), true, 'downloadSvg'); 67 $options->registerArgument('output', 'File to save, defaults to <name>.svg in current dir', false, 68 'downloadSvg'); 69 $options->registerOption('keep-ns', 'Keep the SVG namespace. Use when the file is not inlined into HTML.', 'k', 70 false, 'downloadSvg'); 71 72 $options->registerCommand('cleanSvg', 'Clean a existing SVG files to reduce their file size.'); 73 $options->registerArgument('files...', 'The files to clean (will be overwritten)', true, 'cleanSvg'); 74 $options->registerOption('keep-ns', 'Keep the SVG namespace. Use when the file is not inlined into HTML.', 'k', 75 false, 'cleanSvg'); 76 77 $options->registerCommand('cleanLang', 78 'Clean language files from unused language strings. Detecting which strings are truly in use may ' . 79 'not always correctly work. Use with caution.'); 80 81 $options->registerCommand( 82 'test', 83 'Run the unit tests for this extension. (calls phpunit using the proper config and group)' 84 ); 85 $options->registerOption( 86 'filter', 87 'Filter tests to run by a given string. (passed to phpunit)', 88 null, 89 true, 90 'test' 91 ); 92 $options->registerArgument('files...', 'The test files to run. Defaults to all.', false, 'test'); 93 94 $options->registerCommand('check', 'Check for code style violations.'); 95 $options->registerArgument('files...', 'The files to check. Defaults to the whole extension.', false, 'check'); 96 97 $options->registerCommand('fix', 'Fix code style violations and refactor outdated code.'); 98 $options->registerArgument('files...', 'The files to check. Defaults to the whole extension.', false, 'fix'); 99 } 100 101 /** @inheritDoc */ 102 protected function main(Options $options) 103 { 104 $args = $options->getArgs(); 105 106 switch ($options->getCmd()) { 107 case 'init': 108 return $this->cmdInit(); 109 case 'addTest': 110 $test = array_shift($args); 111 return $this->cmdAddTest($test); 112 case 'addConf': 113 return $this->cmdAddConf(); 114 case 'addLang': 115 return $this->cmdAddLang(); 116 case 'addComponent': 117 $type = array_shift($args); 118 $component = array_shift($args); 119 return $this->cmdAddComponent($type, $component); 120 case 'deletedFiles': 121 return $this->cmdDeletedFiles(); 122 case 'rmObsolete': 123 return $this->cmdRmObsolete(); 124 case 'downloadSvg': 125 $ident = array_shift($args); 126 $save = array_shift($args); 127 $keep = $options->getOpt('keep-ns'); 128 return $this->cmdDownloadSVG($ident, $save, $keep); 129 case 'cleanSvg': 130 $keep = $options->getOpt('keep-ns'); 131 return $this->cmdCleanSVG($args, $keep); 132 case 'cleanLang': 133 return $this->cmdCleanLang(); 134 case 'test': 135 $filter = $options->getOpt('filter'); 136 return $this->cmdTest($filter, $args); 137 case 'check': 138 return $this->cmdCheck($args); 139 case 'fix': 140 return $this->cmdFix(); 141 default: 142 $this->error('Unknown command'); 143 echo $options->help(); 144 return 0; 145 } 146 } 147 148 /** 149 * Get the extension name from the current working directory 150 * 151 * @throws CliException if something's wrong 152 * @param string $dir 153 * @return string[] name, type 154 */ 155 protected function getTypedNameFromDir($dir) 156 { 157 $pdir = fullpath(DOKU_PLUGIN); 158 $tdir = fullpath(tpl_incdir() . '../'); 159 160 if (strpos($dir, $pdir) === 0) { 161 $ldir = substr($dir, strlen($pdir)); 162 $type = 'plugin'; 163 } elseif (strpos($dir, $tdir) === 0) { 164 $ldir = substr($dir, strlen($tdir)); 165 $type = 'template'; 166 } else { 167 throw new CliException('Current directory needs to be in plugin or template directory'); 168 } 169 170 $ldir = trim($ldir, '/'); 171 172 if (strpos($ldir, '/') !== false) { 173 throw new CliException('Current directory has to be main extension directory'); 174 } 175 176 return [$ldir, $type]; 177 } 178 179 /** 180 * Interactively ask for a value from the user 181 * 182 * @param string $prompt 183 * @param bool $cache cache given value for next time? 184 * @return string 185 */ 186 protected function readLine($prompt, $cache = false) 187 { 188 $value = ''; 189 $default = ''; 190 $cachename = getCacheName($prompt, '.readline'); 191 if ($cache && file_exists($cachename)) { 192 $default = file_get_contents($cachename); 193 } 194 195 while ($value === '') { 196 echo $prompt; 197 if ($default) echo ' [' . $default . ']'; 198 echo ': '; 199 200 $fh = fopen('php://stdin', 'r'); 201 $value = trim(fgets($fh)); 202 fclose($fh); 203 204 if ($value === '') $value = $default; 205 } 206 207 if ($cache) { 208 file_put_contents($cachename, $value); 209 } 210 211 return $value; 212 } 213 214 /** 215 * Create the given files with their given content 216 * 217 * Ignores all files that already exist 218 * 219 * @param array $files A File array as created by Skeletor::getFiles() 220 */ 221 protected function createFiles($files) 222 { 223 foreach ($files as $path => $content) { 224 if (file_exists($path)) { 225 $this->error($path . ' already exists'); 226 continue; 227 } 228 229 io_makeFileDir($path); 230 file_put_contents($path, $content); 231 $this->success($path . ' created'); 232 } 233 } 234 235 /** 236 * Delete the given file if it exists 237 * 238 * @param string $file 239 */ 240 protected function deleteFile($file) 241 { 242 if (!file_exists($file)) return; 243 if (@unlink($file)) { 244 $this->success('Delete ' . $file); 245 } 246 } 247 248 /** 249 * Run git with the given arguments and return the output 250 * 251 * @throws CliException when the command can't be run 252 * @param string ...$args 253 * @return string[] 254 */ 255 protected function git(...$args) 256 { 257 $args = array_map('escapeshellarg', $args); 258 $cmd = 'git ' . join(' ', $args); 259 $output = []; 260 $result = 0; 261 262 $this->info($cmd); 263 $last = exec($cmd, $output, $result); 264 if ($last === false || $result !== 0) { 265 throw new CliException('Running git failed'); 266 } 267 268 return $output; 269 } 270 271 // region Commands 272 273 /** 274 * Intialize the current directory as a plugin or template 275 * 276 * @return int 277 */ 278 protected function cmdInit() 279 { 280 $dir = fullpath(getcwd()); 281 if ((new FilesystemIterator($dir))->valid()) { 282 // existing directory, initialize from info file 283 $skeletor = Skeletor::fromDir($dir); 284 } else { 285 // new directory, ask for info 286 [$base, $type] = $this->getTypedNameFromDir($dir); 287 $user = $this->readLine('Your Name', true); 288 $mail = $this->readLine('Your E-Mail', true); 289 $desc = $this->readLine('Short description'); 290 $skeletor = new Skeletor($type, $base, $desc, $user, $mail); 291 } 292 $skeletor->addBasics(); 293 $this->createFiles($skeletor->getFiles()); 294 295 if (!is_dir("$dir/.git")) { 296 try { 297 $this->git('init'); 298 } catch (CliException $e) { 299 $this->error($e->getMessage()); 300 } 301 } 302 303 return 0; 304 } 305 306 /** 307 * Add test framework 308 * 309 * @param string $test Name of the Test to add 310 * @return int 311 */ 312 protected function cmdAddTest($test = '') 313 { 314 $skeletor = Skeletor::fromDir(getcwd()); 315 $skeletor->addTest($test); 316 $this->createFiles($skeletor->getFiles()); 317 return 0; 318 } 319 320 /** 321 * Add configuration 322 * 323 * @return int 324 */ 325 protected function cmdAddConf() 326 { 327 $skeletor = Skeletor::fromDir(getcwd()); 328 $skeletor->addConf(is_dir('lang')); 329 $this->createFiles($skeletor->getFiles()); 330 return 0; 331 } 332 333 /** 334 * Add language 335 * 336 * @return int 337 */ 338 protected function cmdAddLang() 339 { 340 $skeletor = Skeletor::fromDir(getcwd()); 341 $skeletor->addLang(is_dir('conf')); 342 $this->createFiles($skeletor->getFiles()); 343 return 0; 344 } 345 346 /** 347 * Add another component to the plugin 348 * 349 * @param string $type 350 * @param string $component 351 */ 352 protected function cmdAddComponent($type, $component = '') 353 { 354 $skeletor = Skeletor::fromDir(getcwd()); 355 $skeletor->addComponent($type, $component); 356 $this->createFiles($skeletor->getFiles()); 357 return 0; 358 } 359 360 /** 361 * Generate a list of deleted files from git 362 * 363 * @link https://stackoverflow.com/a/6018049/172068 364 */ 365 protected function cmdDeletedFiles() 366 { 367 if (!is_dir('.git')) throw new CliException('This extension seems not to be managed by git'); 368 369 $output = $this->git('log', '--no-renames', '--pretty=format:', '--name-only', '--diff-filter=D'); 370 $output = array_map('trim', $output); 371 $output = array_filter($output); 372 $output = array_unique($output); 373 $output = array_filter($output, function ($item) { 374 return !file_exists($item); 375 }); 376 sort($output); 377 378 if (!count($output)) { 379 $this->info('No deleted files found'); 380 return 0; 381 } 382 383 $content = "# This is a list of files that were present in previous releases\n" . 384 "# but were removed later. They should not exist in your installation.\n" . 385 join("\n", $output) . "\n"; 386 387 file_put_contents('deleted.files', $content); 388 $this->success('written deleted.files'); 389 return 0; 390 } 391 392 /** 393 * Remove files that shouldn't be here anymore 394 */ 395 protected function cmdRmObsolete() 396 { 397 $this->deleteFile('_test/general.test.php'); 398 $this->deleteFile('.travis.yml'); 399 $this->deleteFile('.github/workflows/phpTestLinux.yml'); 400 401 return 0; 402 } 403 404 /** 405 * Download a remote icon 406 * 407 * @param string $ident 408 * @param string $save 409 * @param bool $keep 410 * @return int 411 * @throws Exception 412 */ 413 protected function cmdDownloadSVG($ident, $save = '', $keep = false) 414 { 415 $svg = new SVGIcon($this); 416 $svg->keepNamespace($keep); 417 return (int)$svg->downloadRemoteIcon($ident, $save); 418 } 419 420 /** 421 * @param string[] $files 422 * @param bool $keep 423 * @return int 424 * @throws Exception 425 */ 426 protected function cmdCleanSVG($files, $keep = false) 427 { 428 $svg = new SVGIcon($this); 429 $svg->keepNamespace($keep); 430 431 $ok = true; 432 foreach ($files as $file) { 433 $ok = $ok && $svg->cleanSVGFile($file); 434 } 435 return (int)$ok; 436 } 437 438 /** 439 * @return int 440 */ 441 protected function cmdCleanLang() 442 { 443 $lp = new LangProcessor($this); 444 445 $files = glob('./lang/*/lang.php'); 446 foreach ($files as $file) { 447 $lp->processLangFile($file); 448 } 449 450 $files = glob('./lang/*/settings.php'); 451 foreach ($files as $file) { 452 $lp->processSettingsFile($file); 453 } 454 455 return 0; 456 } 457 458 /** 459 * Run the unit tests for this extension 460 * 461 * @param string $filter Optional filter string for phpunit 462 * @param string[] $args Additional arguments to pass to phpunit (files) 463 * @return int 464 */ 465 protected function cmdTest($filter = '', $args = []) 466 { 467 $dir = fullpath(getcwd()); 468 [$base, $type] = $this->getTypedNameFromDir($dir); 469 470 if ($this->colors->isEnabled()) { 471 $colors = 'always'; 472 } else { 473 $colors = 'never'; 474 } 475 476 $bin = fullpath(__DIR__ . '/../../../_test/vendor/bin/phpunit');; 477 if (!file_exists($bin)) { 478 $this->error('Testing framework not found. Please run "composer install" in the _test/ directory first.'); 479 return 1; 480 } 481 482 $runArgs = [ 483 $bin, 484 '--verbose', 485 "--colors=$colors", 486 '--configuration', fullpath(__DIR__ . '/../../../_test/phpunit.xml'), 487 '--group', $type . '_' . $base, 488 ]; 489 if ($filter) { 490 $runArgs[] = '--filter'; 491 $runArgs[] = $filter; 492 } 493 494 $runArgs = array_merge($runArgs, $args); 495 $cmd = join(' ', array_map('escapeshellarg', $runArgs)); 496 $this->info("Running $cmd"); 497 498 $result = 0; 499 passthru($cmd, $result); 500 return $result; 501 } 502 503 /** 504 * @return int 505 */ 506 protected function cmdCheck($files = []) 507 { 508 $dir = fullpath(getcwd()); 509 510 $args = [ 511 fullpath(__DIR__ . '/../../../_test/vendor/bin/phpcs'), 512 '--standard=' . fullpath(__DIR__ . '/../../../_test/phpcs.xml'), 513 ($this->colors->isEnabled()) ? '--colors' : '--no-colors', 514 '--', 515 ]; 516 517 if ($files) { 518 $args = array_merge($args, $files); 519 } else { 520 $args[] = fullpath($dir); 521 } 522 523 $cmd = join(' ', array_map('escapeshellarg', $args)); 524 $this->info("Running $cmd"); 525 526 $result = 0; 527 passthru($cmd, $result); 528 return $result; 529 } 530 531 /** 532 * @return int 533 */ 534 protected function cmdFix($files = []) 535 { 536 $dir = fullpath(getcwd()); 537 538 // first run rector to refactor outdated code 539 $args = [ 540 fullpath(__DIR__ . '/../../../_test/vendor/bin/rector'), 541 ($this->colors->isEnabled()) ? '--ansi' : '--no-ansi', 542 '--config=' . fullpath(__DIR__ . '/../../../_test/rector.php'), 543 '--no-diffs', 544 'process', 545 ]; 546 547 if ($files) { 548 $args = array_merge($args, $files); 549 } else { 550 $args[] = fullpath($dir); 551 } 552 553 $cmd = join(' ', array_map('escapeshellarg', $args)); 554 $this->info("Running $cmd"); 555 556 $result = 0; 557 passthru($cmd, $result); 558 if ($result !== 0) return $result; 559 560 // now run phpcbf to clean up code style 561 $args = [ 562 fullpath(__DIR__ . '/../../../_test/vendor/bin/phpcbf'), 563 '--standard=' . fullpath(__DIR__ . '/../../../_test/phpcs.xml'), 564 ($this->colors->isEnabled()) ? '--colors' : '--no-colors', 565 '--', 566 ]; 567 568 if ($files) { 569 $args = array_merge($args, $files); 570 } else { 571 $args[] = fullpath($dir); 572 } 573 574 $cmd = join(' ', array_map('escapeshellarg', $args)); 575 $this->info("Running $cmd"); 576 577 $result = 0; 578 passthru($cmd, $result); 579 return $result; 580 } 581 582 //endregion 583} 584