diff --git a/src/Rules/ListRule.php b/src/Rules/ListRule.php index d4a3cf4..6e49aa3 100644 --- a/src/Rules/ListRule.php +++ b/src/Rules/ListRule.php @@ -25,31 +25,32 @@ public function parse(Parser $parser): ?Token $newlines = $parser->consumeWhile(Parser::NEW_LINE); $childContent = ''; - $indent = strspn($parser->content, ' ', $parser->position); - while ($indent >= 2 && $parser->current !== null) { - if (strspn($parser->content, ' ', $parser->position) < $indent) { - break; - } + while ($parser->current !== null) { + $indent = strspn($parser->content, ' ', $parser->position); - $parser->consume($indent); - $childContent .= $parser->consumeUntil(Parser::NEW_LINE) . PHP_EOL; - $newlines = $parser->consumeWhile(Parser::NEW_LINE); - } + if ($indent >= 2) { + while ($parser->current !== null && strspn($parser->content, ' ', $parser->position) >= $indent) { + $parser->consume($indent); + $childContent .= $parser->consumeUntil(Parser::NEW_LINE) . PHP_EOL; + $newlines = $parser->consumeWhile(Parser::NEW_LINE); + } - $children = null; + if (preg_match('/(?:^|\n)[-*+] /', $childContent)) { + break; + } - if (preg_match('/(?:^|\n)[-*+] /', $childContent)) { - $children = $parser->withRules(new ListRule())->lex($childContent)[0]; - } + $content .= ' ' . trim(preg_replace('/\s+/u', ' ', $childContent) ?? ''); + $childContent = ''; + continue; + } - if ($children === null && $childContent !== '') { - $content .= ' ' . trim(preg_replace('/\s+/u', ' ', $childContent) ?? ''); - } + if ($newlines !== "\n" && $newlines !== "\r\n") { + break; + } - while ($children === null && ($newlines === "\n" || $newlines === "\r\n") && $parser->current !== null) { $nextLine = substr($parser->content, $parser->position, strcspn($parser->content, Parser::NEW_LINE, $parser->position)); - if (trim($nextLine) === '' || preg_match('/^(?:[-*+] |[0-9]+[.)] |#{1,6}[ \t]|> |`{3}|~{3})/', $nextLine)) { + if (trim($nextLine) === '' || preg_match('/^(?:[-*+] |[0-9]+[.)] |#{1,6}[ \t]|>|`{3}|~{3}|-{3}|={3}|<|:{3})/', $nextLine)) { break; } @@ -57,6 +58,8 @@ public function parse(Parser $parser): ?Token $newlines = $parser->consumeWhile(Parser::NEW_LINE); } + $children = $childContent === '' ? null : $parser->withRules(new ListRule())->lex($childContent)[0]; + $item = new ListItem($content, $children); if ($parser->lastToken instanceof ListToken) { diff --git a/src/Rules/ParagraphRule.php b/src/Rules/ParagraphRule.php index 98c4785..13684f8 100644 --- a/src/Rules/ParagraphRule.php +++ b/src/Rules/ParagraphRule.php @@ -20,27 +20,31 @@ public function parse(Parser $parser): Token $content = ''; while ($parser->current !== null) { - $content .= $parser->consumeUntil(Parser::NEW_LINE); + $line = $parser->consumeUntil(Parser::NEW_LINE); - if ($parser->current === null) { - break; - } + // A blank line ends the paragraph and stays available to the newline rule. + $endsParagraph = + $parser->position >= $parser->length + || $parser->comesNext("\n\n", 2) + || $parser->comesNext("\r\n\r\n", 4) + || $parser->comesNext("\n\r\n", 3) + || $parser->comesNext("\r\n\n", 3); - // A blank line (two consecutive newlines) ends the paragraph - if ($parser->comesNext("\n\n", 2) || $parser->comesNext("\r\n\r\n", 4) || $parser->comesNext("\n\r\n", 3) || $parser->comesNext("\r\n\n", 3)) { - break; - } + $newline = $endsParagraph ? '' : $parser->consumeWhile(Parser::NEW_LINE); - // Single newline — consume it and continue to the next line - $content .= $parser->consumeWhile(Parser::NEW_LINE); - } + $matches = []; + if ($content !== '' && preg_match('/\A {0,3}(=+|-+)[ \t]*\z/', $line, $matches)) { + $heading = trim($content); + $id = mb_strtolower($heading) |> (fn (string $value) => trim(preg_replace('/[^\p{L}\p{N}]+/u', '-', $value) ?? '', '-')); - $trimmed = rtrim($content, "\r\n"); - if (preg_match('/\A(.+)\r?\n {0,3}(=+|-+)[ \t]*\z/s', $trimmed, $matches)) { - $heading = trim($matches[1]); - $id = mb_strtolower($heading) |> (fn (string $value) => trim(preg_replace('/[^\p{L}\p{N}]+/u', '-', $value) ?? '', '-')); + return new HeadingToken($heading, $matches[1][0] === '=' ? 1 : 2, $id); + } + + $content .= $line . $newline; - return new HeadingToken($heading, $matches[2][0] === '=' ? 1 : 2, $id); + if ($endsParagraph) { + break; + } } return new ParagraphToken($content); diff --git a/src/Rules/StrikethroughRule.php b/src/Rules/StrikethroughRule.php index 14b4067..64b41ee 100644 --- a/src/Rules/StrikethroughRule.php +++ b/src/Rules/StrikethroughRule.php @@ -8,6 +8,7 @@ use Tempest\Markdown\Rule; use Tempest\Markdown\Token; use Tempest\Markdown\Tokens\StrikethroughToken; +use Tempest\Markdown\Tokens\TextToken; final class StrikethroughRule implements Rule, ProvidesFirstChar, ProvidesStopChar { @@ -16,18 +17,17 @@ final class StrikethroughRule implements Rule, ProvidesFirstChar, ProvidesStopCh public function shouldParse(Parser $parser): bool { - if (! $parser->comesNext('~', 1)) { - return false; - } - - $openingLength = strspn($parser->content, '~', $parser->position); - - return strpos($parser->content, '~', $parser->position + $openingLength) !== false; + return $parser->comesNext('~', 1); } public function parse(Parser $parser): Token { - $parser->consumeWhile('~'); + $opening = $parser->consumeWhile('~'); + + if (strpos($parser->content, '~', $parser->position) === false) { + return new TextToken($opening); + } + $buffer = $parser->consumeUntil('~'); $parser->consumeWhile('~'); diff --git a/src/Rules/ThinRulerRule.php b/src/Rules/ThinRulerRule.php index 3c24f7f..53f0b86 100644 --- a/src/Rules/ThinRulerRule.php +++ b/src/Rules/ThinRulerRule.php @@ -21,6 +21,7 @@ public function shouldParse(Parser $parser): bool public function parse(Parser $parser): Token { $content = $parser->consumeWhile('-'); + $parser->consumeWhile(" \t"); return new RulerToken( $content, diff --git a/tests/Rules/FrontMatterRuleTest.php b/tests/Rules/FrontMatterRuleTest.php index f5305fe..dba3bfb 100644 --- a/tests/Rules/FrontMatterRuleTest.php +++ b/tests/Rules/FrontMatterRuleTest.php @@ -25,6 +25,8 @@ public function standalone_delimiter_is_a_thematic_break(): void $this->assertSame("
\n", $parser->parse("---\n")->html); $this->assertSame('
', $parser->parse('-----')->html); $this->assertSame('
', new Markdown(highlighter: null)->parse('---')->html); + $this->assertSame('
', new Markdown(highlighter: null)->parse("--- \t")->html); + $this->assertSame("
\n", new Markdown(highlighter: null)->parse("--- \t\n")->html); } #[Test] diff --git a/tests/Rules/ListRuleTest.php b/tests/Rules/ListRuleTest.php index b91e5a5..f9d72a8 100644 --- a/tests/Rules/ListRuleTest.php +++ b/tests/Rules/ListRuleTest.php @@ -44,6 +44,28 @@ public function test_lazy_continuation(): void $this->assertSame('', (string) $parser->parse("* one\ncontinued")); } + #[Test] + public function test_lazy_continuation_preserves_following_blocks(): void + { + $parser = new Parser(highlighter: null); + + $this->assertSame("
\n

after

", (string) $parser->parse("- one\n---\nafter")); + $this->assertSame('
block
', (string) $parser->parse("- one\n
block
")); + } + + #[Test] + public function test_nested_list_after_lazy_continuation(): void + { + $parser = new Parser(highlighter: null); + + foreach (['-', '*', '+'] as $marker) { + $this->assertSame( + '', + (string) $parser->parse("{$marker} one\ncontinued\n {$marker} child\n{$marker} two"), + ); + } + } + #[Test] public function test_lex_multiline_items(): void { diff --git a/tests/Rules/ParagraphRuleTest.php b/tests/Rules/ParagraphRuleTest.php index 16daa5e..df03c51 100644 --- a/tests/Rules/ParagraphRuleTest.php +++ b/tests/Rules/ParagraphRuleTest.php @@ -58,4 +58,20 @@ public function test_setext_heading_can_span_multiple_lines(): void $this->assertSame("

First\nSecond

", (string) $parser->parse("First\nSecond\n---")); } + + #[Test] + public function test_setext_heading_followed_by_paragraph(): void + { + $parser = new Parser(highlighter: null); + + $this->assertSame('

Title

Body

', (string) $parser->parse("Title\n---\nBody")); + } + + #[Test] + public function test_consecutive_setext_headings(): void + { + $parser = new Parser(highlighter: null); + + $this->assertSame('

One

Two

', (string) $parser->parse("One\n===\nTwo\n---")); + } } diff --git a/tests/Rules/StrikethroughRuleTest.php b/tests/Rules/StrikethroughRuleTest.php index 37fee53..c56f077 100644 --- a/tests/Rules/StrikethroughRuleTest.php +++ b/tests/Rules/StrikethroughRuleTest.php @@ -8,6 +8,7 @@ use Tempest\Markdown\Rules\StrikethroughRule; use Tempest\Markdown\Rules\TextRule; use Tempest\Markdown\Tests\ParserTestCase; +use Tempest\Markdown\Tokens\TextToken; class StrikethroughRuleTest extends ParserTestCase { @@ -37,4 +38,26 @@ public function unmatched_tildes_remain_literal(): void $this->assertSame('~~~', (string) $parser->parse('~~~')); $this->assertSame('

Duration: ~2h30.

', new Markdown(highlighter: null)->parse('Duration: ~2h30.')->html); } + + #[Test] + public function test_unmatched_run_is_consumed_as_one_literal_token(): void + { + $opening = str_repeat('~', 80_000); + $parser = new Parser(highlighter: null)->setContent($opening . '**bold**'); + $rule = new StrikethroughRule(); + + $this->assertTrue($rule->shouldParse($parser)); + $this->assertEquals(new TextToken($opening), $rule->parse($parser)); + $this->assertSame(strlen($opening), $parser->position); + $this->assertTrue($parser->comesNext('**bold**')); + } + + #[Test] + public function test_inline_formatting_after_an_unmatched_run(): void + { + $this->assertSame( + '

About ~5 km

', + new Markdown(highlighter: null)->parse('About ~**5 km**')->html, + ); + } }