diff --git a/src/Parser.php b/src/Parser.php
index a039197..e6ed9b7 100644
--- a/src/Parser.php
+++ b/src/Parser.php
@@ -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);
diff --git a/src/Rules/LinkRule.php b/src/Rules/LinkRule.php
index 007d9b3..fdbaef5 100644
--- a/src/Rules/LinkRule.php
+++ b/src/Rules/LinkRule.php
@@ -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(')');
}
diff --git a/tests/Rules/LinkRuleTest.php b/tests/Rules/LinkRuleTest.php
index c87eb1e..5d99fc4 100644
--- a/tests/Rules/LinkRuleTest.php
+++ b/tests/Rules/LinkRuleTest.php
@@ -32,4 +32,30 @@ public function test_lex_with_image_content(): void
$this->assertSame('
', $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(
+ '.NET best practices',
+ $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(
+ '.NET best practices',
+ $html,
+ );
+ }
}