From f555969ba51d1030da65f50471ad44252a62637c Mon Sep 17 00:00:00 2001 From: Fabien Potencier Date: Fri, 21 Aug 2026 17:07:08 +0200 Subject: [PATCH] Fix terminal highlighting for diff lines --- src/Languages/Diff/DiffLanguage.php | 6 +- src/Languages/Diff/DiffParser.php | 210 +++++++++++++++ src/Languages/Diff/DiffTokenType.php | 28 ++ .../Diff/Injections/DiffAdditionInjection.php | 24 +- .../Diff/Injections/DiffDeletionInjection.php | 24 +- .../Injections/DiffStructureInjection.php | 23 ++ src/Themes/CssTheme.php | 7 + src/Themes/InlineTheme.php | 7 + src/Themes/LightTerminalTheme.php | 15 ++ tests/Languages/Diff/DiffLanguageTest.php | 246 ++++++++++++++++-- tests/stylesheets/diff.css | 19 ++ 11 files changed, 567 insertions(+), 42 deletions(-) create mode 100644 src/Languages/Diff/DiffParser.php create mode 100644 src/Languages/Diff/DiffTokenType.php create mode 100644 src/Languages/Diff/Injections/DiffStructureInjection.php create mode 100644 tests/stylesheets/diff.css diff --git a/src/Languages/Diff/DiffLanguage.php b/src/Languages/Diff/DiffLanguage.php index 36b1371f..d94517ef 100644 --- a/src/Languages/Diff/DiffLanguage.php +++ b/src/Languages/Diff/DiffLanguage.php @@ -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 { @@ -21,8 +20,7 @@ public function getInjections(): array { return [ ...parent::getInjections(), - new DiffAdditionInjection(), - new DiffDeletionInjection(), + new DiffStructureInjection(), ]; } } diff --git a/src/Languages/Diff/DiffParser.php b/src/Languages/Diff/DiffParser.php new file mode 100644 index 00000000..8181497a --- /dev/null +++ b/src/Languages/Diff/DiffParser.php @@ -0,0 +1,210 @@ +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('/^(?@{2,})(?:\s|$)/', $line, $matches) !== 1) { + return null; + } + + $parentCount = strlen($matches['marker']) - 1; + $sourceLines = null; + $targetLines = null; + + if ( + $parentCount === 1 + && preg_match('/^@@ -\d+(?:,(?\d+))? \+\d+(?:,(?\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 $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; + } +} diff --git a/src/Languages/Diff/DiffTokenType.php b/src/Languages/Diff/DiffTokenType.php new file mode 100644 index 00000000..9615adcf --- /dev/null +++ b/src/Languages/Diff/DiffTokenType.php @@ -0,0 +1,28 @@ +value; + } + + public function canContain(TokenType $other): bool + { + return $this !== $other; + } +} diff --git a/src/Languages/Diff/Injections/DiffAdditionInjection.php b/src/Languages/Diff/Injections/DiffAdditionInjection.php index 59f9eb12..2e89e2f8 100644 --- a/src/Languages/Diff/Injections/DiffAdditionInjection.php +++ b/src/Languages/Diff/Injections/DiffAdditionInjection.php @@ -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('+ '); - $close = Escape::tokens(''); + preg_match_all('/^(?\+(?!\+\+(?:\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); } } diff --git a/src/Languages/Diff/Injections/DiffDeletionInjection.php b/src/Languages/Diff/Injections/DiffDeletionInjection.php index 531c92a8..bc4b2253 100644 --- a/src/Languages/Diff/Injections/DiffDeletionInjection.php +++ b/src/Languages/Diff/Injections/DiffDeletionInjection.php @@ -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('- '); - $close = Escape::tokens(''); + preg_match_all('/^(?-(?!--(?:\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); } } diff --git a/src/Languages/Diff/Injections/DiffStructureInjection.php b/src/Languages/Diff/Injections/DiffStructureInjection.php new file mode 100644 index 00000000..8c9d8ee0 --- /dev/null +++ b/src/Languages/Diff/Injections/DiffStructureInjection.php @@ -0,0 +1,23 @@ +parser->parse($content)); + } +} diff --git a/src/Themes/CssTheme.php b/src/Themes/CssTheme.php index a0c3bc4e..ba83c555 100644 --- a/src/Themes/CssTheme.php +++ b/src/Themes/CssTheme.php @@ -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; @@ -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(), }; diff --git a/src/Themes/InlineTheme.php b/src/Themes/InlineTheme.php index 7e2cbe60..437ca94f 100644 --- a/src/Themes/InlineTheme.php +++ b/src/Themes/InlineTheme.php @@ -6,6 +6,7 @@ use Exception; use Tempest\Highlight\Highlighter; +use Tempest\Highlight\Languages\Diff\DiffTokenType; use Tempest\Highlight\Theme; use Tempest\Highlight\Tokens\TokenType; use Tempest\Highlight\Tokens\TokenTypeEnum; @@ -54,6 +55,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(), }; diff --git a/src/Themes/LightTerminalTheme.php b/src/Themes/LightTerminalTheme.php index a14e3f5e..295571ee 100644 --- a/src/Themes/LightTerminalTheme.php +++ b/src/Themes/LightTerminalTheme.php @@ -4,6 +4,7 @@ namespace Tempest\Highlight\Themes; +use Tempest\Highlight\Languages\Diff\DiffTokenType; use Tempest\Highlight\TerminalTheme; use Tempest\Highlight\Tokens\TokenType; use Tempest\Highlight\Tokens\TokenTypeEnum; @@ -14,6 +15,10 @@ class LightTerminalTheme implements TerminalTheme public function before(TokenType $tokenType): string { + if ($tokenType === DiffTokenType::CONTEXT) { + return ''; + } + $style = match ($tokenType) { TokenTypeEnum::KEYWORD => TerminalStyle::FG_DARK_BLUE, TokenTypeEnum::TYPE => TerminalStyle::FG_DARK_RED, @@ -23,6 +28,12 @@ public function before(TokenType $tokenType): string TokenTypeEnum::PROPERTY => TerminalStyle::FG_DARK_GREEN, TokenTypeEnum::GENERIC => TerminalStyle::FG_DARK_CYAN, TokenTypeEnum::COMMENT => TerminalStyle::FG_GRAY, + DiffTokenType::METADATA => TerminalStyle::FG_GRAY, + DiffTokenType::FILE_HEADER => TerminalStyle::FG_DARK_CYAN, + DiffTokenType::HUNK_HEADER => TerminalStyle::FG_DARK_MAGENTA, + DiffTokenType::ADDITION => TerminalStyle::FG_DARK_GREEN, + DiffTokenType::DELETION => TerminalStyle::FG_DARK_RED, + DiffTokenType::SPECIAL => TerminalStyle::FG_DARK_YELLOW, default => TerminalStyle::RESET, }; @@ -31,6 +42,10 @@ public function before(TokenType $tokenType): string public function after(TokenType $tokenType): string { + if ($tokenType === DiffTokenType::CONTEXT) { + return ''; + } + return TerminalStyle::ESC->value . TerminalStyle::RESET->value; } } diff --git a/tests/Languages/Diff/DiffLanguageTest.php b/tests/Languages/Diff/DiffLanguageTest.php index ac030f52..44874349 100644 --- a/tests/Languages/Diff/DiffLanguageTest.php +++ b/tests/Languages/Diff/DiffLanguageTest.php @@ -4,35 +4,249 @@ namespace Tempest\Highlight\Tests\Languages\Diff; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Tempest\Highlight\Highlighter; +use Tempest\Highlight\Themes\InlineTheme; +use Tempest\Highlight\Themes\LightTerminalTheme; +use Tempest\Highlight\Themes\TerminalStyle; class DiffLanguageTest extends TestCase { - #[DataProvider('provide_highlight_cases')] - public function test_highlight(string $content, string $expected): void + public function test_highlight_unified_git_diff_in_web(): void { + $content = <<<'TXT' +commit 0123456789abcdef +Merge: 1111111 2222222 +Author: Tempest +diff --git a/src/Foo.php b/src/Foo.php +old mode 100644 +new mode 100755 +similarity index 98% +rename from src/Old.php +rename to src/Foo.php +index 1111111..2222222 100755 +--- a/src/Foo.php ++++ b/src/Foo.php +@@ -1,4 +1,4 @@ + context +-old & stale ++new & fresh +\ No newline at end of file +Binary files a/logo.png and b/logo.png differ +GIT binary patch +literal 0 +diff --cc src/Foo.php +index 1111111,2222222..3333333 +@@@ -1,1 -1,1 +1,1 @@@ +TXT; + + $highlighter = new Highlighter(); + + $this->assertSame( + <<<'HTML' +commit 0123456789abcdef +Merge: 1111111 2222222 +Author: Tempest <hello@example.com> +diff --git a/src/Foo.php b/src/Foo.php +old mode 100644 +new mode 100755 +similarity index 98% +rename from src/Old.php +rename to src/Foo.php +index 1111111..2222222 100755 +--- a/src/Foo.php ++++ b/src/Foo.php +@@ -1,4 +1,4 @@ + context <tag> +-old & stale ++new & fresh +\ No newline at end of file +Binary files a/logo.png and b/logo.png differ +GIT binary patch +literal 0 +diff --cc src/Foo.php +index 1111111,2222222..3333333 +@@@ -1,1 -1,1 +1,1 @@@ +HTML, + $highlighter->parse($content, 'diff'), + ); + } + + public function test_highlight_unified_diff_with_inline_css(): void + { + $content = <<<'TXT' +diff --git a/file.txt b/file.txt +--- a/file.txt ++++ b/file.txt +@@ -1 +1 @@ + context +-removed ++added +\ No newline at end of file +TXT; + + $highlighter = new Highlighter(new InlineTheme(__DIR__ . '/../../stylesheets/diff.css')); + + $this->assertSame( + <<<'HTML' +diff --git a/file.txt b/file.txt +--- a/file.txt ++++ b/file.txt +@@ -1 +1 @@ + context +-removed ++added +\ No newline at end of file +HTML, + $highlighter->parse($content, 'diff'), + ); + } + + public function test_file_headers_take_precedence_over_changed_content(): void + { + $content = <<<'TXT' +--- old/path ++++ new/path +----removed content beginning with dashes +++++added content beginning with pluses +TXT; + + $highlighter = new Highlighter(); + + $this->assertSame( + <<<'HTML' +--- old/path ++++ new/path +----removed content beginning with dashes +++++added content beginning with pluses +HTML, + $highlighter->parse($content, 'diff'), + ); + } + + public function test_file_header_recognition_is_hunk_state_aware(): void + { + $content = <<<'TXT' +--- old/path ++++ new/path +@@ -1 +1 @@ +--- removed payload beginning with dashes and a space ++++ added payload beginning with pluses and a space +--- next/old/path ++++ next/new/path +TXT; + $highlighter = new Highlighter(); $this->assertSame( - $expected, + <<<'HTML' +--- old/path ++++ new/path +@@ -1 +1 @@ +--- removed payload beginning with dashes and a space ++++ added payload beginning with pluses and a space +--- next/old/path ++++ next/new/path +HTML, $highlighter->parse($content, 'diff'), ); } - public static function provide_highlight_cases(): iterable + public function test_combined_diff_body_status_columns(): void + { + $content = <<<'TXT' +@@@ -1,4 -1,4 +1,5 @@@ + unchanged in both parents +- removed from the first parent + -removed from the second parent ++ added relative to the first parent + +added relative to the second parent +++added relative to both parents +--removed from both parents +TXT; + + $highlighter = new Highlighter(); + + $this->assertSame( + <<<'HTML' +@@@ -1,4 -1,4 +1,5 @@@ + unchanged in both parents +- removed from the first parent + -removed from the second parent ++ added relative to the first parent + +added relative to the second parent +++added relative to both parents +--removed from both parents +HTML, + $highlighter->parse($content, 'diff'), + ); + } + + public function test_unmatched_file_header_candidates_are_parsed_in_linear_time(): void + { + $lineCount = 5000; + $content = implode("\n", array_map( + static fn (int $index): string => "--- candidate {$index}", + range(1, $lineCount), + )); + + $highlighter = new Highlighter(); + $startedAt = hrtime(true); + $output = $highlighter->parse($content, 'diff'); + $elapsedSeconds = (hrtime(true) - $startedAt) / 1_000_000_000; + + $this->assertSame($lineCount, substr_count($output, '')); + $this->assertLessThan(1.0, $elapsedSeconds); + } + + public function test_highlight_unified_git_diff_in_terminal(): void + { + $content = <<<'TXT' +diff --git a/file.txt b/file.txt +index 1111111..2222222 100644 +--- a/file.txt ++++ b/file.txt +@@ -1 +1 @@ + context +-removed ++added +\ No newline at end of file +Binary files a/logo.png and b/logo.png differ +@@@ -1,1 -1,1 +1,1 @@@ +TXT; + + $highlighter = new Highlighter(new LightTerminalTheme()); + $output = $highlighter->parse($content, 'diff'); + + $this->assertSame( + implode("\n", [ + $this->style(TerminalStyle::FG_GRAY, 'diff --git a/file.txt b/file.txt'), + $this->style(TerminalStyle::FG_GRAY, 'index 1111111..2222222 100644'), + $this->style(TerminalStyle::FG_DARK_CYAN, '--- a/file.txt'), + $this->style(TerminalStyle::FG_DARK_CYAN, '+++ b/file.txt'), + $this->style(TerminalStyle::FG_DARK_MAGENTA, '@@ -1 +1 @@'), + ' context', + $this->style(TerminalStyle::FG_DARK_RED, '-removed'), + $this->style(TerminalStyle::FG_DARK_GREEN, '+added'), + $this->style(TerminalStyle::FG_DARK_YELLOW, '\ No newline at end of file'), + $this->style(TerminalStyle::FG_DARK_YELLOW, 'Binary files a/logo.png and b/logo.png differ'), + $this->style(TerminalStyle::FG_DARK_MAGENTA, '@@@ -1,1 -1,1 +1,1 @@@'), + ]), + $output, + ); + + $this->assertSame( + $content, + preg_replace('/\e\[[0-9;]*m/', '', $output), + ); + } + + private function style(TerminalStyle $style, string $content): string { - return [ - [<<<'TXT' -+ $this->newCode(); -- $this->oldCode(); -TXT, - <<<'TXT' -+ $this->newCode(); -- $this->oldCode(); -TXT - ], - ]; + return TerminalStyle::ESC->value + . $style->value + . $content + . TerminalStyle::ESC->value + . TerminalStyle::RESET->value; } } diff --git a/tests/stylesheets/diff.css b/tests/stylesheets/diff.css new file mode 100644 index 00000000..723bc891 --- /dev/null +++ b/tests/stylesheets/diff.css @@ -0,0 +1,19 @@ +.hl-comment { + color: gray; +} + +.hl-type { + color: cyan; +} + +.hl-generic { + color: magenta; +} + +.hl-addition { + color: green; +} + +.hl-deletion { + color: red; +}