xref: /dokuwiki/_test/tests/Parsing/Lexer/LexerTest.php (revision 057ab5a057a76df652cfe4176512a550ca9937f6)
1<?php
2
3namespace dokuwiki\test\Parsing\Lexer;
4
5use dokuwiki\Parsing\Lexer\Lexer;
6
7class LexerTest extends \DokuWikiTest
8{
9    public function testNoPatterns()
10    {
11        $handler = new RecordingHandler();
12        $lexer = new Lexer($handler);
13        $this->assertFalse($lexer->parse("abcdef"));
14        $this->assertSame([], $handler->recorded);
15    }
16
17    public function testEmptyPage()
18    {
19        $handler = new RecordingHandler();
20        $lexer = new Lexer($handler);
21        $lexer->addPattern("a+");
22        $this->assertTrue($lexer->parse(""));
23        $this->assertSame([], $handler->recorded);
24    }
25
26    public function testSinglePattern()
27    {
28        $handler = new RecordingHandler();
29        $lexer = new Lexer($handler);
30        $lexer->addPattern("a+");
31        $this->assertTrue($lexer->parse("aaaxayyyaxaaaz"));
32        $this->assertSame([
33            ['accept', 'aaa', \DOKU_LEXER_MATCHED, 0],
34            ['accept', 'x', \DOKU_LEXER_UNMATCHED, 3],
35            ['accept', 'a', \DOKU_LEXER_MATCHED, 4],
36            ['accept', 'yyy', \DOKU_LEXER_UNMATCHED, 5],
37            ['accept', 'a', \DOKU_LEXER_MATCHED, 8],
38            ['accept', 'x', \DOKU_LEXER_UNMATCHED, 9],
39            ['accept', 'aaa', \DOKU_LEXER_MATCHED, 10],
40            ['accept', 'z', \DOKU_LEXER_UNMATCHED, 13],
41        ], $handler->recorded);
42    }
43
44    public function testMultiplePattern()
45    {
46        $handler = new RecordingHandler();
47        $lexer = new Lexer($handler);
48        $lexer->addPattern("a+");
49        $lexer->addPattern("b+");
50        $this->assertTrue($lexer->parse("ababbxbaxxxxxxax"));
51        $expected = ['a', 'b', 'a', 'bb', 'x', 'b', 'a', 'xxxxxx', 'a', 'x'];
52        $actual = array_column($handler->recorded, 1);
53        $this->assertSame($expected, $actual);
54    }
55
56    public function testIsolatedPattern()
57    {
58        $handler = new RecordingHandler();
59        $lexer = new Lexer($handler, "a");
60        $lexer->addPattern("a+", "a");
61        $lexer->addPattern("b+", "b");
62        $this->assertTrue($lexer->parse("abaabxbaaaxaaaax"));
63        $this->assertSame([
64            ['a', 'a', \DOKU_LEXER_MATCHED, 0],
65            ['a', 'b', \DOKU_LEXER_UNMATCHED, 1],
66            ['a', 'aa', \DOKU_LEXER_MATCHED, 2],
67            ['a', 'bxb', \DOKU_LEXER_UNMATCHED, 4],
68            ['a', 'aaa', \DOKU_LEXER_MATCHED, 7],
69            ['a', 'x', \DOKU_LEXER_UNMATCHED, 10],
70            ['a', 'aaaa', \DOKU_LEXER_MATCHED, 11],
71            ['a', 'x', \DOKU_LEXER_UNMATCHED, 15],
72        ], $handler->recorded);
73    }
74
75    public function testModeChange()
76    {
77        $handler = new RecordingHandler();
78        $lexer = new Lexer($handler, "a");
79        $lexer->addPattern("a+", "a");
80        $lexer->addEntryPattern(":", "a", "b");
81        $lexer->addPattern("b+", "b");
82        $this->assertTrue($lexer->parse("abaabaaa:ababbabbba"));
83        $this->assertSame([
84            ['a', 'a', \DOKU_LEXER_MATCHED, 0],
85            ['a', 'b', \DOKU_LEXER_UNMATCHED, 1],
86            ['a', 'aa', \DOKU_LEXER_MATCHED, 2],
87            ['a', 'b', \DOKU_LEXER_UNMATCHED, 4],
88            ['a', 'aaa', \DOKU_LEXER_MATCHED, 5],
89            ['b', ':', \DOKU_LEXER_ENTER, 8],
90            ['b', 'a', \DOKU_LEXER_UNMATCHED, 9],
91            ['b', 'b', \DOKU_LEXER_MATCHED, 10],
92            ['b', 'a', \DOKU_LEXER_UNMATCHED, 11],
93            ['b', 'bb', \DOKU_LEXER_MATCHED, 12],
94            ['b', 'a', \DOKU_LEXER_UNMATCHED, 14],
95            ['b', 'bbb', \DOKU_LEXER_MATCHED, 15],
96            ['b', 'a', \DOKU_LEXER_UNMATCHED, 18],
97        ], $handler->recorded);
98    }
99
100    public function testNesting()
101    {
102        $handler = new RecordingHandler();
103        $lexer = new Lexer($handler, "a");
104        $lexer->addPattern("a+", "a");
105        $lexer->addEntryPattern("(", "a", "b");
106        $lexer->addPattern("b+", "b");
107        $lexer->addExitPattern(")", "b");
108        $this->assertTrue($lexer->parse("aabaab(bbabb)aab"));
109        $this->assertSame([
110            ['a', 'aa', \DOKU_LEXER_MATCHED, 0],
111            ['a', 'b', \DOKU_LEXER_UNMATCHED, 2],
112            ['a', 'aa', \DOKU_LEXER_MATCHED, 3],
113            ['a', 'b', \DOKU_LEXER_UNMATCHED, 5],
114            ['b', '(', \DOKU_LEXER_ENTER, 6],
115            ['b', 'bb', \DOKU_LEXER_MATCHED, 7],
116            ['b', 'a', \DOKU_LEXER_UNMATCHED, 9],
117            ['b', 'bb', \DOKU_LEXER_MATCHED, 10],
118            ['b', ')', \DOKU_LEXER_EXIT, 12],
119            ['a', 'aa', \DOKU_LEXER_MATCHED, 13],
120            ['a', 'b', \DOKU_LEXER_UNMATCHED, 15],
121        ], $handler->recorded);
122    }
123
124    public function testSingular()
125    {
126        $handler = new RecordingHandler();
127        $lexer = new Lexer($handler, "a");
128        $lexer->addPattern("a+", "a");
129        $lexer->addSpecialPattern("b+", "a", "b");
130        $this->assertTrue($lexer->parse("aabaaxxbbbxx"));
131        $this->assertSame([
132            ['a', 'aa', \DOKU_LEXER_MATCHED, 0],
133            ['b', 'b', \DOKU_LEXER_SPECIAL, 2],
134            ['a', 'aa', \DOKU_LEXER_MATCHED, 3],
135            ['a', 'xx', \DOKU_LEXER_UNMATCHED, 5],
136            ['b', 'bbb', \DOKU_LEXER_SPECIAL, 7],
137            ['a', 'xx', \DOKU_LEXER_UNMATCHED, 10],
138        ], $handler->recorded);
139    }
140
141    public function testUnwindTooFar()
142    {
143        $handler = new RecordingHandler();
144        $lexer = new Lexer($handler, "a");
145        $lexer->addPattern("a+", "a");
146        $lexer->addExitPattern(")", "a");
147        $this->assertFalse($lexer->parse("aa)aa"));
148        $this->assertSame([
149            ['a', 'aa', \DOKU_LEXER_MATCHED, 0],
150            ['a', ')', \DOKU_LEXER_EXIT, 2],
151        ], $handler->recorded);
152    }
153
154    public function testModeMapping()
155    {
156        $handler = new RecordingHandler();
157        $lexer = new Lexer($handler, "mode_a");
158        $lexer->addPattern("a+", "mode_a");
159        $lexer->addEntryPattern("(", "mode_a", "mode_b");
160        $lexer->addPattern("b+", "mode_b");
161        $lexer->addExitPattern(")", "mode_b");
162        $lexer->mapHandler("mode_a", "a");
163        $lexer->mapHandler("mode_b", "a");
164        $this->assertTrue($lexer->parse("aa(bbabb)b"));
165        $this->assertSame([
166            ['a', 'aa', \DOKU_LEXER_MATCHED, 0],
167            ['a', '(', \DOKU_LEXER_ENTER, 2],
168            ['a', 'bb', \DOKU_LEXER_MATCHED, 3],
169            ['a', 'a', \DOKU_LEXER_UNMATCHED, 5],
170            ['a', 'bb', \DOKU_LEXER_MATCHED, 6],
171            ['a', ')', \DOKU_LEXER_EXIT, 8],
172            ['a', 'b', \DOKU_LEXER_UNMATCHED, 9],
173        ], $handler->recorded);
174    }
175
176    public function testIndex()
177    {
178        $doc = "aaa<file>bcd</file>eee";
179        $handler = new RecordingHandler();
180        $lexer = new Lexer($handler, "ignore");
181        $lexer->addEntryPattern("<file>", "ignore", "caught");
182        $lexer->addExitPattern("</file>", "caught");
183        $lexer->addSpecialPattern('b', 'caught', 'special');
184        $lexer->mapHandler('special', 'caught');
185        $lexer->addPattern('c', 'caught');
186        $this->assertTrue($lexer->parse($doc));
187
188        $caught = array_values(array_filter($handler->recorded, fn($c) => $c[0] === 'caught'));
189        $this->assertSame([
190            ['caught', '<file>', \DOKU_LEXER_ENTER, strpos($doc, '<file>')],
191            ['caught', 'b', \DOKU_LEXER_SPECIAL, strpos($doc, 'b')],
192            ['caught', 'c', \DOKU_LEXER_MATCHED, strpos($doc, 'c')],
193            ['caught', 'd', \DOKU_LEXER_UNMATCHED, strpos($doc, 'd')],
194            ['caught', '</file>', \DOKU_LEXER_EXIT, strpos($doc, '</file>')],
195        ], $caught);
196    }
197
198    public function testIndexLookaheadEqual()
199    {
200        $doc = "aaa<file>bcd</file>eee";
201        $handler = new RecordingHandler();
202        $lexer = new Lexer($handler, "ignore");
203        $lexer->addEntryPattern('<file>(?=.*</file>)', "ignore", "caught");
204        $lexer->addExitPattern("</file>", "caught");
205        $lexer->addSpecialPattern('b', 'caught', 'special');
206        $lexer->mapHandler('special', 'caught');
207        $lexer->addPattern('c', 'caught');
208        $this->assertTrue($lexer->parse($doc));
209
210        $caught = array_values(array_filter($handler->recorded, fn($c) => $c[0] === 'caught'));
211        $this->assertSame([
212            ['caught', '<file>', \DOKU_LEXER_ENTER, strpos($doc, '<file>')],
213            ['caught', 'b', \DOKU_LEXER_SPECIAL, strpos($doc, 'b')],
214            ['caught', 'c', \DOKU_LEXER_MATCHED, strpos($doc, 'c')],
215            ['caught', 'd', \DOKU_LEXER_UNMATCHED, strpos($doc, 'd')],
216            ['caught', '</file>', \DOKU_LEXER_EXIT, strpos($doc, '</file>')],
217        ], $caught);
218    }
219
220    public function testIndexLookaheadNotEqual()
221    {
222        $doc = "aaa<file>bcd</file>eee";
223        $handler = new RecordingHandler();
224        $lexer = new Lexer($handler, "ignore");
225        $lexer->addEntryPattern('<file>(?!foo)', "ignore", "caught");
226        $lexer->addExitPattern("</file>", "caught");
227        $lexer->addSpecialPattern('b', 'caught', 'special');
228        $lexer->mapHandler('special', 'caught');
229        $lexer->addPattern('c', 'caught');
230        $this->assertTrue($lexer->parse($doc));
231
232        $caught = array_values(array_filter($handler->recorded, fn($c) => $c[0] === 'caught'));
233        $this->assertSame([
234            ['caught', '<file>', \DOKU_LEXER_ENTER, strpos($doc, '<file>')],
235            ['caught', 'b', \DOKU_LEXER_SPECIAL, strpos($doc, 'b')],
236            ['caught', 'c', \DOKU_LEXER_MATCHED, strpos($doc, 'c')],
237            ['caught', 'd', \DOKU_LEXER_UNMATCHED, strpos($doc, 'd')],
238            ['caught', '</file>', \DOKU_LEXER_EXIT, strpos($doc, '</file>')],
239        ], $caught);
240    }
241
242    public function testIndexLookbehindEqual()
243    {
244        $doc = "aaa<file>bcd</file>eee";
245        $handler = new RecordingHandler();
246        $lexer = new Lexer($handler, "ignore");
247        $lexer->addEntryPattern('<file>', "ignore", "caught");
248        $lexer->addExitPattern("(?<=d)</file>", "caught");
249        $lexer->addSpecialPattern('b', 'caught', 'special');
250        $lexer->mapHandler('special', 'caught');
251        $lexer->addPattern('c', 'caught');
252        $this->assertTrue($lexer->parse($doc));
253
254        $caught = array_values(array_filter($handler->recorded, fn($c) => $c[0] === 'caught'));
255        $this->assertSame([
256            ['caught', '<file>', \DOKU_LEXER_ENTER, strpos($doc, '<file>')],
257            ['caught', 'b', \DOKU_LEXER_SPECIAL, strpos($doc, 'b')],
258            ['caught', 'c', \DOKU_LEXER_MATCHED, strpos($doc, 'c')],
259            ['caught', 'd', \DOKU_LEXER_UNMATCHED, strpos($doc, 'd')],
260            ['caught', '</file>', \DOKU_LEXER_EXIT, strpos($doc, '</file>')],
261        ], $caught);
262    }
263
264    public function testIndexLookbehindNotEqual()
265    {
266        $doc = "aaa<file>bcd</file>eee";
267        $handler = new RecordingHandler();
268        $lexer = new Lexer($handler, 'ignore');
269        $lexer->addEntryPattern('<file>', 'ignore', 'caught');
270        $lexer->addExitPattern('(?<!c)</file>', 'caught');
271        $lexer->addSpecialPattern('b', 'caught', 'special');
272        $lexer->mapHandler('special', 'caught');
273        $lexer->addPattern('c', 'caught');
274        $this->assertTrue($lexer->parse($doc));
275
276        $caught = array_values(array_filter($handler->recorded, fn($c) => $c[0] === 'caught'));
277        $this->assertSame([
278            ['caught', '<file>', \DOKU_LEXER_ENTER, strpos($doc, '<file>')],
279            ['caught', 'b', \DOKU_LEXER_SPECIAL, strpos($doc, 'b')],
280            ['caught', 'c', \DOKU_LEXER_MATCHED, strpos($doc, 'c')],
281            ['caught', 'd', \DOKU_LEXER_UNMATCHED, strpos($doc, 'd')],
282            ['caught', '</file>', \DOKU_LEXER_EXIT, strpos($doc, '</file>')],
283        ], $caught);
284    }
285
286    /**
287     * Exit-pattern lookbehind must see characters that were already consumed
288     * by a preceding token in the same mode.
289     *
290     * Regression: the Lexer used to hand PCRE a shrinking tail of the subject
291     * — once a match was consumed, the bytes before the new cursor were gone
292     * and `(?<=X)` assertions silently failed. The Lexer now tracks an offset
293     * and passes the full subject to ParallelRegex, so lookbehinds work
294     * across token boundaries.
295     *
296     * Here the exit pattern `(?<=\/>)</x>` requires the `/>` of a self-closing
297     * `<a/>` that was consumed as a SPECIAL token on the previous step. Before
298     * the fix, `</x>` would fall out as UNMATCHED instead of EXIT.
299     */
300    public function testIndexLookbehindAcrossConsumedToken()
301    {
302        $doc = "<x><a/></x>";
303        $handler = new RecordingHandler();
304        $lexer = new Lexer($handler, 'ignore');
305        $lexer->addEntryPattern('<x>', 'ignore', 'caught');
306        $lexer->addSpecialPattern('<a\/>', 'caught', 'selfclose');
307        $lexer->mapHandler('selfclose', 'caught');
308        $lexer->addExitPattern('(?<=\/>)<\/x>', 'caught');
309        $this->assertTrue($lexer->parse($doc));
310
311        $caught = array_values(array_filter($handler->recorded, fn($c) => $c[0] === 'caught'));
312        $this->assertSame([
313            ['caught', '<x>',   \DOKU_LEXER_ENTER,   strpos($doc, '<x>')],
314            ['caught', '<a/>',  \DOKU_LEXER_SPECIAL, strpos($doc, '<a/>')],
315            ['caught', '</x>',  \DOKU_LEXER_EXIT,    strpos($doc, '</x>')],
316        ], $caught);
317    }
318
319    /**
320     * This test is primarily to ensure the correct match is chosen
321     * when there are non-captured elements in the pattern.
322     */
323    public function testIndexSelectCorrectMatch()
324    {
325        $doc = "ALL FOOLS ARE FOO";
326        $pattern = '\bFOO\b';
327        $handler = new RecordingHandler();
328        $lexer = new Lexer($handler, "ignore");
329        $lexer->addSpecialPattern($pattern, 'ignore', 'caught');
330        $this->assertTrue($lexer->parse($doc));
331
332        $caught = array_values(array_filter($handler->recorded, fn($c) => $c[0] === 'caught'));
333        $matches = [];
334        preg_match('/' . $pattern . '/', $doc, $matches, PREG_OFFSET_CAPTURE);
335        $this->assertCount(1, $caught);
336        $this->assertSame('FOO', $caught[0][1]);
337        $this->assertSame(\DOKU_LEXER_SPECIAL, $caught[0][2]);
338        $this->assertSame($matches[0][1], $caught[0][3]);
339    }
340
341    public function testCloserPatternSkipsCandidateWithoutCloserBeforeBoundary()
342    {
343        // no closer (\w followed by **) before the blank line: the first
344        // ** stays literal; the span after the boundary still matches
345        $doc = "** foo\n\n**bar**";
346        $handler = new RecordingHandler();
347        $lexer = new Lexer($handler, 'ignore', true);
348        $lexer->addEntryPattern('\*\*', 'ignore', 'caught');
349        $lexer->addCloserPattern('\w\*\*', 'caught', '\n\n');
350        $lexer->addExitPattern('\*\*', 'caught');
351
352        $this->assertTrue($lexer->parse($doc));
353        $caught = array_values(array_filter($handler->recorded, fn($c) => $c[0] === 'caught'));
354        $this->assertSame([
355            ['caught', '**', \DOKU_LEXER_ENTER, 8],
356            ['caught', 'bar', \DOKU_LEXER_UNMATCHED, 10],
357            ['caught', '**', \DOKU_LEXER_EXIT, 13],
358        ], $caught);
359        // the rejected candidate stays part of the unmatched text
360        $this->assertSame(['ignore', "** foo\n\n", \DOKU_LEXER_UNMATCHED, 0], $handler->recorded[0]);
361    }
362
363    public function testCloserPatternWithoutBoundaryScansWholeSubject()
364    {
365        // same shape, but with no boundary the closer behind the blank
366        // line counts and the first ** does enter the mode
367        $doc = "** foo\n\n**bar**";
368        $handler = new RecordingHandler();
369        $lexer = new Lexer($handler, 'ignore', true);
370        $lexer->addEntryPattern('\*\*', 'ignore', 'caught');
371        $lexer->addCloserPattern('\w\*\*', 'caught');
372        $lexer->addExitPattern('\*\*', 'caught');
373
374        $this->assertTrue($lexer->parse($doc));
375        $this->assertSame(['caught', '**', \DOKU_LEXER_ENTER, 0], $handler->recorded[0]);
376    }
377
378    public function testCloserPatternRejectsAllCandidates()
379    {
380        $doc = 'a ** b ** c';
381        $handler = new RecordingHandler();
382        $lexer = new Lexer($handler, 'ignore', true);
383        $lexer->addEntryPattern('\*\*', 'ignore', 'caught');
384        $lexer->addCloserPattern('\w\*\*', 'caught');
385        $lexer->addExitPattern('\*\*', 'caught');
386
387        $this->assertTrue($lexer->parse($doc));
388        $this->assertSame([
389            ['ignore', 'a ** b ** c', \DOKU_LEXER_UNMATCHED, 0],
390        ], $handler->recorded);
391    }
392
393    public function testCloserScanIgnoresCloserInsideSpecialPatternMatch()
394    {
395        // the only closer candidate sits inside a special pattern's match,
396        // which the lexer consumes atomically — the scan must not count it,
397        // so the entry is rejected
398        $doc = 'a **b %%c** d%% e';
399        $handler = new RecordingHandler();
400        $lexer = new Lexer($handler, 'ignore', true);
401        $lexer->addEntryPattern('\*\*', 'ignore', 'caught');
402        $lexer->addCloserPattern('(?<=\w)\*\*', 'caught');
403        $lexer->addSpecialPattern('%%.*?%%', 'caught', 'prot');
404        $lexer->addExitPattern('\*\*', 'caught');
405
406        $this->assertTrue($lexer->parse($doc));
407        $this->assertSame([
408            ['ignore', $doc, \DOKU_LEXER_UNMATCHED, 0],
409        ], $handler->recorded);
410    }
411
412    public function testCloserScanFindsCloserBehindSpecialPatternMatch()
413    {
414        // a real closer behind the atomically consumed span still
415        // validates the entry
416        $doc = 'a **b %%c** d%% e** f';
417        $handler = new RecordingHandler();
418        $lexer = new Lexer($handler, 'ignore', true);
419        $lexer->addEntryPattern('\*\*', 'ignore', 'caught');
420        $lexer->addCloserPattern('(?<=\w)\*\*', 'caught');
421        $lexer->addSpecialPattern('%%.*?%%', 'caught', 'prot');
422        $lexer->addExitPattern('\*\*', 'caught');
423
424        $this->assertTrue($lexer->parse($doc));
425        $this->assertSame(['caught', '**', \DOKU_LEXER_ENTER, 2], $handler->recorded[1]);
426    }
427
428    public function testCloserScanIgnoresCloserInsideVerbatimModeSpan()
429    {
430        // the only closer candidate sits inside the span of a nested
431        // verbatim mode (only exit patterns of its own): the scan derives
432        // the span from the entry and exit patterns and skips it
433        $doc = 'a **b <v>c** d</v> e';
434        $handler = new RecordingHandler();
435        $lexer = new Lexer($handler, 'ignore', true);
436        $lexer->addEntryPattern('\*\*', 'ignore', 'caught');
437        $lexer->addCloserPattern('(?<=\w)\*\*', 'caught');
438        $lexer->addEntryPattern('<v>', 'caught', 'verb');
439        $lexer->addExitPattern('</v>', 'verb');
440        $lexer->addExitPattern('\*\*', 'caught');
441
442        $this->assertTrue($lexer->parse($doc));
443        $this->assertSame([
444            ['ignore', $doc, \DOKU_LEXER_UNMATCHED, 0],
445        ], $handler->recorded);
446    }
447
448    public function testCloserMemoIsResetBetweenParses()
449    {
450        $handler = new RecordingHandler();
451        $lexer = new Lexer($handler, 'ignore', true);
452        $lexer->addEntryPattern('\*\*', 'ignore', 'caught');
453        $lexer->addCloserPattern('\w\*\*', 'caught');
454        $lexer->addExitPattern('\*\*', 'caught');
455
456        // first parse memoizes "no closer from position 4 onwards"
457        $this->assertTrue($lexer->parse('x ** a'));
458        $this->assertSame([
459            ['ignore', 'x ** a', \DOKU_LEXER_UNMATCHED, 0],
460        ], $handler->recorded);
461
462        // a stale memo would reject the same position in the next subject
463        $handler->recorded = [];
464        $this->assertTrue($lexer->parse('x **b** c'));
465        $this->assertSame(['caught', '**', \DOKU_LEXER_ENTER, 2], $handler->recorded[1]);
466    }
467
468    public function testCloserCheckLooksPastUnguardedEnclosingMode()
469    {
470        // inner // sits in an unguarded middle mode inside a guarded outer
471        // mode; its only closer lies beyond the outer closer. The enclosing
472        // check must step over the unguarded middle, reach the outer, and
473        // reject, so the inner // stays literal instead of pairing across the
474        // middle and outer boundaries.
475        $doc = '**A ((B //C)) D** E F//G//';
476        $handler = new RecordingHandler();
477        $lexer = new Lexer($handler, 'ignore', true);
478        $lexer->addEntryPattern('\*\*', 'ignore', 'outer');
479        $lexer->addCloserPattern('(?<=\w)\*\*', 'outer');
480        $lexer->addEntryPattern('\(\(', 'outer', 'middle'); // unguarded: no closer
481        $lexer->addExitPattern('\)\)', 'middle');
482        $lexer->addEntryPattern('//', 'middle', 'inner');
483        $lexer->addCloserPattern('(?<=\w)//', 'inner');
484        $lexer->addExitPattern('//', 'inner');
485        $lexer->addExitPattern('\*\*', 'outer');
486
487        $this->assertTrue($lexer->parse($doc));
488        $enterModes = array_column(
489            array_filter($handler->recorded, fn($c) => $c[2] === \DOKU_LEXER_ENTER),
490            0
491        );
492        $this->assertContains('outer', $enterModes);
493        $this->assertContains('middle', $enterModes);
494        $this->assertNotContains('inner', $enterModes);
495    }
496
497    public function testCloserCheckAllowsInnerClosingBeforeGuardedAncestor()
498    {
499        // same shape, but the inner // now closes before the outer closer, so
500        // nesting through the unguarded middle is allowed
501        $doc = '**A ((B //C// D)) E**';
502        $handler = new RecordingHandler();
503        $lexer = new Lexer($handler, 'ignore', true);
504        $lexer->addEntryPattern('\*\*', 'ignore', 'outer');
505        $lexer->addCloserPattern('(?<=\w)\*\*', 'outer');
506        $lexer->addEntryPattern('\(\(', 'outer', 'middle'); // unguarded: no closer
507        $lexer->addExitPattern('\)\)', 'middle');
508        $lexer->addEntryPattern('//', 'middle', 'inner');
509        $lexer->addCloserPattern('(?<=\w)//', 'inner');
510        $lexer->addExitPattern('//', 'inner');
511        $lexer->addExitPattern('\*\*', 'outer');
512
513        $this->assertTrue($lexer->parse($doc));
514        $enterModes = array_column(
515            array_filter($handler->recorded, fn($c) => $c[2] === \DOKU_LEXER_ENTER),
516            0
517        );
518        $this->assertContains('inner', $enterModes);
519    }
520}
521