From 667810cdd0dc3e689de9595731f0b98578e62ee6 Mon Sep 17 00:00:00 2001
From: wilaak <54738571+wilaak@users.noreply.github.com>
Date: Thu, 14 May 2026 01:21:38 +0200
Subject: [PATCH 1/3] bench: lower JIT hot-func/loop thresholds
---
phpbench.json | 8 +++++++-
tests/Bench/MarkdownBench.php | 12 ++++++++----
2 files changed, 15 insertions(+), 5 deletions(-)
diff --git a/phpbench.json b/phpbench.json
index d62e210..4e17cab 100644
--- a/phpbench.json
+++ b/phpbench.json
@@ -2,5 +2,11 @@
"$schema": "./vendor/phpbench/phpbench/phpbench.schema.json",
"runner.bootstrap": "vendor/autoload.php",
"runner.path": "tests/Bench",
- "runner.file_pattern": "*Bench.php"
+ "runner.file_pattern": "*Bench.php",
+ "runner.php_config": {
+ "opcache.jit_hot_loop": "2",
+ "opcache.jit_hot_func": "2",
+ "opcache.jit_hot_return": "2",
+ "opcache.jit_hot_side_exit": "2"
+ }
}
diff --git a/tests/Bench/MarkdownBench.php b/tests/Bench/MarkdownBench.php
index 659ce67..0f782ef 100644
--- a/tests/Bench/MarkdownBench.php
+++ b/tests/Bench/MarkdownBench.php
@@ -9,11 +9,15 @@
use PhpBench\Attributes as Bench;
use Tempest\Markdown\Parser;
-#[Bench\Warmup(1)]
-#[Bench\RetryThreshold(5)]
+// Each iteration is a fresh subprocess, so JIT must warm up inside Warmup.
+// phpbench.json drops opcache.jit_hot_func/loop to 2 so JIT settles within a
+// handful of calls; Warmup(15) covers it, Revs(15)/Iterations(3) keeps stddev
+// tight without blowing up wall time.
+#[Bench\Warmup(15)]
+#[Bench\RetryThreshold(2)]
#[Bench\OutputTimeUnit('milliseconds', 3)]
-#[Bench\Iterations(5)]
-#[Bench\Revs(3)]
+#[Bench\Iterations(3)]
+#[Bench\Revs(15)]
#[Bench\ParamProviders('provideFiles')]
final readonly class MarkdownBench
{
From 0e3aaf87578be997bd2e919b143df7e9b6291782 Mon Sep 17 00:00:00 2001
From: wilaak <54738571+wilaak@users.noreply.github.com>
Date: Thu, 14 May 2026 02:33:52 +0200
Subject: [PATCH 2/3] improve perf a bit
---
src/Lexer.php | 163 -----
src/LexerRules/BoldRule.php | 28 -
src/LexerRules/CodeRule.php | 38 --
src/LexerRules/DivRule.php | 35 -
src/LexerRules/FrontMatterRule.php | 44 --
src/LexerRules/HeadingRule.php | 25 -
src/LexerRules/HtmlRule.php | 59 --
src/LexerRules/ImageRule.php | 36 --
src/LexerRules/ItalicRule.php | 28 -
src/LexerRules/LinkRule.php | 36 --
src/LexerRules/ListRule.php | 45 --
src/LexerRules/NewLineRule.php | 23 -
src/LexerRules/OrderedListRule.php | 46 --
src/LexerRules/ParagraphRule.php | 23 -
src/LexerRules/PreRule.php | 35 -
src/LexerRules/QuoteRule.php | 42 --
src/LexerRules/StrikethroughRule.php | 28 -
src/LexerRules/TableRule.php | 48 --
src/LexerRules/TextRule.php | 39 --
src/LexerRules/ThickRulerRule.php | 27 -
src/LexerRules/ThinRulerRule.php | 27 -
src/Markdown.php | 9 +-
src/NeedsStopChars.php | 8 -
src/Parser.php | 711 ++++++++++++++++++++-
src/ProvidesStopChar.php | 8 -
src/Rule.php | 10 -
src/Token.php | 8 -
src/TokenCollection.php | 51 --
src/Tokens/BoldToken.php | 31 -
src/Tokens/CodeToken.php | 33 -
src/Tokens/DivToken.php | 38 --
src/Tokens/FrontMatterToken.php | 18 -
src/Tokens/HeadingToken.php | 25 -
src/Tokens/HtmlToken.php | 18 -
src/Tokens/ImageToken.php | 21 -
src/Tokens/ItalicToken.php | 31 -
src/Tokens/LinkToken.php | 40 --
src/Tokens/ListItem.php | 13 -
src/Tokens/ListToken.php | 44 --
src/Tokens/NewLineToken.php | 18 -
src/Tokens/OrderedListToken.php | 44 --
src/Tokens/ParagraphToken.php | 35 -
src/Tokens/PreToken.php | 33 -
src/Tokens/QuoteToken.php | 35 -
src/Tokens/RulerToken.php | 19 -
src/Tokens/RulerType.php | 9 -
src/Tokens/StrikethroughToken.php | 31 -
src/Tokens/TableRow.php | 12 -
src/Tokens/TableToken.php | 73 ---
src/Tokens/TextToken.php | 23 -
tests/Bench/buffer_micro.php | 143 +++++
tests/BoldTest.php | 66 ++
tests/CodeTest.php | 45 ++
tests/DivTest.php | 84 +++
tests/FrontMatterTest.php | 79 +++
tests/HeadingTest.php | 76 +++
tests/HtmlTest.php | 66 ++
tests/ImageTest.php | 49 ++
tests/ItalicTest.php | 66 ++
tests/LexerRules/BoldRuleTest.php | 28 -
tests/LexerRules/CodeRuleTest.php | 28 -
tests/LexerRules/DivRuleTest.php | 44 --
tests/LexerRules/FrontMatterRuleTest.php | 73 ---
tests/LexerRules/HeadingRuleTest.php | 28 -
tests/LexerRules/HtmlRuleTest.php | 47 --
tests/LexerRules/ImageRuleTest.php | 28 -
tests/LexerRules/ItalicRuleTest.php | 28 -
tests/LexerRules/LinkRuleTest.php | 28 -
tests/LexerRules/ListRuleTest.php | 73 ---
tests/LexerRules/NewLineRuleTest.php | 28 -
tests/LexerRules/OrderedListRuleTest.php | 81 ---
tests/LexerRules/ParagraphRuleTest.php | 20 -
tests/LexerRules/PreRuleTest.php | 48 --
tests/LexerRules/QuoteRuleTest.php | 38 --
tests/LexerRules/StrikethroughRuleTest.php | 28 -
tests/LexerRules/TableRuleTest.php | 69 --
tests/LexerRules/TextRuleTest.php | 32 -
tests/LexerRules/ThickRulerRuleTest.php | 29 -
tests/LexerRules/ThinRulerRuleTest.php | 29 -
tests/LexerTest.php | 57 --
tests/LinkTest.php | 75 +++
tests/ListTest.php | 140 ++++
tests/OrderedListTest.php | 112 ++++
tests/ParagraphTest.php | 86 +++
tests/PreTest.php | 47 ++
tests/QuoteTest.php | 87 +++
tests/RulerTest.php | 58 ++
tests/StrikethroughTest.php | 90 +++
tests/TableTest.php | 68 ++
tests/Tokens/BoldTokenTest.php | 43 --
tests/Tokens/CodeTokenTest.php | 35 -
tests/Tokens/DivTokenTest.php | 59 --
tests/Tokens/FrontMatterTokenTest.php | 19 -
tests/Tokens/HeadingTokenTest.php | 35 -
tests/Tokens/ImageTokenTest.php | 27 -
tests/Tokens/ItalicTokenTest.php | 43 --
tests/Tokens/LinkTokenTest.php | 51 --
tests/Tokens/ListTokenTest.php | 98 ---
tests/Tokens/NewLineTokenTest.php | 19 -
tests/Tokens/OrderedListTokenTest.php | 85 ---
tests/Tokens/ParagraphTokenTest.php | 71 --
tests/Tokens/PreTokenTest.php | 44 --
tests/Tokens/QuoteTokenTest.php | 74 ---
tests/Tokens/RulerTokenTest.php | 28 -
tests/Tokens/StrikethroughTokenTest.php | 43 --
tests/Tokens/TableTokenTest.php | 81 ---
tests/Tokens/TextTokenTest.php | 19 -
107 files changed, 2135 insertions(+), 3364 deletions(-)
delete mode 100644 src/Lexer.php
delete mode 100644 src/LexerRules/BoldRule.php
delete mode 100644 src/LexerRules/CodeRule.php
delete mode 100644 src/LexerRules/DivRule.php
delete mode 100644 src/LexerRules/FrontMatterRule.php
delete mode 100644 src/LexerRules/HeadingRule.php
delete mode 100644 src/LexerRules/HtmlRule.php
delete mode 100644 src/LexerRules/ImageRule.php
delete mode 100644 src/LexerRules/ItalicRule.php
delete mode 100644 src/LexerRules/LinkRule.php
delete mode 100644 src/LexerRules/ListRule.php
delete mode 100644 src/LexerRules/NewLineRule.php
delete mode 100644 src/LexerRules/OrderedListRule.php
delete mode 100644 src/LexerRules/ParagraphRule.php
delete mode 100644 src/LexerRules/PreRule.php
delete mode 100644 src/LexerRules/QuoteRule.php
delete mode 100644 src/LexerRules/StrikethroughRule.php
delete mode 100644 src/LexerRules/TableRule.php
delete mode 100644 src/LexerRules/TextRule.php
delete mode 100644 src/LexerRules/ThickRulerRule.php
delete mode 100644 src/LexerRules/ThinRulerRule.php
delete mode 100644 src/NeedsStopChars.php
delete mode 100644 src/ProvidesStopChar.php
delete mode 100644 src/Rule.php
delete mode 100644 src/Token.php
delete mode 100644 src/TokenCollection.php
delete mode 100644 src/Tokens/BoldToken.php
delete mode 100644 src/Tokens/CodeToken.php
delete mode 100644 src/Tokens/DivToken.php
delete mode 100644 src/Tokens/FrontMatterToken.php
delete mode 100644 src/Tokens/HeadingToken.php
delete mode 100644 src/Tokens/HtmlToken.php
delete mode 100644 src/Tokens/ImageToken.php
delete mode 100644 src/Tokens/ItalicToken.php
delete mode 100644 src/Tokens/LinkToken.php
delete mode 100644 src/Tokens/ListItem.php
delete mode 100644 src/Tokens/ListToken.php
delete mode 100644 src/Tokens/NewLineToken.php
delete mode 100644 src/Tokens/OrderedListToken.php
delete mode 100644 src/Tokens/ParagraphToken.php
delete mode 100644 src/Tokens/PreToken.php
delete mode 100644 src/Tokens/QuoteToken.php
delete mode 100644 src/Tokens/RulerToken.php
delete mode 100644 src/Tokens/RulerType.php
delete mode 100644 src/Tokens/StrikethroughToken.php
delete mode 100644 src/Tokens/TableRow.php
delete mode 100644 src/Tokens/TableToken.php
delete mode 100644 src/Tokens/TextToken.php
create mode 100644 tests/Bench/buffer_micro.php
create mode 100644 tests/BoldTest.php
create mode 100644 tests/CodeTest.php
create mode 100644 tests/DivTest.php
create mode 100644 tests/FrontMatterTest.php
create mode 100644 tests/HeadingTest.php
create mode 100644 tests/HtmlTest.php
create mode 100644 tests/ImageTest.php
create mode 100644 tests/ItalicTest.php
delete mode 100644 tests/LexerRules/BoldRuleTest.php
delete mode 100644 tests/LexerRules/CodeRuleTest.php
delete mode 100644 tests/LexerRules/DivRuleTest.php
delete mode 100644 tests/LexerRules/FrontMatterRuleTest.php
delete mode 100644 tests/LexerRules/HeadingRuleTest.php
delete mode 100644 tests/LexerRules/HtmlRuleTest.php
delete mode 100644 tests/LexerRules/ImageRuleTest.php
delete mode 100644 tests/LexerRules/ItalicRuleTest.php
delete mode 100644 tests/LexerRules/LinkRuleTest.php
delete mode 100644 tests/LexerRules/ListRuleTest.php
delete mode 100644 tests/LexerRules/NewLineRuleTest.php
delete mode 100644 tests/LexerRules/OrderedListRuleTest.php
delete mode 100644 tests/LexerRules/ParagraphRuleTest.php
delete mode 100644 tests/LexerRules/PreRuleTest.php
delete mode 100644 tests/LexerRules/QuoteRuleTest.php
delete mode 100644 tests/LexerRules/StrikethroughRuleTest.php
delete mode 100644 tests/LexerRules/TableRuleTest.php
delete mode 100644 tests/LexerRules/TextRuleTest.php
delete mode 100644 tests/LexerRules/ThickRulerRuleTest.php
delete mode 100644 tests/LexerRules/ThinRulerRuleTest.php
delete mode 100644 tests/LexerTest.php
create mode 100644 tests/LinkTest.php
create mode 100644 tests/ListTest.php
create mode 100644 tests/OrderedListTest.php
create mode 100644 tests/ParagraphTest.php
create mode 100644 tests/PreTest.php
create mode 100644 tests/QuoteTest.php
create mode 100644 tests/RulerTest.php
create mode 100644 tests/StrikethroughTest.php
create mode 100644 tests/TableTest.php
delete mode 100644 tests/Tokens/BoldTokenTest.php
delete mode 100644 tests/Tokens/CodeTokenTest.php
delete mode 100644 tests/Tokens/DivTokenTest.php
delete mode 100644 tests/Tokens/FrontMatterTokenTest.php
delete mode 100644 tests/Tokens/HeadingTokenTest.php
delete mode 100644 tests/Tokens/ImageTokenTest.php
delete mode 100644 tests/Tokens/ItalicTokenTest.php
delete mode 100644 tests/Tokens/LinkTokenTest.php
delete mode 100644 tests/Tokens/ListTokenTest.php
delete mode 100644 tests/Tokens/NewLineTokenTest.php
delete mode 100644 tests/Tokens/OrderedListTokenTest.php
delete mode 100644 tests/Tokens/ParagraphTokenTest.php
delete mode 100644 tests/Tokens/PreTokenTest.php
delete mode 100644 tests/Tokens/QuoteTokenTest.php
delete mode 100644 tests/Tokens/RulerTokenTest.php
delete mode 100644 tests/Tokens/StrikethroughTokenTest.php
delete mode 100644 tests/Tokens/TableTokenTest.php
delete mode 100644 tests/Tokens/TextTokenTest.php
diff --git a/src/Lexer.php b/src/Lexer.php
deleted file mode 100644
index 3573071..0000000
--- a/src/Lexer.php
+++ /dev/null
@@ -1,163 +0,0 @@
-rules = $rules ?? [
- new NewLineRule(),
- new FrontMatterRule(),
- new HeadingRule(),
- new QuoteRule(),
- new PreRule(),
- new DivRule(),
- new ThinRulerRule(),
- new ThickRulerRule(),
- new TableRule(),
- new HtmlRule(),
- new ParagraphRule(),
- ];
- }
-
- public function withRules(Rule ...$rules): self
- {
- return clone($this, [
- 'rules' => $rules,
- ]);
- }
-
- public function lex(string $content): TokenCollection
- {
- $lexer = clone $this;
-
- $lexer->content = $content;
- $lexer->position = 0;
- $lexer->current = $lexer->content[$lexer->position] ?? null;
-
- $tokens = [];
-
- /** @var \Tempest\Markdown\NeedsStopChars[] $needsStopChars */
- $needsStopChars = [];
- $providedStopChars = '';
-
- foreach ($this->rules as $rule) {
- if ($rule instanceof ProvidesStopChar) {
- $providedStopChars .= $rule->stopChar;
- }
-
- if ($rule instanceof NeedsStopChars) {
- $needsStopChars[] = $rule;
- }
- }
-
- foreach ($needsStopChars as $rule) {
- $rule->stopChars .= $providedStopChars;
- }
-
- while ($lexer->current !== null) {
- foreach ($this->rules as $rule) {
- if (! $rule->shouldLex($lexer)) {
- continue;
- }
-
- $token = $rule->lex($lexer);
-
- if ($token instanceof Token) {
- $tokens[] = $token;
- $lexer->lastToken = $token;
- }
-
- continue 2;
- }
-
- $lexer->consume();
- }
-
- return new TokenCollection($tokens);
- }
-
- public function comesNext(string $search, ?int $length = null): bool
- {
- $length ??= strlen($search);
-
- if ($length === 1) {
- return ($this->content[$this->position] ?? null) === $search;
- }
-
- return substr_compare($this->content, $search, $this->position, $length) === 0;
- }
-
- public function consume(int $length = 1): string
- {
- if ($length === 0) {
- return '';
- }
-
- if ($length === 1) {
- $char = $this->content[$this->position++] ?? null;
- $this->current = $this->content[$this->position] ?? null;
- return $char ?? '';
- }
-
- $buffer = substr($this->content, $this->position, $length);
- $this->position += $length;
- $this->current = $this->content[$this->position] ?? null;
-
- return $buffer;
- }
-
- public function consumeUntil(string $stopAt): string
- {
- $offset = strcspn($this->content, $stopAt, $this->position);
-
- return $this->consume($offset);
- }
-
- public function consumeUntilString(string $stopAt): string
- {
- $pos = strpos($this->content, $stopAt, $this->position);
-
- if ($pos === false) {
- return $this->consume(strlen($this->content) - $this->position);
- }
-
- return $this->consume($pos - $this->position);
- }
-
- public function consumeWhile(string $continueWhile): string
- {
- $offset = strspn($this->content, $continueWhile, $this->position);
-
- return $this->consume($offset);
- }
-
- public function consumeIncluding(string $search): string
- {
- return $this->consumeUntil($search) . $this->consume(strlen($search));
- }
-}
diff --git a/src/LexerRules/BoldRule.php b/src/LexerRules/BoldRule.php
deleted file mode 100644
index 5ab0365..0000000
--- a/src/LexerRules/BoldRule.php
+++ /dev/null
@@ -1,28 +0,0 @@
-comesNext('*', 1);
- }
-
- public function lex(Lexer $lexer): Token
- {
- $lexer->consumeWhile('*');
- $buffer = $lexer->consumeUntil('*');
- $lexer->consumeWhile('*');
-
- return new BoldToken($buffer);
- }
-}
diff --git a/src/LexerRules/CodeRule.php b/src/LexerRules/CodeRule.php
deleted file mode 100644
index cb95c5b..0000000
--- a/src/LexerRules/CodeRule.php
+++ /dev/null
@@ -1,38 +0,0 @@
-comesNext('`', 1);
- }
-
- public function lex(Lexer $lexer): Token
- {
- $lexer->consumeIncluding('`');
-
- $language = null;
-
- if ($lexer->comesNext('{', 1)) {
- $lexer->consume();
- $language = $lexer->consumeUntil('}');
- $lexer->consume();
- }
-
- $content = $lexer->consumeUntil('`');
-
- $lexer->consumeIncluding('`');
-
- return new CodeToken($language, $content);
- }
-}
diff --git a/src/LexerRules/DivRule.php b/src/LexerRules/DivRule.php
deleted file mode 100644
index 9a4a63b..0000000
--- a/src/LexerRules/DivRule.php
+++ /dev/null
@@ -1,35 +0,0 @@
-comesNext(':::', 3);
- }
-
- public function lex(Lexer $lexer): Token
- {
- $lexer->consumeWhile(':');
-
- $class = $lexer->consumeUntil(Lexer::NEW_LINE) ?: null;
-
- $lexer->consumeWhile(Lexer::NEW_LINE);
-
- $content = $lexer->consumeUntilString(':::');
-
- $lexer->consumeWhile(':');
- $lexer->consumeWhile(Lexer::NEW_LINE);
-
- return new DivToken(
- class: $class,
- content: $content,
- );
- }
-}
diff --git a/src/LexerRules/FrontMatterRule.php b/src/LexerRules/FrontMatterRule.php
deleted file mode 100644
index d84da70..0000000
--- a/src/LexerRules/FrontMatterRule.php
+++ /dev/null
@@ -1,44 +0,0 @@
-position !== 0) {
- return false;
- }
-
- if (! $lexer->comesNext('---', 3)) {
- return false;
- }
-
- return true;
- }
-
- public function lex(Lexer $lexer): ?Token
- {
- $lexer->consumeWhile('-');
- $lexer->consumeWhile(Lexer::NEW_LINE);
- $content = $lexer->consumeUntilString('---');
- $lexer->consumeWhile('-');
- $lexer->consumeWhile(Lexer::NEW_LINE);
-
- try {
- $data = Yaml::parse($content);
- } catch (Throwable) {
- // TODO: proper error
- return null;
- }
-
- return new FrontMatterToken($data);
- }
-}
diff --git a/src/LexerRules/HeadingRule.php b/src/LexerRules/HeadingRule.php
deleted file mode 100644
index 36b15d9..0000000
--- a/src/LexerRules/HeadingRule.php
+++ /dev/null
@@ -1,25 +0,0 @@
-comesNext('#', 1);
- }
-
- public function lex(Lexer $lexer): Token
- {
- $buffer = $lexer->consumeUntil(Lexer::NEW_LINE);
-
- $level = strspn($buffer, '#');
-
- return new HeadingToken(substr($buffer, $level) |> trim(...), $level);
- }
-}
diff --git a/src/LexerRules/HtmlRule.php b/src/LexerRules/HtmlRule.php
deleted file mode 100644
index 5e7e2d8..0000000
--- a/src/LexerRules/HtmlRule.php
+++ /dev/null
@@ -1,59 +0,0 @@
-comesNext('<');
- }
-
- public function lex(Lexer $lexer): Token
- {
- $openingTag = $lexer->consumeIncluding('>');
-
- // Self-closing tags (,
) need no closing tag.
- if (str_ends_with($openingTag, '/>')) {
- return new HtmlToken($openingTag . $lexer->consumeWhile(Lexer::NEW_LINE));
- }
-
- // Extract tag name from opening tag: "
'; + emit_inline($source, $position, $paragraph_end, InlineContext::PARAGRAPH, 0, $out); + $out .= '
'; + return $paragraph_end; +} + +// `- ` / `\d+. ` markers (trailing space required). children are 2-space-indented +// lines, recursively rendered as the same kind (mixing kinds silently drops). +function consume_list( + string $source, + int $position, + int $length_bytes, + bool $ordered, + string &$out, +): int { + $tag = $ordered ? 'ol' : 'ul'; + $out .= "<{$tag}>"; + + while (($marker_length = list_marker_length($source, $position, $length_bytes, $ordered)) > 0) { + $position += $marker_length; + + $line_end = $position + \strcspn($source, "\r\n", $position); + $content = \trim(\substr($source, $position, $line_end - $position)); + $position = $line_end + \strspn($source, "\r\n", $line_end); + + // 2-space-indented child lines, prefix stripped; deeper indents are kept + // so further nesting de-indents correctly on recursion. + $child_content = ''; + while ( + $position + 1 < $length_bytes + && $source[$position] === ' ' + && $source[$position + 1] === ' ' + ) { + $position += 2; + $child_line_end = $position + \strcspn($source, "\r\n", $position); + $child_content .= \substr($source, $position, $child_line_end - $position) . "\n"; + $position = $child_line_end + \strspn($source, "\r\n", $child_line_end); + } + + $out .= ''; + emit_inline($content, 0, \strlen($content), InlineContext::QUOTE, $depth + 1, $out); + $out .= ''; + + return $position; +} + +function consume_pre( + string $source, + int $position, + int $length_bytes, + string &$out, +): int { + $position += 3; + $line_end = $position + \strcspn($source, "\r\n", $position); + $language = \substr($source, $position, $line_end - $position); + $position = $line_end + \strspn($source, "\r\n", $line_end); + + $close = \strpos($source, '```', $position); + if ($close === false) { + $content = \substr($source, $position); + $position = $length_bytes; + } else { + $content = \substr($source, $position, $close - $position); + $position = $close + 3; + } + + $position += \strspn($source, "\r\n", $position); + $content = \trim($content); + $language = $language !== '' ? $language : null; + + $out .= '
'; + emit_code_tag($content, $language, $out); + $out .= ''; + + return $position; +} + +function consume_div( + string $source, + int $position, + int $length_bytes, + string &$out, +): int { + $position += \strspn($source, ':', $position); + $line_end = $position + \strcspn($source, "\r\n", $position); + $class = \substr($source, $position, $line_end - $position); + $class = $class !== '' ? $class : null; + $position = $line_end + \strspn($source, "\r\n", $line_end); + + $close = \strpos($source, ':::', $position); + if ($close === false) { + $content_start = $position; + $content_end = $length_bytes; + $position = $length_bytes; + } else { + $content_start = $position; + $content_end = $close; + $position = $close; + } + + $position += \strspn($source, ':', $position); + $position += \strspn($source, "\r\n", $position); + + $class_attr = $class !== null ? " class=\"{$class}\"" : ''; + $out .= "
with optional language-X class; block contexts wrap in at the call site.
+function emit_code_tag(string $content, ?string $language, string &$out): void
+{
+ $highlighter = ParserState::$highlighter;
+
+ if ($language === null && $highlighter !== null) {
+ $language = $highlighter->fallbackLanguage?->getName();
+ }
+
+ if ($highlighter !== null) {
+ $content = $highlighter->parse($content, $language);
+ }
+
+ $class = $language !== null ? " class=\"language-{$language}\"" : '';
+ $out .= "{$content}";
+}
+
+// emit html for slice [$start, $end) of $content.
+// add an inline rule: STOP_CHARS entry + match arm below + emit_X helper.
+function emit_inline(
+ string $content,
+ int $start,
+ int $end,
+ int $context,
+ int $depth,
+ string &$out,
+): void {
+ \assert($depth < InlineContext::DEPTH_MAX);
+ \assert($start >= 0 && $end >= $start);
+
+ $stop = InlineContext::STOP_CHARS[$context];
+
+ $position = $start;
+
+ while ($position < $end) {
+ $run = \strcspn($content, $stop, $position, $end - $position);
+ if ($run > 0) {
+ $out .= \substr($content, $position, $run);
+ $position += $run;
+ if ($position >= $end) {
+ break;
+ }
+ }
+
+ $char = $content[$position];
+
+ // bare `!` not followed by `[` is literal text; bail keeps the match one-arm-per-char.
+ if ($char === '!' && ($position + 1 >= $end || $content[$position + 1] !== '[')) {
+ $out .= '!';
+ $position++;
+ continue;
+ }
+
+ $position = match ($char) {
+ '*' => emit_paired($content, $position, $end, '*', 'strong', InlineContext::BOLD, $depth, $out),
+ '_' => emit_paired($content, $position, $end, '_', 'em', InlineContext::ITALIC, $depth, $out),
+ '~' => emit_paired($content, $position, $end, '~', 's', InlineContext::STRIKE, $depth, $out),
+ '[' => emit_link($content, $position, $end, $depth, $out),
+ '!' => emit_image($content, $position, $end, $out),
+ '`' => emit_code_inline($content, $position, $end, $out),
+ '>' => emit_blockquote($content, $position, $end, $depth, $out),
+ };
+ }
+}
+
+// same-marker emphasis: skip leading run, capture inner, skip trailing run, recurse.
+function emit_paired(
+ string $content,
+ int $position,
+ int $end,
+ string $marker,
+ string $tag,
+ int $inner_context,
+ int $depth,
+ string &$out,
+): int {
+ $position += \strspn($content, $marker, $position, $end - $position);
+ $inner_start = $position;
+ $position += \strcspn($content, $marker, $position, $end - $position);
+ $inner_end = $position;
+ $position += \strspn($content, $marker, $position, $end - $position);
+
+ $out .= "<{$tag}>";
+ emit_inline($content, $inner_start, $inner_end, $inner_context, $depth + 1, $out);
+ $out .= "{$tag}>";
+
+ return $position;
+}
+
+function emit_link(
+ string $content,
+ int $position,
+ int $end,
+ int $depth,
+ string &$out,
+): int {
+ $position++; // skip '['
+ $inner_start = $position;
+ $position += \strcspn($content, ']', $position, $end - $position);
+ $inner_end = $position;
+ if ($position < $end && $content[$position] === ']') {
+ $position++;
+ }
+
+ $href = null;
+ if ($position < $end && $content[$position] === '(') {
+ $position++;
+ $href_run = \strcspn($content, ')', $position, $end - $position);
+ $href = \substr($content, $position, $href_run);
+ $position += $href_run;
+ if ($position < $end && $content[$position] === ')') {
+ $position++;
+ }
+ }
+
+ // `*` prefix on href opens in a new tab (matches LinkToken).
+ $target_attr = '';
+ if ($href !== null && \str_starts_with($href, '*')) {
+ $href = \substr($href, 1);
+ $target_attr = ' target="_blank" rel="noopener noreferrer"';
+ }
+ $href_attr = $href ?? '';
+
+ $out .= "";
+ emit_inline($content, $inner_start, $inner_end, InlineContext::LINK, $depth + 1, $out);
+ $out .= '';
+
+ return $position;
+}
+
+function emit_image(string $content, int $position, int $end, string &$out): int
+{
+ $position += 2; // skip '!['
+ $alt_run = \strcspn($content, ']', $position, $end - $position);
+ $alt = $alt_run > 0 ? \substr($content, $position, $alt_run) : null;
+ $position += $alt_run;
+ if ($position < $end && $content[$position] === ']') {
+ $position++;
+ }
+
+ $href = '';
+ if ($position < $end && $content[$position] === '(') {
+ $position++;
+ $href_run = \strcspn($content, ')', $position, $end - $position);
+ $href = \substr($content, $position, $href_run);
+ $position += $href_run;
+ if ($position < $end && $content[$position] === ')') {
+ $position++;
+ }
+ }
+
+ $alt_attr = $alt !== null ? " alt=\"{$alt}\"" : '';
+ $out .= "
";
+
+ return $position;
+}
+
+function emit_code_inline(string $content, int $position, int $end, string &$out): int
+{
+ $position++; // skip opening '`'
+
+ // optional `{lang}` info string after the opening backtick:
+ $language = null;
+ if ($position < $end && $content[$position] === '{') {
+ $position++;
+ $brace_run = \strcspn($content, '}', $position, $end - $position);
+ $language = \substr($content, $position, $brace_run);
+ $position += $brace_run;
+ if ($position < $end && $content[$position] === '}') {
+ $position++;
+ }
+ }
+
+ $code_run = \strcspn($content, '`', $position, $end - $position);
+ $code = \substr($content, $position, $code_run);
+ $position += $code_run;
+ if ($position < $end && $content[$position] === '`') {
+ $position++;
+ }
+
+ emit_code_tag($code, $language, $out);
+
+ return $position;
}
diff --git a/src/ProvidesStopChar.php b/src/ProvidesStopChar.php
deleted file mode 100644
index 4333d06..0000000
--- a/src/ProvidesStopChar.php
+++ /dev/null
@@ -1,8 +0,0 @@
-
- * @implements ArrayAccess
- */
-final class TokenCollection implements IteratorAggregate, ArrayAccess
-{
- public function __construct(
- private array $tokens = [],
- ) {}
-
- public function add(Token $token): self
- {
- $this->tokens[] = $token;
-
- return $this;
- }
-
- public function getIterator(): Traversable
- {
- return new ArrayIterator($this->tokens);
- }
-
- public function offsetExists(mixed $offset): bool
- {
- return isset($this->tokens[$offset]);
- }
-
- public function offsetGet(mixed $offset): mixed
- {
- return $this->tokens[$offset] ?? null;
- }
-
- public function offsetSet(mixed $offset, mixed $value): void
- {
- $this->tokens[$offset] = $value;
- }
-
- public function offsetUnset(mixed $offset): void
- {
- unset($this->tokens[$offset]);
- }
-}
diff --git a/src/Tokens/BoldToken.php b/src/Tokens/BoldToken.php
deleted file mode 100644
index 2405911..0000000
--- a/src/Tokens/BoldToken.php
+++ /dev/null
@@ -1,31 +0,0 @@
-withRules(
- new ItalicRule(),
- new StrikethroughRule(),
- new LinkRule(),
- new TextRule(),
- )
- ->parse($this->content);
-
- return "{$content}";
- }
-}
diff --git a/src/Tokens/CodeToken.php b/src/Tokens/CodeToken.php
deleted file mode 100644
index 7458538..0000000
--- a/src/Tokens/CodeToken.php
+++ /dev/null
@@ -1,33 +0,0 @@
-language;
-
- if (! $language && $parser->highlighter) {
- $language = $parser->highlighter->fallbackLanguage?->getName();
- }
-
- if ($parser->highlighter) {
- $content = $parser->highlighter->parse($this->content, $language);
- } else {
- $content = $this->content;
- }
-
- $class = $language ? " class=\"language-{$language}\"" : '';
-
- return "{$content}";
- }
-}
diff --git a/src/Tokens/DivToken.php b/src/Tokens/DivToken.php
deleted file mode 100644
index 09f6baf..0000000
--- a/src/Tokens/DivToken.php
+++ /dev/null
@@ -1,38 +0,0 @@
-withRules(
- new QuoteRule(),
- new BoldRule(),
- new ItalicRule(),
- new LinkRule(),
- new ImageRule(),
- new TextRule(),
- )
- ->parse($this->content);
-
- $class = $this->class ? " class=\"{$this->class}\"" : '';
-
- return "{$content}";
- }
-}
diff --git a/src/Tokens/FrontMatterToken.php b/src/Tokens/FrontMatterToken.php
deleted file mode 100644
index f44d4a2..0000000
--- a/src/Tokens/FrontMatterToken.php
+++ /dev/null
@@ -1,18 +0,0 @@
-level}";
-
- $slug = $this->content |> trim(...) |> strtolower(...) |> (fn (string $x) => str_replace(' ', '-', $x));
-
- $id = " id=\"{$slug}\"";
-
- return "<{$tag}{$id}>{$this->content}{$tag}>";
- }
-}
diff --git a/src/Tokens/HtmlToken.php b/src/Tokens/HtmlToken.php
deleted file mode 100644
index a000be7..0000000
--- a/src/Tokens/HtmlToken.php
+++ /dev/null
@@ -1,18 +0,0 @@
-html;
- }
-}
diff --git a/src/Tokens/ImageToken.php b/src/Tokens/ImageToken.php
deleted file mode 100644
index 88d9b8d..0000000
--- a/src/Tokens/ImageToken.php
+++ /dev/null
@@ -1,21 +0,0 @@
-alt ? " alt=\"{$this->alt}\"" : '';
-
- return "
href}\"{$alt}>";
- }
-}
diff --git a/src/Tokens/ItalicToken.php b/src/Tokens/ItalicToken.php
deleted file mode 100644
index 800be57..0000000
--- a/src/Tokens/ItalicToken.php
+++ /dev/null
@@ -1,31 +0,0 @@
-withRules(
- new BoldRule(),
- new StrikethroughRule(),
- new LinkRule(),
- new TextRule(),
- )
- ->parse($this->content);
-
- return "{$content}";
- }
-}
diff --git a/src/Tokens/LinkToken.php b/src/Tokens/LinkToken.php
deleted file mode 100644
index a8de571..0000000
--- a/src/Tokens/LinkToken.php
+++ /dev/null
@@ -1,40 +0,0 @@
-withRules(
- new BoldRule(),
- new ItalicRule(),
- new StrikethroughRule(),
- new TextRule(),
- )
- ->parse($this->content);
-
- $href = $this->href ?? '';
- $blank = '';
-
- if (str_starts_with($href, '*')) {
- $href = substr($href, 1);
- $blank = ' target="_blank" rel="noopener noreferrer"';
- }
-
- return "{$content}";
- }
-}
diff --git a/src/Tokens/ListItem.php b/src/Tokens/ListItem.php
deleted file mode 100644
index 1ec19ce..0000000
--- a/src/Tokens/ListItem.php
+++ /dev/null
@@ -1,13 +0,0 @@
-withRules(
- new BoldRule(),
- new ItalicRule(),
- new LinkRule(),
- new ImageRule(),
- new CodeRule(),
- new TextRule(),
- );
-
- $list = '';
-
- foreach ($this->items as $item) {
- $content = $parser->parse($item->content);
- $children = $item->children?->parse($parser) ?? '';
- $list .= "- {$content}{$children}
";
- }
-
- $list .= '
';
-
- return $list;
- }
-}
diff --git a/src/Tokens/NewLineToken.php b/src/Tokens/NewLineToken.php
deleted file mode 100644
index 88a3224..0000000
--- a/src/Tokens/NewLineToken.php
+++ /dev/null
@@ -1,18 +0,0 @@
-content;
- }
-}
diff --git a/src/Tokens/OrderedListToken.php b/src/Tokens/OrderedListToken.php
deleted file mode 100644
index 396211f..0000000
--- a/src/Tokens/OrderedListToken.php
+++ /dev/null
@@ -1,44 +0,0 @@
-withRules(
- new BoldRule(),
- new ItalicRule(),
- new LinkRule(),
- new ImageRule(),
- new CodeRule(),
- new TextRule(),
- );
-
- $list = '';
-
- foreach ($this->items as $item) {
- $content = $parser->parse($item->content);
- $children = $item->children?->parse($parser) ?? '';
- $list .= "- {$content}{$children}
";
- }
-
- $list .= '
';
-
- return $list;
- }
-}
diff --git a/src/Tokens/ParagraphToken.php b/src/Tokens/ParagraphToken.php
deleted file mode 100644
index c839181..0000000
--- a/src/Tokens/ParagraphToken.php
+++ /dev/null
@@ -1,35 +0,0 @@
-withRules(
- new BoldRule(),
- new ItalicRule(),
- new LinkRule(),
- new ImageRule(),
- new CodeRule(),
- new TextRule(),
- );
-
- $content = $parser->parse($this->content);
-
- return "{$content}
";
- }
-}
diff --git a/src/Tokens/PreToken.php b/src/Tokens/PreToken.php
deleted file mode 100644
index c25a959..0000000
--- a/src/Tokens/PreToken.php
+++ /dev/null
@@ -1,33 +0,0 @@
-language;
-
- if (! $language && $parser->highlighter) {
- $language = $parser->highlighter->fallbackLanguage?->getName();
- }
-
- if ($parser->highlighter) {
- $content = $parser->highlighter->parse($this->content, $language);
- } else {
- $content = $this->content;
- }
-
- $class = $language ? " class=\"language-{$language}\"" : '';
-
- return "{$content}
";
- }
-}
diff --git a/src/Tokens/QuoteToken.php b/src/Tokens/QuoteToken.php
deleted file mode 100644
index d3c5438..0000000
--- a/src/Tokens/QuoteToken.php
+++ /dev/null
@@ -1,35 +0,0 @@
-withRules(
- new QuoteRule(),
- new BoldRule(),
- new ItalicRule(),
- new LinkRule(),
- new ImageRule(),
- new TextRule(),
- )
- ->parse($this->content);
-
- return "{$content}
";
- }
-}
diff --git a/src/Tokens/RulerToken.php b/src/Tokens/RulerToken.php
deleted file mode 100644
index fc2159d..0000000
--- a/src/Tokens/RulerToken.php
+++ /dev/null
@@ -1,19 +0,0 @@
-';
- }
-}
diff --git a/src/Tokens/RulerType.php b/src/Tokens/RulerType.php
deleted file mode 100644
index 04393d7..0000000
--- a/src/Tokens/RulerType.php
+++ /dev/null
@@ -1,9 +0,0 @@
-withRules(
- new ItalicRule(),
- new BoldRule(),
- new LinkRule(),
- new TextRule(),
- )
- ->parse($this->content);
-
- return "{$content}";
- }
-}
diff --git a/src/Tokens/TableRow.php b/src/Tokens/TableRow.php
deleted file mode 100644
index 77ef2c2..0000000
--- a/src/Tokens/TableRow.php
+++ /dev/null
@@ -1,12 +0,0 @@
-withRules(
- new BoldRule(),
- new ItalicRule(),
- new LinkRule(),
- new CodeRule(),
- new ImageRule(),
- new TextRule(),
- );
-
- $headerRows = array_values(array_filter($this->rows, fn (TableRow $row) => $row->isHeader));
- $dataRows = array_values(array_filter($this->rows, fn (TableRow $row) => ! $row->isHeader));
-
- $table = '';
-
- if ($headerRows !== []) {
- $table .= '';
-
- foreach ($headerRows as $row) {
- $table .= '';
-
- foreach ($row->cells as $cell) {
- $table .= '' . $parser->parse($cell)->html . ' ';
- }
-
- $table .= ' ';
- }
-
- $table .= '';
- }
-
- if ($dataRows !== []) {
- $table .= '';
-
- foreach ($dataRows as $row) {
- $table .= '';
-
- foreach ($row->cells as $cell) {
- $table .= '' . $parser->parse($cell)->html . ' ';
- }
-
- $table .= ' ';
- }
-
- $table .= '';
- }
-
- $table .= '
';
-
- return $table;
- }
-}
diff --git a/src/Tokens/TextToken.php b/src/Tokens/TextToken.php
deleted file mode 100644
index 98cfef4..0000000
--- a/src/Tokens/TextToken.php
+++ /dev/null
@@ -1,23 +0,0 @@
-content;
- }
-
- public function append(string $content): void
- {
- $this->content .= $content;
- }
-}
diff --git a/tests/Bench/buffer_micro.php b/tests/Bench/buffer_micro.php
new file mode 100644
index 0000000..d986eb0
--- /dev/null
+++ b/tests/Bench/buffer_micro.php
@@ -0,0 +1,143 @@
+';
+ if ($depth === 0) {
+ for ($i = 0; $i < RECURSION_FANOUT; $i++) {
+ $out .= CHUNK;
+ }
+ } else {
+ for ($i = 0; $i < RECURSION_FANOUT; $i++) {
+ byref_emit($depth - 1, $out);
+ }
+ }
+ $out .= '';
+}
+function bench_byref(): string {
+ $out = '';
+ byref_emit(RECURSION_DEPTH, $out);
+ return $out;
+}
+
+// --- 2. return string, caller concatenates --------------------------------
+function returning_emit(int $depth): string {
+ $s = '';
+ if ($depth === 0) {
+ for ($i = 0; $i < RECURSION_FANOUT; $i++) {
+ $s .= CHUNK;
+ }
+ } else {
+ for ($i = 0; $i < RECURSION_FANOUT; $i++) {
+ $s .= returning_emit($depth - 1);
+ }
+ }
+ return $s . '';
+}
+function bench_returning(): string {
+ return returning_emit(RECURSION_DEPTH);
+}
+
+// --- 3. by-ref array of chunks + implode ----------------------------------
+function array_emit(int $depth, array &$out): void {
+ $out[] = '';
+ if ($depth === 0) {
+ for ($i = 0; $i < RECURSION_FANOUT; $i++) {
+ $out[] = CHUNK;
+ }
+ } else {
+ for ($i = 0; $i < RECURSION_FANOUT; $i++) {
+ array_emit($depth - 1, $out);
+ }
+ }
+ $out[] = '';
+}
+function bench_array(): string {
+ $out = [];
+ array_emit(RECURSION_DEPTH, $out);
+ return implode('', $out);
+}
+
+// --- 4. ob_start / ob_get_clean -------------------------------------------
+function ob_emit(int $depth): void {
+ echo '';
+ if ($depth === 0) {
+ for ($i = 0; $i < RECURSION_FANOUT; $i++) {
+ echo CHUNK;
+ }
+ } else {
+ for ($i = 0; $i < RECURSION_FANOUT; $i++) {
+ ob_emit($depth - 1);
+ }
+ }
+ echo '';
+}
+function bench_ob(): string {
+ ob_start();
+ ob_emit(RECURSION_DEPTH);
+ return ob_get_clean();
+}
+
+// --- driver ----------------------------------------------------------------
+function run(string $name, callable $fn, int $iters): array {
+ // warmup
+ for ($i = 0; $i < 200; $i++) $fn();
+ $best = PHP_FLOAT_MAX;
+ $worst = 0.0;
+ $sum = 0.0;
+ $runs = 3;
+ $sample = null;
+ for ($r = 0; $r < $runs; $r++) {
+ $t0 = hrtime(true);
+ for ($i = 0; $i < $iters; $i++) {
+ $sample = $fn();
+ }
+ $dt = (hrtime(true) - $t0) / 1e6; // ms
+ $best = min($best, $dt);
+ $worst = max($worst, $dt);
+ $sum += $dt;
+ }
+ return ['name' => $name, 'best_ms' => $best, 'mean_ms' => $sum / $runs, 'worst_ms' => $worst, 'len' => strlen($sample)];
+}
+
+// sanity: outputs equal?
+$ref = bench_byref();
+foreach (['bench_returning', 'bench_array', 'bench_ob'] as $fn) {
+ if ($fn() !== $ref) {
+ fwrite(STDERR, "MISMATCH: $fn\n");
+ exit(1);
+ }
+}
+echo "Output length: " . strlen($ref) . " bytes\n";
+echo "Iterations: " . ITERS . "\n";
+echo "Tree: fanout=" . RECURSION_FANOUT . " depth=" . RECURSION_DEPTH . "\n\n";
+
+$results = [
+ run('by-ref string (.=)', 'bench_byref', ITERS),
+ run('returning string', 'bench_returning', ITERS),
+ run('by-ref array + implode', 'bench_array', ITERS),
+ run('ob_start/ob_get_clean', 'bench_ob', ITERS),
+];
+
+printf("%-28s %10s %10s %10s\n", 'strategy', 'best(ms)', 'mean(ms)', 'worst(ms)');
+foreach ($results as $r) {
+ printf("%-28s %10.2f %10.2f %10.2f\n", $r['name'], $r['best_ms'], $r['mean_ms'], $r['worst_ms']);
+}
+$baseline = $results[0]['best_ms'];
+echo "\nrelative to by-ref string (best):\n";
+foreach ($results as $r) {
+ printf(" %-28s %.2fx\n", $r['name'], $r['best_ms'] / $baseline);
+}
diff --git a/tests/BoldTest.php b/tests/BoldTest.php
new file mode 100644
index 0000000..a3d572a
--- /dev/null
+++ b/tests/BoldTest.php
@@ -0,0 +1,66 @@
+parser = new Parser(highlighter: null);
+ }
+
+ #[Test]
+ public function double_asterisk(): void
+ {
+ $this->assertSame(
+ 'bold
',
+ $this->parser->parse('**bold**')->html,
+ );
+ }
+
+ #[Test]
+ public function single_asterisk(): void
+ {
+ $this->assertSame(
+ 'bold
',
+ $this->parser->parse('*bold*')->html,
+ );
+ }
+
+ #[Test]
+ public function bold_containing_italic(): void
+ {
+ $this->assertSame(
+ 'hello world
',
+ $this->parser->parse('**hello __world__**')->html,
+ );
+ }
+
+ #[Test]
+ public function bold_containing_strikethrough(): void
+ {
+ $this->assertSame(
+ 'hello world
',
+ $this->parser->parse('**hello ~~world~~**')->html,
+ );
+ }
+
+ #[Test]
+ public function bold_containing_link(): void
+ {
+ $this->assertSame(
+ 'hello world
',
+ $this->parser->parse('**hello [world](#)**')->html,
+ );
+ }
+}
diff --git a/tests/CodeTest.php b/tests/CodeTest.php
new file mode 100644
index 0000000..3e45c57
--- /dev/null
+++ b/tests/CodeTest.php
@@ -0,0 +1,45 @@
+assertSame(
+ '$foo
',
+ $parser->parse('`$foo`')->html,
+ );
+ }
+
+ #[Test]
+ public function inline_code_with_explicit_language_emits_language_class(): void
+ {
+ // Without highlighter, no syntax highlighting is applied but the class is still set.
+ $parser = new Parser(highlighter: null);
+ $this->assertSame(
+ 'echo "hi";
',
+ $parser->parse('`{php}echo "hi";`')->html,
+ );
+ }
+
+ #[Test]
+ public function inline_code_without_highlighter_omits_language_class(): void
+ {
+ // Without highlighter and without explicit language, no class is emitted.
+ $parser = new Parser(highlighter: null);
+ $this->assertSame(
+ 'code
',
+ $parser->parse('`code`')->html,
+ );
+ }
+}
diff --git a/tests/DivTest.php b/tests/DivTest.php
new file mode 100644
index 0000000..332b3e0
--- /dev/null
+++ b/tests/DivTest.php
@@ -0,0 +1,84 @@
+parser = new Parser(highlighter: null);
+ }
+
+ #[Test]
+ public function without_class(): void
+ {
+ $this->assertSame(
+ "Hello\n",
+ $this->parser->parse(":::\nHello\n:::")->html,
+ );
+ }
+
+ #[Test]
+ public function with_class(): void
+ {
+ $this->assertSame(
+ "Hello\n",
+ $this->parser->parse(":::warning\nHello\n:::")->html,
+ );
+ }
+
+ #[Test]
+ public function with_multiple_classes(): void
+ {
+ $this->assertSame(
+ "Hello\n",
+ $this->parser->parse(":::foo bar\nHello\n:::")->html,
+ );
+ }
+
+ #[Test]
+ public function with_inline_bold(): void
+ {
+ $this->assertSame(
+ "Hello world\n",
+ $this->parser->parse(":::\nHello **world**\n:::")->html,
+ );
+ }
+
+ #[Test]
+ public function with_inline_italic(): void
+ {
+ $this->assertSame(
+ "Hello world\n",
+ $this->parser->parse(":::\nHello __world__\n:::")->html,
+ );
+ }
+
+ #[Test]
+ public function with_inline_link(): void
+ {
+ $this->assertSame(
+ "Hello world\n",
+ $this->parser->parse(":::\nHello [world](#)\n:::")->html,
+ );
+ }
+
+ #[Test]
+ public function multiline_content(): void
+ {
+ $this->assertSame(
+ "line one\nline two\n",
+ $this->parser->parse(":::warning\nline one\nline two\n:::")->html,
+ );
+ }
+}
diff --git a/tests/FrontMatterTest.php b/tests/FrontMatterTest.php
new file mode 100644
index 0000000..ce2a0bf
--- /dev/null
+++ b/tests/FrontMatterTest.php
@@ -0,0 +1,79 @@
+parser = new Parser(highlighter: null);
+ }
+
+ #[Test]
+ public function basic_yaml_extracted_and_html_omits_front_matter(): void
+ {
+ $input = <<<'MD'
+ ---
+ title: Hello
+ foo: bar
+ ---
+
+ Bar
+ MD;
+
+ $parsed = $this->parser->parse($input);
+
+ $this->assertSame('Bar
', $parsed->html);
+ $this->assertSame(['title' => 'Hello', 'foo' => 'bar'], $parsed->frontMatter);
+ }
+
+ #[Test]
+ public function longer_delimiter_lines(): void
+ {
+ $input = <<<'MD'
+ -----
+ title: Hello
+ foo: bar
+ -----
+
+ Bar
+ MD;
+
+ $parsed = $this->parser->parse($input);
+
+ $this->assertSame('Bar
', $parsed->html);
+ $this->assertSame(['title' => 'Hello', 'foo' => 'bar'], $parsed->frontMatter);
+ }
+
+ #[Test]
+ public function multiline_quoted_yaml_value(): void
+ {
+ $input = <<<'MD'
+ ---
+ title: Introduction
+ description: "Tempest is a framework for PHP development, designed to get out of your way.
+ Its core philosophy is to help you focus on your application code, without being bothered hand-holding the framework."
+ ---
+
+ Bar
+ MD;
+
+ $parsed = $this->parser->parse($input);
+
+ $this->assertSame('Bar
', $parsed->html);
+ $this->assertSame([
+ 'title' => 'Introduction',
+ 'description' => 'Tempest is a framework for PHP development, designed to get out of your way. Its core philosophy is to help you focus on your application code, without being bothered hand-holding the framework.',
+ ], $parsed->frontMatter);
+ }
+}
diff --git a/tests/HeadingTest.php b/tests/HeadingTest.php
new file mode 100644
index 0000000..27c95c6
--- /dev/null
+++ b/tests/HeadingTest.php
@@ -0,0 +1,76 @@
+parser = new Parser(highlighter: null);
+ }
+
+ #[Test]
+ public function h1(): void
+ {
+ $this->assertSame(
+ 'Hello
',
+ $this->parser->parse('# Hello')->html,
+ );
+ }
+
+ #[Test]
+ public function h2(): void
+ {
+ $this->assertSame(
+ 'Hello
',
+ $this->parser->parse('## Hello')->html,
+ );
+ }
+
+ #[Test]
+ public function h3(): void
+ {
+ $this->assertSame(
+ 'Hello
',
+ $this->parser->parse('### Hello')->html,
+ );
+ }
+
+ #[Test]
+ public function h6(): void
+ {
+ $this->assertSame(
+ 'Hello World
',
+ $this->parser->parse('###### Hello World')->html,
+ );
+ }
+
+ #[Test]
+ public function slug_lowercases_and_dashes_spaces(): void
+ {
+ $this->assertSame(
+ 'Hello World
',
+ $this->parser->parse('## Hello World')->html,
+ );
+ }
+
+ #[Test]
+ public function heading_content_is_emitted_raw_without_inline_parsing(): void
+ {
+ // HeadingToken did not apply inline parsing to its content; markers render literally.
+ $this->assertSame(
+ '**bold**
',
+ $this->parser->parse('# **bold**')->html,
+ );
+ }
+}
diff --git a/tests/HtmlTest.php b/tests/HtmlTest.php
new file mode 100644
index 0000000..ff64b93
--- /dev/null
+++ b/tests/HtmlTest.php
@@ -0,0 +1,66 @@
+parser = new Parser(highlighter: null);
+ }
+
+ #[Test]
+ public function html_at_block_start_passes_through(): void
+ {
+ $this->assertSame(
+ 'Hi
',
+ $this->parser->parse('Hi
')->html,
+ );
+ }
+
+ #[Test]
+ public function nested_same_name_tags_are_balanced(): void
+ {
+ $this->assertSame(
+ 'Hi',
+ $this->parser->parse('Hi')->html,
+ );
+ }
+
+ #[Test]
+ public function html_inside_paragraph_is_inlined(): void
+ {
+ // A `
`-style tag appearing mid-paragraph is consumed as paragraph content.
+ $this->assertSame(
+ 'paragraph with
break
',
+ $this->parser->parse('paragraph with
break')->html,
+ );
+ }
+
+ #[Test]
+ public function html_block_after_paragraph(): void
+ {
+ $input = <<<'MD'
+ Hello
+
+
+ Hi
+
+
+ World
+ MD;
+
+ $this->assertStringContainsString('Hello', $this->parser->parse($input)->html);
+ $this->assertStringContainsString("
\nHi\n
", $this->parser->parse($input)->html);
+ }
+}
diff --git a/tests/ImageTest.php b/tests/ImageTest.php
new file mode 100644
index 0000000..77bff8f
--- /dev/null
+++ b/tests/ImageTest.php
@@ -0,0 +1,49 @@
+parser = new Parser(highlighter: null);
+ }
+
+ #[Test]
+ public function image_with_alt(): void
+ {
+ $this->assertSame(
+ '
',
+ $this->parser->parse('')->html,
+ );
+ }
+
+ #[Test]
+ public function image_without_alt(): void
+ {
+ $this->assertSame(
+ '
',
+ $this->parser->parse('')->html,
+ );
+ }
+
+ #[Test]
+ public function bare_exclamation_passes_through(): void
+ {
+ // A `!` not followed by `[` is not an image trigger and must render as text.
+ $this->assertSame(
+ 'hello!
',
+ $this->parser->parse('hello!')->html,
+ );
+ }
+}
diff --git a/tests/ItalicTest.php b/tests/ItalicTest.php
new file mode 100644
index 0000000..9bb805d
--- /dev/null
+++ b/tests/ItalicTest.php
@@ -0,0 +1,66 @@
+parser = new Parser(highlighter: null);
+ }
+
+ #[Test]
+ public function double_underscore(): void
+ {
+ $this->assertSame(
+ 'italic
',
+ $this->parser->parse('__italic__')->html,
+ );
+ }
+
+ #[Test]
+ public function single_underscore(): void
+ {
+ $this->assertSame(
+ 'italic
',
+ $this->parser->parse('_italic_')->html,
+ );
+ }
+
+ #[Test]
+ public function italic_containing_bold(): void
+ {
+ $this->assertSame(
+ 'hello world
',
+ $this->parser->parse('__hello **world**__')->html,
+ );
+ }
+
+ #[Test]
+ public function italic_containing_strikethrough(): void
+ {
+ $this->assertSame(
+ 'hello world
',
+ $this->parser->parse('__hello ~~world~~__')->html,
+ );
+ }
+
+ #[Test]
+ public function italic_containing_link(): void
+ {
+ $this->assertSame(
+ 'hello world
',
+ $this->parser->parse('__hello [world](#)__')->html,
+ );
+ }
+}
diff --git a/tests/LexerRules/BoldRuleTest.php b/tests/LexerRules/BoldRuleTest.php
deleted file mode 100644
index 835b25b..0000000
--- a/tests/LexerRules/BoldRuleTest.php
+++ /dev/null
@@ -1,28 +0,0 @@
-lex('**bold**')[0];
-
- $this->assertEquals(new BoldToken('bold'), $token);
- }
-
- #[Test]
- public function test_lex_single_asterisk(): void
- {
- $token = new Lexer([new BoldRule()])->lex('*bold*')[0];
-
- $this->assertEquals(new BoldToken('bold'), $token);
- }
-}
diff --git a/tests/LexerRules/CodeRuleTest.php b/tests/LexerRules/CodeRuleTest.php
deleted file mode 100644
index ea72afe..0000000
--- a/tests/LexerRules/CodeRuleTest.php
+++ /dev/null
@@ -1,28 +0,0 @@
-lex('`code`')[0];
-
- $this->assertEquals(new CodeToken(null, 'code'), $token);
- }
-
- #[Test]
- public function test_lex_with_language(): void
- {
- $token = new Lexer([new CodeRule()])->lex('`{php}code`')[0];
-
- $this->assertEquals(new CodeToken('php', 'code'), $token);
- }
-}
diff --git a/tests/LexerRules/DivRuleTest.php b/tests/LexerRules/DivRuleTest.php
deleted file mode 100644
index e7042ca..0000000
--- a/tests/LexerRules/DivRuleTest.php
+++ /dev/null
@@ -1,44 +0,0 @@
-lex(":::\nHello\n:::\n")[0];
-
- $this->assertEquals(new DivToken(class: null, content: "Hello\n"), $token);
- }
-
- #[Test]
- public function test_lex_with_class(): void
- {
- $token = new Lexer([new DivRule()])->lex(":::warning\nHello\n:::\n")[0];
-
- $this->assertEquals(new DivToken(class: 'warning', content: "Hello\n"), $token);
- }
-
- #[Test]
- public function test_lex_with_multiple_classes(): void
- {
- $token = new Lexer([new DivRule()])->lex(":::foo bar\nHello\n:::\n")[0];
-
- $this->assertEquals(new DivToken(class: 'foo bar', content: "Hello\n"), $token);
- }
-
- #[Test]
- public function test_lex_multiline_content(): void
- {
- $token = new Lexer([new DivRule()])->lex(":::warning\nline one\nline two\n:::\n")[0];
-
- $this->assertEquals(new DivToken(class: 'warning', content: "line one\nline two\n"), $token);
- }
-}
diff --git a/tests/LexerRules/FrontMatterRuleTest.php b/tests/LexerRules/FrontMatterRuleTest.php
deleted file mode 100644
index 4284046..0000000
--- a/tests/LexerRules/FrontMatterRuleTest.php
+++ /dev/null
@@ -1,73 +0,0 @@
-lex(<<<'MD'
- ---
- title: Hello
- foo: bar
- ---
-
- Bar
- MD);
-
- $this->assertCount(2, $tokens);
- $this->assertEquals(new FrontMatterToken(['title' => 'Hello', 'foo' => 'bar']), $tokens[0]);
- $this->assertEquals(new ParagraphToken('Bar'), $tokens[1]);
- }
-
- #[Test]
- public function test_lex_with_longer_frontmatter_lines(): void
- {
- $tokens = new Lexer([new FrontMatterRule(), new NewLineRule(), new ParagraphRule()])->lex(<<<'MD'
- -----
- title: Hello
- foo: bar
- -----
-
- Bar
- MD);
-
- $this->assertCount(2, $tokens);
- $this->assertEquals(new FrontMatterToken(['title' => 'Hello', 'foo' => 'bar']), $tokens[0]);
- $this->assertEquals(new ParagraphToken('Bar'), $tokens[1]);
- }
-
- #[Test]
- public function test_complex_frontmatter(): void
- {
- $tokens = new Lexer([new FrontMatterRule(), new NewLineRule(), new ParagraphRule()])->lex(<<<'MD'
- ---
- title: Introduction
- description: "Tempest is a framework for PHP development, designed to get out of your way.
- Its core philosophy is to help you focus on your application code, without being bothered hand-holding the framework."
- ---
-
- Bar
- MD);
-
- $this->assertCount(2, $tokens);
- $this->assertEquals(
- new FrontMatterToken([
- 'title' => 'Introduction',
- 'description' => 'Tempest is a framework for PHP development, designed to get out of your way. Its core philosophy is to help you focus on your application code, without being bothered hand-holding the framework.',
- ]),
- $tokens[0],
- );
- $this->assertEquals(new ParagraphToken('Bar'), $tokens[1]);
- }
-}
diff --git a/tests/LexerRules/HeadingRuleTest.php b/tests/LexerRules/HeadingRuleTest.php
deleted file mode 100644
index 276bc78..0000000
--- a/tests/LexerRules/HeadingRuleTest.php
+++ /dev/null
@@ -1,28 +0,0 @@
-lex('# Hello')[0];
-
- $this->assertEquals(new HeadingToken('Hello', 1), $token);
- }
-
- #[Test]
- public function test_lex_deep_heading(): void
- {
- $token = new Lexer([new HeadingRule()])->lex('### Hello')[0];
-
- $this->assertEquals(new HeadingToken('Hello', 3), $token);
- }
-}
diff --git a/tests/LexerRules/HtmlRuleTest.php b/tests/LexerRules/HtmlRuleTest.php
deleted file mode 100644
index f686e46..0000000
--- a/tests/LexerRules/HtmlRuleTest.php
+++ /dev/null
@@ -1,47 +0,0 @@
-lex('Hi
')[0];
-
- $this->assertEquals(new HtmlToken('Hi
'), $token);
- }
-
- #[Test]
- public function test_lex_nested(): void
- {
- $token = new Lexer([new HtmlRule()])->lex('Hi')[0];
-
- $this->assertEquals(new HtmlToken('Hi'), $token);
- }
-
- #[Test]
- public function test_lex_multiline(): void
- {
- $html = <<<'HTML'
- Hello
-
- Hi
-
- World
- HTML;
-
- $tokens = new Lexer([new NewLineRule(), new HtmlRule(), new ParagraphRule()])->lex($html);
-
- $this->assertCount(3, $tokens);
- $this->assertEquals(new HtmlToken("\nHi\n
\n"), $tokens[1]);
- }
-}
diff --git a/tests/LexerRules/ImageRuleTest.php b/tests/LexerRules/ImageRuleTest.php
deleted file mode 100644
index 8c7efe3..0000000
--- a/tests/LexerRules/ImageRuleTest.php
+++ /dev/null
@@ -1,28 +0,0 @@
-lex('')[0];
-
- $this->assertEquals(new ImageToken('href', 'alt'), $token);
- }
-
- #[Test]
- public function test_lex_without_alt(): void
- {
- $token = new Lexer([new ImageRule()])->lex('')[0];
-
- $this->assertEquals(new ImageToken('href', null), $token);
- }
-}
diff --git a/tests/LexerRules/ItalicRuleTest.php b/tests/LexerRules/ItalicRuleTest.php
deleted file mode 100644
index a5fc09d..0000000
--- a/tests/LexerRules/ItalicRuleTest.php
+++ /dev/null
@@ -1,28 +0,0 @@
-lex('__italic__')[0];
-
- $this->assertEquals(new ItalicToken('italic'), $token);
- }
-
- #[Test]
- public function test_lex_single_underscore(): void
- {
- $token = new Lexer([new ItalicRule()])->lex('_italic_')[0];
-
- $this->assertEquals(new ItalicToken('italic'), $token);
- }
-}
diff --git a/tests/LexerRules/LinkRuleTest.php b/tests/LexerRules/LinkRuleTest.php
deleted file mode 100644
index aba6c8e..0000000
--- a/tests/LexerRules/LinkRuleTest.php
+++ /dev/null
@@ -1,28 +0,0 @@
-lex('[click here](#)')[0];
-
- $this->assertEquals(new LinkToken('click here', '#'), $token);
- }
-
- #[Test]
- public function test_lex_without_href(): void
- {
- $token = new Lexer([new LinkRule()])->lex('[click here]')[0];
-
- $this->assertEquals(new LinkToken('click here', null), $token);
- }
-}
diff --git a/tests/LexerRules/ListRuleTest.php b/tests/LexerRules/ListRuleTest.php
deleted file mode 100644
index a34fb3c..0000000
--- a/tests/LexerRules/ListRuleTest.php
+++ /dev/null
@@ -1,73 +0,0 @@
-lex("- item\n")[0];
-
- $this->assertEquals(new ListToken([new ListItem('item')]), $token);
- }
-
- #[Test]
- public function test_lex_multiple_items(): void
- {
- $token = new Lexer([new ListRule()])->lex("- one\n- two\n")[0];
-
- $this->assertEquals(new ListToken([new ListItem('one'), new ListItem('two')]), $token);
- }
-
- #[Test]
- public function test_lex_nested(): void
- {
- $token = new Lexer([new ListRule()])->lex("- parent\n - child\n")[0];
-
- $expected = new ListToken([
- new ListItem('parent', new ListToken([
- new ListItem('child'),
- ])),
- ]);
-
- $this->assertEquals($expected, $token);
- }
-
- #[Test]
- public function test_lex_nested_multiple_children(): void
- {
- $token = new Lexer([new ListRule()])->lex("- parent\n - child one\n - child two\n")[0];
-
- $expected = new ListToken([
- new ListItem('parent', new ListToken([
- new ListItem('child one'),
- new ListItem('child two'),
- ])),
- ]);
-
- $this->assertEquals($expected, $token);
- }
-
- #[Test]
- public function test_lex_nested_sibling_after_sublist(): void
- {
- $token = new Lexer([new ListRule()])->lex("- one\n - child\n- two\n")[0];
-
- $expected = new ListToken([
- new ListItem('one', new ListToken([
- new ListItem('child'),
- ])),
- new ListItem('two'),
- ]);
-
- $this->assertEquals($expected, $token);
- }
-}
diff --git a/tests/LexerRules/NewLineRuleTest.php b/tests/LexerRules/NewLineRuleTest.php
deleted file mode 100644
index ba1f1cf..0000000
--- a/tests/LexerRules/NewLineRuleTest.php
+++ /dev/null
@@ -1,28 +0,0 @@
-lex("\n")[0];
-
- $this->assertEquals(new NewLineToken("\n"), $token);
- }
-
- #[Test]
- public function test_lex_multiple_newlines(): void
- {
- $token = new Lexer([new NewLineRule()])->lex("\n\n\n")[0];
-
- $this->assertEquals(new NewLineToken("\n\n\n"), $token);
- }
-}
diff --git a/tests/LexerRules/OrderedListRuleTest.php b/tests/LexerRules/OrderedListRuleTest.php
deleted file mode 100644
index 5877ff1..0000000
--- a/tests/LexerRules/OrderedListRuleTest.php
+++ /dev/null
@@ -1,81 +0,0 @@
-lex("1. item\n")[0];
-
- $this->assertEquals(new OrderedListToken([new ListItem('item')]), $token);
- }
-
- #[Test]
- public function test_lex_multiple_items(): void
- {
- $token = new Lexer([new OrderedListRule()])->lex("1. one\n2. two\n")[0];
-
- $this->assertEquals(new OrderedListToken([new ListItem('one'), new ListItem('two')]), $token);
- }
-
- #[Test]
- public function test_lex_multi_digit_numbers(): void
- {
- $token = new Lexer([new OrderedListRule()])->lex("10. ten\n11. eleven\n")[0];
-
- $this->assertEquals(new OrderedListToken([new ListItem('ten'), new ListItem('eleven')]), $token);
- }
-
- #[Test]
- public function test_lex_nested(): void
- {
- $token = new Lexer([new OrderedListRule()])->lex("1. parent\n 1. child\n")[0];
-
- $expected = new OrderedListToken([
- new ListItem('parent', new OrderedListToken([
- new ListItem('child'),
- ])),
- ]);
-
- $this->assertEquals($expected, $token);
- }
-
- #[Test]
- public function test_lex_nested_multiple_children(): void
- {
- $token = new Lexer([new OrderedListRule()])->lex("1. parent\n 1. child one\n 2. child two\n")[0];
-
- $expected = new OrderedListToken([
- new ListItem('parent', new OrderedListToken([
- new ListItem('child one'),
- new ListItem('child two'),
- ])),
- ]);
-
- $this->assertEquals($expected, $token);
- }
-
- #[Test]
- public function test_lex_nested_sibling_after_sublist(): void
- {
- $token = new Lexer([new OrderedListRule()])->lex("1. one\n 1. child\n2. two\n")[0];
-
- $expected = new OrderedListToken([
- new ListItem('one', new OrderedListToken([
- new ListItem('child'),
- ])),
- new ListItem('two'),
- ]);
-
- $this->assertEquals($expected, $token);
- }
-}
diff --git a/tests/LexerRules/ParagraphRuleTest.php b/tests/LexerRules/ParagraphRuleTest.php
deleted file mode 100644
index 9be7727..0000000
--- a/tests/LexerRules/ParagraphRuleTest.php
+++ /dev/null
@@ -1,20 +0,0 @@
-lex("Hello, world!\n")[0];
-
- $this->assertEquals(new ParagraphToken("Hello, world!\n"), $token);
- }
-}
diff --git a/tests/LexerRules/PreRuleTest.php b/tests/LexerRules/PreRuleTest.php
deleted file mode 100644
index 8aa7925..0000000
--- a/tests/LexerRules/PreRuleTest.php
+++ /dev/null
@@ -1,48 +0,0 @@
-lex(<<<'MD'
- ```php
- echo "hi";
- ```
- MD)[0];
-
- $this->assertEquals(new PreToken(language: 'php', content: 'echo "hi";'), $token);
- }
-
- #[Test]
- public function test_lex_without_language(): void
- {
- $token = new Lexer([new PreRule()])->lex(<<<'MD'
- ```
- echo "hi";
- ```
- MD)[0];
-
- $this->assertEquals(new PreToken(language: null, content: 'echo "hi";'), $token);
- }
-
- #[Test]
- public function test_lex_with_backtick_in_content(): void
- {
- $token = new Lexer([new PreRule()])->lex(<<<'MD'
- ```php
- echo `uname`;
- ```
- MD)[0];
-
- $this->assertEquals(new PreToken(language: 'php', content: 'echo `uname`;'), $token);
- }
-}
diff --git a/tests/LexerRules/QuoteRuleTest.php b/tests/LexerRules/QuoteRuleTest.php
deleted file mode 100644
index d4e82fb..0000000
--- a/tests/LexerRules/QuoteRuleTest.php
+++ /dev/null
@@ -1,38 +0,0 @@
-lex('> quote')[0];
-
- $this->assertSame('quote', $token->content);
- }
-
- #[Test]
- public function test_lex_multiline(): void
- {
- /** @var QuoteToken $token */
- $token = new Lexer([new QuoteRule()])->lex(<<<'MD'
- > line 1
- > > line 2
- > line 3
- MD)[0];
-
- $this->assertSame(<<<'TXT'
- line 1
- > line 2
- line 3
- TXT, $token->content);
- }
-}
diff --git a/tests/LexerRules/StrikethroughRuleTest.php b/tests/LexerRules/StrikethroughRuleTest.php
deleted file mode 100644
index 82ebc3f..0000000
--- a/tests/LexerRules/StrikethroughRuleTest.php
+++ /dev/null
@@ -1,28 +0,0 @@
-lex('~~strikethrough~~')[0];
-
- $this->assertEquals(new StrikethroughToken('strikethrough'), $token);
- }
-
- #[Test]
- public function test_lex_single_tilde(): void
- {
- $token = new Lexer([new StrikethroughRule()])->lex('~strikethrough~')[0];
-
- $this->assertEquals(new StrikethroughToken('strikethrough'), $token);
- }
-}
diff --git a/tests/LexerRules/TableRuleTest.php b/tests/LexerRules/TableRuleTest.php
deleted file mode 100644
index 62c9655..0000000
--- a/tests/LexerRules/TableRuleTest.php
+++ /dev/null
@@ -1,69 +0,0 @@
-lex("| A | B |\n| --- | --- |")[0];
-
- $this->assertEquals(
- new TableToken([
- new TableRow(['A', 'B'], isHeader: true),
- ]),
- $token,
- );
- }
-
- #[Test]
- public function test_lex_full_table(): void
- {
- $token = new Lexer([new TableRule()])->lex("| A | B |\n| --- | --- |\n| 1 | 2 |")[0];
-
- $this->assertEquals(
- new TableToken([
- new TableRow(['A', 'B'], isHeader: true),
- new TableRow(['1', '2'], isHeader: false),
- ]),
- $token,
- );
- }
-
- #[Test]
- public function test_lex_multiple_data_rows(): void
- {
- $token = new Lexer([new TableRule()])->lex("| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |")[0];
-
- $this->assertEquals(
- new TableToken([
- new TableRow(['A', 'B'], isHeader: true),
- new TableRow(['1', '2'], isHeader: false),
- new TableRow(['3', '4'], isHeader: false),
- ]),
- $token,
- );
- }
-
- #[Test]
- public function test_lex_separator_with_alignment(): void
- {
- $token = new Lexer([new TableRule()])->lex("| A | B | C |\n| :--- | :---: | ---: |\n| 1 | 2 | 3 |")[0];
-
- $this->assertEquals(
- new TableToken([
- new TableRow(['A', 'B', 'C'], isHeader: true),
- new TableRow(['1', '2', '3'], isHeader: false),
- ]),
- $token,
- );
- }
-}
diff --git a/tests/LexerRules/TextRuleTest.php b/tests/LexerRules/TextRuleTest.php
deleted file mode 100644
index 23dcda0..0000000
--- a/tests/LexerRules/TextRuleTest.php
+++ /dev/null
@@ -1,32 +0,0 @@
-lex('hello')[0];
-
- $this->assertEquals(new TextToken('hello'), $token);
- }
-
- #[Test]
- public function test_lex_appends_to_previous_text_token(): void
- {
- $tokens = new Lexer([new BoldRule(), new TextRule()])->lex('Hello **world**!');
-
- $this->assertEquals(new TextToken('Hello '), $tokens[0]);
- $this->assertEquals(new BoldToken('world'), $tokens[1]);
- $this->assertEquals(new TextToken('!'), $tokens[2]);
- }
-}
diff --git a/tests/LexerRules/ThickRulerRuleTest.php b/tests/LexerRules/ThickRulerRuleTest.php
deleted file mode 100644
index 3f4ef7e..0000000
--- a/tests/LexerRules/ThickRulerRuleTest.php
+++ /dev/null
@@ -1,29 +0,0 @@
-lex('===')[0];
-
- $this->assertEquals(new RulerToken('===', RulerType::THICK), $token);
- }
-
- #[Test]
- public function test_lex_long(): void
- {
- $token = new Lexer([new ThickRulerRule()])->lex('=====')[0];
-
- $this->assertEquals(new RulerToken('=====', RulerType::THICK), $token);
- }
-}
diff --git a/tests/LexerRules/ThinRulerRuleTest.php b/tests/LexerRules/ThinRulerRuleTest.php
deleted file mode 100644
index 043850e..0000000
--- a/tests/LexerRules/ThinRulerRuleTest.php
+++ /dev/null
@@ -1,29 +0,0 @@
-lex('---')[0];
-
- $this->assertEquals(new RulerToken('---', RulerType::THIN), $token);
- }
-
- #[Test]
- public function test_lex_long(): void
- {
- $token = new Lexer([new ThinRulerRule()])->lex('-----')[0];
-
- $this->assertEquals(new RulerToken('-----', RulerType::THIN), $token);
- }
-}
diff --git a/tests/LexerTest.php b/tests/LexerTest.php
deleted file mode 100644
index 483e78d..0000000
--- a/tests/LexerTest.php
+++ /dev/null
@@ -1,57 +0,0 @@
-lexer = new Lexer();
- }
-
- #[Test]
- public function test_lex_snippet(): void
- {
- $tokens = $this->lexer->lex(<<<'MD'
- # Test
- Hello **world**
- MD);
-
- $this->assertTokens(
- expected: [
- new HeadingToken('Test', 1),
- new NewLineToken("\n"),
- new ParagraphToken('Hello **world**'),
- ],
- actual: $tokens,
- );
- }
-
- private function assertTokens(array $expected, TokenCollection $actual): void
- {
- $this->assertCount(count($expected), $actual);
-
- foreach ($actual as $i => $token) {
- /** @var Token $expected */
- $expectedToken = $expected[$i];
- $actualProperties = (array) $token;
- $expectedProperties = (array) $expectedToken;
-
- $this->assertSame($token::class, $expectedToken::class);
- $this->assertSame($expectedProperties, $actualProperties);
- }
- }
-}
diff --git a/tests/LinkTest.php b/tests/LinkTest.php
new file mode 100644
index 0000000..c1caa1a
--- /dev/null
+++ b/tests/LinkTest.php
@@ -0,0 +1,75 @@
+parser = new Parser(highlighter: null);
+ }
+
+ #[Test]
+ public function link_with_href(): void
+ {
+ $this->assertSame(
+ '',
+ $this->parser->parse('[click here](#)')->html,
+ );
+ }
+
+ #[Test]
+ public function link_without_href(): void
+ {
+ $this->assertSame(
+ '',
+ $this->parser->parse('[click here]')->html,
+ );
+ }
+
+ #[Test]
+ public function link_containing_bold(): void
+ {
+ $this->assertSame(
+ '',
+ $this->parser->parse('[click **here**](#)')->html,
+ );
+ }
+
+ #[Test]
+ public function link_containing_italic(): void
+ {
+ $this->assertSame(
+ '',
+ $this->parser->parse('[click __here__](#)')->html,
+ );
+ }
+
+ #[Test]
+ public function link_containing_strikethrough(): void
+ {
+ $this->assertSame(
+ '',
+ $this->parser->parse('[click ~~here~~](#)')->html,
+ );
+ }
+
+ #[Test]
+ public function asterisk_prefix_opens_new_tab(): void
+ {
+ $this->assertSame(
+ '',
+ $this->parser->parse('[click here](*https://tempestphp.com)')->html,
+ );
+ }
+}
diff --git a/tests/ListTest.php b/tests/ListTest.php
new file mode 100644
index 0000000..33c96dd
--- /dev/null
+++ b/tests/ListTest.php
@@ -0,0 +1,140 @@
+parser = new Parser(highlighter: null);
+ }
+
+ #[Test]
+ public function single_item(): void
+ {
+ $this->assertSame(
+ '- item
',
+ $this->parser->parse('- item')->html,
+ );
+ }
+
+ #[Test]
+ public function multiple_items(): void
+ {
+ $this->assertSame(
+ '- one
- two
- three
',
+ $this->parser->parse("- one\n- two\n- three")->html,
+ );
+ }
+
+ #[Test]
+ public function item_with_bold(): void
+ {
+ $this->assertSame(
+ '- hello world
',
+ $this->parser->parse('- hello **world**')->html,
+ );
+ }
+
+ #[Test]
+ public function item_with_italic(): void
+ {
+ $this->assertSame(
+ '- hello world
',
+ $this->parser->parse('- hello __world__')->html,
+ );
+ }
+
+ #[Test]
+ public function item_with_link(): void
+ {
+ $this->assertSame(
+ '',
+ $this->parser->parse('- [world](#)')->html,
+ );
+ }
+
+ #[Test]
+ public function item_with_inline_code(): void
+ {
+ $this->assertSame(
+ '- run
php tempest
',
+ $this->parser->parse('- run `php tempest`')->html,
+ );
+ }
+
+ #[Test]
+ public function nested_single_child(): void
+ {
+ $this->assertSame(
+ '- parent
- child
',
+ $this->parser->parse("- parent\n - child")->html,
+ );
+ }
+
+ #[Test]
+ public function nested_multiple_children(): void
+ {
+ $this->assertSame(
+ '- parent
- child one
- child two
',
+ $this->parser->parse("- parent\n - child one\n - child two")->html,
+ );
+ }
+
+ #[Test]
+ public function nested_sibling_after_sublist(): void
+ {
+ $this->assertSame(
+ '- one
- child
- two
',
+ $this->parser->parse("- one\n - child\n- two")->html,
+ );
+ }
+
+ #[Test]
+ public function three_levels_of_nesting(): void
+ {
+ $this->assertSame(
+ '- a
- b
- c
',
+ $this->parser->parse("- a\n - b\n - c")->html,
+ );
+ }
+
+ #[Test]
+ public function bare_dash_followed_by_digit_is_paragraph_not_list(): void
+ {
+ // Bug fix vs. the original ListRule, which triggered on any `-`.
+ $this->assertSame(
+ '-2 is negative two
',
+ $this->parser->parse('-2 is negative two')->html,
+ );
+ }
+
+ #[Test]
+ public function dash_with_no_space_is_paragraph(): void
+ {
+ $this->assertSame(
+ '-foo
',
+ $this->parser->parse('-foo')->html,
+ );
+ }
+
+ #[Test]
+ public function thin_ruler_still_works(): void
+ {
+ // Three dashes must remain a horizontal rule, not a list of '--'.
+ $this->assertSame(
+ "x\n
",
+ $this->parser->parse("x\n---")->html,
+ );
+ }
+}
diff --git a/tests/OrderedListTest.php b/tests/OrderedListTest.php
new file mode 100644
index 0000000..177ee5a
--- /dev/null
+++ b/tests/OrderedListTest.php
@@ -0,0 +1,112 @@
+parser = new Parser(highlighter: null);
+ }
+
+ #[Test]
+ public function single_item(): void
+ {
+ $this->assertSame(
+ '- item
',
+ $this->parser->parse('1. item')->html,
+ );
+ }
+
+ #[Test]
+ public function multiple_items(): void
+ {
+ $this->assertSame(
+ '- one
- two
- three
',
+ $this->parser->parse("1. one\n2. two\n3. three")->html,
+ );
+ }
+
+ #[Test]
+ public function multi_digit_marker(): void
+ {
+ $this->assertSame(
+ '- tenth
',
+ $this->parser->parse('10. tenth')->html,
+ );
+ }
+
+ #[Test]
+ public function item_with_bold(): void
+ {
+ $this->assertSame(
+ '- hello world
',
+ $this->parser->parse('1. hello **world**')->html,
+ );
+ }
+
+ #[Test]
+ public function item_with_link(): void
+ {
+ $this->assertSame(
+ '',
+ $this->parser->parse('1. [world](#)')->html,
+ );
+ }
+
+ #[Test]
+ public function nested(): void
+ {
+ $this->assertSame(
+ '- parent
- child
',
+ $this->parser->parse("1. parent\n 2. child")->html,
+ );
+ }
+
+ #[Test]
+ public function nested_sibling_after_sublist(): void
+ {
+ $this->assertSame(
+ '- one
- child
- two
',
+ $this->parser->parse("1. one\n 2. child\n3. two")->html,
+ );
+ }
+
+ #[Test]
+ public function decimal_number_is_paragraph_not_list(): void
+ {
+ // Bug fix vs. the original OrderedListRule, which triggered on any digit.
+ $this->assertSame(
+ '1.5 is a number
',
+ $this->parser->parse('1.5 is a number')->html,
+ );
+ }
+
+ #[Test]
+ public function digit_followed_by_dot_no_space_is_paragraph(): void
+ {
+ $this->assertSame(
+ '1.foo
',
+ $this->parser->parse('1.foo')->html,
+ );
+ }
+
+ #[Test]
+ public function digit_with_no_dot_is_paragraph(): void
+ {
+ $this->assertSame(
+ '1 item
',
+ $this->parser->parse('1 item')->html,
+ );
+ }
+}
diff --git a/tests/ParagraphTest.php b/tests/ParagraphTest.php
new file mode 100644
index 0000000..6827519
--- /dev/null
+++ b/tests/ParagraphTest.php
@@ -0,0 +1,86 @@
+parser = new Parser(highlighter: null);
+ }
+
+ #[Test]
+ public function plain_text(): void
+ {
+ $this->assertSame(
+ 'Hello, world!
',
+ $this->parser->parse('Hello, world!')->html,
+ );
+ }
+
+ #[Test]
+ public function paragraph_with_bold(): void
+ {
+ $this->assertSame(
+ 'Hello, world!
',
+ $this->parser->parse('Hello, **world**!')->html,
+ );
+ }
+
+ #[Test]
+ public function paragraph_with_italic(): void
+ {
+ $this->assertSame(
+ 'Hello, world!
',
+ $this->parser->parse('Hello, __world__!')->html,
+ );
+ }
+
+ #[Test]
+ public function paragraph_with_link(): void
+ {
+ $this->assertSame(
+ 'Hello, world!
',
+ $this->parser->parse('Hello, [world](#)!')->html,
+ );
+ }
+
+ #[Test]
+ public function paragraph_with_image(): void
+ {
+ $this->assertSame(
+ 'Hello,
!
',
+ $this->parser->parse('Hello, !')->html,
+ );
+ }
+
+ #[Test]
+ public function paragraph_with_inline_code(): void
+ {
+ $this->assertSame(
+ 'Hello, world!
',
+ $this->parser->parse('Hello, `world`!')->html,
+ );
+ }
+
+ #[Test]
+ public function paragraph_swallows_trailing_blank_lines(): void
+ {
+ // ParagraphRule consumes trailing newlines into the paragraph content,
+ // so `` includes the literal blank lines.
+ $this->assertSame(
+ "
Hello\n\n
World
",
+ $this->parser->parse("Hello\n\nWorld")->html,
+ );
+ }
+}
diff --git a/tests/PreTest.php b/tests/PreTest.php
new file mode 100644
index 0000000..a00a86b
--- /dev/null
+++ b/tests/PreTest.php
@@ -0,0 +1,47 @@
+assertSame(
+ 'echo "hi";
',
+ $parser->parse($input)->html,
+ );
+ }
+
+ #[Test]
+ public function fenced_code_without_language_no_highlighter(): void
+ {
+ // Without highlighter and without explicit language, no class is emitted.
+ $parser = new Parser(highlighter: null);
+ $input = "```\necho \"hi\";\n```";
+ $this->assertSame(
+ 'echo "hi";
',
+ $parser->parse($input)->html,
+ );
+ }
+
+ #[Test]
+ public function fenced_code_without_language_uses_txt_fallback_with_default_highlighter(): void
+ {
+ // Default highlighter's fallback is "txt"; non-php content passes through escaped by the highlighter.
+ $parser = new Parser();
+ $input = "```\necho \"hi\";\n```";
+ $this->assertSame(
+ 'echo "hi";
',
+ $parser->parse($input)->html,
+ );
+ }
+}
diff --git a/tests/QuoteTest.php b/tests/QuoteTest.php
new file mode 100644
index 0000000..6eebeb4
--- /dev/null
+++ b/tests/QuoteTest.php
@@ -0,0 +1,87 @@
+parser = new Parser(highlighter: null);
+ }
+
+ #[Test]
+ public function single_line(): void
+ {
+ $this->assertSame(
+ 'Hello
',
+ $this->parser->parse('> Hello')->html,
+ );
+ }
+
+ #[Test]
+ public function quote_with_bold(): void
+ {
+ $this->assertSame(
+ 'Hello world
',
+ $this->parser->parse('> Hello **world**')->html,
+ );
+ }
+
+ #[Test]
+ public function quote_with_italic(): void
+ {
+ $this->assertSame(
+ 'Hello world
',
+ $this->parser->parse('> Hello __world__')->html,
+ );
+ }
+
+ #[Test]
+ public function quote_with_link(): void
+ {
+ $this->assertSame(
+ 'Hello world
',
+ $this->parser->parse('> Hello [world](#)')->html,
+ );
+ }
+
+ #[Test]
+ public function quote_with_image(): void
+ {
+ $this->assertSame(
+ 'Hello 
',
+ $this->parser->parse('> Hello ')->html,
+ );
+ }
+
+ #[Test]
+ public function nested_levels(): void
+ {
+ $input = <<<'MD'
+ > One
+ > > Two
+ > > > Three
+ > >>> Four
+ > > Two again
+ MD;
+
+ $expected = <<<'HTML'
+ One
+ Two
+ Three
+ Four
Two again
+ HTML;
+
+ $this->assertSame($expected, $this->parser->parse($input)->html);
+ }
+}
diff --git a/tests/RulerTest.php b/tests/RulerTest.php
new file mode 100644
index 0000000..b8dc66e
--- /dev/null
+++ b/tests/RulerTest.php
@@ -0,0 +1,58 @@
+parser = new Parser(highlighter: null);
+ }
+
+ #[Test]
+ public function thin_ruler_three_dashes(): void
+ {
+ // `---` at byte 0 is front matter; we put a paragraph above so it's a ruler.
+ $this->assertSame(
+ "x\n
",
+ $this->parser->parse("x\n---")->html,
+ );
+ }
+
+ #[Test]
+ public function thin_ruler_long(): void
+ {
+ $this->assertSame(
+ "x\n
",
+ $this->parser->parse("x\n-----")->html,
+ );
+ }
+
+ #[Test]
+ public function thick_ruler_three_equals(): void
+ {
+ $this->assertSame(
+ '
',
+ $this->parser->parse('===')->html,
+ );
+ }
+
+ #[Test]
+ public function thick_ruler_long(): void
+ {
+ $this->assertSame(
+ '
',
+ $this->parser->parse('=====')->html,
+ );
+ }
+}
diff --git a/tests/StrikethroughTest.php b/tests/StrikethroughTest.php
new file mode 100644
index 0000000..27ad95c
--- /dev/null
+++ b/tests/StrikethroughTest.php
@@ -0,0 +1,90 @@
+parser = new Parser(highlighter: null);
+ }
+
+ #[Test]
+ public function strike_inside_bold_double_tilde(): void
+ {
+ $this->assertSame(
+ 'deleted
',
+ $this->parser->parse('**~~deleted~~**')->html,
+ );
+ }
+
+ #[Test]
+ public function strike_inside_bold_single_tilde(): void
+ {
+ $this->assertSame(
+ 'deleted
',
+ $this->parser->parse('**~deleted~**')->html,
+ );
+ }
+
+ #[Test]
+ public function strike_inside_italic(): void
+ {
+ $this->assertSame(
+ 'deleted
',
+ $this->parser->parse('__~~deleted~~__')->html,
+ );
+ }
+
+ #[Test]
+ public function strike_inside_link(): void
+ {
+ $this->assertSame(
+ '',
+ $this->parser->parse('[~~deleted~~](#)')->html,
+ );
+ }
+
+ #[Test]
+ public function strike_containing_italic(): void
+ {
+ $this->assertSame(
+ 'hello world
',
+ $this->parser->parse('**~~hello __world__~~**')->html,
+ );
+ }
+
+ #[Test]
+ public function strike_containing_link(): void
+ {
+ $this->assertSame(
+ 'hello world
',
+ $this->parser->parse('**~~hello [world](#)~~**')->html,
+ );
+ }
+
+ #[Test]
+ public function tildes_in_plain_paragraph_pass_through(): void
+ {
+ // No strike at top-level paragraph: tildes render as literal text.
+ $this->assertSame(
+ '~~plain~~
',
+ $this->parser->parse('~~plain~~')->html,
+ );
+ }
+}
diff --git a/tests/TableTest.php b/tests/TableTest.php
new file mode 100644
index 0000000..289f041
--- /dev/null
+++ b/tests/TableTest.php
@@ -0,0 +1,68 @@
+parser = new Parser(highlighter: null);
+ }
+
+ #[Test]
+ public function header_only(): void
+ {
+ $this->assertSame(
+ 'A B
',
+ $this->parser->parse("| A | B |\n| --- | --- |")->html,
+ );
+ }
+
+ #[Test]
+ public function full_table(): void
+ {
+ $this->assertSame(
+ 'A B 1 2
',
+ $this->parser->parse("| A | B |\n| --- | --- |\n| 1 | 2 |")->html,
+ );
+ }
+
+ #[Test]
+ public function multiple_data_rows(): void
+ {
+ $this->assertSame(
+ 'A B 1 2 3 4
',
+ $this->parser->parse("| A | B |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |")->html,
+ );
+ }
+
+ #[Test]
+ public function separator_with_alignment_is_filtered(): void
+ {
+ // Separator rows with leading/trailing `:` (alignment markers) are still detected
+ // and not emitted as data rows.
+ $this->assertSame(
+ 'A B C 1 2 3
',
+ $this->parser->parse("| A | B | C |\n| :--- | :---: | ---: |\n| 1 | 2 | 3 |")->html,
+ );
+ }
+
+ #[Test]
+ public function inline_formatting_in_cells(): void
+ {
+ $this->assertSame(
+ 'Name Notes Alice code
',
+ $this->parser->parse("| Name | Notes |\n| --- | --- |\n| **Alice** | `code` |")->html,
+ );
+ }
+}
diff --git a/tests/Tokens/BoldTokenTest.php b/tests/Tokens/BoldTokenTest.php
deleted file mode 100644
index b3d6079..0000000
--- a/tests/Tokens/BoldTokenTest.php
+++ /dev/null
@@ -1,43 +0,0 @@
-assertEquals('world', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_italic_text(): void
- {
- $token = new BoldToken('hello __world__');
-
- $this->assertEquals('hello world', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_strikethrough_text(): void
- {
- $token = new BoldToken('hello ~~world~~');
-
- $this->assertEquals('hello world', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_link(): void
- {
- $token = new BoldToken('hello [world](#)');
-
- $this->assertEquals('hello world', $token->parse(new Parser()));
- }
-}
diff --git a/tests/Tokens/CodeTokenTest.php b/tests/Tokens/CodeTokenTest.php
deleted file mode 100644
index 1936480..0000000
--- a/tests/Tokens/CodeTokenTest.php
+++ /dev/null
@@ -1,35 +0,0 @@
-assertEquals('$foo', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_language(): void
- {
- $token = new CodeToken('php', 'echo "hi";');
-
- $this->assertEquals('echo "hi";', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_language_without_highlighter(): void
- {
- $token = new CodeToken('php', 'echo "hi";');
-
- $this->assertEquals('echo "hi";', $token->parse(new Parser(highlighter: null)));
- }
-}
diff --git a/tests/Tokens/DivTokenTest.php b/tests/Tokens/DivTokenTest.php
deleted file mode 100644
index c13c85f..0000000
--- a/tests/Tokens/DivTokenTest.php
+++ /dev/null
@@ -1,59 +0,0 @@
-assertEquals('Hello', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_class(): void
- {
- $token = new DivToken(class: 'warning', content: 'Hello');
-
- $this->assertEquals('Hello', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_multiple_classes(): void
- {
- $token = new DivToken(class: 'foo bar', content: 'Hello');
-
- $this->assertEquals('', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_bold(): void
- {
- $token = new DivToken(class: null, content: 'Hello **world**');
-
- $this->assertEquals('Hello world', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_italic(): void
- {
- $token = new DivToken(class: null, content: 'Hello __world__');
-
- $this->assertEquals('Hello world', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_link(): void
- {
- $token = new DivToken(class: null, content: 'Hello [world](#)');
-
- $this->assertEquals('Hello world', $token->parse(new Parser()));
- }
-}
diff --git a/tests/Tokens/FrontMatterTokenTest.php b/tests/Tokens/FrontMatterTokenTest.php
deleted file mode 100644
index 51d3a4b..0000000
--- a/tests/Tokens/FrontMatterTokenTest.php
+++ /dev/null
@@ -1,19 +0,0 @@
- 'bar']);
-
- $this->assertEquals('', $token->parse(new Parser()));
- }
-}
diff --git a/tests/Tokens/HeadingTokenTest.php b/tests/Tokens/HeadingTokenTest.php
deleted file mode 100644
index f874bea..0000000
--- a/tests/Tokens/HeadingTokenTest.php
+++ /dev/null
@@ -1,35 +0,0 @@
-assertEquals('Hello
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_h2(): void
- {
- $token = new HeadingToken('Hello', 2);
-
- $this->assertEquals('Hello
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_h6(): void
- {
- $token = new HeadingToken('Hello World', 6);
-
- $this->assertEquals('Hello World
', $token->parse(new Parser()));
- }
-}
diff --git a/tests/Tokens/ImageTokenTest.php b/tests/Tokens/ImageTokenTest.php
deleted file mode 100644
index 0f3bcc6..0000000
--- a/tests/Tokens/ImageTokenTest.php
+++ /dev/null
@@ -1,27 +0,0 @@
-assertEquals('
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_without_alt(): void
- {
- $token = new ImageToken('https://example.com/img.png', null);
-
- $this->assertEquals('
', $token->parse(new Parser()));
- }
-}
diff --git a/tests/Tokens/ItalicTokenTest.php b/tests/Tokens/ItalicTokenTest.php
deleted file mode 100644
index 41e4bc3..0000000
--- a/tests/Tokens/ItalicTokenTest.php
+++ /dev/null
@@ -1,43 +0,0 @@
-assertEquals('world', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_bold_text(): void
- {
- $token = new ItalicToken('hello **world**');
-
- $this->assertEquals('hello world', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_strikethrough_text(): void
- {
- $token = new ItalicToken('hello ~~world~~');
-
- $this->assertEquals('hello world', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_link(): void
- {
- $token = new ItalicToken('hello [world](#)');
-
- $this->assertEquals('hello world', $token->parse(new Parser()));
- }
-}
diff --git a/tests/Tokens/LinkTokenTest.php b/tests/Tokens/LinkTokenTest.php
deleted file mode 100644
index 9c5d57d..0000000
--- a/tests/Tokens/LinkTokenTest.php
+++ /dev/null
@@ -1,51 +0,0 @@
-assertEquals('click here', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_bold_text(): void
- {
- $token = new LinkToken('click **here**', '#');
-
- $this->assertEquals('click here', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_italic_text(): void
- {
- $token = new LinkToken('click __here__', '#');
-
- $this->assertEquals('click here', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_strikethrough_text(): void
- {
- $token = new LinkToken('click ~~here~~', '#');
-
- $this->assertEquals('click here', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_target_blank_text(): void
- {
- $token = new LinkToken('click here', '*https://tempestphp.com');
-
- $this->assertEquals('click here', $token->parse(new Parser()));
- }
-}
diff --git a/tests/Tokens/ListTokenTest.php b/tests/Tokens/ListTokenTest.php
deleted file mode 100644
index cfd11d2..0000000
--- a/tests/Tokens/ListTokenTest.php
+++ /dev/null
@@ -1,98 +0,0 @@
-assertEquals('- item
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_multiple_items(): void
- {
- $token = new ListToken([new ListItem('one'), new ListItem('two'), new ListItem('three')]);
-
- $this->assertEquals('- one
- two
- three
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_bold(): void
- {
- $token = new ListToken([new ListItem('hello **world**')]);
-
- $this->assertEquals('- hello world
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_italic(): void
- {
- $token = new ListToken([new ListItem('hello __world__')]);
-
- $this->assertEquals('- hello world
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_link(): void
- {
- $token = new ListToken([new ListItem('[world](#)')]);
-
- $this->assertEquals('', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_code(): void
- {
- $token = new ListToken([new ListItem('run `php tempest`')]);
-
- $this->assertEquals('- run
php tempest
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_nested(): void
- {
- $token = new ListToken([
- new ListItem('parent', new ListToken([
- new ListItem('child'),
- ])),
- ]);
-
- $this->assertEquals('- parent
- child
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_nested_multiple_children(): void
- {
- $token = new ListToken([
- new ListItem('parent', new ListToken([
- new ListItem('child one'),
- new ListItem('child two'),
- ])),
- ]);
-
- $this->assertEquals('- parent
- child one
- child two
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_nested_sibling_after_sublist(): void
- {
- $token = new ListToken([
- new ListItem('one', new ListToken([
- new ListItem('child'),
- ])),
- new ListItem('two'),
- ]);
-
- $this->assertEquals('- one
- child
- two
', $token->parse(new Parser()));
- }
-}
diff --git a/tests/Tokens/NewLineTokenTest.php b/tests/Tokens/NewLineTokenTest.php
deleted file mode 100644
index 9b05027..0000000
--- a/tests/Tokens/NewLineTokenTest.php
+++ /dev/null
@@ -1,19 +0,0 @@
-assertEquals("\n\n", $token->parse(new Parser()));
- }
-}
diff --git a/tests/Tokens/OrderedListTokenTest.php b/tests/Tokens/OrderedListTokenTest.php
deleted file mode 100644
index 99e0f6f..0000000
--- a/tests/Tokens/OrderedListTokenTest.php
+++ /dev/null
@@ -1,85 +0,0 @@
-assertEquals('- item
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_multiple_items(): void
- {
- $token = new OrderedListToken([new ListItem('one'), new ListItem('two'), new ListItem('three')]);
-
- $this->assertEquals('- one
- two
- three
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_bold(): void
- {
- $token = new OrderedListToken([new ListItem('hello **world**')]);
-
- $this->assertEquals('- hello world
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_italic(): void
- {
- $token = new OrderedListToken([new ListItem('hello __world__')]);
-
- $this->assertEquals('- hello world
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_link(): void
- {
- $token = new OrderedListToken([new ListItem('[world](#)')]);
-
- $this->assertEquals('', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_code(): void
- {
- $token = new OrderedListToken([new ListItem('run `php tempest`')]);
-
- $this->assertEquals('- run
php tempest
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_nested(): void
- {
- $token = new OrderedListToken([
- new ListItem('parent', new OrderedListToken([
- new ListItem('child'),
- ])),
- ]);
-
- $this->assertEquals('- parent
- child
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_nested_sibling_after_sublist(): void
- {
- $token = new OrderedListToken([
- new ListItem('one', new OrderedListToken([
- new ListItem('child'),
- ])),
- new ListItem('two'),
- ]);
-
- $this->assertEquals('- one
- child
- two
', $token->parse(new Parser()));
- }
-}
diff --git a/tests/Tokens/ParagraphTokenTest.php b/tests/Tokens/ParagraphTokenTest.php
deleted file mode 100644
index 03b80e2..0000000
--- a/tests/Tokens/ParagraphTokenTest.php
+++ /dev/null
@@ -1,71 +0,0 @@
-Hello, world!';
-
- $actualHtml = $token->parse(new Parser());
-
- $this->assertEquals($expectedHtml, $actualHtml);
- }
-
- #[Test]
- public function test_parse_with_italic(): void
- {
- $token = new ParagraphToken('Hello, __world__!');
-
- $expectedHtml = 'Hello, world!
';
-
- $actualHtml = $token->parse(new Parser());
-
- $this->assertEquals($expectedHtml, $actualHtml);
- }
-
- #[Test]
- public function test_parse_with_link(): void
- {
- $token = new ParagraphToken('Hello, [world](#)!');
-
- $expectedHtml = 'Hello, world!
';
-
- $actualHtml = $token->parse(new Parser());
-
- $this->assertEquals($expectedHtml, $actualHtml);
- }
-
- #[Test]
- public function test_parse_with_image(): void
- {
- $token = new ParagraphToken('Hello, !');
-
- $expectedHtml = 'Hello,
!
';
-
- $actualHtml = $token->parse(new Parser());
-
- $this->assertEquals($expectedHtml, $actualHtml);
- }
-
- #[Test]
- public function test_parse_with_code(): void
- {
- $token = new ParagraphToken('Hello, `world`!');
-
- $expectedHtml = 'Hello, world!
';
-
- $actualHtml = $token->parse(new Parser());
-
- $this->assertEquals($expectedHtml, $actualHtml);
- }
-}
diff --git a/tests/Tokens/PreTokenTest.php b/tests/Tokens/PreTokenTest.php
deleted file mode 100644
index ba3d1e0..0000000
--- a/tests/Tokens/PreTokenTest.php
+++ /dev/null
@@ -1,44 +0,0 @@
-assertEquals(
- 'echo "hi";
',
- $token->parse(new Parser()),
- );
- }
-
- #[Test]
- public function test_parse_without_language(): void
- {
- $token = new PreToken(language: null, content: 'echo "hi";');
-
- $this->assertEquals(
- 'echo "hi";
',
- $token->parse(new Parser()),
- );
- }
-
- #[Test]
- public function test_parse_without_highlighter(): void
- {
- $token = new PreToken(language: null, content: 'echo "hi";');
-
- $this->assertEquals(
- 'echo "hi";
',
- $token->parse(new Parser(highlighter: null)),
- );
- }
-}
diff --git a/tests/Tokens/QuoteTokenTest.php b/tests/Tokens/QuoteTokenTest.php
deleted file mode 100644
index 4108a05..0000000
--- a/tests/Tokens/QuoteTokenTest.php
+++ /dev/null
@@ -1,74 +0,0 @@
-assertEquals('Hello
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_multiple_levels(): void
- {
- $token = new QuoteToken(<<<'TXT'
- One
- > Two
- > > Three
- >>> Four
- > Two again
- TXT);
-
- $parsed = $token->parse(new Parser());
-
- $expected = <<<'HTML'
- One
- Two
- Three
- Four
Two again
- HTML;
-
- $this->assertEquals($expected, $parsed);
- }
-
- #[Test]
- public function test_bold_text(): void
- {
- $token = new QuoteToken('Hello **world**');
-
- $this->assertEquals('Hello world
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_italic_text(): void
- {
- $token = new QuoteToken('Hello __world__');
-
- $this->assertEquals('Hello world
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_link(): void
- {
- $token = new QuoteToken('Hello [world](#)');
-
- $this->assertEquals('Hello world
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_image(): void
- {
- $token = new QuoteToken('Hello ');
-
- $this->assertEquals('Hello 
', $token->parse(new Parser()));
- }
-}
diff --git a/tests/Tokens/RulerTokenTest.php b/tests/Tokens/RulerTokenTest.php
deleted file mode 100644
index 2014588..0000000
--- a/tests/Tokens/RulerTokenTest.php
+++ /dev/null
@@ -1,28 +0,0 @@
-assertEquals('
', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_thick(): void
- {
- $token = new RulerToken('===', RulerType::THICK);
-
- $this->assertEquals('
', $token->parse(new Parser()));
- }
-}
diff --git a/tests/Tokens/StrikethroughTokenTest.php b/tests/Tokens/StrikethroughTokenTest.php
deleted file mode 100644
index e3a5e3d..0000000
--- a/tests/Tokens/StrikethroughTokenTest.php
+++ /dev/null
@@ -1,43 +0,0 @@
-assertEquals('deleted', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_italic_text(): void
- {
- $token = new StrikethroughToken('hello __world__');
-
- $this->assertEquals('hello world', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_bold_text(): void
- {
- $token = new StrikethroughToken('hello **world**');
-
- $this->assertEquals('hello world', $token->parse(new Parser()));
- }
-
- #[Test]
- public function test_parse_with_link(): void
- {
- $token = new StrikethroughToken('hello [world](#)');
-
- $this->assertEquals('hello world', $token->parse(new Parser()));
- }
-}
diff --git a/tests/Tokens/TableTokenTest.php b/tests/Tokens/TableTokenTest.php
deleted file mode 100644
index ecfdab5..0000000
--- a/tests/Tokens/TableTokenTest.php
+++ /dev/null
@@ -1,81 +0,0 @@
-assertEquals(
- 'Name Age Alice 30
',
- $token->parse(new Parser()),
- );
- }
-
- #[Test]
- public function test_parse_header_only(): void
- {
- $token = new TableToken([
- new TableRow(['A', 'B'], isHeader: true),
- ]);
-
- $this->assertEquals(
- 'A B
',
- $token->parse(new Parser()),
- );
- }
-
- #[Test]
- public function test_parse_with_inline_formatting(): void
- {
- $token = new TableToken([
- new TableRow(['Name', 'Notes'], isHeader: true),
- new TableRow(['**Alice**', '`code`'], isHeader: false),
- ]);
-
- $this->assertEquals(
- 'Name Notes Alice code
',
- $token->parse(new Parser()),
- );
- }
-
- #[Test]
- public function test_parse_with_image(): void
- {
- $token = new TableToken([
- new TableRow(['', '`code`'], isHeader: false),
- ]);
-
- $this->assertEquals(
- '
code
',
- $token->parse(new Parser()),
- );
- }
-
- #[Test]
- public function test_parse_multiple_rows(): void
- {
- $token = new TableToken([
- new TableRow(['A', 'B'], isHeader: true),
- new TableRow(['1', '2'], isHeader: false),
- new TableRow(['3', '4'], isHeader: false),
- ]);
-
- $this->assertEquals(
- 'A B 1 2 3 4
',
- $token->parse(new Parser()),
- );
- }
-}
diff --git a/tests/Tokens/TextTokenTest.php b/tests/Tokens/TextTokenTest.php
deleted file mode 100644
index 2757b02..0000000
--- a/tests/Tokens/TextTokenTest.php
+++ /dev/null
@@ -1,19 +0,0 @@
-assertEquals('Hello, world!', $token->parse(new Parser()));
- }
-}
From d0d3b93a5c3e2567172cde6e4f29f4dc54800e95 Mon Sep 17 00:00:00 2001
From: wilaak <54738571+wilaak@users.noreply.github.com>
Date: Thu, 14 May 2026 02:48:44 +0200
Subject: [PATCH 3/3] remove junk
---
tests/Bench/buffer_micro.php | 143 -----------------------------------
1 file changed, 143 deletions(-)
delete mode 100644 tests/Bench/buffer_micro.php
diff --git a/tests/Bench/buffer_micro.php b/tests/Bench/buffer_micro.php
deleted file mode 100644
index d986eb0..0000000
--- a/tests/Bench/buffer_micro.php
+++ /dev/null
@@ -1,143 +0,0 @@
-';
- if ($depth === 0) {
- for ($i = 0; $i < RECURSION_FANOUT; $i++) {
- $out .= CHUNK;
- }
- } else {
- for ($i = 0; $i < RECURSION_FANOUT; $i++) {
- byref_emit($depth - 1, $out);
- }
- }
- $out .= '';
-}
-function bench_byref(): string {
- $out = '';
- byref_emit(RECURSION_DEPTH, $out);
- return $out;
-}
-
-// --- 2. return string, caller concatenates --------------------------------
-function returning_emit(int $depth): string {
- $s = '';
- if ($depth === 0) {
- for ($i = 0; $i < RECURSION_FANOUT; $i++) {
- $s .= CHUNK;
- }
- } else {
- for ($i = 0; $i < RECURSION_FANOUT; $i++) {
- $s .= returning_emit($depth - 1);
- }
- }
- return $s . '';
-}
-function bench_returning(): string {
- return returning_emit(RECURSION_DEPTH);
-}
-
-// --- 3. by-ref array of chunks + implode ----------------------------------
-function array_emit(int $depth, array &$out): void {
- $out[] = '';
- if ($depth === 0) {
- for ($i = 0; $i < RECURSION_FANOUT; $i++) {
- $out[] = CHUNK;
- }
- } else {
- for ($i = 0; $i < RECURSION_FANOUT; $i++) {
- array_emit($depth - 1, $out);
- }
- }
- $out[] = '';
-}
-function bench_array(): string {
- $out = [];
- array_emit(RECURSION_DEPTH, $out);
- return implode('', $out);
-}
-
-// --- 4. ob_start / ob_get_clean -------------------------------------------
-function ob_emit(int $depth): void {
- echo '';
- if ($depth === 0) {
- for ($i = 0; $i < RECURSION_FANOUT; $i++) {
- echo CHUNK;
- }
- } else {
- for ($i = 0; $i < RECURSION_FANOUT; $i++) {
- ob_emit($depth - 1);
- }
- }
- echo '';
-}
-function bench_ob(): string {
- ob_start();
- ob_emit(RECURSION_DEPTH);
- return ob_get_clean();
-}
-
-// --- driver ----------------------------------------------------------------
-function run(string $name, callable $fn, int $iters): array {
- // warmup
- for ($i = 0; $i < 200; $i++) $fn();
- $best = PHP_FLOAT_MAX;
- $worst = 0.0;
- $sum = 0.0;
- $runs = 3;
- $sample = null;
- for ($r = 0; $r < $runs; $r++) {
- $t0 = hrtime(true);
- for ($i = 0; $i < $iters; $i++) {
- $sample = $fn();
- }
- $dt = (hrtime(true) - $t0) / 1e6; // ms
- $best = min($best, $dt);
- $worst = max($worst, $dt);
- $sum += $dt;
- }
- return ['name' => $name, 'best_ms' => $best, 'mean_ms' => $sum / $runs, 'worst_ms' => $worst, 'len' => strlen($sample)];
-}
-
-// sanity: outputs equal?
-$ref = bench_byref();
-foreach (['bench_returning', 'bench_array', 'bench_ob'] as $fn) {
- if ($fn() !== $ref) {
- fwrite(STDERR, "MISMATCH: $fn\n");
- exit(1);
- }
-}
-echo "Output length: " . strlen($ref) . " bytes\n";
-echo "Iterations: " . ITERS . "\n";
-echo "Tree: fanout=" . RECURSION_FANOUT . " depth=" . RECURSION_DEPTH . "\n\n";
-
-$results = [
- run('by-ref string (.=)', 'bench_byref', ITERS),
- run('returning string', 'bench_returning', ITERS),
- run('by-ref array + implode', 'bench_array', ITERS),
- run('ob_start/ob_get_clean', 'bench_ob', ITERS),
-];
-
-printf("%-28s %10s %10s %10s\n", 'strategy', 'best(ms)', 'mean(ms)', 'worst(ms)');
-foreach ($results as $r) {
- printf("%-28s %10.2f %10.2f %10.2f\n", $r['name'], $r['best_ms'], $r['mean_ms'], $r['worst_ms']);
-}
-$baseline = $results[0]['best_ms'];
-echo "\nrelative to by-ref string (best):\n";
-foreach ($results as $r) {
- printf(" %-28s %.2fx\n", $r['name'], $r['best_ms'] / $baseline);
-}