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