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('test', 'Run the unit tests for this extension.'); 82 83 $options->registerCommand('check', 'Check for code style violations.'); 84 $options->registerArgument('files...', 'The files to check. Defaults to the whole extension.', false, 'check'); 85 86 $options->registerCommand('fix', 'Fix code style violations and refactor outdated code.'); 87 $options->registerArgument('files...', 'The files to check. Defaults to the whole extension.', false, 'fix'); 88 } 89 90 /** @inheritDoc */ 91 protected function main(Options $options) 92 { 93 $args = $options->getArgs(); 94 95 switch ($options->getCmd()) { 96 case 'init': 97 return $this->cmdInit(); 98 case 'addTest': 99 $test = array_shift($args); 100 return $this->cmdAddTest($test); 101 case 'addConf': 102 return $this->cmdAddConf(); 103 case 'addLang': 104 return $this->cmdAddLang(); 105 case 'addComponent': 106 $type = array_shift($args); 107 $component = array_shift($args); 108 return $this->cmdAddComponent($type, $component); 109 case 'deletedFiles': 110 return $this->cmdDeletedFiles(); 111 case 'rmObsolete': 112 return $this->cmdRmObsolete(); 113 case 'downloadSvg': 114 $ident = array_shift($args); 115 $save = array_shift($args); 116 $keep = $options->getOpt('keep-ns'); 117 return $this->cmdDownloadSVG($ident, $save, $keep); 118 case 'cleanSvg': 119 $keep = $options->getOpt('keep-ns'); 120 return $this->cmdCleanSVG($args, $keep); 121 case 'cleanLang': 122 return $this->cmdCleanLang(); 123 case 'test': 124 return $this->cmdTest(); 125 case 'check': 126 return $this->cmdCheck($args); 127 case 'fix': 128 return $this->cmdFix(); 129 default: 130 $this->error('Unknown command'); 131 echo $options->help(); 132 return 0; 133 } 134 } 135 136 /** 137 * Get the extension name from the current working directory 138 * 139 * @throws CliException if something's wrong 140 * @param string $dir 141 * @return string[] name, type 142 */ 143 protected function getTypedNameFromDir($dir) 144 { 145 $pdir = fullpath(DOKU_PLUGIN); 146 $tdir = fullpath(tpl_incdir() . '../'); 147 148 if (strpos($dir, $pdir) === 0) { 149 $ldir = substr($dir, strlen($pdir)); 150 $type = 'plugin'; 151 } elseif (strpos($dir, $tdir) === 0) { 152 $ldir = substr($dir, strlen($tdir)); 153 $type = 'template'; 154 } else { 155 throw new CliException('Current directory needs to be in plugin or template directory'); 156 } 157 158 $ldir = trim($ldir, '/'); 159 160 if (strpos($ldir, '/') !== false) { 161 throw new CliException('Current directory has to be main extension directory'); 162 } 163 164 return [$ldir, $type]; 165 } 166 167 /** 168 * Interactively ask for a value from the user 169 * 170 * @param string $prompt 171 * @param bool $cache cache given value for next time? 172 * @return string 173 */ 174 protected function readLine($prompt, $cache = false) 175 { 176 $value = ''; 177 $default = ''; 178 $cachename = getCacheName($prompt, '.readline'); 179 if ($cache && file_exists($cachename)) { 180 $default = file_get_contents($cachename); 181 } 182 183 while ($value === '') { 184 echo $prompt; 185 if ($default) echo ' [' . $default . ']'; 186 echo ': '; 187 188 $fh = fopen('php://stdin', 'r'); 189 $value = trim(fgets($fh)); 190 fclose($fh); 191 192 if ($value === '') $value = $default; 193 } 194 195 if ($cache) { 196 file_put_contents($cachename, $value); 197 } 198 199 return $value; 200 } 201 202 /** 203 * Create the given files with their given content 204 * 205 * Ignores all files that already exist 206 * 207 * @param array $files A File array as created by Skeletor::getFiles() 208 */ 209 protected function createFiles($files) 210 { 211 foreach ($files as $path => $content) { 212 if (file_exists($path)) { 213 $this->error($path . ' already exists'); 214 continue; 215 } 216 217 io_makeFileDir($path); 218 file_put_contents($path, $content); 219 $this->success($path . ' created'); 220 } 221 } 222 223 /** 224 * Delete the given file if it exists 225 * 226 * @param string $file 227 */ 228 protected function deleteFile($file) 229 { 230 if (!file_exists($file)) return; 231 if (@unlink($file)) { 232 $this->success('Delete ' . $file); 233 } 234 } 235 236 /** 237 * Run git with the given arguments and return the output 238 * 239 * @throws CliException when the command can't be run 240 * @param string ...$args 241 * @return string[] 242 */ 243 protected function git(...$args) 244 { 245 $args = array_map('escapeshellarg', $args); 246 $cmd = 'git ' . join(' ', $args); 247 $output = []; 248 $result = 0; 249 250 $this->info($cmd); 251 $last = exec($cmd, $output, $result); 252 if ($last === false || $result !== 0) { 253 throw new CliException('Running git failed'); 254 } 255 256 return $output; 257 } 258 259 // region Commands 260 261 /** 262 * Intialize the current directory as a plugin or template 263 * 264 * @return int 265 */ 266 protected function cmdInit() 267 { 268 $dir = fullpath(getcwd()); 269 if ((new FilesystemIterator($dir))->valid()) { 270 // existing directory, initialize from info file 271 $skeletor = Skeletor::fromDir($dir); 272 } else { 273 // new directory, ask for info 274 [$base, $type] = $this->getTypedNameFromDir($dir); 275 $user = $this->readLine('Your Name', true); 276 $mail = $this->readLine('Your E-Mail', true); 277 $desc = $this->readLine('Short description'); 278 $skeletor = new Skeletor($type, $base, $desc, $user, $mail); 279 } 280 $skeletor->addBasics(); 281 $this->createFiles($skeletor->getFiles()); 282 283 if (!is_dir("$dir/.git")) { 284 try { 285 $this->git('init'); 286 } catch (CliException $e) { 287 $this->error($e->getMessage()); 288 } 289 } 290 291 return 0; 292 } 293 294 /** 295 * Add test framework 296 * 297 * @param string $test Name of the Test to add 298 * @return int 299 */ 300 protected function cmdAddTest($test = '') 301 { 302 $skeletor = Skeletor::fromDir(getcwd()); 303 $skeletor->addTest($test); 304 $this->createFiles($skeletor->getFiles()); 305 return 0; 306 } 307 308 /** 309 * Add configuration 310 * 311 * @return int 312 */ 313 protected function cmdAddConf() 314 { 315 $skeletor = Skeletor::fromDir(getcwd()); 316 $skeletor->addConf(is_dir('lang')); 317 $this->createFiles($skeletor->getFiles()); 318 return 0; 319 } 320 321 /** 322 * Add language 323 * 324 * @return int 325 */ 326 protected function cmdAddLang() 327 { 328 $skeletor = Skeletor::fromDir(getcwd()); 329 $skeletor->addLang(is_dir('conf')); 330 $this->createFiles($skeletor->getFiles()); 331 return 0; 332 } 333 334 /** 335 * Add another component to the plugin 336 * 337 * @param string $type 338 * @param string $component 339 */ 340 protected function cmdAddComponent($type, $component = '') 341 { 342 $skeletor = Skeletor::fromDir(getcwd()); 343 $skeletor->addComponent($type, $component); 344 $this->createFiles($skeletor->getFiles()); 345 return 0; 346 } 347 348 /** 349 * Generate a list of deleted files from git 350 * 351 * @link https://stackoverflow.com/a/6018049/172068 352 */ 353 protected function cmdDeletedFiles() 354 { 355 if (!is_dir('.git')) throw new CliException('This extension seems not to be managed by git'); 356 357 $output = $this->git('log', '--no-renames', '--pretty=format:', '--name-only', '--diff-filter=D'); 358 $output = array_map('trim', $output); 359 $output = array_filter($output); 360 $output = array_unique($output); 361 $output = array_filter($output, function ($item) { 362 return !file_exists($item); 363 }); 364 sort($output); 365 366 if (!count($output)) { 367 $this->info('No deleted files found'); 368 return 0; 369 } 370 371 $content = "# This is a list of files that were present in previous releases\n" . 372 "# but were removed later. They should not exist in your installation.\n" . 373 join("\n", $output) . "\n"; 374 375 file_put_contents('deleted.files', $content); 376 $this->success('written deleted.files'); 377 return 0; 378 } 379 380 /** 381 * Remove files that shouldn't be here anymore 382 */ 383 protected function cmdRmObsolete() 384 { 385 $this->deleteFile('_test/general.test.php'); 386 $this->deleteFile('.travis.yml'); 387 $this->deleteFile('.github/workflows/phpTestLinux.yml'); 388 389 return 0; 390 } 391 392 /** 393 * Download a remote icon 394 * 395 * @param string $ident 396 * @param string $save 397 * @param bool $keep 398 * @return int 399 * @throws Exception 400 */ 401 protected function cmdDownloadSVG($ident, $save = '', $keep = false) 402 { 403 $svg = new SVGIcon($this); 404 $svg->keepNamespace($keep); 405 return (int)$svg->downloadRemoteIcon($ident, $save); 406 } 407 408 /** 409 * @param string[] $files 410 * @param bool $keep 411 * @return int 412 * @throws Exception 413 */ 414 protected function cmdCleanSVG($files, $keep = false) 415 { 416 $svg = new SVGIcon($this); 417 $svg->keepNamespace($keep); 418 419 $ok = true; 420 foreach ($files as $file) { 421 $ok = $ok && $svg->cleanSVGFile($file); 422 } 423 return (int)$ok; 424 } 425 426 /** 427 * @return int 428 */ 429 protected function cmdCleanLang() 430 { 431 $lp = new LangProcessor($this); 432 433 $files = glob('./lang/*/lang.php'); 434 foreach ($files as $file) { 435 $lp->processLangFile($file); 436 } 437 438 $files = glob('./lang/*/settings.php'); 439 foreach ($files as $file) { 440 $lp->processSettingsFile($file); 441 } 442 443 return 0; 444 } 445 446 /** 447 * @return int 448 */ 449 protected function cmdTest() 450 { 451 $dir = fullpath(getcwd()); 452 [$base, $type] = $this->getTypedNameFromDir($dir); 453 454 if ($this->colors->isEnabled()) { 455 $colors = 'always'; 456 } else { 457 $colors = 'never'; 458 } 459 460 $args = [ 461 fullpath(__DIR__ . '/../../../_test/vendor/bin/phpunit'), 462 '--verbose', 463 "--colors=$colors", 464 '--configuration', fullpath(__DIR__ . '/../../../_test/phpunit.xml'), 465 '--group', $type . '_' . $base, 466 ]; 467 $cmd = join(' ', array_map('escapeshellarg', $args)); 468 $this->info("Running $cmd"); 469 470 $result = 0; 471 passthru($cmd, $result); 472 return $result; 473 } 474 475 /** 476 * @return int 477 */ 478 protected function cmdCheck($files = []) 479 { 480 $dir = fullpath(getcwd()); 481 482 $args = [ 483 fullpath(__DIR__ . '/../../../_test/vendor/bin/phpcs'), 484 '--standard=' . fullpath(__DIR__ . '/../../../_test/phpcs.xml'), 485 ($this->colors->isEnabled()) ? '--colors' : '--no-colors', 486 '--', 487 ]; 488 489 if ($files) { 490 $args = array_merge($args, $files); 491 } else { 492 $args[] = fullpath($dir); 493 } 494 495 $cmd = join(' ', array_map('escapeshellarg', $args)); 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 cmdFix($files = []) 507 { 508 $dir = fullpath(getcwd()); 509 510 // first run rector to refactor outdated code 511 $args = [ 512 fullpath(__DIR__ . '/../../../_test/vendor/bin/rector'), 513 ($this->colors->isEnabled()) ? '--ansi' : '--no-ansi', 514 '--config=' . fullpath(__DIR__ . '/../../../_test/rector.php'), 515 '--no-diffs', 516 'process', 517 ]; 518 519 if ($files) { 520 $args = array_merge($args, $files); 521 } else { 522 $args[] = fullpath($dir); 523 } 524 525 $cmd = join(' ', array_map('escapeshellarg', $args)); 526 $this->info("Running $cmd"); 527 528 $result = 0; 529 passthru($cmd, $result); 530 if($result !== 0) return $result; 531 532 // now run phpcbf to clean up code style 533 $args = [ 534 fullpath(__DIR__ . '/../../../_test/vendor/bin/phpcbf'), 535 '--standard=' . fullpath(__DIR__ . '/../../../_test/phpcs.xml'), 536 ($this->colors->isEnabled()) ? '--colors' : '--no-colors', 537 '--', 538 ]; 539 540 if ($files) { 541 $args = array_merge($args, $files); 542 } else { 543 $args[] = fullpath($dir); 544 } 545 546 $cmd = join(' ', array_map('escapeshellarg', $args)); 547 $this->info("Running $cmd"); 548 549 $result = 0; 550 passthru($cmd, $result); 551 return $result; 552 } 553 554 //endregion 555} 556