From eb97322aeb24cf565fdc09e1bb9195e2a3dfe009 Mon Sep 17 00:00:00 2001 From: Christen Dijoux <123602948+Akiyo974@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:13:40 -0400 Subject: [PATCH 1/3] fix: ignore marker delimiters that come from ordinary code --- src/Languages/Base/IsHighlightInjection.php | 47 +++++++++++++++---- tests/Languages/Base/HighlightMarkersTest.php | 45 ++++++++++++++++++ 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/src/Languages/Base/IsHighlightInjection.php b/src/Languages/Base/IsHighlightInjection.php index dd8370c8..73a9936c 100644 --- a/src/Languages/Base/IsHighlightInjection.php +++ b/src/Languages/Base/IsHighlightInjection.php @@ -22,28 +22,35 @@ public function parse(string $content, Highlighter $highlighter): ParsedInjectio $pattern = '/(?{' . $token . ')(?(.|\n)*?)(?' . $token . '})(?!})/'; - preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE); - $tokens = []; + $offset = 0; + + while (preg_match($pattern, $content, $match, PREG_OFFSET_CAPTURE, $offset) === 1) { + if ($this->leavesItsBraces($match['match'][0])) { + $offset = (int)$match['start'][1] + 1; + + continue; + } - foreach (array_keys($matches[0]) as $key) { $tokens[] = new Token( - offset: (int)$matches['start'][$key][1], - value: $matches['start'][$key][0], + offset: (int)$match['start'][1], + value: $match['start'][0], type: TokenTypeEnum::HIDDEN, ); $tokens[] = new Token( - offset: (int)$matches['match'][$key][1], - value: $matches['match'][$key][0], + offset: (int)$match['match'][1], + value: $match['match'][0], type: new DynamicTokenType($this->getClassname()), ); $tokens[] = new Token( - offset: (int)$matches['end'][$key][1], - value: $matches['end'][$key][0], + offset: (int)$match['end'][1], + value: $match['end'][0], type: TokenTypeEnum::HIDDEN, ); + + $offset = (int)$match['end'][1] + strlen($match['end'][0]); } return new ParsedInjection( @@ -51,4 +58,26 @@ public function parse(string $content, Highlighter $highlighter): ParsedInjectio tokens: $tokens, ); } + + /** + * In code like `const {_id} = a; const {name_} = b;`, the brace of `{_` is closed by the code itself, + * so the matched content leaves one pair of braces and ends inside another: `id} = a; const {name`. + * Real markers can wrap an unbalanced `{` or `}`, but never a `}` followed by a `{` that stays open. + */ + private function leavesItsBraces(string $match): bool + { + $depth = 0; + $lowestDepth = 0; + + foreach (str_split($match) as $character) { + if ($character === '{') { + $depth++; + } elseif ($character === '}') { + $depth--; + $lowestDepth = min($lowestDepth, $depth); + } + } + + return $lowestDepth < 0 && $depth > $lowestDepth; + } } diff --git a/tests/Languages/Base/HighlightMarkersTest.php b/tests/Languages/Base/HighlightMarkersTest.php index 9d5f255f..138dc181 100644 --- a/tests/Languages/Base/HighlightMarkersTest.php +++ b/tests/Languages/Base/HighlightMarkersTest.php @@ -168,6 +168,51 @@ public static function provide_wrapping_code_in_markers_does_not_change_its_high ]; } + #[DataProvider('provide_marker_delimiters_in_ordinary_code_are_not_markers_cases')] + public function test_marker_delimiters_in_ordinary_code_are_not_markers(string $language, string $content): void + { + $parsed = (new Highlighter())->parse($content, $language); + + $this->assertStringNotContainsString('display: none', $parsed); + $this->assertStringNotContainsString('hl-em', $parsed); + $this->assertStringNotContainsString('hl-strong', $parsed); + $this->assertStringNotContainsString('hl-blur', $parsed); + } + + public static function provide_marker_delimiters_in_ordinary_code_are_not_markers_cases(): iterable + { + return [ + 'javascript destructuring (#240)' => ['javascript', 'const {_id} = a; const {name_} = b;'], + 'python dicts (#240)' => ['python', "a = {_: 1}\nb = {1: _}"], + 'php braces' => ['php', '$a = "{_id}" . $b . "{name_}";'], + 'strong' => ['javascript', 'f({*a}, {b*});'], + 'blur' => ['javascript', 'f({~a}, {b~});'], + ]; + } + + public function test_marker_after_ordinary_code_delimiter_still_works(): void + { + $this->assertSame( + 'const {_id} = a; {_b_};', + (new Highlighter())->parse('const {_id} = a; {_b_};', 'javascript'), + ); + } + + #[DataProvider('provide_markers_around_unbalanced_braces_cases')] + public function test_markers_around_unbalanced_braces(string $content): void + { + $this->assertStringContainsString('', (new Highlighter())->parse($content, 'php')); + } + + public static function provide_markers_around_unbalanced_braces_cases(): iterable + { + return [ + 'opening brace' => ["{_function foo() {_}\n return 1;\n}"], + 'closing brace' => ["function foo() {\n{_ return 1;\n}_}"], + 'closing then balanced braces' => ['{_}): Foo {}_}'], + ]; + } + public function test_markers_work_with_gutter(): void { $highlighter = (new Highlighter())->withGutter(); From e4b60b4e0ffdfdb6ecd3ae9d2c042671bdab54a3 Mon Sep 17 00:00:00 2001 From: Christen Dijoux <123602948+Akiyo974@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:44:31 -0400 Subject: [PATCH 2/3] fix: keep markers around block transitions and scan delimiters in a single pass --- src/Languages/Base/IsHighlightInjection.php | 116 ++++++++++++++---- tests/Languages/Base/HighlightMarkersTest.php | 19 ++- 2 files changed, 107 insertions(+), 28 deletions(-) diff --git a/src/Languages/Base/IsHighlightInjection.php b/src/Languages/Base/IsHighlightInjection.php index 73a9936c..24a87113 100644 --- a/src/Languages/Base/IsHighlightInjection.php +++ b/src/Languages/Base/IsHighlightInjection.php @@ -18,39 +18,71 @@ abstract private function getClassname(): string; public function parse(string $content, Highlighter $highlighter): ParsedInjection { - $token = '\\' . $this->getToken(); + $start = '{' . $this->getToken(); + $end = $this->getToken() . '}'; - $pattern = '/(?{' . $token . ')(?(.|\n)*?)(?' . $token . '})(?!})/'; + $openers = $this->findOffsets('/' . preg_quote($start, '/') . '/', $content); + $closers = $this->findOffsets('/' . preg_quote($end, '/') . '(?!})/', $content); $tokens = []; - $offset = 0; + $openerIndex = 0; + $closerIndex = 0; + $cursor = 0; - while (preg_match($pattern, $content, $match, PREG_OFFSET_CAPTURE, $offset) === 1) { - if ($this->leavesItsBraces($match['match'][0])) { - $offset = (int)$match['start'][1] + 1; + while ($openerIndex < count($openers)) { + if ($openers[$openerIndex] < $cursor) { + $openerIndex++; continue; } + $contentStart = $openers[$openerIndex] + strlen($start); + + while ($closerIndex < count($closers) && $closers[$closerIndex] < $contentStart) { + $closerIndex++; + } + + if ($closerIndex === count($closers)) { + break; + } + + $closer = $closers[$closerIndex]; + + // Every opener before this closer would end with it, the first one that is a real marker wins + $candidates = []; + + while ($openerIndex < count($openers) && $openers[$openerIndex] + strlen($start) <= $closer) { + $candidates[] = $openers[$openerIndex]; + $openerIndex++; + } + + $opener = $this->findMarkerOpener($content, $candidates, strlen($start), $closer); + + if ($opener === null) { + continue; + } + + $contentStart = $opener + strlen($start); + $tokens[] = new Token( - offset: (int)$match['start'][1], - value: $match['start'][0], + offset: $opener, + value: $start, type: TokenTypeEnum::HIDDEN, ); $tokens[] = new Token( - offset: (int)$match['match'][1], - value: $match['match'][0], + offset: $contentStart, + value: substr($content, $contentStart, $closer - $contentStart), type: new DynamicTokenType($this->getClassname()), ); $tokens[] = new Token( - offset: (int)$match['end'][1], - value: $match['end'][0], + offset: $closer, + value: $end, type: TokenTypeEnum::HIDDEN, ); - $offset = (int)$match['end'][1] + strlen($match['end'][0]); + $cursor = $closer + strlen($end); } return new ParsedInjection( @@ -59,25 +91,63 @@ public function parse(string $content, Highlighter $highlighter): ParsedInjectio ); } + /** + * @return int[] + */ + private function findOffsets(string $pattern, string $content): array + { + preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE); + + return array_map(static fn (array $match): int => (int)$match[1], $matches[0]); + } + /** * In code like `const {_id} = a; const {name_} = b;`, the brace of `{_` is closed by the code itself, - * so the matched content leaves one pair of braces and ends inside another: `id} = a; const {name`. - * Real markers can wrap an unbalanced `{` or `}`, but never a `}` followed by a `{` that stays open. + * so the content between the delimiters leaves one pair of braces and ends inside another: `id} = a; const {name`. + * Real markers can wrap an unbalanced `{` or `}`, and can start right before a closing brace + * or end right after an opening brace, like around `} else {`. + * + * Brace depths are counted backwards from the closer, so that all openers are checked in a single scan. + * + * @param int[] $openers */ - private function leavesItsBraces(string $match): bool + private function findMarkerOpener(string $content, array $openers, int $openerLength, int $closer): ?int { + $endsWithOpeningBrace = substr(rtrim(substr($content, $openers[0] + $openerLength, $closer - $openers[0] - $openerLength)), -1) === '{'; + $depth = 0; $lowestDepth = 0; + $position = $closer; + $isMarker = []; + + foreach (array_reverse($openers) as $opener) { + $contentStart = $opener + $openerLength; + + while ($position > $contentStart) { + $position--; + + if ($content[$position] === '{') { + $depth++; + $lowestDepth = min(0, $lowestDepth + 1); + } elseif ($content[$position] === '}') { + $depth--; + $lowestDepth = min(0, $lowestDepth - 1); + } + } + + $leavesItsBraces = $lowestDepth < 0 && $depth > $lowestDepth; + + $isMarker[$opener] = ! $leavesItsBraces + || $endsWithOpeningBrace + || str_starts_with(ltrim(substr($content, $contentStart, 1 + strspn($content, " \t\r\n", $contentStart))), '}'); + } - foreach (str_split($match) as $character) { - if ($character === '{') { - $depth++; - } elseif ($character === '}') { - $depth--; - $lowestDepth = min($lowestDepth, $depth); + foreach ($openers as $opener) { + if ($isMarker[$opener]) { + return $opener; } } - return $lowestDepth < 0 && $depth > $lowestDepth; + return null; } } diff --git a/tests/Languages/Base/HighlightMarkersTest.php b/tests/Languages/Base/HighlightMarkersTest.php index 138dc181..7a765958 100644 --- a/tests/Languages/Base/HighlightMarkersTest.php +++ b/tests/Languages/Base/HighlightMarkersTest.php @@ -199,17 +199,26 @@ public function test_marker_after_ordinary_code_delimiter_still_works(): void } #[DataProvider('provide_markers_around_unbalanced_braces_cases')] - public function test_markers_around_unbalanced_braces(string $content): void + public function test_markers_around_unbalanced_braces(string $content, string $classname): void { - $this->assertStringContainsString('', (new Highlighter())->parse($content, 'php')); + $parsed = (new Highlighter())->parse($content, 'php'); + + $this->assertStringContainsString('', $parsed); + $visible = strip_tags(preg_replace('/.*?<\/span>/', '', $parsed)); + + $this->assertDoesNotMatchRegularExpression('/{[_*~]|[_*~]}/', $visible); } public static function provide_markers_around_unbalanced_braces_cases(): iterable { return [ - 'opening brace' => ["{_function foo() {_}\n return 1;\n}"], - 'closing brace' => ["function foo() {\n{_ return 1;\n}_}"], - 'closing then balanced braces' => ['{_}): Foo {}_}'], + 'opening brace' => ["{_function foo() {_}\n return 1;\n}", 'hl-em'], + 'closing brace' => ["function foo() {\n{_ return 1;\n}_}", 'hl-em'], + 'closing then balanced braces' => ['{_}): Foo {}_}', 'hl-em'], + 'else' => ["if (\$x) {\n{_} else {_}\n}", 'hl-em'], + 'end of a block and else' => ["if (\$x) {\n{_ return 1;\n} else {_}\n}", 'hl-em'], + 'catch' => ["try {\n a();\n{*} catch (E \$e) {*}\n}", 'hl-strong'], + 'finally' => ["try {\n a();\n{~} finally {~}\n}", 'hl-blur'], ]; } From 82977424207a60228f9d92d6be321af016c60632 Mon Sep 17 00:00:00 2001 From: Christen Dijoux <123602948+Akiyo974@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:43:44 -0400 Subject: [PATCH 3/3] perf: skip marker parsing when the content has no opening delimiter --- src/Languages/Base/IsHighlightInjection.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Languages/Base/IsHighlightInjection.php b/src/Languages/Base/IsHighlightInjection.php index 24a87113..476c0828 100644 --- a/src/Languages/Base/IsHighlightInjection.php +++ b/src/Languages/Base/IsHighlightInjection.php @@ -21,6 +21,10 @@ public function parse(string $content, Highlighter $highlighter): ParsedInjectio $start = '{' . $this->getToken(); $end = $this->getToken() . '}'; + if (! str_contains($content, $start)) { + return new ParsedInjection($content); + } + $openers = $this->findOffsets('/' . preg_quote($start, '/') . '/', $content); $closers = $this->findOffsets('/' . preg_quote($end, '/') . '(?!})/', $content);