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