diff --git a/src/Languages/Base/IsHighlightInjection.php b/src/Languages/Base/IsHighlightInjection.php index dd8370c8..476c0828 100644 --- a/src/Languages/Base/IsHighlightInjection.php +++ b/src/Languages/Base/IsHighlightInjection.php @@ -18,32 +18,75 @@ 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 . '})(?!})/'; + if (! str_contains($content, $start)) { + return new ParsedInjection($content); + } - preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE); + $openers = $this->findOffsets('/' . preg_quote($start, '/') . '/', $content); + $closers = $this->findOffsets('/' . preg_quote($end, '/') . '(?!})/', $content); $tokens = []; + $openerIndex = 0; + $closerIndex = 0; + $cursor = 0; + + 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); - foreach (array_keys($matches[0]) as $key) { $tokens[] = new Token( - offset: (int)$matches['start'][$key][1], - value: $matches['start'][$key][0], + offset: $opener, + value: $start, type: TokenTypeEnum::HIDDEN, ); $tokens[] = new Token( - offset: (int)$matches['match'][$key][1], - value: $matches['match'][$key][0], + offset: $contentStart, + value: substr($content, $contentStart, $closer - $contentStart), type: new DynamicTokenType($this->getClassname()), ); $tokens[] = new Token( - offset: (int)$matches['end'][$key][1], - value: $matches['end'][$key][0], + offset: $closer, + value: $end, type: TokenTypeEnum::HIDDEN, ); + + $cursor = $closer + strlen($end); } return new ParsedInjection( @@ -51,4 +94,64 @@ public function parse(string $content, Highlighter $highlighter): ParsedInjectio tokens: $tokens, ); } + + /** + * @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 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 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 ($openers as $opener) { + if ($isMarker[$opener]) { + return $opener; + } + } + + return null; + } } diff --git a/tests/Languages/Base/HighlightMarkersTest.php b/tests/Languages/Base/HighlightMarkersTest.php index 9d5f255f..7a765958 100644 --- a/tests/Languages/Base/HighlightMarkersTest.php +++ b/tests/Languages/Base/HighlightMarkersTest.php @@ -168,6 +168,60 @@ 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, string $classname): void + { + $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}", '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'], + ]; + } + public function test_markers_work_with_gutter(): void { $highlighter = (new Highlighter())->withGutter();