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