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
61 changes: 61 additions & 0 deletions src/Parser.php
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,67 @@ public function consumeUntilString(string $stopAt): string
return $this->consume($pos - $this->position);
}

public function consumeUntilUnescaped(string $stopAt, ?string $allowNestedAt = null): string
{
$result = '';
$depth = 0;

// Compile a string of characters that require
// special handling while scanning. This includes escapes,
// the closing delimiter, and (optionally) a nested opening delimiter.
$specialChars = '\\' . $stopAt . ($allowNestedAt ?? '');

while ($this->current !== null) {
// Scan forward until we hit an interesting
// character in our compiled string.
$offset = strcspn(
$this->content,
$specialChars,
$this->position,
);

if ($offset > 0) {
$result .= $this->consume($offset);
}

$current = $this->current;

if ($current === null) {
break;
}

// Consume the backslash and append the escaped
// character literally.
if ($current === '\\') {
$this->consume();

if ($this->current !== null) {
$result .= $this->consume();
}

continue;
}

// Handle nested characters.
if ($allowNestedAt !== null && $current === $allowNestedAt) {
$depth++;
$result .= $this->consume();
continue;
}

if ($current === $stopAt) {
if ($depth === 0) {
break;
}

$depth--;
$result .= $this->consume();
}
}

return $result;
}

public function consumeWhile(string $continueWhile): string
{
$offset = strspn($this->content, $continueWhile, $this->position);
Expand Down
2 changes: 1 addition & 1 deletion src/Rules/LinkRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public function parse(Parser $parser): Token

if ($parser->comesNext('(', 1)) {
$parser->consumeIncluding('(');
$href = $parser->consumeUntil(')');
$href = $parser->consumeUntilUnescaped(stopAt: ')', allowNestedAt: '(');
$parser->consumeIncluding(')');
}

Expand Down
26 changes: 26 additions & 0 deletions tests/Rules/LinkRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,30 @@ public function test_lex_with_image_content(): void

$this->assertSame('<a href="/link"><img src="/image.jpg" alt="alt"></a>', $html);
}

#[Test]
public function lex_with_parenthesis_content(): void
{
$html = (string) new Parser(highlighter: null, rules: [new LinkRule()])->parse(
'[.NET best practices](https://learn.microsoft.com/en-us/previous-versions/dotnet/netframework-4.0/ms229043(v=vs.100)?redirectedfrom=MSDN)',
);

$this->assertSame(
'<a href="https://learn.microsoft.com/en-us/previous-versions/dotnet/netframework-4.0/ms229043(v=vs.100)?redirectedfrom=MSDN">.NET best practices</a>',
$html,
);
}

#[Test]
public function lex_with_end_parenthesis_without_start_parenthesis(): void
{
$html = (string) new Parser(highlighter: null, rules: [new LinkRule()])->parse(
'[.NET best practices](https://learn.microsoft.com/en-us/previous-versions/dotnet/netframework-4.0/ms229043v=vs.100\)?redirectedfrom=MSDN)',
);

$this->assertSame(
'<a href="https://learn.microsoft.com/en-us/previous-versions/dotnet/netframework-4.0/ms229043v=vs.100)?redirectedfrom=MSDN">.NET best practices</a>',
$html,
);
}
}
Loading