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