From 6538db12280240eaff184f0349f59c831a39990b Mon Sep 17 00:00:00 2001 From: "aidan.casey" Date: Fri, 4 Sep 2026 16:44:46 -0400 Subject: [PATCH 1/8] Add test. --- tests/Rules/LinkRuleTest.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/Rules/LinkRuleTest.php b/tests/Rules/LinkRuleTest.php index c87eb1e..d3d56a2 100644 --- a/tests/Rules/LinkRuleTest.php +++ b/tests/Rules/LinkRuleTest.php @@ -32,4 +32,13 @@ public function test_lex_with_image_content(): void $this->assertSame('alt', $html); } + + #[Test] + + public function test_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); + } } From 6b034360a6f13193f00823db5102bf0e419ab576 Mon Sep 17 00:00:00 2001 From: "aidan.casey" Date: Fri, 4 Sep 2026 17:04:33 -0400 Subject: [PATCH 2/8] Fix parenthesis content in link. --- src/Rules/LinkRule.php | 45 +++++++++++++++++++++++++++++++++++- tests/Rules/LinkRuleTest.php | 11 +++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/Rules/LinkRule.php b/src/Rules/LinkRule.php index 007d9b3..9e97fee 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 = $this->consumeHref($parser); $parser->consumeIncluding(')'); } @@ -57,4 +57,47 @@ private function consumeContent(Parser $parser): string return $content; } + + private function consumeHref(Parser $parser): string + { + $href = ''; + $parenthesisDepth = 0; + + while ($parser->current !== null) { + // Escaped character: consume the backslash and the following + // character as part of the URL. + if ($parser->comesNext('\\')) { + $parser->consume(); + + if ($parser->current !== null) { + $href .= $parser->consume(); + } + + continue; + } + + // The following parentheses indicates an + // opening parentheses in the URL, which should + // be parsed as part of the URL. + if ($parser->comesNext('(')) { + $parenthesisDepth++; + $href .= $parser->consume(); + continue; + } + + if ($parser->comesNext(')')) { + if ($parenthesisDepth === 0) { + break; + } + + $parenthesisDepth--; + $href .= $parser->consume(); + continue; + } + + $href .= $parser->consume(); + } + + return $href; + } } diff --git a/tests/Rules/LinkRuleTest.php b/tests/Rules/LinkRuleTest.php index d3d56a2..6592794 100644 --- a/tests/Rules/LinkRuleTest.php +++ b/tests/Rules/LinkRuleTest.php @@ -34,11 +34,18 @@ public function test_lex_with_image_content(): void } #[Test] - - public function test_lex_with_parenthesis_content(): void + 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); + } } From 09a4c13aea5552e3b303a1287a33a1531d173d49 Mon Sep 17 00:00:00 2001 From: "aidan.casey" Date: Fri, 4 Sep 2026 17:06:28 -0400 Subject: [PATCH 3/8] Fix styling. --- tests/Rules/LinkRuleTest.php | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/Rules/LinkRuleTest.php b/tests/Rules/LinkRuleTest.php index 6592794..5d99fc4 100644 --- a/tests/Rules/LinkRuleTest.php +++ b/tests/Rules/LinkRuleTest.php @@ -36,16 +36,26 @@ public function test_lex_with_image_content(): void #[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); + $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); + $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, + ); } } From 98b21711d7be7b173d4ea9c12041cab0a751bbdb Mon Sep 17 00:00:00 2001 From: "aidan.casey" Date: Fri, 4 Sep 2026 17:18:41 -0400 Subject: [PATCH 4/8] Testing --- src/Rules/LinkRule.php | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/Rules/LinkRule.php b/src/Rules/LinkRule.php index 9e97fee..35cf30d 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 = $this->consumeHref($parser); + $href = $this->consumeHrefTwo($parser); $parser->consumeIncluding(')'); } @@ -100,4 +100,42 @@ private function consumeHref(Parser $parser): string return $href; } + + private function consumeHrefTwo(Parser $parser): string + { + $href = ''; + $depth = 0; + + while (($current = $parser->current) !== null) { + if ($current !== '(' && $current !== ')' && $current !== '\\') { + $href .= $parser->consume(); + continue; + } + + if ($current === '(') { + $depth++; + $href .= $parser->consume(); + continue; + } + + if ($current === ')') { + if ($depth === 0) { + break; + } + + $depth--; + $href .= $parser->consume(); + continue; + } + + // Backslash + $parser->consume(); + + if ($parser->current !== null) { + $href .= $parser->consume(); + } + } + + return $href; + } } From 0a1a406bfca9baa413c37d8af3ad623f66063604 Mon Sep 17 00:00:00 2001 From: "aidan.casey" Date: Fri, 4 Sep 2026 17:28:15 -0400 Subject: [PATCH 5/8] Test --- src/Parser.php | 54 ++++++++++++++++++++++++++++++++++++++++++ src/Rules/LinkRule.php | 2 +- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/Parser.php b/src/Parser.php index a039197..0bedd72 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -307,6 +307,60 @@ public function consumeUntilString(string $stopAt): string return $this->consume($pos - $this->position); } + public function consumeUntilUnescaped(string $stopAt, ?string $nestedAt = null): string + { + $start = $this->position; + $result = ''; + $depth = 0; + + $specialChars = '\\' . $stopAt . ($nestedAt ?? ''); + + while ($this->current !== null) { + $offset = strcspn( + $this->content, + $specialChars, + $this->position, + ); + + if ($offset > 0) { + $result .= $this->consume($offset); + } + + $current = $this->current; + + if ($current === null) { + break; + } + + if ($current === '\\') { + $this->consume(); + + if ($this->current !== null) { + $result .= $this->consume(); + } + + continue; + } + + if ($nestedAt !== null && $current === $nestedAt) { + $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 35cf30d..c91539e 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 = $this->consumeHrefTwo($parser); + $href = $parser->consumeUntilUnescaped(stopAt: ')', nestedAt: '('); $parser->consumeIncluding(')'); } From 53d8b329e974e8ad85b66e91e7871e52110f5214 Mon Sep 17 00:00:00 2001 From: "aidan.casey" Date: Fri, 4 Sep 2026 17:36:43 -0400 Subject: [PATCH 6/8] Testing. --- src/Parser.php | 7 ++-- src/Rules/LinkRule.php | 83 +----------------------------------------- 2 files changed, 4 insertions(+), 86 deletions(-) diff --git a/src/Parser.php b/src/Parser.php index 0bedd72..233d7e1 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -307,13 +307,12 @@ public function consumeUntilString(string $stopAt): string return $this->consume($pos - $this->position); } - public function consumeUntilUnescaped(string $stopAt, ?string $nestedAt = null): string + public function consumeUntilUnescaped(string $stopAt, ?string $allowNestedAt = null): string { - $start = $this->position; $result = ''; $depth = 0; - $specialChars = '\\' . $stopAt . ($nestedAt ?? ''); + $specialChars = '\\' . $stopAt . ($allowNestedAt ?? ''); while ($this->current !== null) { $offset = strcspn( @@ -342,7 +341,7 @@ public function consumeUntilUnescaped(string $stopAt, ?string $nestedAt = null): continue; } - if ($nestedAt !== null && $current === $nestedAt) { + if ($allowNestedAt !== null && $current === $allowNestedAt) { $depth++; $result .= $this->consume(); continue; diff --git a/src/Rules/LinkRule.php b/src/Rules/LinkRule.php index c91539e..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->consumeUntilUnescaped(stopAt: ')', nestedAt: '('); + $href = $parser->consumeUntilUnescaped(stopAt: ')', allowNestedAt: '('); $parser->consumeIncluding(')'); } @@ -57,85 +57,4 @@ private function consumeContent(Parser $parser): string return $content; } - - private function consumeHref(Parser $parser): string - { - $href = ''; - $parenthesisDepth = 0; - - while ($parser->current !== null) { - // Escaped character: consume the backslash and the following - // character as part of the URL. - if ($parser->comesNext('\\')) { - $parser->consume(); - - if ($parser->current !== null) { - $href .= $parser->consume(); - } - - continue; - } - - // The following parentheses indicates an - // opening parentheses in the URL, which should - // be parsed as part of the URL. - if ($parser->comesNext('(')) { - $parenthesisDepth++; - $href .= $parser->consume(); - continue; - } - - if ($parser->comesNext(')')) { - if ($parenthesisDepth === 0) { - break; - } - - $parenthesisDepth--; - $href .= $parser->consume(); - continue; - } - - $href .= $parser->consume(); - } - - return $href; - } - - private function consumeHrefTwo(Parser $parser): string - { - $href = ''; - $depth = 0; - - while (($current = $parser->current) !== null) { - if ($current !== '(' && $current !== ')' && $current !== '\\') { - $href .= $parser->consume(); - continue; - } - - if ($current === '(') { - $depth++; - $href .= $parser->consume(); - continue; - } - - if ($current === ')') { - if ($depth === 0) { - break; - } - - $depth--; - $href .= $parser->consume(); - continue; - } - - // Backslash - $parser->consume(); - - if ($parser->current !== null) { - $href .= $parser->consume(); - } - } - - return $href; - } } From df371c92e61c34218c8f540a7f408bb52ce038b6 Mon Sep 17 00:00:00 2001 From: "aidan.casey" Date: Fri, 4 Sep 2026 17:46:36 -0400 Subject: [PATCH 7/8] Add comments. --- src/Parser.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Parser.php b/src/Parser.php index 233d7e1..5c7d975 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -312,9 +312,14 @@ public function consumeUntilUnescaped(string $stopAt, ?string $allowNestedAt = n $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 and interesting + // character in our compiled string. $offset = strcspn( $this->content, $specialChars, @@ -331,6 +336,8 @@ public function consumeUntilUnescaped(string $stopAt, ?string $allowNestedAt = n break; } + // Consume the backslash and append the escaped + // character literally. if ($current === '\\') { $this->consume(); @@ -341,6 +348,7 @@ public function consumeUntilUnescaped(string $stopAt, ?string $allowNestedAt = n continue; } + // Handle nested characters. if ($allowNestedAt !== null && $current === $allowNestedAt) { $depth++; $result .= $this->consume(); From 1f25464c8a969018f261ca00302fc1074f540f2f Mon Sep 17 00:00:00 2001 From: "aidan.casey" Date: Fri, 4 Sep 2026 17:46:49 -0400 Subject: [PATCH 8/8] Fix typo. --- src/Parser.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Parser.php b/src/Parser.php index 5c7d975..e6ed9b7 100644 --- a/src/Parser.php +++ b/src/Parser.php @@ -318,7 +318,7 @@ public function consumeUntilUnescaped(string $stopAt, ?string $allowNestedAt = n $specialChars = '\\' . $stopAt . ($allowNestedAt ?? ''); while ($this->current !== null) { - // Scan forward until we hit and interesting + // Scan forward until we hit an interesting // character in our compiled string. $offset = strcspn( $this->content,