1<?php
2
3/**
4 * DokuWiki Plugin doxycode (Snippet Syntax 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\SyntaxPlugin;
11use dokuwiki\Cache\Cache;
12
13/**
14 * Class syntax_plugin_doxycode_snippet
15 *
16 * This is the main syntax of the doxycode plugin.
17 * It takes the code from a code snippet and renders it with doxygen for cross referencing.
18 *
19 * The rendering is split into building of doxygen XML files with the helper_plugin_doxycode_buildmanager
20 * helper and parsing of the XML files to HTML with the helper_plugin_doxycode_parser helper.
21 *
22 * If the sqlite plugin is installed it builds the XML through task runner jobs/task if enabled by the user,
23 * force enabled for a tag file or if a doxygen instance is already running.
24 *
25 * Which tag files and which cache files are used in the page is stored in the meta data of the page. This
26 * then used in the action_plugin_doxycode plugin for invalidating the cache.
27 *
28 * If a snippet is build through the task runner a marker is placed in the code snippet for dynamically loading
29 * the snippet and informing the user of the build progress through AJAX calls that are handled by the
30 * action_plugin_doxycode plugin.
31 */
32class syntax_plugin_doxycode_snippet extends SyntaxPlugin
33{
34    private $doc;
35
36    public function getType()
37    {
38        return 'substition';
39    }
40
41    public function getSort()
42    {
43        return 158;
44    }
45
46    public function connectTo($mode)
47    {
48        $this->Lexer->addEntryPattern('<doxycode.*?>(?=.*?</doxycode>)', $mode, 'plugin_doxycode_snippet');
49        $this->Lexer->addSpecialPattern('<doxycode.*?/>', $mode, 'plugin_doxycode_snippet');
50    }
51
52    public function postConnect()
53    {
54        $this->Lexer->addExitPattern('</doxycode>', 'plugin_doxycode_snippet');
55    }
56
57    public function handle($match, $state, $pos, Doku_Handler $handler)
58    {
59        static $args;
60        switch ($state) {
61            case DOKU_LEXER_ENTER:
62            case DOKU_LEXER_SPECIAL:
63                // Parse the attributes and content here
64                $args = $this->parseAttributes($match);
65                return [$state, $args];
66            case DOKU_LEXER_UNMATCHED:
67                // Handle internal content if any
68                return [$state, ['conf' => $args, 'text' => $match]];
69            case DOKU_LEXER_EXIT:
70                return [$state, $args];
71        }
72        return [];
73    }
74
75    private function parseAttributes($string)
76    {
77        // Use regular expressions to parse attributes
78        // Return an associative array of attributes
79
80        $args = [];
81
82        // Split the string by spaces and get the last element as the filename
83        $parts = preg_split('/\s+/', trim($string));
84        $lastPart = array_pop($parts); // Potentially the filename
85
86        // Remove ">" if it is at the end of the last part
87        $lastPart = rtrim($lastPart, '>');
88
89        // Check if the last part is a filename with an extension
90        if (preg_match('/^\w+\.\w+$/', $lastPart)) {
91            $args['filename'] = $lastPart;
92        } else {
93            // If it's not a filename, add it back to the parts array
94            $parts[] = $lastPart;
95        }
96
97        // Re-join the parts without the filename
98        $remainingString = implode(' ', $parts);
99
100        // Regular expression to match key="value" pairs and flag options
101        $pattern = '/(\w+)=(?:"([^"]*)"|([^"\s]*))|(\w+)/';
102        preg_match_all($pattern, $remainingString, $matches, PREG_SET_ORDER);
103
104        foreach ($matches as $m) {
105            if (!empty($m[1])) {
106                if (!empty($m[2])) {
107                    // This is a key="value" argument
108                    $args[$m[1]] = $m[2];
109                } elseif (!empty($m[3])) {
110                    // This is a key=value argument
111                    $args[$m[1]] = $m[3];
112                }
113            } elseif (!empty($m[4])) {
114                // This is a flag option
115                $args[$m[4]] = 1;
116            }
117        }
118
119        unset($args['doxycode']);
120
121        // validate the settings
122        // we need at least $text from DOKU_LEXER_UNMATCHED or VCS src
123        // TODO: if VCS import is implemented later we need to implement this check here!
124
125        // if we don't have filename, we need the language extension!
126        if (!isset($args['language']) && isset($args['filename'])) {
127            $args['language'] = pathinfo($args['filename'], PATHINFO_EXTENSION);
128        }
129
130        // TODO: sort arguments, so hashes for the attributes always stay the same
131        // otherwise the hash might change if we change the order of the arguments in the page
132
133        return $args;
134    }
135
136    /**
137     * Prepare the content of the code snippet.
138     *
139     * Currently this only removes newlines at the start and end.
140     *
141     * @param String &$text The code snippet content
142     */
143    private function prepareText(&$text)
144    {
145
146        if ($text[0] == "\n") {
147            $text = substr($text, 1);
148        }
149        if (substr($text, -1) == "\n") {
150            $text = substr($text, 0, -1);
151        }
152    }
153
154    public function render($mode, Doku_Renderer $renderer, $data)
155    {
156
157        list($state, $data) = $data;
158        if ($mode === 'xhtml') {
159            $this->doc = '';
160
161            // DOKU_LEXER_ENTER and DOKU_LEXER_SPECIAL: output the start of the code block
162            if ($state == DOKU_LEXER_SPECIAL || $state == DOKU_LEXER_ENTER) {
163                $this->startCodeBlock("file", $data['filename']);
164            }
165
166            // DOKU_LEXER_UNMATCHED: call renderer and output the content to the document
167            if ($state == DOKU_LEXER_UNMATCHED) {
168                $conf = $data['conf'];
169                $text = $data['text'];
170
171                // strip empty lines at start and end
172                $this->prepareText($text);
173
174                if (!isset($conf['language'])) {
175                    $renderer->doc .= $this->getLang('error_language_missing');
176                    return;
177                }
178
179                // load helpers
180                // the helper functions were split so that tagmanager can be used alone in admin.php,
181                // parser can be reused by other plugins, better structure, ...
182
183                /** @var helper_plugin_doxycode_tagmanager $tagmanager */
184                $tagmanager = plugin_load('helper', 'doxycode_tagmanager');
185                /** @var helper_plugin_doxycode_parser $parser */
186                $parser = plugin_load('helper', 'doxycode_parser');
187                /** @var helper_plugin_doxycode_buildmanager $buildmanager */
188                $buildmanager = plugin_load('helper', 'doxycode_buildmanager');
189                /** @var helper_plugin_doxycode $helper */
190                $helper = plugin_load('helper', 'doxycode');
191
192                // get the tag file configuration from the tag file name list from the syntax
193                $tag_conf = $tagmanager->getFilteredTagConfig($conf['tagfiles']);
194
195
196                // load HTML from cache
197
198                // TODO: is it ok to reuse the same HTML file for multiple instances with the same settings?
199                // example problems: ACL? tag file settings per page?
200
201                // the cache name is the hash from options + code
202                $html_cacheID = md5(
203                    json_encode($buildmanager->filterDoxygenAttributes($conf, true)) . $text
204                );  // cache identifier for this code snippet
205                $xml_cacheID = md5(
206                    json_encode($buildmanager->filterDoxygenAttributes($conf, false)) . $text
207                );  // cache identifier for this code snippet
208
209                $html_cache = new Cache($html_cacheID, '.html');
210                $xml_cache = new Cache($xml_cacheID, '.xml');
211
212                // use the helper for loading the file dependencies (conf, tag_conf, tagfiles)
213                $depends = [];
214                $helper->getHTMLFileDependencies($depends, $xml_cacheID, $tag_conf);
215
216                // check if we have parsed HTML ready
217                if ($html_cache->useCache($depends)) {
218                    // we have a valid HTML!
219
220                    if ($cachedContent = @file_get_contents($html_cache->cache)) {
221                        // append cached HTML to document
222                        $renderer->doc .= $cachedContent;
223                    } else {
224                        msg($this->getLang('error_cache_not_readable'), 2);
225                    }
226
227                    // do not invoke other actions!
228                    return;
229                }
230
231                // no valid HTML was found
232                // we now try to use the cached XML
233
234                $depends = [];
235                $helper->getXMLFileDependencies($depends, $tag_conf);
236
237                // this variable makes it easier to decide
238                // if we want to try to parse the XML output of doxygen at the end
239                //  - cache is valid
240                //      - assume STATE_FINISHED
241                //  - cache was invalidated (purge, dependencies)
242                //      - try directly build
243                //          - direct build successful
244                //          -> set STATE_FINISHED manually
245                //          - build was scheduled (doxygen already running)
246                //          -> $job_state reflects actual state
247                //      - schedule build
248                //      -> $job_state reflects actual state
249                $job_state = helper_plugin_doxycode_buildmanager::STATE_FINISHED;
250
251                if (!$xml_cache->useCache($depends)) {
252                    // no valid XML cache available
253
254                    // the taskID is the md5 of only the doxygen configuration
255                    $conf['taskID'] = md5(json_encode($buildmanager->filterDoxygenAttributes($conf)));
256
257                    // if the "render_task" option is set:
258                    // output file to tmp folder for a configuration and save task in sqlite
259                    // 'task runner' -> is doxygen task runner available for this page?
260                    // -> loop over all meta entries
261                    // -> each meta entry: unique settings comination for doxygen (tag files)
262                    // -> run doxygen
263                    // -> then check if rendered version is available? otherwise output information here
264                    if (!$conf['render_task']) {
265                        $conf['render_task'] = $tagmanager->isForceRenderTaskSet($tag_conf);
266                    }
267
268                    // if job handling through sqlite is not available, we get STATE_NON_EXISTENT
269                    // if job handling is available the building of the XML might be already in progress
270                    $job_state = $buildmanager->getJobState($xml_cacheID);
271
272                    $buildsuccess = false;  // vary output depending on availability of job handling and doxygen builder
273
274                    // if the state is finished or non existent, we need to either schedule or build now
275                    if (
276                        $job_state == helper_plugin_doxycode_buildmanager::STATE_FINISHED
277                        || $job_state == helper_plugin_doxycode_buildmanager::STATE_NON_EXISTENT
278                        || $job_state == helper_plugin_doxycode_buildmanager::STATE_ERROR
279                    ) {
280                        if (!$conf['render_task'] || plugin_isdisabled('sqlite')) {
281                            // either job handling is not available or this snippet should immediately be rendered
282
283                            // if lock is present: try to append as job!
284                            $buildsuccess = $buildmanager->tryBuildNow($xml_cacheID, $conf, $text, $tag_conf);
285                        } else {
286                            // append as job
287                            $buildmanager->addBuildJob($xml_cacheID, $conf, $text, $tag_conf);
288                        }
289                    }
290
291                    // if snippet could not be build immediately or run through job handling
292                    if (!$buildsuccess || $conf['render_task']) {
293                        // get job state again
294                        $job_state = $buildmanager->getJobState($xml_cacheID);
295
296                        // add marker for javascript dynamic loading of snippet
297                        $renderer->doc .= '<div class="doxycode_marker" data-doxycode-xml-hash="' . $xml_cacheID .
298                                            '" data-doxycode-html-hash="' . $html_cacheID . '">';
299
300                        // check if we have a job for this snippet and what its state is
301                        switch ($job_state) {
302                            case helper_plugin_doxycode_buildmanager::STATE_FINISHED: {
303                                // this should be a good sign - next try to load the file
304                                break;
305                            }
306                            case helper_plugin_doxycode_buildmanager::STATE_NON_EXISTENT: {
307                                // task runner not available (missing sqlite?)
308                                $renderer->doc .= $this->getLang('msg_not_existent');
309                                break;
310                            }
311                            case helper_plugin_doxycode_buildmanager::STATE_RUNNING: {
312                                $renderer->doc .= $this->getLang('msg_running');
313                                break;
314                            }
315                            case helper_plugin_doxycode_buildmanager::STATE_SCHEDULED: {
316                                $renderer->doc .= $this->getLang('msg_scheduled');
317                                break;
318                            }
319                            case helper_plugin_doxycode_buildmanager::STATE_ERROR: {
320                                // task runner not available (missing sqlite?)
321                                $renderer->doc .= $this->getLang('msg_error');
322                                break;
323                            }
324                        }
325
326                        $renderer->doc .= '</div>';
327                    } else {
328                        // if buildsuccess==true we want to parse the XML now
329                        $job_state = helper_plugin_doxycode_buildmanager::STATE_FINISHED;
330                    }
331                }
332
333                // render task is only set if we previously determined with a missing XML cache file that
334                // the snippet should be built through job handling
335                if ($job_state == helper_plugin_doxycode_buildmanager::STATE_FINISHED) {
336                    // here we ignore the default decision
337                    // the XML should be available in this case
338                    // otherwise purging leaves us with empty code snippets
339                    if (file_exists($xml_cache->cache)) {
340                        // we have a valid XML!
341
342                        $xml_content = @file_get_contents($xml_cache->cache);
343
344                        $rendered_text = $parser->renderXMLToDokuWikiCode(
345                            $xml_content,
346                            $conf['linenumbers'],
347                            $tag_conf
348                        );
349
350                        // save content to cache
351                        @file_put_contents($html_cache->cache, $rendered_text);
352
353                        // append cached HTML to document
354                        $renderer->doc .= $rendered_text;
355                    }
356                }
357
358                return true;
359            }
360
361            // DOKU_LEXER_EXIT: output the end of the code block
362            if ($state == DOKU_LEXER_EXIT) {
363                $this->endCodeBlock("file", $data['filename']);
364            }
365
366            $renderer->doc .= $this->doc;
367        } elseif ($mode === 'metadata') {
368            if ($state == DOKU_LEXER_SPECIAL || $state == DOKU_LEXER_ENTER) {
369                /** @var helper_plugin_doxycode_tagmanager $tagmanager */
370                $tagmanager = plugin_load('helper', 'doxycode_tagmanager');
371
372                $tag_conf = $tagmanager->getFilteredTagConfig($data['tagfiles']);
373
374                // save used tag files in this page for cache invalidation if a newer tag file is available
375                // TODO: what happens if a tag file is already present in the meta data?
376                foreach ($tag_conf as $key => $conf) {
377                    $renderer->meta['doxycode']['tagfiles'][] = $key;
378                }
379            }
380
381            if ($state == DOKU_LEXER_UNMATCHED) {
382                /** @var helper_plugin_doxycode_buildmanager $buildmanager */
383                $buildmanager = plugin_load('helper', 'doxycode_buildmanager');
384                $conf = $data['conf'];
385                $text = $data['text'];
386
387                // this is needed so the cacheID is the same as in the xhtml context
388                $this->prepareText($text);
389
390                $xml_cacheID = md5(json_encode($buildmanager->filterDoxygenAttributes($conf, false)) . $text);
391                $html_cacheID = md5(json_encode($buildmanager->filterDoxygenAttributes($conf, true)) . $text);
392
393                // add cache files to render context so page cache is invalidated if a new XML or HTML is available
394                $renderer->meta['doxycode']['xml_cachefiles'][] = $xml_cacheID;
395                $renderer->meta['doxycode']['html_cachefiles'][] = $html_cacheID;
396            }
397        }
398
399
400
401        return true;
402    }
403
404    private function startCodeBlock($type, $filename = null)
405    {
406        global $INPUT;
407        global $ID;
408        global $lang;
409
410        $ext = '';
411        if ($filename) {
412            // add icon
413            list($ext) = mimetype($filename, false);
414            $class = preg_replace('/[^_\-a-z0-9]+/i', '_', $ext);
415            $class = 'mediafile mf_' . $class;
416
417            $offset = 0;
418            if ($INPUT->has('codeblockOffset')) {
419                $offset = $INPUT->str('codeblockOffset');
420            }
421            $this->doc .= '<dl class="' . $type . '">' . DOKU_LF;
422            $this->doc .= '<dt><a href="' .
423                exportlink(
424                    $ID,
425                    'code',
426                    array('codeblock' => $offset + 0)
427                ) . '" title="' . $lang['download'] . '" class="' . $class . '">';
428            $this->doc .= hsc($filename);
429            $this->doc .= '</a></dt>' . DOKU_LF . '<dd>';
430        }
431
432        $class = 'code'; //we always need the code class to make the syntax highlighting apply
433        if ($type != 'code') $class .= ' ' . $type;
434
435        $this->doc .= "<pre class=\"$class $ext\">";
436    }
437
438    private function endCodeBlock($type, $filename = null)
439    {
440        $class = 'code'; //we always need the code class to make the syntax highlighting apply
441        if ($type != 'code') $class .= ' ' . $type;
442
443        $this->doc .= '</pre>' . DOKU_LF;
444
445        if ($filename) {
446            $this->doc .= ' </dd></dl>' . DOKU_LF;
447        }
448    }
449}
450