Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 113 additions & 10 deletions src/Languages/Base/IsHighlightInjection.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,37 +18,140 @@ abstract private function getClassname(): string;

public function parse(string $content, Highlighter $highlighter): ParsedInjection
{
$token = '\\' . $this->getToken();
$start = '{' . $this->getToken();
$end = $this->getToken() . '}';

$pattern = '/(?<start>{' . $token . ')(?<match>(.|\n)*?)(?<end>' . $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(
content: $content,
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;
}
}
54 changes: 54 additions & 0 deletions tests/Languages/Base/HighlightMarkersTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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(
'<span class="hl-keyword">const</span> {_id} = a; <span style="display: none">{_</span><span class="hl-em">b</span><span style="display: none">_}</span>;',
(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('<span class="' . $classname . '">', $parsed);
$visible = strip_tags(preg_replace('/<span style="display: none">.*?<\/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();
Expand Down
Loading