1<?php
2/**
3 * DokuWiki Plugin parserfunctions (Syntax Component)
4 *
5 * @license  GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
6 * @author   Daniel "Nerun" Rodrigues <danieldiasr@gmail.com>
7 * @created  Sat, 09 Dec 2023 14:59 -0300
8 *
9 * This is my first plugin, and I don't even know PHP well, that's why it's full
10 * of comments, but I'll leave it that way so I can consult it in the future.
11 *
12 */
13use dokuwiki\Extension\SyntaxPlugin;
14use dokuwiki\Utf8\PhpString;
15
16class syntax_plugin_parserfunctions extends SyntaxPlugin
17{
18    /** @var helper_plugin_parserfunctions $helper */
19    private $helper;
20
21    public function __construct() {
22        $this->helper = plugin_load('helper', 'parserfunctions');
23    }
24
25    /** @inheritDoc */
26    public function getType()
27    {
28        /* READ: https://www.dokuwiki.org/devel:syntax_plugins#syntax_types
29         * substition  — modes where the token is simply replaced – they can not
30         * contain any other modes
31         */
32        return 'substition';
33    }
34
35    /** @inheritDoc */
36    public function getPType()
37    {
38        /* READ: https://www.dokuwiki.org/devel:syntax_plugins
39         * normal — Default value, will be used if the method is not overridden.
40         *          The plugin output will be inside a paragraph (or another
41         *          block element), no paragraphs will be inside.
42         */
43        return 'normal';
44    }
45
46    /** @inheritDoc */
47    public function getSort()
48    {
49        /* READ: https://www.dokuwiki.org/devel:parser:getsort_list
50         * Don't understand exactly what it does, need more study.
51         *
52         * Must go after Templater (302) and WST (319) plugin, to be able to
53         * render @parameter@ and {{{parameter}}}.
54         */
55        return 320;
56    }
57
58    /** @inheritDoc */
59    public function connectTo($mode)
60    {
61        /* READ: https://www.dokuwiki.org/devel:syntax_plugins#patterns
62         * This pattern accepts any alphanumeric function AND nested functions.
63         *
64         * $this->Lexer->addSpecialPattern('\{\{#.+?#\}\}', $mode, 'plugin_parserfunctions');
65         * Captures nested functions up to level-1:
66         * $this->Lexer->addSpecialPattern('\{\{#[[:alnum:]]+:(?:(?:[^\{#]*?\{\{.*?#\}\})|.*?)+?#\}\}', $mode, 'plugin_parserfunctions');
67         *
68         * SEE action.php
69         */
70    }
71
72    // @author  ChatGPT -- Wed, 02 jul 2025 12:04:42 -0300
73    public function resolveFunction($text)
74    {
75        // Remove {{# and #}} delimiters if present
76        if (substr($text, 0, 3) === '{{#' && substr($text, -3) === '#}}') {
77            $text = substr($text, 3, -3);
78        }
79
80        // Recursively resolves all nested functions from the inside out
81        $text = $this->resolveNestedFunctions($text);
82
83        // Separates function name and parameters
84        preg_match('/^([[:alnum:]]+):(.*)$/s', $text, $m);
85
86        if (empty($m[1])) {
87            return $this->helper->formatError('important', 'function_name', 'invalid_syntax');
88        }
89
90        $funcName = PhpString::strtolower($m[1]);
91        $paramsText = $m[2] ?? '';
92
93        $params = $this->helper->parseParameters($paramsText);
94
95        switch ($funcName) {
96            case 'if':
97                return $this->_IF($params, $funcName);
98            case 'ifeq':
99                return $this->_IFEQ($params, $funcName);
100            case 'ifexist':
101                return $this->_IFEXIST($params, $funcName);
102            case 'switch':
103                return $this->_SWITCH($params, $funcName);
104            case 'expr':
105                return $this->_EXPR($params, $funcName);
106            default:
107                return $this->helper->formatError('important', $funcName, 'no_such_function');
108        }
109    }
110
111    // @author  ChatGPT -- Wed, 02 jul 2025 12:04:42 -0300
112    private function resolveNestedFunctions($text)
113    {
114        $offset = 0;
115
116        while (($start = strpos($text, '{{#', $offset)) !== false) {
117            $level = 0;
118            $length = strlen($text);
119
120            for ($i = $start; $i < $length - 2; $i++) {
121                if (substr($text, $i, 3) === '{{#') {
122                    $level++;
123                    $i += 2;
124                } elseif (substr($text, $i, 3) === '#}}') {
125                    $level--;
126                    $i += 2;
127
128                    if ($level === 0) {
129                        $full = substr($text, $start, $i - $start + 1);
130                        $resolved = $this->resolveFunction($full);
131                        $text = substr_replace($text, $resolved, $start, strlen($full));
132                        // Start from the beginning because the text has changed
133                        $offset = 0;
134                        continue 2;
135                    }
136                }
137            }
138
139            // If you got here, invalid syntax (no #}})
140            break;
141        }
142
143        return $text;
144    }
145
146    /** @inheritDoc */
147    public function handle($match, $state, $pos, Doku_Handler $handler) {
148        /* This method is only called if the Lexer, in the connectTo() method,
149         * finds a $match.
150         *
151         * READ: https://www.dokuwiki.org/devel:syntax_plugins#handle_method
152         * This is the part of your plugin which should do all the work. Before
153         * DokuWiki renders the wiki page it creates a list of instructions for
154         * the renderer. The plugin's handle() method generates the render
155         * instructions for the plugin's own syntax mode. At some later time,
156         * these will be interpreted by the plugin's render() method.
157         *
158         * Parameters:
159         *
160         *   $match   (string)  — The text matched by the patterns
161         *
162         *   $state   (int)     — The lexer state for the match, representing
163         *                        the type of pattern which triggered this call
164         *                        to handle(): DOKU_LEXER_SPECIAL — a pattern
165         *                        set by addSpecialPattern().
166         *
167         *   $pos     (int)     — The character position of the matched text.
168         *
169         *   $handler           — Object Reference to the Doku_Handler object.
170         */
171        return $this->resolveFunction($match);
172    }
173
174    /** @inheritDoc */
175    public function render($mode, Doku_Renderer $renderer, $data)
176    {
177        /* READ: https://www.dokuwiki.org/devel:syntax_plugins#render_method
178         * The part of the plugin that provides the output for the final web
179         * page.
180         *
181         * Parameters:
182         *
183         *   $mode     — Name for the format mode of the final output produced
184         *               by the renderer.
185         *
186         *   $renderer — Give access to the object Doku_Renderer, which contains
187         *               useful functions and values.
188         *
189         *   $data     — An array containing the instructions previously
190         *               prepared and returned by the plugin's own handle()
191         *               method. The render() must interpret the instruction and
192         *               generate the appropriate output.
193         */
194
195        if ($mode !== 'xhtml') {
196            return false;
197        }
198
199        if (!$data) {
200            return false;
201        }
202
203        // escape sequences
204        $data = $this->helper->processEscapes($data);
205
206        // Do not use <div></div> because we need inline substitution!
207		$data = $renderer->render_text($data, 'xhtml');
208		// Remove the first '<p>' and the last '</p>'
209		if (substr($data, 0, 3) === '<p>' && substr($data, -4) === '</p>') {
210            $data = substr($data, 3, -4);
211        }
212		$renderer->doc .= $data;
213
214        return true;
215    }
216
217    /**
218     * ========== #IF
219     * {{#if: 1st parameter | 2nd parameter | 3rd parameter #}}
220     * {{#if: test string | value if test string is not empty | value if test
221     * string is empty (or only white space) #}}
222     */
223    function _IF($params, $funcName)
224    {
225        if ( count($params) < 1 ) {
226            $result = $this->helper->formatError('alert', $funcName, 'not_enough_params');
227        } else {
228            if ( !empty($params[0]) ) {
229                $result = $params[1] ?? '';
230            } else {
231                $result = $params[2] ?? '';
232            }
233        }
234
235        return $result;
236    }
237
238    /**
239     * ========== #IFEQ
240     * {{#ifeq: 1st parameter | 2nd parameter | 3rd parameter | 4th parameter #}}
241     * {{#ifeq: string 1 | string 2 | value if identical | value if different #}}
242     */
243    function _IFEQ($params, $funcName)
244    {
245        if ( count($params) < 2 ) {
246            $result = $this->helper->formatError('alert', $funcName, 'not_enough_params');
247        } else {
248            if ( $params[0] == $params[1] ) {
249                $result = $params[2] ?? '';
250            } else {
251                $result = $params[3] ?? '';
252            }
253        }
254
255        return $result;
256    }
257
258    /**
259     * ======= #IFEXIST
260     * Syntax: {{#ifexist: target | if-true | if-false #}}
261     *
262     * Accepts:
263     * - DokuWiki page/media IDs (e.g. "wiki:start", "wiki:image.png")
264     * - Namespaces (must end with ":", e.g. "wiki:")
265     * - Absolute or relative filesystem paths
266     *
267     * @param array $params [
268     *     0 => string $target   Path or ID to check (required)
269     *     1 => string $ifTrue   Value to return if target exists (optional)
270     *     2 => string $ifFalse  Value to return if target doesn't exist (optional)
271     * ]
272     * @param string $funcName Name of the parser function (for error messages)
273     * @return string Rendered output based on existence check
274     */
275    function _IFEXIST($params, $funcName)
276    {
277        if (count($params) < 1) {
278            return $this->helper->formatError('alert', $funcName, 'not_enough_params');
279        }
280
281        $target = trim($params[0]);
282        if ($target === '') {
283            return $this->helper->formatError('alert', $funcName, 'empty_test_parameter');
284        }
285
286        $exists = $this->helper->checkExistence($target);
287
288        return $exists
289            ? ($params[1] ?? '')
290            : ($params[2] ?? '');
291    }
292
293    /**
294     * ========== #SWITCH
295     * {{#switch: comparison string
296     * | case1 = result1
297     * | ...
298     * | caseN = resultN
299     * | default result
300     * #}}
301     */
302    function _SWITCH($params, $funcName) {
303        if (count($params) < 2) {
304            return $this->helper->formatError('alert', $funcName, 'not_enough_params');
305        }
306
307        $parsed = $this->helper->parseSwitchCases($params);
308
309        // Checks if the test string exists as a key in the switch cases array
310        if (array_key_exists($parsed['test'], $parsed['cases'])) {
311            return $parsed['cases'][$parsed['test']]; // ← May return empty string
312        }
313
314        // Returns the default (explicit or implicit) only if the case does not exist
315        return $parsed['default'] ?? '';
316    }
317
318    /**
319     * ========== #EXPR
320     * This function evaluates a mathematical expression and returns the
321     * calculated value.
322     */
323    private function _EXPR($params, $funcName) {
324        if (!isset($params[0])) {
325            return $this->helper->formatError('alert', $funcName, 'empty_test_parameter');
326        }
327
328        return $this->helper->evaluateMathExpression($params[0]);
329    }
330}
331
332