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