P->addMode('strong', new Strong()); $this->P->parse('Foo **Bar** Baz'); $calls = [ ['document_start', []], ['p_open', []], ['cdata', ["\nFoo "]], ['strong_open', []], ['cdata', ['Bar']], ['strong_close', []], ['cdata', [' Baz']], ['p_close', []], ['document_end', []], ]; $this->assertCalls($calls, $this->H->calls); } public function testEmphasis() { $this->P->addMode('emphasis', new Emphasis()); $this->P->parse('Foo //Bar// Baz'); $calls = [ ['document_start', []], ['p_open', []], ['cdata', ["\nFoo "]], ['emphasis_open', []], ['cdata', ['Bar']], ['emphasis_close', []], ['cdata', [' Baz']], ['p_close', []], ['document_end', []], ]; $this->assertCalls($calls, $this->H->calls); } public function testUnderline() { $this->P->addMode('underline', new Underline()); $this->P->parse('Foo __Bar__ Baz'); $calls = [ ['document_start', []], ['p_open', []], ['cdata', ["\nFoo "]], ['underline_open', []], ['cdata', ['Bar']], ['underline_close', []], ['cdata', [' Baz']], ['p_close', []], ['document_end', []], ]; $this->assertCalls($calls, $this->H->calls); } public function testMonospace() { $this->P->addMode('monospace', new Monospace()); $this->P->parse("Foo ''Bar'' Baz"); $calls = [ ['document_start', []], ['p_open', []], ['cdata', ["\nFoo "]], ['monospace_open', []], ['cdata', ['Bar']], ['monospace_close', []], ['cdata', [' Baz']], ['p_close', []], ['document_end', []], ]; $this->assertCalls($calls, $this->H->calls); } public function testSubscript() { $this->P->addMode('subscript', new Subscript()); $this->P->parse('Foo Bar Baz'); $calls = [ ['document_start', []], ['p_open', []], ['cdata', ["\nFoo "]], ['subscript_open', []], ['cdata', ['Bar']], ['subscript_close', []], ['cdata', [' Baz']], ['p_close', []], ['document_end', []], ]; $this->assertCalls($calls, $this->H->calls); } public function testSuperscript() { $this->P->addMode('superscript', new Superscript()); $this->P->parse('Foo Bar Baz'); $calls = [ ['document_start', []], ['p_open', []], ['cdata', ["\nFoo "]], ['superscript_open', []], ['cdata', ['Bar']], ['superscript_close', []], ['cdata', [' Baz']], ['p_close', []], ['document_end', []], ]; $this->assertCalls($calls, $this->H->calls); } public function testDeleted() { $this->P->addMode('deleted', new Deleted()); $this->P->parse('Foo Bar Baz'); $calls = [ ['document_start', []], ['p_open', []], ['cdata', ["\nFoo "]], ['deleted_open', []], ['cdata', ['Bar']], ['deleted_close', []], ['cdata', [' Baz']], ['p_close', []], ['document_end', []], ]; $this->assertCalls($calls, $this->H->calls); } public function testNesting() { $this->P->addMode('strong', new Strong()); $this->P->addMode('emphasis', new Emphasis()); $this->P->parse('Foo **bold //and italic// text** Bar'); $calls = [ ['document_start', []], ['p_open', []], ['cdata', ["\nFoo "]], ['strong_open', []], ['cdata', ['bold ']], ['emphasis_open', []], ['cdata', ['and italic']], ['emphasis_close', []], ['cdata', [' text']], ['strong_close', []], ['cdata', [' Bar']], ['p_close', []], ['document_end', []], ]; $this->assertCalls($calls, $this->H->calls); } public function testStrongClosesAfterLink() { // Regression: `**[[link]]**` must close Strong on the trailing `**`. // Strong's exit pattern `(?<=[^\s])\*\*` needs to see the `]` that // ends the link as the preceding non-whitespace char. The lexer // passes the full subject + offset to PCRE so lookbehinds work // across consumed tokens. $this->P->addMode('strong', new Strong()); $this->P->addMode('internallink', new Internallink()); $this->P->parse('**[[wiki:x|link]]** bar'); $calls = [ ['document_start', []], ['p_open', []], ['cdata', ["\n"]], ['strong_open', []], ['internallink', ['wiki:x', 'link']], ['strong_close', []], ['cdata', [' bar']], ['p_close', []], ['document_end', []], ]; $this->assertCalls($calls, $this->H->calls); } public function testStrongClosesAfterEmphasis() { // Regression: `**foo//bar//**` — after emphasis closes, Strong's // closing `**` must still match; its lookbehind sees the `/` left // behind by the emphasis exit. $this->P->addMode('strong', new Strong()); $this->P->addMode('emphasis', new Emphasis()); $this->P->parse('**foo//bar//**'); $calls = [ ['document_start', []], ['p_open', []], ['cdata', ["\n"]], ['strong_open', []], ['cdata', ['foo']], ['emphasis_open', []], ['cdata', ['bar']], ['emphasis_close', []], ['strong_close', []], ['cdata', ['']], ['p_close', []], ['document_end', []], ]; $this->assertCalls($calls, $this->H->calls); } public function testNoSelfNesting() { // With flanking-aware Strong: an opener matches only if a valid // closer exists (closer preceded by non-whitespace); a closer only // fires at `**` preceded by non-whitespace. Here the inner `**`s // are adjacent to spaces, so they can't close; the outermost `**` // on the right is preceded by `d` and closes the outermost opener. // Strong does not re-open inside itself. $this->P->addMode('strong', new Strong()); $this->P->parse('Foo **bold **not nested** end** Bar'); $calls = [ ['document_start', []], ['p_open', []], ['cdata', ["\nFoo "]], ['strong_open', []], ['cdata', ['bold **not nested']], ['strong_close', []], ['cdata', [' end** Bar']], ['p_close', []], ['document_end', []], ]; $this->assertCalls($calls, $this->H->calls); } /** * @dataProvider provideParagraphBoundaryModes * * Formatting delimiters must not match across a blank line. An unclosed * delimiter followed by a blank line and then an unrelated delimiter * further down must stay literal — otherwise the lexer greedily swallows * the paragraph break. */ public function testDelimitersDoNotSpanParagraphBoundary( string $modeName, $mode, string $input ) { $this->P->addMode($modeName, $mode); $this->P->parse($input); foreach ($this->H->calls as $call) { $this->assertNotSame( $modeName . '_open', $call[0], "Mode '$modeName' must not open across a blank line in: " . json_encode($input) ); } } public static function provideParagraphBoundaryModes(): array { return [ 'strong' => ['strong', new Strong(), "**open\n\nclose**"], 'emphasis' => ['emphasis', new Emphasis(), "//open\n\nclose//"], 'underline' => ['underline', new Underline(), "__open\n\nclose__"], 'monospace' => ['monospace', new Monospace(), "''open\n\nclose''"], 'subscript' => ['subscript', new Subscript(), "open\n\nclose"], 'superscript' => ['superscript', new Superscript(), "open\n\nclose"], 'deleted' => ['deleted', new Deleted(), "open\n\nclose"], ]; } /** * A single newline inside a delimiter pair is still valid (multi-line * formatting), only blank lines end it. */ public function testStrongAllowsSingleNewline() { $this->P->addMode('strong', new Strong()); $this->P->parse("**open\nclose**"); $this->assertContains( 'strong_open', array_column($this->H->calls, 0), 'Strong must still match across a single newline' ); } /** * @dataProvider provideFlankingCases * * Flanking rules (simplified): an opening delimiter must be followed by * a non-whitespace character, and a closing delimiter must be preceded * by one. Empty delimiter pairs stay literal. */ public function testFlankingRejectsInvalidDelimiters( string $modeName, $mode, string $input ) { $this->P->addMode($modeName, $mode); $this->P->parse($input); foreach ($this->H->calls as $call) { $this->assertNotSame( $modeName . '_open', $call[0], "Mode '$modeName' must not open in: " . json_encode($input) ); } } public static function provideFlankingCases(): array { return [ // Leading-whitespace opener 'strong-lead-ws' => ['strong', new Strong(), '** foo bar**'], 'emphasis-lead-ws' => ['emphasis', new Emphasis(), '// foo bar//'], 'underline-lead-ws' => ['underline', new Underline(), '__ foo bar__'], 'monospace-lead-ws' => ['monospace', new Monospace(), "'' foo bar''"], 'subscript-lead-ws' => ['subscript', new Subscript(), ' foo bar'], 'superscript-lead-ws' => ['superscript', new Superscript(), ' foo bar'], 'deleted-lead-ws' => ['deleted', new Deleted(), ' foo bar'], // Trailing-whitespace closer 'strong-trail-ws' => ['strong', new Strong(), '**foo bar **'], 'emphasis-trail-ws' => ['emphasis', new Emphasis(), '//foo bar //'], 'underline-trail-ws' => ['underline', new Underline(), '__foo bar __'], 'monospace-trail-ws' => ['monospace', new Monospace(), "''foo bar ''"], 'subscript-trail-ws' => ['subscript', new Subscript(), 'foo bar '], 'superscript-trail-ws'=> ['superscript', new Superscript(), 'foo bar '], 'deleted-trail-ws' => ['deleted', new Deleted(), 'foo bar '], // Empty delimiter pairs 'strong-empty' => ['strong', new Strong(), '**** stays literal'], 'underline-empty' => ['underline', new Underline(), '____ stays literal'], 'monospace-empty' => ['monospace', new Monospace(), "'''' stays literal"], ]; } /** * Single-character bodies still match, they're the smallest valid span. */ public function testStrongSingleCharacterBody() { $this->P->addMode('strong', new Strong()); $this->P->parse('**a**'); $this->assertContains('strong_open', array_column($this->H->calls, 0)); $this->assertContains('strong_close', array_column($this->H->calls, 0)); } /** * An opener without a valid closer must stay literal while a later * valid span in the same paragraph still matches. The closer scan * rejects the first candidate (its only potential closer is preceded * by whitespace, or is the very opener of the valid span) without * blocking the second. * * @dataProvider provideRejectedOpenerBeforeValidSpan */ public function testRejectedOpenerBeforeValidSpanStaysLiteral( string $modeName, $mode, string $input, string $openInstruction ) { $this->P->addMode($modeName, $mode); $this->P->parse($input); $calls = array_map(fn($call) => [$call[0], $call[1]], $this->H->calls); $this->assertContains([$openInstruction, []], $calls); $this->assertContains(['cdata', ['bar']], $calls); $this->assertStringContainsString(' foo ', $this->H->calls[2][1][0]); } public static function provideRejectedOpenerBeforeValidSpan(): array { return [ 'strong' => ['strong', new Strong(), '** foo **bar**', 'strong_open'], 'emphasis' => ['emphasis', new Emphasis(), '// foo //bar//', 'emphasis_open'], 'underline' => ['underline', new Underline(), '__ foo __bar__', 'underline_open'], 'monospace' => ['monospace', new Monospace(), "'' foo ''bar''", 'monospace_open'], 'deleted' => ['deleted', new Deleted(), ' foo bar', 'deleted_open'], 'subscript' => ['subscript', new Subscript(), ' foo bar', 'subscript_open'], 'superscript' => ['superscript', new Superscript(), ' foo bar', 'superscript_open'], ]; } /** * A closer in the next paragraph must not validate an opener in the * current one, and the paragraph after a rejected opener must parse * normally (the closer-free memo range ends at the paragraph break). */ public function testRejectedOpenerDoesNotAffectNextParagraph() { $this->P->addMode('strong', new Strong()); $this->P->parse("**no closer here\n\n**closed** here"); $calls = array_map(fn($call) => [$call[0], $call[1]], $this->H->calls); $this->assertContains(['cdata', ["\n**no closer here\n\n"]], $calls); $this->assertContains(['strong_open', []], $calls); $this->assertContains(['cdata', ['closed']], $calls); } /** * There is no length limit on a formatting span within a paragraph: * real pages contain styled spans well beyond 32KB, which must not * degrade to literal text. This is what caps the closer scan cannot * provide (a PCRE counted repeat maxes out at 65535) and why the scan * runs as a memoized lexer closer pattern instead. */ public function testVeryLongSpanIsRecognized() { $body = trim(str_repeat('some words ', 7000)); // ~77KB $this->P->addMode('strong', new Strong()); $this->P->parse('**' . $body . '**'); $calls = array_map(fn($call) => [$call[0], $call[1]], $this->H->calls); $this->assertContains(['strong_open', []], $calls); $this->assertContains(['cdata', [$body]], $calls); } /** * Delimiter-dense input without any closer must stay literal — and, * although a test can't assert wall clock, it exercises the memoized * closer-free range that keeps this shape linear instead of scanning * the paragraph once per opener. */ public function testManyOpenersWithoutCloserStayLiteral() { $text = trim(str_repeat('**a ', 500)); $this->P->addMode('strong', new Strong()); $this->P->parse($text); $calls = array_map(fn($call) => [$call[0], $call[1]], $this->H->calls); $this->assertNotContains(['strong_open', []], $calls); $this->assertContains(['cdata', ["\n" . $text]], $calls); } /** * An inner delimiter must not pair with one in a following sibling span: * its closer lies beyond the enclosing mode's closer, so it can never * close within the parent and must stay literal. Here the `//` inside the * first `''…''` would otherwise reach the `//` of the second, dragging the * monospace boundary along with it. */ public function testInnerDelimiterDoesNotSpanEnclosingCloser() { $this->P->addMode('monospace', new Monospace()); $this->P->addMode('emphasis', new Emphasis()); $this->P->parse("''a//b'', ''c//d''"); $calls = array_map(fn($call) => [$call[0], $call[1]], $this->H->calls); $this->assertNotContains(['emphasis_open', []], $calls); $this->assertContains(['cdata', ['a//b']], $calls); $this->assertContains(['cdata', ['c//d']], $calls); } /** * The counterpart to the above: an inner delimiter whose closer does sit * inside the enclosing span still nests normally. */ public function testInnerDelimiterClosingWithinEnclosingSpanNests() { $this->P->addMode('monospace', new Monospace()); $this->P->addMode('emphasis', new Emphasis()); $this->P->parse("''a//b//c''"); $calls = array_map(fn($call) => [$call[0], $call[1]], $this->H->calls); $this->assertContains(['monospace_open', []], $calls); $this->assertContains(['emphasis_open', []], $calls); $this->assertContains(['cdata', ['b']], $calls); } /** * The enclosing closer may directly follow the inner opener. The `//` * in the first `''…''` sits right before the closing `''`, so it can * only pair with the `//` of the second span — it must stay literal * even though no character separates it from the monospace closer. */ public function testInnerDelimiterAdjacentToEnclosingCloserStaysLiteral() { $this->P->addMode('monospace', new Monospace()); $this->P->addMode('emphasis', new Emphasis()); $this->P->parse("''//'', ''a c//d''"); $calls = array_map(fn($call) => [$call[0], $call[1]], $this->H->calls); $this->assertNotContains(['emphasis_open', []], $calls); $this->assertContains(['cdata', ['//']], $calls); $this->assertContains(['cdata', ['a c//d']], $calls); } /** * A closer lookalike inside protected content does not count against a * nested delimiter: the `''` inside the nowiki span never closes the * monospace mode, so the emphasis pair around it still nests. */ public function testFakeEnclosingCloserInProtectedContentStillNests() { $this->P->addMode('monospace', new Monospace()); $this->P->addMode('emphasis', new Emphasis()); $this->P->addMode('unformatted', new Unformatted()); $this->P->parse("''a //b x''y c// d''"); $calls = array_map(fn($call) => [$call[0], $call[1]], $this->H->calls); $this->assertContains(['emphasis_open', []], $calls); $this->assertContains(['unformatted', ["x''y"]], $calls); $this->assertContains(['cdata', [' d']], $calls); } /** * A closer lookalike inside protected content does not validate an * opener either: the only `''` after the candidate lies in a nowiki * span the lexer will consume verbatim, so monospace never opens. */ public function testFakeCloserInProtectedContentDoesNotOpenFormatting() { $this->P->addMode('monospace', new Monospace()); $this->P->addMode('unformatted', new Unformatted()); $this->P->parse("x ''a b'' c"); $calls = array_map(fn($call) => [$call[0], $call[1]], $this->H->calls); $this->assertNotContains(['monospace_open', []], $calls); $this->assertContains(['unformatted', ["b''"]], $calls); } /** * A guarded formatting mode may enclose an unguarded mode (a footnote) * that itself contains formatting. The enclosing-closer check must look * past the unguarded footnote to the strong span: the inner // has its * only closer beyond the strong closer, so it stays literal instead of * opening inside the footnote and pairing across the footnote and strong * boundaries (which left strong unclosed). */ public function testFormattingInsideFootnoteDoesNotPairAcrossEnclosingStrong() { $this->P->addMode('strong', new Strong()); $this->P->addMode('emphasis', new Emphasis()); $this->P->addMode('footnote', new Footnote()); $this->P->parse('**b ((n //x)) c** and //y//'); $names = array_column($this->flattenCalls($this->H->calls), 0); $counts = array_count_values($names); // emphasis opens once, for the trailing //y// only, not inside the footnote $this->assertSame(1, $counts['emphasis_open'] ?? 0); // strong is closed properly rather than left dangling $this->assertArrayHasKey('strong_close', $counts); } /** * A // preceded by a colon is a valid emphasis closer, so the span in * //identifier:// closes at that delimiter and the following **label** * is emitted as strong. */ public function testEmphasisClosesAtColonPrecededDelimiter() { $this->P->addMode('strong', new Strong()); $this->P->addMode('emphasis', new Emphasis()); $this->P->addMode('externallink', new Externallink()); $this->P->parse('//identifier:// **label**'); $calls = [ ['document_start', []], ['p_open', []], ['cdata', ["\n"]], ['emphasis_open', []], ['cdata', ['identifier:']], ['emphasis_close', []], ['cdata', [' ']], ['strong_open', []], ['cdata', ['label']], ['strong_close', []], ['cdata', ['']], ['p_close', []], ['document_end', []], ]; $this->assertCalls($calls, $this->H->calls); } /** * A recognized URL scheme is consumed as a link token whose content the * closer scan never looks into, so the // in http:// cannot close the * span. Emphasis wraps the whole run and closes at the trailing //. */ public function testEmphasisWrapsRecognizedUrl() { $this->P->addMode('strong', new Strong()); $this->P->addMode('emphasis', new Emphasis()); $this->P->addMode('externallink', new Externallink()); $this->P->parse('//italic known url http://foo.com/bar is here//'); $calls = [ ['document_start', []], ['p_open', []], ['cdata', ["\n"]], ['emphasis_open', []], ['cdata', ['italic known url ']], ['externallink', ['http://foo.com/bar', null]], ['cdata', [' is here']], ['emphasis_close', []], ['cdata', ['']], ['p_close', []], ['document_end', []], ]; $this->assertCalls($calls, $this->H->calls); } /** * An unrecognized scheme is plain text and gets no special guarding: the * // in proto:// closes emphasis like any other delimiter, and the text * past that closer stays literal. */ public function testUnrecognizedSchemeClosesEmphasis() { $this->P->addMode('strong', new Strong()); $this->P->addMode('emphasis', new Emphasis()); $this->P->addMode('externallink', new Externallink()); $this->P->parse('//italic unknown url proto://foo.com/bar is here//'); $calls = [ ['document_start', []], ['p_open', []], ['cdata', ["\n"]], ['emphasis_open', []], ['cdata', ['italic unknown url proto:']], ['emphasis_close', []], ['cdata', ['foo.com/bar is here//']], ['p_close', []], ['document_end', []], ]; $this->assertCalls($calls, $this->H->calls); } /** * A recognized URL greedily consumes the trailing //, so no closer * remains before the paragraph break. The opener therefore stays literal * rather than opening a span that could never close. */ public function testRecognizedUrlEatingTrailingCloserLeavesOpenerLiteral() { $this->P->addMode('strong', new Strong()); $this->P->addMode('emphasis', new Emphasis()); $this->P->addMode('externallink', new Externallink()); $this->P->parse('//italic known url http://foo.com/bar//'); $calls = [ ['document_start', []], ['p_open', []], ['cdata', ["\n//italic known url "]], ['externallink', ['http://foo.com/bar//', null]], ['cdata', ['']], ['p_close', []], ['document_end', []], ]; $this->assertCalls($calls, $this->H->calls); } /** * An unrecognized scheme's // closes emphasis at proto://; the trailing * // has nothing after it to open a new span and stays literal. */ public function testUnrecognizedSchemeClosesEmphasisWithLiteralTail() { $this->P->addMode('strong', new Strong()); $this->P->addMode('emphasis', new Emphasis()); $this->P->addMode('externallink', new Externallink()); $this->P->parse('//italic unknown url proto://foo.com/bar//'); $calls = [ ['document_start', []], ['p_open', []], ['cdata', ["\n"]], ['emphasis_open', []], ['cdata', ['italic unknown url proto:']], ['emphasis_close', []], ['cdata', ['foo.com/bar//']], ['p_close', []], ['document_end', []], ]; $this->assertCalls($calls, $this->H->calls); } /** * Flatten a handler call list, inlining the calls a footnote buffers in * its nest rewrite, into a list of [name, args] pairs. * * @param array $calls * @return array[] */ private function flattenCalls(array $calls): array { $out = []; foreach ($calls as $call) { if ($call[0] === 'nest') { $out = array_merge($out, $this->flattenCalls($call[1][0])); } else { $out[] = [$call[0], $call[1]]; } } return $out; } }