1<?php
2/**
3 * DokuWiki Plugin Code Prettifier
4 *
5 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html)
6 * @author Satoshi Sahara <sahara.satoshi@gmail.com>
7 *
8 * usage: ex. <Code:css linenums:5 lang-css | title > ... </Code>
9 */
10class syntax_plugin_codeprettify_code extends DokuWiki_Syntax_Plugin
11{
12    public function getType()
13    {   // Syntax Type
14        return 'protected';
15    }
16
17    public function getPType()
18    {   // Paragraph Type
19        return 'block';
20    }
21
22    /**
23     * Connect pattern to lexer
24     */
25    protected $mode, $pattern;
26
27    public function getSort()
28    {   // sort number used to determine priority of this mode
29        return 199; // < native 'code' mode (=200)
30    }
31
32    public function preConnect()
33    {
34        // syntax mode, drop 'syntax_' from class name
35        $this->mode = substr(__CLASS__, 7);
36
37        // allowing nested "<angle pairs>" in title using regex atomic grouping
38        $n = 3;
39        $param = str_repeat('(?>[^<>\n]+|<', $n).str_repeat('>)*', $n);
40
41        // syntax patterns
42        $this->pattern[1] = '<Code\b'.$param.'>'.'(?=.*?</Code>)';
43        $this->pattern[4] = '</Code>';
44
45        // DokuWiki original syntax patterns
46        $this->pattern[11] = '<code\b.*?>(?=.*?</code>)';
47        $this->pattern[14] = '</code>';
48    }
49
50    public function connectTo($mode)
51    {
52        $this->Lexer->addEntryPattern($this->pattern[1], $mode, $this->mode);
53        if ($this->getConf('override')) {
54            $this->Lexer->addEntryPattern($this->pattern[11], $mode, $this->mode);
55        }
56    }
57
58    public function postConnect()
59    {
60        $this->Lexer->addExitPattern($this->pattern[4], $this->mode);
61        if ($this->getConf('override')) {
62            $this->Lexer->addExitPattern($this->pattern[14], $this->mode);
63        }
64    }
65
66
67    /**
68     * GeSHi Options Parser
69     *
70     * DokuWiki release 2018-04-22 "Greebo" supports some GeSHi options
71     * for syntax highlighting
72     * alternative of parse_highlight_options() in inc/parser/handler.php
73     *
74     * @param string $params  space separated list of key-value pairs
75     * @return array
76     * @see also https://www.dokuwiki.org/syntax_highlighting
77     */
78    private function getGeshiOption($params)
79    {
80        $opts = [];
81        // remove enclosing brackets and double-quotes
82        $params = str_replace('"', '', trim($params, '[]'));
83        if (preg_match_all('/(\w+)=?(\w+)?/', $params, $matches)) {
84
85            // make keys lowercase
86            $keys   = array_map('strtolower', $matches[1]);
87            // interpret boolian string values
88            $values = array_map(
89                function($value) {
90                    if (is_numeric($value)) {
91                        return $value;
92                    } else {
93                        $s = strtolower($value);
94                        if ($s == 'true')  $value = 1;
95                        if ($s == 'false') $value = 0;
96                        return $value;
97                    }
98                },
99                $matches[2]
100            );
101
102           // Note: last one prevails if same keys have appeared
103           $opts = array_combine($keys, $values);
104        }
105        return $opts;
106    }
107
108    /**
109     * Convert/interpret GeSHi Options to correspondent Prettifier options
110     * - enable_line_numbers=0    -> nolinenums
111     * - start_line_numbers_at=1  -> linenums:1
112     *
113     * @param array $opts  GeSHi options
114     * @return string  Prettifier linenums parameter
115     * @see also https://www.dokuwiki.org/syntax_highlighting
116     */
117    private function strGeshiOptions(array $opts=[])
118    {
119        $option = 'linenums';
120        if (isset($opts['enable_line_numbers']) && $opts['enable_line_numbers'] == 0) {
121            $option = 'no'.$option;
122        }
123        if (isset($opts['start_line_numbers_at']) && $opts['start_line_numbers_at'] > 0) {
124            $option = $option.':'.$opts['start_line_numbers_at'];
125        }
126        return $option;
127    }
128
129
130    /**
131     * Prettifier Options Parser
132     *
133     * @param string $params
134     * @return array
135     */
136    private function getPrettifierOptions($params)
137    {
138        $opts = [];
139
140        // offset holds the position of the matched string
141        // if offset become 0, the first token of given params is NOT language
142        $offset = 1;
143        if (preg_match('/\b(no)?linenums(:\d+)?/', $params, $m, PREG_OFFSET_CAPTURE)) {
144            $offset = ($offset > 0) ? $m[0][1] : 1;
145            $opts['linenums'] = ('no' == $m[1][0] ?? '') ? 'linenums:0' : $m[0][0];
146        } else {
147            $opts['linenums'] = $this->getConf('linenums') ? 'linenums' : '';
148        }
149        if (preg_match('/\blang-\w+/', $params, $m, PREG_OFFSET_CAPTURE)) {
150            $offset = ($offset > 0) ? $m[0][1] : 1;
151            $opts['language'] = $m[0][0];
152        } elseif ($offset) {
153            // assume the first token is language; ex. C, php, css
154            list ($lang, ) = explode(' ', $params, 2);
155            $opts['language'] = $lang ? 'lang-'.$lang : '';
156        }
157        return $opts;
158    }
159
160
161    /**
162     * Handle the match
163     */
164    public function handle($match, $state, $pos, Doku_Handler $handler)
165    {
166        switch ($state) {
167            case DOKU_LEXER_ENTER:
168                list($params, $title) = array_pad(explode('|', substr($match, 5, -1), 2), 2, '');
169
170                // title parameter
171                if ($title) {
172                    // remove first "document_start" and last "document_end" instructions
173                    $calls = array_slice(p_get_instructions($title), 1, -1);
174                } else {
175                    $calls = null;
176                }
177
178                // prettifier parameters
179                $params = trim($params, ' :');
180
181                if ( preg_match('/\[.*\]/', $params, $matches) ) {
182                    // replace GeSHi parameters
183                    $params = str_replace(
184                        $matches[0],
185                        $this->strGeshiOptions( $this->getGeshiOption($matches[0]) ),
186                        $params
187                    );
188                }
189
190                $opts['prettify'] = 'prettyprint';
191                $opts += $this->getPrettifierOptions($params);
192                $params= implode(' ', $opts);
193
194                return $data = [$state, $params, $calls];
195            case DOKU_LEXER_UNMATCHED:
196                return $data = [$state, $match];
197            case DOKU_LEXER_EXIT:
198                return $data = [$state, ''];
199        }
200        return false;
201    }
202
203    /**
204     * Create output
205     */
206    function render($format, Doku_Renderer $renderer, $data)
207    {
208        if ($format == 'metadata') return false;
209        if (empty($data)) return false;
210
211        $state = $data[0];
212        switch ($state) {
213            case DOKU_LEXER_ENTER:
214                list($args, $calls) = array($data[1], $data[2]);
215                if (isset($calls)) {
216                    // title of code box
217                    $renderer->doc .= '<div class="plugin_codeprettify">';
218                    $renderer->nest($calls);
219                    $renderer->doc .= '</div>';
220                }
221                $renderer->doc .= '<pre class="'.hsc($args).'">';
222                break;
223            case DOKU_LEXER_UNMATCHED:
224                $match = $data[1];
225                $renderer->doc .= $renderer->_xmlEntities($match);
226                break;
227            case DOKU_LEXER_EXIT:
228                $renderer->doc .= '</pre>';
229                break;
230        }
231        return true;
232    }
233
234}
235