Skip to content
Open
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
6 changes: 2 additions & 4 deletions src/Languages/Diff/DiffLanguage.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@

use Override;
use Tempest\Highlight\Languages\Base\BaseLanguage;
use Tempest\Highlight\Languages\Diff\Injections\DiffAdditionInjection;
use Tempest\Highlight\Languages\Diff\Injections\DiffDeletionInjection;
use Tempest\Highlight\Languages\Diff\Injections\DiffStructureInjection;

class DiffLanguage extends BaseLanguage
{
Expand All @@ -21,8 +20,7 @@ public function getInjections(): array
{
return [
...parent::getInjections(),
new DiffAdditionInjection(),
new DiffDeletionInjection(),
new DiffStructureInjection(),
];
}
}
210 changes: 210 additions & 0 deletions src/Languages/Diff/DiffParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
<?php

declare(strict_types=1);

namespace Tempest\Highlight\Languages\Diff;

use Tempest\Highlight\Tokens\Token;

final readonly class DiffParser
{
private const string GIT_METADATA_PATTERN = '/^(?:commit [0-9a-f]{7,}|(?:Author|AuthorDate|Commit|CommitDate|Date|Merge):.*|diff --(?:git|cc|combined) .+|index [0-9a-f]+(?:,[0-9a-f]+)*\.\.[0-9a-f]+(?: \d{6})?|(?:old|new) mode \d{6}|(?:deleted|new) file mode \d{6}|mode \d{6}(?:,\d{6})*\.\.\d{6}|(?:similarity|dissimilarity) index \d+%|(?:rename|copy) (?:from|to) .*)$/';

/**
* @return Token[]
*/
public function parse(string $content): array
{
preg_match_all('/^.*$/m', $content, $matches, PREG_OFFSET_CAPTURE);

$tokens = [];
$pendingFileHeaders = [];
$parentCount = 0;
$remainingSourceLines = null;
$remainingTargetLines = null;

foreach ($matches[0] as $match) {
$line = $match[0];

if ($parentCount === 0 && $pendingFileHeaders !== []) {
if ($this->isOldFileHeader($line)) {
$pendingFileHeaders[] = $match;

continue;
}

if ($this->isNewFileHeader($line)) {
$this->appendTokens($tokens, $pendingFileHeaders, DiffTokenType::FILE_HEADER);
$pendingFileHeaders = [];
$tokens[] = new Token(
offset: $match[1],
value: $line,
type: DiffTokenType::FILE_HEADER,
);

continue;
}

$this->appendTokens($tokens, $pendingFileHeaders, DiffTokenType::DELETION);
$pendingFileHeaders = [];
}

$type = null;

if ($this->isGitMetadata($line)) {
$type = DiffTokenType::METADATA;
$parentCount = 0;
} elseif (($hunk = $this->parseHunkHeader($line)) !== null) {
$type = DiffTokenType::HUNK_HEADER;
$parentCount = $hunk['parentCount'];
$remainingSourceLines = $hunk['sourceLines'];
$remainingTargetLines = $hunk['targetLines'];
} elseif ($parentCount > 0) {
$type = $this->isSpecialMarker($line)
? DiffTokenType::SPECIAL
: $this->classifyHunkLine($line, $parentCount);

if ($parentCount === 1 && $type instanceof DiffTokenType) {
[$remainingSourceLines, $remainingTargetLines] = $this->consumeUnifiedLine(
$type,
$remainingSourceLines,
$remainingTargetLines,
);

if ($remainingSourceLines === 0 && $remainingTargetLines === 0) {
$parentCount = 0;
}
}
} elseif ($this->isOldFileHeader($line)) {
$pendingFileHeaders[] = $match;

continue;
} else {
$type = match (true) {
$this->isSpecialMarker($line) => DiffTokenType::SPECIAL,
str_starts_with($line, '+') => DiffTokenType::ADDITION,
str_starts_with($line, '-') => DiffTokenType::DELETION,
default => null,
};
}

if (! $type instanceof DiffTokenType) {
continue;
}

$tokens[] = new Token(
offset: $match[1],
value: $line,
type: $type,
);
}

$this->appendTokens($tokens, $pendingFileHeaders, DiffTokenType::DELETION);

return $tokens;
}

private function isGitMetadata(string $line): bool
{
return preg_match(self::GIT_METADATA_PATTERN, $line) === 1;
}

/**
* @return null|array{parentCount: int, sourceLines: ?int, targetLines: ?int}
*/
private function parseHunkHeader(string $line): ?array
{
if (preg_match('/^(?<marker>@{2,})(?:\s|$)/', $line, $matches) !== 1) {
return null;
}

$parentCount = strlen($matches['marker']) - 1;
$sourceLines = null;
$targetLines = null;

if (
$parentCount === 1
&& preg_match('/^@@ -\d+(?:,(?<source>\d+))? \+\d+(?:,(?<target>\d+))? @@(?:\s|$)/', $line, $ranges) === 1
) {
$sourceLines = ($ranges['source'] ?? '') === '' ? 1 : (int) $ranges['source'];
$targetLines = ($ranges['target'] ?? '') === '' ? 1 : (int) $ranges['target'];
}

return [
'parentCount' => $parentCount,
'sourceLines' => $sourceLines,
'targetLines' => $targetLines,
];
}

private function classifyHunkLine(string $line, int $parentCount): ?DiffTokenType
{
$status = substr($line, 0, $parentCount);

if (strlen($status) !== $parentCount || strspn($status, ' +-') !== $parentCount) {
return null;
}

return match (true) {
str_contains($status, '+') => DiffTokenType::ADDITION,
str_contains($status, '-') => DiffTokenType::DELETION,
default => DiffTokenType::CONTEXT,
};
}

/**
* @return array{?int, ?int}
*/
private function consumeUnifiedLine(
DiffTokenType $type,
?int $remainingSourceLines,
?int $remainingTargetLines,
): array {
if ($remainingSourceLines === null || $remainingTargetLines === null) {
return [$remainingSourceLines, $remainingTargetLines];
}

if ($type === DiffTokenType::CONTEXT || $type === DiffTokenType::DELETION) {
$remainingSourceLines = max(0, $remainingSourceLines - 1);
}

if ($type === DiffTokenType::CONTEXT || $type === DiffTokenType::ADDITION) {
$remainingTargetLines = max(0, $remainingTargetLines - 1);
}

return [$remainingSourceLines, $remainingTargetLines];
}

/**
* @param Token[] $tokens
* @param array<int, array{0: string, 1: int}> $lines
*/
private function appendTokens(array &$tokens, array $lines, DiffTokenType $type): void
{
foreach ($lines as $line) {
$tokens[] = new Token(
offset: $line[1],
value: $line[0],
type: $type,
);
}
}

private function isOldFileHeader(string $line): bool
{
return preg_match('/^---(?:\s|$)/', $line) === 1;
}

private function isNewFileHeader(string $line): bool
{
return preg_match('/^\+\+\+(?:\s|$)/', $line) === 1;
}

private function isSpecialMarker(string $line): bool
{
return $line === '\\ No newline at end of file'
|| $line === 'GIT binary patch'
|| preg_match('/^(?:Binary files|Files) .+ differ$/', $line) === 1
|| preg_match('/^(?:literal|delta) \d+$/', $line) === 1;
}
}
28 changes: 28 additions & 0 deletions src/Languages/Diff/DiffTokenType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

declare(strict_types=1);

namespace Tempest\Highlight\Languages\Diff;

use Tempest\Highlight\Tokens\TokenType;

enum DiffTokenType: string implements TokenType
{
case METADATA = 'diff-metadata';
case FILE_HEADER = 'diff-file-header';
case HUNK_HEADER = 'diff-hunk-header';
case ADDITION = 'diff-addition';
case DELETION = 'diff-deletion';
case CONTEXT = 'diff-context';
case SPECIAL = 'diff-special';

public function getValue(): string
{
return $this->value;
}

public function canContain(TokenType $other): bool
{
return $this !== $other;
}
}
24 changes: 13 additions & 11 deletions src/Languages/Diff/Injections/DiffAdditionInjection.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,28 @@

namespace Tempest\Highlight\Languages\Diff\Injections;

use Tempest\Highlight\Escape;
use Tempest\Highlight\Highlighter;
use Tempest\Highlight\Injection;
use Tempest\Highlight\Languages\Diff\DiffTokenType;
use Tempest\Highlight\ParsedInjection;
use Tempest\Highlight\Tokens\Token;

class DiffAdditionInjection implements Injection
{
public function parse(string $content, Highlighter $highlighter): ParsedInjection
{
$content = preg_replace_callback(
'/^\+(.*)$/m',
function (array $matches): string {
$open = Escape::tokens('<span class="hl-addition">+ ');
$close = Escape::tokens('</span>');
preg_match_all('/^(?<match>\+(?!\+\+(?:\s|$)).*)$/m', $content, $matches, PREG_OFFSET_CAPTURE);

return $open . $matches[1] . $close;
},
$content
);
$tokens = [];

return new ParsedInjection($content);
foreach ($matches['match'] as $match) {
$tokens[] = new Token(
offset: $match[1],
value: $match[0],
type: DiffTokenType::ADDITION,
);
}

return new ParsedInjection($content, $tokens);
}
}
24 changes: 13 additions & 11 deletions src/Languages/Diff/Injections/DiffDeletionInjection.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,28 @@

namespace Tempest\Highlight\Languages\Diff\Injections;

use Tempest\Highlight\Escape;
use Tempest\Highlight\Highlighter;
use Tempest\Highlight\Injection;
use Tempest\Highlight\Languages\Diff\DiffTokenType;
use Tempest\Highlight\ParsedInjection;
use Tempest\Highlight\Tokens\Token;

class DiffDeletionInjection implements Injection
{
public function parse(string $content, Highlighter $highlighter): ParsedInjection
{
$content = preg_replace_callback(
'/^\-(.*)$/m', // Matches lines starting with '+'
function (array $matches): string {
$open = Escape::tokens('<span class="hl-deletion">- ');
$close = Escape::tokens('</span>');
preg_match_all('/^(?<match>-(?!--(?:\s|$)).*)$/m', $content, $matches, PREG_OFFSET_CAPTURE);

return $open . $matches[1] . $close; // Wraps the matched line with the span
},
$content
);
$tokens = [];

return new ParsedInjection($content);
foreach ($matches['match'] as $match) {
$tokens[] = new Token(
offset: $match[1],
value: $match[0],
type: DiffTokenType::DELETION,
);
}

return new ParsedInjection($content, $tokens);
}
}
23 changes: 23 additions & 0 deletions src/Languages/Diff/Injections/DiffStructureInjection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace Tempest\Highlight\Languages\Diff\Injections;

use Tempest\Highlight\Highlighter;
use Tempest\Highlight\Injection;
use Tempest\Highlight\Languages\Diff\DiffParser;
use Tempest\Highlight\ParsedInjection;

final readonly class DiffStructureInjection implements Injection
{
public function __construct(
private DiffParser $parser = new DiffParser(),
) {
}

public function parse(string $content, Highlighter $highlighter): ParsedInjection
{
return new ParsedInjection($content, $this->parser->parse($content));
}
}
7 changes: 7 additions & 0 deletions src/Themes/CssTheme.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Tempest\Highlight\Themes;

use Tempest\Highlight\Highlighter;
use Tempest\Highlight\Languages\Diff\DiffTokenType;
use Tempest\Highlight\Tokens\TokenType;
use Tempest\Highlight\Tokens\TokenTypeEnum;
use Tempest\Highlight\WebTheme;
Expand Down Expand Up @@ -32,6 +33,12 @@ public function before(TokenType $tokenType): string
TokenTypeEnum::LITERAL => 'hl-literal',
TokenTypeEnum::COMMENT => 'hl-comment',
TokenTypeEnum::INJECTION => 'hl-injection',
DiffTokenType::METADATA, DiffTokenType::SPECIAL => 'hl-comment',
DiffTokenType::FILE_HEADER => 'hl-type',
DiffTokenType::HUNK_HEADER => 'hl-generic',
DiffTokenType::ADDITION => 'hl-addition',
DiffTokenType::DELETION => 'hl-deletion',
DiffTokenType::CONTEXT => 'hl-diff-context',
default => $tokenType->getValue(),
};

Expand Down
Loading
Loading