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('alt', $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, + ); + } }