xref: /plugin/dev/cli.php (revision 719b484105aa94c31e9f4f3518d8f152ab8d8089)
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