1<?php
2
3/**
4 * DokuWiki Plugin doxycode (Buildmanager Helper Component)
5 *
6 * @license     GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
7 * @author      Lukas Probsthain <lukas.probsthain@gmail.com>
8 */
9
10use dokuwiki\Extension\Plugin;
11use dokuwiki\plugin\sqlite\SQLiteDB;
12use dokuwiki\ErrorHandler;
13
14/**
15 * Class helper_plugin_doxycode_buildmanager
16 *
17 * This class manages the build of code snippets with doxygen for cross referencing.
18 *
19 * Job: A build job is a single code snippet.
20 * JobID: md5 of doxygen config + code.
21 * Task: A build task has one or multiple build jobs.
22 * TaskID: md5 of doxygen config.
23 *
24 * The build is executed in build tasks that are either directly executed or scheduled with
25 * the help of the sqlite plugin. If the sqlite plugin is not available, the scheduling fails.
26 * Since the task is then not scheduled and no cache file is available, the snippet syntax
27 * should then try to build the code snippet again.
28 *
29 * Depending on the used tag files for doxygen, each build can both take long and be ressource
30 * hungry. It therefore is only allowed to have one doxygen instance running at all times. This
31 * is enforced with a lock that indicates if doxygen is running or not. In case of immediate build
32 * tasks through tryBuildNow() the buildmanager will then try to schedule the build task.
33 *
34 * If a build with the same TaskID is already running, a new TaskID will be randomly created.
35 * This way we ensure that we don't mess with an already running task.
36 *
37 * @author      Lukas Probsthain <lukas.probsthain@gmail.com>
38 */
39class helper_plugin_doxycode_buildmanager extends Plugin
40{
41    public const STATE_NON_EXISTENT = 1;
42    public const STATE_RUNNING = 2;
43    public const STATE_SCHEDULED = 3;
44    public const STATE_FINISHED = 4;
45    public const STATE_ERROR = 5;
46
47    /**
48     * @var string Filename of the lock file for only allowing one doxygen process.
49     */
50    protected const LOCKFILENAME = '_plugin_doxycode.lock';
51
52    protected $db = null;
53
54
55    /**
56     * @var array Allowed configuration strings that are relevant for doxygen.
57     */
58    private $conf_doxygen_keys = array(
59        'tagfiles',
60        'doxygen_conf',
61        'language'
62    );
63
64    /**
65     * @var String[] Configuration strings that are only relevant for the snippet syntax.
66     */
67    private $conf_doxycode_keys = array(
68        'render_task'
69    );
70
71    public function __construct()
72    {
73        // check if sqlite is available
74        if (!plugin_isdisabled('sqlite')) {
75            if ($this->db === null) {
76                try {
77                    $this->db = new SQLiteDB('doxycode', DOKU_PLUGIN . 'doxycode/db/');
78                } catch (\Exception $exception) {
79                    if (defined('DOKU_UNITTEST')) throw new \RuntimeException('Could not load SQLite', 0, $exception);
80                    ErrorHandler::logException($exception);
81                    msg('Couldn\'t load sqlite.', -1);
82                }
83            }
84        }
85    }
86
87    /**
88     * Add a build job to the task runner builder and create or update a build task if necessary.
89     *
90     * This function adds a new build job to the Jobs table in sqlite.
91     * Each build job corresponds to a build task. If no build task exists for the build job it will create a new one
92     * and insert it to the Tasks table in sqlite.
93     *
94     * If the build task for this build job is existing it will try to
95     * change its state to 'STATE_SCHEDULED' to run it again.
96     * If the build task is already running, we don't want to interfere the doxygen build process. In that case
97     * we create a new build task for this build job with a random taskID.
98     *
99     * @param String $jobID Identifier for this build job
100     * @param Array &$config Arguments from the snippet syntax containing the configuration for the snippet
101     * @param String $content Code snippet content
102     * @return Bool If adding the build job was successful
103     */
104    public function addBuildJob($jobID, &$config, $content)
105    {
106        if ($this->db === null) {
107            return false;
108        }
109
110        // TODO: is a race condition possible where we add a job to a
111        // task and during that operation the task runner start executing the task?
112
113        // check if the Task is already running
114        $row = $this->db->queryRecord('SELECT * FROM Tasks WHERE TaskID = ?', [$config['taskID']]);
115
116        switch ($row['State']) {
117            case self::STATE_ERROR: // fall through
118            case self::STATE_FINISHED: {
119                // this means that the build directory probably was already deleted
120                // we can just recreate the directory or put our job into the existing build directory
121                $id = $this->db->exec(
122                    'UPDATE Tasks SET Timestamp = CURRENT_TIMESTAMP, State = ? WHERE TaskID = ?',
123                    [self::STATE_SCHEDULED, $config['taskID']]
124                );
125                break;
126            }
127            case self::STATE_SCHEDULED: {
128                // just update the timestamp so we don't accidentally delete this task
129                $id = $this->db->exec(
130                    'UPDATE Tasks SET Timestamp = CURRENT_TIMESTAMP WHERE TaskID = ?',
131                    [$config['taskID']]
132                );
133                break;
134            }
135            case self::STATE_RUNNING: {
136                // Generate an new TaskID
137                $config['taskID'] = md5(microtime(true) . mt_rand());
138            }
139            case null;
140            case '': {
141                // we need to create a new task!
142                $id = $this->db->exec(
143                    'INSERT INTO Tasks (TaskID, State, Timestamp, Configuration) VALUES (?, ?, CURRENT_TIMESTAMP, ?)',
144                    [
145                        $config['taskID'],
146                        self::STATE_SCHEDULED,
147                        json_encode(
148                            $this->filterDoxygenAttributes($config, false),
149                            true
150                        )
151                    ]
152                );
153                break;
154            }
155        }
156
157        // create the job file with the code snippet content
158        $tmp_dir = $this->createJobFile($jobID, $config, $content);
159
160        if (strlen($tmp_dir) == 0) {
161            return false;
162        }
163
164        // create the job in sqlite
165
166        $data = [
167            'JobID' => $jobID,
168            'TaskID' => $config['taskID'],
169            'Configuration' => json_encode($this->filterDoxygenAttributes($config, true), true)
170        ];
171
172        $new = $this->db->saveRecord('Jobs', $data);
173
174        return true;
175    }
176
177    /**
178     * Try to immediately build a code snippet.
179     *
180     * If a lock for the doxygen process is present doxygen is already running.
181     * In that case we try to add the build job to the build queue (if sqlite is available).
182     *
183     * @param String $jobID Identifier for this build job
184     * @param Array &$config Arguments from the snippet syntax containing the configuration for the snippet
185     * @param String $content Code snippet content
186     * @param Array $tag_conf Tag file configuration used for passing the tag files to doxygen
187     * @return Bool If build or adding it to the build queue as a build job was successful
188     */
189    public function tryBuildNow($jobID, &$config, $content, $tag_conf)
190    {
191        global $conf;
192
193        // first try to detect if a doxygen instance is already running
194        if (!$this->lock()) {
195            // we cannot build now because only one doxygen instance is allowed at a time!
196            // this will return false if task runner is not available
197            // otherwise it will create a task and a job
198            return $this->addBuildJob($jobID, $conf, $content);
199
200            $config['render_task'] = true;
201        }
202
203        // no doxygen instance is running - we can immediately build the file!
204
205        // TODO: should we also create entries for Task and Job in sqlite if we directly build the snippet?
206
207        // create the directory where rendering with doxygen takes place
208        $tmp_dir = $this->createJobFile($jobID, $config, $content);
209
210        if (strlen($tmp_dir) == 0) {
211            $this->unlock();
212
213            return false;
214        }
215
216        // run doxygen on our file with XML output
217        $buildsuccess = $this->runDoxygen($tmp_dir, $tag_conf);
218
219        // delete tmp_dir
220        if (!$conf['allowdebug']) {
221            $this->deleteTaskDir($tmp_dir);
222        }
223
224
225        $this->unlock();
226
227        return $buildsuccess;
228    }
229
230    /**
231     * Get the state of a build task from the Tasks table in sqlite.
232     *
233     * If no entry for this task could be found in sqlite we return STATE_NON_EXISTENT.
234     *
235     * @param String $id TaskID of the build task
236     * @return Num Task State
237     */
238    public function getTaskState($id)
239    {
240        if ($this->db === null) {
241            // TODO: better return value?
242            return self::STATE_NON_EXISTENT;
243        }
244
245        $row = $this->db->queryRecord('SELECT * FROM Tasks WHERE TaskID = ?', $id);
246
247        if ($row !== null) {
248            return $row['State'];
249        } else {
250            return self::STATE_NON_EXISTENT;
251        }
252    }
253
254    /**
255     * Get the state of a build job.
256     *
257     * Here we first lookup the corresponding build task for the build job and then lookup
258     * the task state with getTaskState.
259     *
260     * @param String $jobID JobID for this build job
261     * @return Num Job State
262     */
263    public function getJobState($jobID)
264    {
265        if ($this->db === null) {
266            // TODO: better return value?
267            return self::STATE_NON_EXISTENT;
268        }
269
270        // get the TaskID from sqlite
271        $row = $this->db->queryRecord('SELECT * FROM Jobs WHERE JobID = ?', $jobID);
272
273        // check the task state and return as job state
274        if ($row !== null) {
275            // TODO: can we directly retreive the Task from our reference in sqlite?
276            return $this->getTaskState($row['TaskID']);
277        } else {
278            return self::STATE_NON_EXISTENT;
279        }
280    }
281
282    /**
283     * Return the doxygen relevant build task configuration of a build task.
284     *
285     * This is useful in a context where the configuration can not be obtained from the snippet syntax.
286     *
287     * An example for this is the 'plugin_doxycode_get_snippet_html' ajax call where the doxygen output XML is parsed.
288     * There we need the configuration for matching the used tag files.
289     *
290     * @param String $taskID TaskID for this build task
291     * @return Array Task configuration including the used tag files
292     */
293    public function getTaskConf($taskID)
294    {
295        if ($this->db === null) {
296            // TODO: better return value?
297            return [];
298        }
299
300        $row = $this->db->queryRecord('SELECT Configuration FROM Tasks WHERE TaskID = ?', $taskID);
301
302        if ($row !== null) {
303            return json_decode($row['Configuration'], true);
304        } else {
305            return [];
306        }
307    }
308
309    /**
310     * Return the doxygen relevant build task configuration configuration of a build job.
311     *
312     * This is useful in a context where the configuration can not be obtained from the snippet syntax.
313     *
314     * We first obtain the corresponding TaskID from the Jobs table in sqlite.
315     * We then call getTaskConf to get the task configuration.
316     *
317     * @param String $jobID JobID for this Job
318     * @return Array Task configuration including the used tag files
319     */
320    public function getJobTaskConf($jobID)
321    {
322        if ($this->db === null) {
323            // TODO: better return value?
324            return [];
325        }
326
327        // get the TaskID from sqlite
328        $row = $this->db->queryRecord('SELECT * FROM Jobs WHERE JobID = ?', $jobID);
329
330        // get the Configuration from the Task
331        if ($row !== null) {
332            return $this->getTaskConf($row['TaskID']);
333        } else {
334            return [];
335        }
336    }
337
338    /**
339     * Get the HTML relevant configuration of a build job.
340     *
341     * This is useful in a context where the configuration can not be obtained from the snippet syntax.
342     *
343     * @param String $jobID JobID for this Job
344     * @return Array Task configuration including linenumbers, filename, etc.
345     */
346    public function getJobConf($jobID)
347    {
348        if ($this->db === null) {
349            // TODO: better return value?
350            return [];
351        }
352
353        // get the TaskID from sqlite
354        $row = $this->db->queryRecord('SELECT Configuration FROM Jobs WHERE JobID = ?', $jobID);
355
356        if ($row !== null) {
357            return json_decode($row['Configuration'], true);
358        } else {
359            return [];
360        }
361    }
362
363    /**
364     * Check if a lock for the doxygen process is present.
365     *
366     * @return Bool Is a lock present?
367     */
368    private function isRunning()
369    {
370        global $conf;
371
372        $lock = $conf['lockdir'] . self::LOCKFILENAME;
373
374        return file_exists($lock);
375    }
376
377    /**
378     * Create a lock for the doxygen process.
379     *
380     * This is used for ensuring that only one doxygen process can be present at all times.
381     *
382     * If a lock is present we check if the lock file is older than the maximum allowed execution time
383     * of the doxygen task runner. We then assume that the lock is stale and remove it.
384     *
385     * If a lock is present and not stale locking fails.
386     *
387     * @return Bool Was locking successful?
388     */
389    private function lock()
390    {
391        global $conf;
392
393        $lock = $conf['lockdir'] . self::LOCKFILENAME;
394
395        if (file_exists($lock)) {
396            if (time() - @filemtime($lock) > $this->getConf('runner_max_execution_time')) {
397                // looks like a stale lock - remove it
398                unlink($lock);
399            } else {
400                return false;
401            }
402        }
403
404        // try creating the lock file
405        io_savefile($lock, "");
406
407        return true;
408    }
409
410    /**
411     * Remove the doxygen process lock file.
412     *
413     * @return Bool Was removing the lock successful?
414     */
415    private function unlock()
416    {
417        global $conf;
418        $lock = $conf['lockdir'] . self::LOCKFILENAME;
419        return unlink($lock);
420    }
421
422    /**
423     * Execute a doxygen task runner build task.
424     *
425     * We obtain the build task from the Tasks table in sqlite.
426     * The build task row includes used tag files from the snippet syntax.
427     *
428     * We then load the tag file configuration for those tag files and try to execute the build.
429     *
430     * After the doxygen process exited we update the build task state in sqlite.
431     *
432     * @param String $taskID TaskID for this build task
433     * @return Bool Was building successful?
434     */
435    public function runTask($taskID)
436    {
437        global $conf;
438
439        if (!$this->lock()) {
440            // a task is already running
441            return false;
442        }
443
444        // get the config from sqlite!
445        $row = $this->db->queryRecord('SELECT * FROM Tasks WHERE TaskID = ?', $taskID);
446
447        // we only want to run if we have a scheduled job!
448        if ($row === null || $row['State'] != self::STATE_SCHEDULED) {
449            $this->unlock();
450            return false;
451        }
452
453        $config = json_decode($row['Configuration'], true);
454
455
456        /** @var helper_plugin_doxycode_tagmanager $tagmanager */
457        $tagmanager = plugin_load('helper', 'doxycode_tagmanager');
458        // load the tag_config from the tag file list
459        if (!is_array($config['tagfiles'])) {
460            $config['tagfiles'] = [$config['tagfiles']];
461        }
462        $tag_config = $tagmanager->getFilteredTagConfig($config['tagfiles']);
463
464        // update the maximum execution time according to configuration
465        // TODO: maybe check if this configuration is present?
466        set_time_limit($this->getConf('runner_max_execution_time'));
467
468        // this just returns the build dir if already existent
469        $tmpDir = $this->createTaskDir($taskID);
470
471        // update the task state
472        // we do not update the timestamp of the task here
473        $this->db->exec(
474            'UPDATE Tasks SET State = ? WHERE TaskID = ?',
475            [self::STATE_RUNNING, $taskID]
476        );
477
478        // execute doxygen and move cache files into position
479        $success = $this->runDoxygen($tmpDir, $tag_config);
480
481        // update the task state
482        if ($success) {
483            $this->db->exec(
484                'UPDATE Tasks SET State = ? WHERE TaskID = ?',
485                [self::STATE_FINISHED, $taskID]
486            );
487        } else {
488            $this->db->exec(
489                'UPDATE Tasks SET State = ? WHERE TaskID = ?',
490                [self::STATE_ERROR, $taskID]
491            );
492        }
493
494
495        // delete tmp_dir
496        if (!$conf['allowdebug']) {
497            $this->deleteTaskDir($tmpDir);
498        }
499
500        $this->unlock();
501
502        return true;
503    }
504
505    /**
506     * Execute doxygen in a shell.
507     *
508     * The doxygen configuration is passed to doxygen via a pipe and the TAGFILES parameter
509     * is overridden with the tag file configuration passed to this function.
510     *
511     * In the xml output directory all matching XML output files are extracted and placed
512     * where the other plugin components expect the XML cache file. This is done by extracting the XML
513     * cache ID from the doxygen XML output filename (the source files in the doxygen directory where named
514     * after the cache ID).
515     *
516     * @param String $build_dir Directory where doxygen should be executed.
517     * @param Array $tag_conf Tag file configuration
518     * @return Bool Was the execution successful?
519     */
520    private function runDoxygen($build_dir, $tag_conf = null)
521    {
522        if (!is_dir($build_dir)) {
523            // the directory does not exist
524            return false;
525        }
526
527        $doxygenExecutable = $this->getConf('doxygen_executable');
528
529        // check if doxygen executable exists!
530        if (!file_exists($doxygenExecutable)) {
531            return false;
532        }
533
534        // Path to your Doxygen configuration file
535        // TODO: use default doxygen config or allow admin to upload
536        // a doxygen configuration that is not overwritten by plugin updates
537        $doxygenConfig = DOKU_PLUGIN . $this->getPluginName() . '/doxygen.conf';
538
539
540        /** @var helper_plugin_doxycode_tagmanager $tagmanager */
541        $tagmanager = plugin_load('helper', 'doxycode_tagmanager');
542
543        // TAGFILES variable value you want to set
544        $tagfiles = '';
545        $index = 0;
546        foreach ($tag_conf as $key => $conf) {
547            if ($index > 0) {
548                $tagfiles .= ' ';
549            }
550
551            $tagfiles .= '"' . $tagmanager->getTagFileDir() . $key . '.xml=' . $conf['docu_url'] . '"';
552            $index++;
553        }
554
555        // TODO: allow more configuration settings for doxygen execution through the doxygen_conf parameter
556
557
558        // Running the Doxygen command with overridden TAGFILES
559        exec(
560            "cd $build_dir && ( cat $doxygenConfig ; echo 'TAGFILES=$tagfiles' ) | $doxygenExecutable -",
561            $output,
562            $returnVar
563        );
564
565        // now extract the XML files from the build directory
566        // Find all XML files in the directory
567        $files = glob($build_dir . '/xml/*_8*.xml');
568
569        foreach ($files as $file) {
570            // Get the file name without extension
571            $filename = pathinfo($file, PATHINFO_FILENAME);
572
573            $cache_name = pathinfo($filename, PATHINFO_FILENAME);
574
575            $cache_name = explode('_8', $cache_name);
576
577            // move XML to cache file position
578            $cache_name = getCacheName($cache_name[0], ".xml");
579
580            copy($file, $cache_name);
581        }
582
583        return $returnVar === 0;
584    }
585
586
587    /**
588     * The function _getXMLOutputName takes a filename
589     * in the Doxygen workspace and converts it to the output XML filename.
590     *
591     * @todo: can probably be deleted!
592     *
593     * @param string Name of a source file in the doxygen workspace.
594     *
595     * @return string Name of the XML file output by Doxygen for the given source file.
596     */
597    private function getXMLOutputName($filename)
598    {
599        return str_replace(".", "_8", $filename) . '.xml';
600    }
601
602    /**
603     * The function creates a temporary directory for building the Doxygen documentation.
604     *
605     * @return string Directory where Doxygen can be executed.
606     */
607    private function createTaskDir($taskID)
608    {
609        global $conf;
610
611        $tempDir = $conf['tmpdir'] . '/doxycode/';
612
613        // check if we already have a doxycode directory
614        if (!is_dir($tempDir)) {
615            mkdir($tempDir);
616        }
617
618        $tempDir .= $taskID;
619        if (!is_dir($tempDir)) {
620            mkdir($tempDir);
621        }
622
623        return $tempDir;
624    }
625
626    /**
627     * Delete the temporary build task directory.
628     *
629     * @param String $dirPath Build directory where doxygen is executed.
630     */
631    private function deleteTaskDir($dirPath)
632    {
633        if (! is_dir($dirPath)) {
634            throw new InvalidArgumentException("$dirPath must be a directory");
635        }
636
637        io_rmdir($dirPath, true);
638    }
639
640    /**
641     * Create the source file in the build directory of the build task.
642     *
643     * The $config variable includes the corresponding TaskID.
644     * First we try to create the temporary build directory.
645     *
646     * We than place the content of the code snippet in a source file in the build directory.
647     *
648     * The extension of the source file is the 'language' variable in $config.
649     * The filename of the source file is the cache ID (=JobID) of the XML cache file.
650     * This can later be used to place the doxygen output XML at the appropriate XML cache file name.
651     *
652     * @param String $jobID JobID for this build job
653     * @param Array &$config Configuration from the snippet syntax
654     * @param String $content Content from the code snippet
655     */
656    private function createJobFile($jobID, &$config, $content)
657    {
658        // if we do not already have a job directory, create it
659        $tmpDir = $this->createTaskDir($config['taskID']);
660
661        if (!is_dir($tmpDir)) {
662            return '';
663        }
664
665        // we expect a cache filename (md5) - the xml output from the build job will have this filename
666        // thereby the doxygen builder can correctly identify where the XML output file should be placed
667        // getCacheName() can later be used to get the correct place for the cache file
668        $render_file_name = $jobID . '.' . $config['language'];
669
670        // Attempt to write the content to the file
671        $result = file_put_contents($tmpDir . '/' . $render_file_name, $content);
672
673        // TODO: maybe throw error here and try catch where used...
674
675        return $tmpDir;
676    }
677
678    /**
679     * Get a list of scheduled build tasks from the Tasks table in sqlite.
680     *
681     * @param Num $amount Amount of build tasks to return.
682     * @return Array Build tasks
683     */
684    public function getBuildTasks($amount = PHP_INT_MAX)
685    {
686        // get build tasks from SQLite
687        // the order should be the same for all functions
688        // first one is the one currently built or the next one do build
689        if ($this->db === null) {
690            return false;
691        }
692
693        // get the oldest task first
694        $rows = $this->db->queryAll(
695            'SELECT TaskID FROM Tasks WHERE TaskID IS NOT NULL AND State = ? ORDER BY Timestamp ASC LIMIT ?',
696            [self::STATE_SCHEDULED, $amount]
697        );
698
699
700        return $rows;
701    }
702
703
704    /**
705     * Filter the doxygen relevant attributes from a configuration array.
706     *
707     * The doxygen relevant attributes are parameters that are passed to doxygen when building.
708     * Examples: tag file configuration
709     *
710     * The configuration also includes attributes that only influence task scheduling (e.g. 'render_task' which forces
711     * task runner build from the syntax). Here we filter those values out.
712     *
713     * This function is especially useful for generating the cache file IDs.
714     *
715     * @param Array $config Configuration from the snippet syntax.
716     * @param bool $exclude Return only doxygen relevant configuration or everying else
717     * @return Array filtered configuration
718     */
719    public function filterDoxygenAttributes($config, $exclude = false)
720    {
721        $filtered_config = [];
722
723        // filter tag_config by tag_names
724        if (!$exclude) {
725            $filtered_config = array_intersect_key($config, array_flip($this->conf_doxygen_keys));
726        } else {
727            $filtered_config = array_diff_key($config, array_flip($this->conf_doxygen_keys));
728        }
729
730
731        // filter out keys that are only relevant for the snippet syntax
732        $filtered_config = array_diff_key($filtered_config, array_flip($this->conf_doxycode_keys));
733
734        $filtered_config = is_array($filtered_config) ? $filtered_config : [$filtered_config];
735
736        return $filtered_config;
737    }
738}
739