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); } 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); } 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); } 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); } 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); } 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); } 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); } 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); } 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. */ 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. */ 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. */ 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. */ 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)); } }