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