Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/Exceptions/MaximumNestingDepthWasExceeded.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

namespace Tempest\Markdown\Exceptions;

use Exception;
use Tempest\Markdown\MarkdownException;

final class MaximumNestingDepthWasExceeded extends Exception implements MarkdownException
{
public function __construct(int $maxNestingDepth)
{
parent::__construct("Maximum nesting depth of {$maxNestingDepth} was exceeded");
}
}
2 changes: 2 additions & 0 deletions src/Markdown.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ final class Markdown
public function __construct(
public ?Highlighter $highlighter = new Highlighter(),
private ?ResponsiveImageFactory $imageFactory = null,
public int $maxNestingDepth = Parser::DEFAULT_MAX_NESTING_DEPTH,
) {
$this->parser = new Parser(
$this->highlighter,
$this->imageFactory,
$this->maxNestingDepth,
);
}

Expand Down
76 changes: 52 additions & 24 deletions src/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Tempest\Markdown;

use Tempest\Highlight\Highlighter;
use Tempest\Markdown\Exceptions\MaximumNestingDepthWasExceeded;
use Tempest\Markdown\Rules\DivRule;
use Tempest\Markdown\Rules\FrontMatterRule;
use Tempest\Markdown\Rules\HeadingRule;
Expand All @@ -25,6 +26,7 @@ final class Parser
{
public const string WHITESPACE = "\r\n\t\f ";
public const string NEW_LINE = "\r\n";
public const int DEFAULT_MAX_NESTING_DEPTH = 128;

private(set) int $position = 0;
private(set) int $length = 0;
Expand All @@ -41,9 +43,12 @@ final class Parser
/** @var \Tempest\Markdown\Parser[] */
private array $cache = [];

private static int $depth = 0;

public function __construct(
public ?Highlighter $highlighter = new Highlighter(),
public ?ResponsiveImageFactory $imageFactory = null,
public int $maxNestingDepth = self::DEFAULT_MAX_NESTING_DEPTH,
array $rules = [
new NewLineRule(),
new RawRule(),
Expand Down Expand Up @@ -184,51 +189,74 @@ public function setContent(string $content): self

public function lex(string $content): TokenCollection
{
$parser = clone $this;
$this->increaseNestingDepth();

$parser->setContent($content);
try {
$parser = clone $this;

$tokens = [];
$parser->setContent($content);

while ($parser->current !== null) {
foreach ($parser->perCharRules[$parser->current] ?? $parser->defaultRules as $rule) {
if (! $rule->shouldParse($parser)) {
continue;
}
$tokens = [];

while ($parser->current !== null) {
foreach ($parser->perCharRules[$parser->current] ?? $parser->defaultRules as $rule) {
if (! $rule->shouldParse($parser)) {
continue;
}

$token = $rule->parse($parser);
$token = $rule->parse($parser);

if ($token instanceof Token) {
$tokens[] = $token;
$parser->lastToken = $token;
if ($token instanceof Token) {
$tokens[] = $token;
$parser->lastToken = $token;
}

continue 2;
}

continue 2;
$parser->consume();
}

$parser->consume();
return new TokenCollection($tokens);
} finally {
self::$depth--;
}

return new TokenCollection($tokens);
}

public function parse(string $content): ParsedMarkdown
{
$tokens = $this->lex($content);
$this->increaseNestingDepth();

try {
$tokens = $this->lex($content);

$html = '';
$html = '';

$frontMatter = [];
$frontMatter = [];

foreach ($tokens as $token) {
$html .= $token->parse($this);
foreach ($tokens as $token) {
$html .= $token->parse($this);

if ($token instanceof FrontMatterToken) {
$frontMatter = [...$frontMatter, ...$token->data];
if ($token instanceof FrontMatterToken) {
$frontMatter = [...$frontMatter, ...$token->data];
}
}

return new ParsedMarkdown($html, $frontMatter);
} finally {
self::$depth--;
}
}

// Guards both recursion paths: nested lists recurse through lex(), nested tokens through parse().
// Each caller decrements self::$depth in a finally block.
private function increaseNestingDepth(): void
{
if (self::$depth >= $this->maxNestingDepth) {
throw new MaximumNestingDepthWasExceeded($this->maxNestingDepth);
}

return new ParsedMarkdown($html, $frontMatter);
self::$depth++;
}

public function comesNext(string $search, ?int $length = null, int $offset = 0): bool
Expand Down
26 changes: 26 additions & 0 deletions tests/ParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Tempest\Markdown\Tests;

use PHPUnit\Framework\Attributes\Test;
use Tempest\Markdown\Exceptions\MaximumNestingDepthWasExceeded;
use Tempest\Markdown\Parser;
use Tempest\Markdown\Rules\HeadingRule;
use Tempest\Markdown\Rules\ParagraphRule;
Expand Down Expand Up @@ -130,4 +131,29 @@ public function test_configuration_does_not_leak_between_instances(): void

$this->assertSame('<p><code><b>x</b></code></p>', $noHighlighter->parse('`<b>x</b>`')->html);
}

#[Test]
public function test_max_nesting_depth_limits_nested_tokens(): void
{
$this->expectException(MaximumNestingDepthWasExceeded::class);

new Parser(maxNestingDepth: 3)->parse('Hello **world**');
}

#[Test]
public function test_max_nesting_depth_limits_nested_lists(): void
{
$this->expectException(MaximumNestingDepthWasExceeded::class);

new Parser(maxNestingDepth: 3)->parse("- one\n - two\n - three");
}

#[Test]
public function test_default_max_nesting_depth_allows_normal_content(): void
{
$html = (string) new Parser()->parse("Hello **bold** and **more bold**\n\n- one\n - two");

$this->assertStringContainsString('<strong>bold</strong>', $html);
$this->assertStringContainsString('<li>', $html);
}
}
Loading