diff --git a/src/Escape.php b/src/Escape.php index 46774e04..0bfe59b7 100644 --- a/src/Escape.php +++ b/src/Escape.php @@ -26,15 +26,26 @@ public static function terminal(string $input): string ); } + /** + * Escapes HTML special characters and turns tokens back into raw markup in a single pass. + * The escape and token keys are disjoint and strtr never rescans replaced text, + * so this equals escaping first and reversing the tokens afterwards. + */ + private const array HTML_MAP = [ + '&' => '&', + '<' => '<', + '>' => '>', + '"' => '"', + '❶' => '&', + '❷' => '<', + '❸' => '>', + '❹' => '"', + self::INJECTION_TOKEN => '', + ]; + public static function html(string $input): string { - return self::reverse( - str_replace( - ['&', '<', '>', '"'], - ['&', '<', '>', '"'], - $input, - ), - ); + return strtr($input, self::HTML_MAP); } public static function tokens(string $input): string @@ -45,13 +56,4 @@ public static function tokens(string $input): string $input, ); } - - private static function reverse(string $input): string - { - return str_replace( - self::TOKEN_KEYS, - self::TOKEN_VALUES, - $input, - ); - } } diff --git a/src/Highlighter.php b/src/Highlighter.php index 843cf5b6..2ceecbaf 100644 --- a/src/Highlighter.php +++ b/src/Highlighter.php @@ -46,7 +46,17 @@ use Tempest\Highlight\Tokens\GroupTokens; use Tempest\Highlight\Tokens\ParseTokens; use Tempest\Highlight\Tokens\RenderTokens; - +use Tempest\Highlight\Tokens\TokenType; +use WeakMap; + +/** + * @phpstan-type LanguageCacheEntry array{ + * patterns: array, + * patternRequirements: array, + * before: list, + * after: list, + * } + */ final class Highlighter { private array $languages = []; @@ -56,12 +66,18 @@ final class Highlighter private readonly ParseTokens $parseTokens; private readonly GroupTokens $groupTokens; private readonly RenderTokens $renderTokens; - private array $patterns = []; + /** + * Per-language caches, keyed by the language instance itself so entries + * are dropped when a language is freed and can never be served to a + * different language that reuses its object id. Clones share this map, + * which is safe because entries depend only on the language. + * + * @var WeakMap + */ + private WeakMap $languageCache; private array $afterInjections = []; - /** @var array */ - private array $beforeInjectionsCache = []; - /** @var array */ - private array $afterInjectionsCache = []; + /** @var array */ + private array $requiredSubstrings = []; private ?self $nestedHighlighter = null; public function __construct( @@ -105,6 +121,7 @@ public function __construct( ->addLanguage(new JsxLanguage()) ->addLanguage(new TsxLanguage()); + $this->languageCache = new WeakMap(); $this->parseTokens = new ParseTokens(); $this->groupTokens = new GroupTokens(); $this->renderTokens = new RenderTokens($this->theme); @@ -203,8 +220,14 @@ private function parseContent(string $content, Language $language): string $tokens = []; $nestedHighlighter = $this->getNestedHighlighter(); + $languageCache = $this->getLanguageCache($language); + // Before Injections - foreach ($this->getBeforeInjections($language) as $injection) { + foreach ($languageCache['before'] as [$injection, $requiredSubstrings]) { + if ($requiredSubstrings !== null && ! $this->containsAny($content, $requiredSubstrings)) { + continue; + } + $parsedInjection = $injection->parse($content, $nestedHighlighter); $content = $parsedInjection->content; @@ -214,9 +237,15 @@ private function parseContent(string $content, Language $language): string } // Patterns - foreach ( - $this->parseTokens->parse($content, $this->getPatterns($language)) as $token - ) { + $patterns = $languageCache['patterns']; + + foreach ($languageCache['patternRequirements'] as $key => $requiredSubstrings) { + if (! $this->containsAny($content, $requiredSubstrings)) { + unset($patterns[$key]); + } + } + + foreach ($this->parseTokens->parseCompiled($content, $patterns) as $token) { $tokens[] = $token; } @@ -224,9 +253,18 @@ private function parseContent(string $content, Language $language): string $content = ($this->renderTokens)($content, $groupedTokens); // After Injections - foreach ($this->getAfterInjections($language) as $injection) { - $parsedInjection = $injection->parse($content, $nestedHighlighter); - $content = $parsedInjection->content; + if (! $this->isNested) { + foreach ($languageCache['after'] as [$injection, $requiredSubstrings]) { + if ($requiredSubstrings !== null && ! $this->containsAny($content, $requiredSubstrings)) { + continue; + } + + $content = $injection->parse($content, $nestedHighlighter)->content; + } + + if ($this->gutterInjection instanceof GutterInjection) { + $content = $this->gutterInjection->parse($content, $nestedHighlighter)->content; + } } if ($this->isNested) { @@ -237,61 +275,42 @@ private function parseContent(string $content, Language $language): string } /** - * @return Injection[] + * @return LanguageCacheEntry */ - private function getBeforeInjections(Language $language): array + private function getLanguageCache(Language $language): array { - $languageId = spl_object_id($language); - - if (isset($this->beforeInjectionsCache[$languageId])) { - return $this->beforeInjectionsCache[$languageId]; - } - - $this->buildInjectionCaches($language); - - return $this->beforeInjectionsCache[$languageId]; - } - - /** - * @return Injection[] - */ - private function getAfterInjections(Language $language): array - { - if ($this->isNested) { - return []; - } - - $languageId = spl_object_id($language); - - if (! isset($this->afterInjectionsCache[$languageId])) { - $this->buildInjectionCaches($language); - } - - $afterInjections = $this->afterInjectionsCache[$languageId]; - - if ($this->gutterInjection instanceof GutterInjection) { - $afterInjections[] = $this->gutterInjection; + if (isset($this->languageCache[$language])) { + return $this->languageCache[$language]; } - return $afterInjections; - } - - private function buildInjectionCaches(Language $language): void - { - $languageId = spl_object_id($language); $before = []; $after = []; foreach ($language->getInjections() as $injection) { if ($this->isAfterInjection($injection)) { - $after[] = $injection; + $after[] = [$injection, $this->getRequiredSubstrings($injection)]; } else { - $before[] = $injection; + $before[] = [$injection, $this->getRequiredSubstrings($injection)]; + } + } + + $patterns = $this->parseTokens->compile($language->getPatterns()); + $patternRequirements = []; + + foreach ($patterns as $key => [, $pattern]) { + $requiredSubstrings = $this->getRequiredSubstrings($pattern); + + if ($requiredSubstrings !== null) { + $patternRequirements[$key] = $requiredSubstrings; } } - $this->beforeInjectionsCache[$languageId] = $before; - $this->afterInjectionsCache[$languageId] = $after; + return $this->languageCache[$language] = [ + 'patterns' => $patterns, + 'patternRequirements' => $patternRequirements, + 'before' => $before, + 'after' => $after, + ]; } private function normalizeNewline(string $subject): string @@ -303,13 +322,6 @@ private function normalizeNewline(string $subject): string return str_replace(["\r\n", "\r"], "\n", $subject); } - private function getPatterns(Language $language): array - { - $languageId = spl_object_id($language); - - return $this->patterns[$languageId] ??= $language->getPatterns(); - } - private function isAfterInjection(Injection $injection): bool { $class = $injection::class; @@ -317,4 +329,34 @@ private function isAfterInjection(Injection $injection): bool return $this->afterInjections[$class] ??= new ReflectionClass($class)->getAttributes(After::class) !== []; } + + /** + * @return string[]|null + */ + private function getRequiredSubstrings(Injection|Pattern $instance): ?array + { + $class = $instance::class; + + if (! array_key_exists($class, $this->requiredSubstrings)) { + $attribute = new ReflectionClass($class)->getAttributes(RequiresSubstring::class)[0] ?? null; + + $this->requiredSubstrings[$class] = $attribute?->newInstance()->substrings ?: null; + } + + return $this->requiredSubstrings[$class]; + } + + /** + * @param string[] $substrings + */ + private function containsAny(string $content, array $substrings): bool + { + foreach ($substrings as $substring) { + if (str_contains($content, $substring)) { + return true; + } + } + + return false; + } } diff --git a/src/Languages/Base/Injections/AdditionInjection.php b/src/Languages/Base/Injections/AdditionInjection.php index a18fa557..8c36402c 100644 --- a/src/Languages/Base/Injections/AdditionInjection.php +++ b/src/Languages/Base/Injections/AdditionInjection.php @@ -9,8 +9,10 @@ use Tempest\Highlight\Highlighter; use Tempest\Highlight\Injection; use Tempest\Highlight\ParsedInjection; +use Tempest\Highlight\RequiresSubstring; #[After] +#[RequiresSubstring('{+', '+}', "\r")] final readonly class AdditionInjection implements Injection { private const string NEWLINE = "\n"; @@ -22,18 +24,25 @@ public function parse(string $content, Highlighter $highlighter): ParsedInjectio $content = str_replace('❷span class=❹ignore❹❸{+❷/span❸', '{+', $content); $content = str_replace('❷span class=❹ignore❹❸+}❷/span❸', '+}', $content); - preg_match_all('/(\{\+)((.|\n)*?)(\+})/', $content, $matches, PREG_OFFSET_CAPTURE); + $firstOpener = strpos($content, '{+'); + $lastCloser = $firstOpener === false ? false : strrpos($content, '+}', $firstOpener); - $parsedOffset = 0; + if ($lastCloser === false) { + return new ParsedInjection($content); + } + + // No match can end after the last closer, so unclosed openers past it are never rescanned to the end. + preg_match_all('/(\{\+)([\s\S]*?)(\+})/', substr($content, 0, $lastCloser + 2), $matches, PREG_OFFSET_CAPTURE); + + $parsedContent = ''; + $parsedUntil = 0; + $startingLineNumber = 1; + $linesCountedUntil = 0; $open = Escape::tokens(''); $close = Escape::tokens(''); - foreach ($matches[0] as $match) { - $matchedContent = $match[0]; - $offset = $match[1]; - $replacementOffset = $offset + $parsedOffset; - + foreach ($matches[0] as [$matchedContent, $offset]) { $parsedMatchedContent = str_replace( ['{+', self::NEWLINE, '+}'], [$open, $close . self::NEWLINE . $open, $close], @@ -41,11 +50,14 @@ public function parse(string $content, Highlighter $highlighter): ParsedInjectio ); if (($gutter = $highlighter->getGutterInjection()) instanceof GutterInjection) { - $startingLineNumber = substr_count( + $startingLineNumber += substr_count( haystack: $content, needle: self::NEWLINE, - length: $replacementOffset, - ) + 1; + offset: $linesCountedUntil, + length: $offset - $linesCountedUntil, + ); + + $linesCountedUntil = $offset; $totalAmountOfLines = substr_count( haystack: $parsedMatchedContent, @@ -59,17 +71,11 @@ public function parse(string $content, Highlighter $highlighter): ParsedInjectio } } - $content = substr_replace( - $content, - $parsedMatchedContent, - $replacementOffset, - strlen($matchedContent), - ); - - $parsedOffset += strlen($parsedMatchedContent) - strlen($matchedContent); + $parsedContent .= substr($content, $parsedUntil, $offset - $parsedUntil) . $parsedMatchedContent; + $parsedUntil = $offset + strlen($matchedContent); } - return new ParsedInjection($content); + return new ParsedInjection($parsedContent . substr($content, $parsedUntil)); } private function normalizeLineEndings(string $content): string diff --git a/src/Languages/Base/Injections/BlurInjection.php b/src/Languages/Base/Injections/BlurInjection.php index cd7b208e..3126e1b3 100644 --- a/src/Languages/Base/Injections/BlurInjection.php +++ b/src/Languages/Base/Injections/BlurInjection.php @@ -6,7 +6,9 @@ use Tempest\Highlight\Injection; use Tempest\Highlight\Languages\Base\IsHighlightInjection; +use Tempest\Highlight\RequiresSubstring; +#[RequiresSubstring('{~')] final readonly class BlurInjection implements Injection { use IsHighlightInjection; diff --git a/src/Languages/Base/Injections/CustomClassInjection.php b/src/Languages/Base/Injections/CustomClassInjection.php index 2edb9a72..31241deb 100644 --- a/src/Languages/Base/Injections/CustomClassInjection.php +++ b/src/Languages/Base/Injections/CustomClassInjection.php @@ -7,20 +7,27 @@ use Tempest\Highlight\Highlighter; use Tempest\Highlight\Injection; use Tempest\Highlight\ParsedInjection; +use Tempest\Highlight\RequiresSubstring; use Tempest\Highlight\Tokens\DynamicTokenType; use Tempest\Highlight\Tokens\Token; +#[RequiresSubstring('{:')] final readonly class CustomClassInjection implements Injection { public function parse(string $content, Highlighter $highlighter): ParsedInjection { - $pattern = '/(?{\:(?[\w-]+)\:)(?(.|\n)*?)(?:})/'; + $pattern = '/(?{\:(?[\w-]+)\:)(?[\s\S]*?)(?:})/'; $tokens = []; - preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE); + $firstOpener = strpos($content, '{:'); + $lastCloser = $firstOpener === false ? false : strrpos($content, ':}', $firstOpener); + + // No match can end after the last closer, so unclosed openers past it are never rescanned to the end. + preg_match_all($pattern, $lastCloser === false ? '' : substr($content, 0, $lastCloser + 2), $matches, PREG_OFFSET_CAPTURE); $additionalOffset = 0; + $hasMarkers = true; foreach (array_keys($matches[0]) as $key) { $startToken = $matches['start'][$key][0]; @@ -37,7 +44,12 @@ public function parse(string $content, Highlighter $highlighter): ParsedInjectio $additionalOffset += strlen($endToken); - $content = str_replace([$startToken, $endToken], '', $content); + if ($hasMarkers) { + $content = str_replace([$startToken, $endToken], '', $content); + + // Every start token begins with "{:", so once neither "{:" nor ":}" is left the remaining replacements are no-ops. + $hasMarkers = str_contains($content, '{:') || str_contains($content, ':}'); + } } return new ParsedInjection( diff --git a/src/Languages/Base/Injections/DeletionInjection.php b/src/Languages/Base/Injections/DeletionInjection.php index 7b96959e..f2052d1b 100644 --- a/src/Languages/Base/Injections/DeletionInjection.php +++ b/src/Languages/Base/Injections/DeletionInjection.php @@ -9,8 +9,10 @@ use Tempest\Highlight\Highlighter; use Tempest\Highlight\Injection; use Tempest\Highlight\ParsedInjection; +use Tempest\Highlight\RequiresSubstring; #[After] +#[RequiresSubstring('{-', '-}', "\r")] final readonly class DeletionInjection implements Injection { private const string NEWLINE = "\n"; @@ -22,18 +24,30 @@ public function parse(string $content, Highlighter $highlighter): ParsedInjectio $content = str_replace('❷span class=❹ignore❹❸{-❷/span❸', '{-', $content); $content = str_replace('❷span class=❹ignore❹❸-}❷/span❸', '-}', $content); - preg_match_all('/(? 0 ? strrpos($content, '-}', $lastCloser - 1 - strlen($content)) : false; + } + + if ($lastCloser === false) { + return new ParsedInjection($content); + } + + // No match can end after the last valid closer, so unclosed openers past it are never rescanned to the end. + // The extra byte keeps the closer's (?!}) lookahead seeing the same character. + preg_match_all('/(?'); $close = Escape::tokens(''); - foreach ($matches[0] as $match) { - $matchedContent = $match[0]; - $offset = $match[1]; - $replacementOffset = $offset + $parsedOffset; - + foreach ($matches[0] as [$matchedContent, $offset]) { $parsedMatchedContent = str_replace( ['{-', self::NEWLINE, '-}'], [$open, $close . self::NEWLINE . $open, $close], @@ -41,11 +55,14 @@ public function parse(string $content, Highlighter $highlighter): ParsedInjectio ); if (($gutter = $highlighter->getGutterInjection()) instanceof GutterInjection) { - $startingLineNumber = substr_count( + $startingLineNumber += substr_count( haystack: $content, needle: self::NEWLINE, - length: $replacementOffset, - ) + 1; + offset: $linesCountedUntil, + length: $offset - $linesCountedUntil, + ); + + $linesCountedUntil = $offset; $totalAmountOfLines = substr_count( haystack: $parsedMatchedContent, @@ -59,17 +76,11 @@ public function parse(string $content, Highlighter $highlighter): ParsedInjectio } } - $content = substr_replace( - $content, - $parsedMatchedContent, - $replacementOffset, - strlen($matchedContent), - ); - - $parsedOffset += strlen($parsedMatchedContent) - strlen($matchedContent); + $parsedContent .= substr($content, $parsedUntil, $offset - $parsedUntil) . $parsedMatchedContent; + $parsedUntil = $offset + strlen($matchedContent); } - return new ParsedInjection($content); + return new ParsedInjection($parsedContent . substr($content, $parsedUntil)); } private function normalizeLineEndings(string $content): string diff --git a/src/Languages/Base/Injections/EmphasizeInjection.php b/src/Languages/Base/Injections/EmphasizeInjection.php index ef079fbd..d29ad1d2 100644 --- a/src/Languages/Base/Injections/EmphasizeInjection.php +++ b/src/Languages/Base/Injections/EmphasizeInjection.php @@ -6,7 +6,9 @@ use Tempest\Highlight\Injection; use Tempest\Highlight\Languages\Base\IsHighlightInjection; +use Tempest\Highlight\RequiresSubstring; +#[RequiresSubstring('{_')] final readonly class EmphasizeInjection implements Injection { use IsHighlightInjection; diff --git a/src/Languages/Base/Injections/GutterInjection.php b/src/Languages/Base/Injections/GutterInjection.php index f6eae59c..f1f2f476 100644 --- a/src/Languages/Base/Injections/GutterInjection.php +++ b/src/Languages/Base/Injections/GutterInjection.php @@ -35,7 +35,18 @@ public function addClass(int $line, string $class): self public function parse(string $content, Highlighter $highlighter): ParsedInjection { - $lines = preg_split('/\R/u', trim($content, "\n")) ?? ''; + $icons = $this->icons; + $classes = $this->classes; + + // Marks belong to the parse that added them; a reused highlighter must start the next parse clean. + $this->icons = []; + $this->classes = []; + + $content = trim($content, "\n"); + + // Invalid UTF-8 makes the /u split fail; the fallback matches the same line breaks byte by byte. + $lines = preg_split('/\R/u', $content) + ?: preg_split('/\r\n|[\n\x0B\x0C\r]|\xC2\x85|\xE2\x80[\xA8\xA9]/', $content); $gutterNumbers = []; $longestGutterNumber = ''; @@ -43,7 +54,7 @@ public function parse(string $content, Highlighter $highlighter): ParsedInjectio foreach ($lines as $i => $line) { $gutterNumber = $i + $this->startAt; - if ($icon = ($this->icons[$i + $this->startAt] ?? null)) { + if ($icon = ($icons[$i + $this->startAt] ?? null)) { $gutterNumber .= ' ' . $icon; } @@ -59,7 +70,7 @@ public function parse(string $content, Highlighter $highlighter): ParsedInjectio foreach ($lines as $i => $line) { $gutterNumber = $gutterNumbers[$i]; - $hasClasses = $this->classes[$i + $this->startAt] ?? ''; + $hasClasses = $classes[$i + $this->startAt] ?? ''; $gutterClass = 'hl-gutter' . ($hasClasses ? ' ' . $hasClasses : ''); $lines[$i] = sprintf( diff --git a/src/Languages/Base/Injections/StrongInjection.php b/src/Languages/Base/Injections/StrongInjection.php index 6461479d..e8e1a52e 100644 --- a/src/Languages/Base/Injections/StrongInjection.php +++ b/src/Languages/Base/Injections/StrongInjection.php @@ -6,7 +6,9 @@ use Tempest\Highlight\Injection; use Tempest\Highlight\Languages\Base\IsHighlightInjection; +use Tempest\Highlight\RequiresSubstring; +#[RequiresSubstring('{*')] final readonly class StrongInjection implements Injection { use IsHighlightInjection; diff --git a/src/Languages/Base/IsHighlightInjection.php b/src/Languages/Base/IsHighlightInjection.php index dd8370c8..66c517a6 100644 --- a/src/Languages/Base/IsHighlightInjection.php +++ b/src/Languages/Base/IsHighlightInjection.php @@ -20,9 +20,25 @@ public function parse(string $content, Highlighter $highlighter): ParsedInjectio { $token = '\\' . $this->getToken(); - $pattern = '/(?{' . $token . ')(?(.|\n)*?)(?' . $token . '})(?!})/'; + $pattern = '/(?{' . $token . ')(?[\s\S]*?)(?' . $token . '})(?!})/'; - preg_match_all($pattern, $content, $matches, PREG_OFFSET_CAPTURE); + $subject = $content; + $closer = $this->getToken() . '}'; + + // An escaped single non-alphanumeric byte is a literal, so no match can end after the last valid closer + // and unclosed openers past it are never rescanned to the end. The extra byte feeds the (?!}) lookahead. + if (strlen($closer) === 2 && ! str_contains('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ', $closer[0])) { + $firstOpener = strpos($content, '{' . $this->getToken()); + $lastCloser = $firstOpener === false ? false : strrpos($content, $closer, $firstOpener); + + while ($lastCloser !== false && ($content[$lastCloser + 2] ?? '') === '}') { + $lastCloser = $lastCloser > 0 ? strrpos($content, $closer, $lastCloser - 1 - strlen($content)) : false; + } + + $subject = $lastCloser === false ? '' : substr($content, 0, $lastCloser + 3); + } + + preg_match_all($pattern, $subject, $matches, PREG_OFFSET_CAPTURE); $tokens = []; diff --git a/src/Languages/Base/Patterns/AdditionEndTokenPattern.php b/src/Languages/Base/Patterns/AdditionEndTokenPattern.php index 5807a49a..51bc8a26 100644 --- a/src/Languages/Base/Patterns/AdditionEndTokenPattern.php +++ b/src/Languages/Base/Patterns/AdditionEndTokenPattern.php @@ -7,10 +7,12 @@ use Tempest\Highlight\IsPattern; use Tempest\Highlight\Pattern; use Tempest\Highlight\PatternTest; +use Tempest\Highlight\RequiresSubstring; use Tempest\Highlight\Tokens\IgnoreTokenType; use Tempest\Highlight\Tokens\TokenType; #[PatternTest('{+ pull_request_target: +}', '+}')] +#[RequiresSubstring('+}')] final readonly class AdditionEndTokenPattern implements Pattern { use IsPattern; diff --git a/src/Languages/Base/Patterns/AdditionStartTokenPattern.php b/src/Languages/Base/Patterns/AdditionStartTokenPattern.php index 8355b1a7..13f1cf42 100644 --- a/src/Languages/Base/Patterns/AdditionStartTokenPattern.php +++ b/src/Languages/Base/Patterns/AdditionStartTokenPattern.php @@ -7,10 +7,12 @@ use Tempest\Highlight\IsPattern; use Tempest\Highlight\Pattern; use Tempest\Highlight\PatternTest; +use Tempest\Highlight\RequiresSubstring; use Tempest\Highlight\Tokens\IgnoreTokenType; use Tempest\Highlight\Tokens\TokenType; #[PatternTest('{+ pull_request_target: +}', '{+')] +#[RequiresSubstring('{+')] final readonly class AdditionStartTokenPattern implements Pattern { use IsPattern; diff --git a/src/Languages/Base/Patterns/DeletionEndTokenPattern.php b/src/Languages/Base/Patterns/DeletionEndTokenPattern.php index 857c61bf..3850a34b 100644 --- a/src/Languages/Base/Patterns/DeletionEndTokenPattern.php +++ b/src/Languages/Base/Patterns/DeletionEndTokenPattern.php @@ -7,12 +7,14 @@ use Tempest\Highlight\IsPattern; use Tempest\Highlight\Pattern; use Tempest\Highlight\PatternTest; +use Tempest\Highlight\RequiresSubstring; use Tempest\Highlight\Tokens\IgnoreTokenType; use Tempest\Highlight\Tokens\TokenType; #[PatternTest('{- pull_request_target: -}', '-}')] #[PatternTest('{-- pull_request_target: --}', null)] #[PatternTest('{{- variable -}}', null)] +#[RequiresSubstring('-}')] final readonly class DeletionEndTokenPattern implements Pattern { use IsPattern; diff --git a/src/Languages/Base/Patterns/DeletionStartTokenPattern.php b/src/Languages/Base/Patterns/DeletionStartTokenPattern.php index 8ed79d37..72b20cd0 100644 --- a/src/Languages/Base/Patterns/DeletionStartTokenPattern.php +++ b/src/Languages/Base/Patterns/DeletionStartTokenPattern.php @@ -7,12 +7,14 @@ use Tempest\Highlight\IsPattern; use Tempest\Highlight\Pattern; use Tempest\Highlight\PatternTest; +use Tempest\Highlight\RequiresSubstring; use Tempest\Highlight\Tokens\IgnoreTokenType; use Tempest\Highlight\Tokens\TokenType; #[PatternTest('{- pull_request_target: -}', '{-')] #[PatternTest('{-- pull_request_target: --}', null)] #[PatternTest('{{- variable -}}', null)] +#[RequiresSubstring('{-')] final readonly class DeletionStartTokenPattern implements Pattern { use IsPattern; diff --git a/src/Languages/Base/Patterns/InjectionTokenPattern.php b/src/Languages/Base/Patterns/InjectionTokenPattern.php index d3fc47f9..ea1f1ccf 100644 --- a/src/Languages/Base/Patterns/InjectionTokenPattern.php +++ b/src/Languages/Base/Patterns/InjectionTokenPattern.php @@ -7,15 +7,17 @@ use Tempest\Highlight\Escape; use Tempest\Highlight\IsPattern; use Tempest\Highlight\Pattern; +use Tempest\Highlight\RequiresSubstring; use Tempest\Highlight\Tokens\TokenTypeEnum; +#[RequiresSubstring(Escape::INJECTION_TOKEN)] final readonly class InjectionTokenPattern implements Pattern { use IsPattern; public function getPattern(): string { - return Escape::INJECTION_TOKEN . '(?(.|\n)*?)' . Escape::INJECTION_TOKEN; + return Escape::INJECTION_TOKEN . '(?[\s\S]*?)' . Escape::INJECTION_TOKEN; } public function getTokenType(): TokenTypeEnum diff --git a/src/Languages/Blade/Injections/BladeEchoInjection.php b/src/Languages/Blade/Injections/BladeEchoInjection.php index d9ab491b..daab067d 100644 --- a/src/Languages/Blade/Injections/BladeEchoInjection.php +++ b/src/Languages/Blade/Injections/BladeEchoInjection.php @@ -7,7 +7,9 @@ use Tempest\Highlight\Highlighter; use Tempest\Highlight\Injection; use Tempest\Highlight\IsInjection; +use Tempest\Highlight\RequiresSubstring; +#[RequiresSubstring('{{')] final readonly class BladeEchoInjection implements Injection { use IsInjection; diff --git a/src/Languages/Blade/Injections/BladeKeywordInjection.php b/src/Languages/Blade/Injections/BladeKeywordInjection.php index 79ead7b0..5cdda8d7 100644 --- a/src/Languages/Blade/Injections/BladeKeywordInjection.php +++ b/src/Languages/Blade/Injections/BladeKeywordInjection.php @@ -7,7 +7,9 @@ use Tempest\Highlight\Highlighter; use Tempest\Highlight\Injection; use Tempest\Highlight\IsInjection; +use Tempest\Highlight\RequiresSubstring; +#[RequiresSubstring('@')] final readonly class BladeKeywordInjection implements Injection { use IsInjection; diff --git a/src/Languages/Blade/Injections/BladePhpInjection.php b/src/Languages/Blade/Injections/BladePhpInjection.php index a3bd9bf5..5166a1fb 100644 --- a/src/Languages/Blade/Injections/BladePhpInjection.php +++ b/src/Languages/Blade/Injections/BladePhpInjection.php @@ -7,14 +7,17 @@ use Tempest\Highlight\Highlighter; use Tempest\Highlight\Injection; use Tempest\Highlight\IsInjection; +use Tempest\Highlight\RequiresSubstring; +#[RequiresSubstring('@endphp')] final readonly class BladePhpInjection implements Injection { use IsInjection; public function getPattern(): string { - return '\@php(?(.|\n)*?)\@endphp'; + // (*COMMIT) stops the search when an opener has no closer: later openers cannot have one either. + return '\@php(*COMMIT)(?[\s\S]*?)\@endphp'; } public function parseContent(string $content, Highlighter $highlighter): string diff --git a/src/Languages/Blade/Injections/BladeRawEchoInjection.php b/src/Languages/Blade/Injections/BladeRawEchoInjection.php index 85361ca3..52460b5a 100644 --- a/src/Languages/Blade/Injections/BladeRawEchoInjection.php +++ b/src/Languages/Blade/Injections/BladeRawEchoInjection.php @@ -7,7 +7,9 @@ use Tempest\Highlight\Highlighter; use Tempest\Highlight\Injection; use Tempest\Highlight\IsInjection; +use Tempest\Highlight\RequiresSubstring; +#[RequiresSubstring('{!!')] final readonly class BladeRawEchoInjection implements Injection { use IsInjection; diff --git a/src/Languages/Blade/Patterns/BladeCommentPattern.php b/src/Languages/Blade/Patterns/BladeCommentPattern.php index 7a69df23..df52fc88 100644 --- a/src/Languages/Blade/Patterns/BladeCommentPattern.php +++ b/src/Languages/Blade/Patterns/BladeCommentPattern.php @@ -19,7 +19,8 @@ public function getPattern(): string { - return '(?\{\{\-\-(.|\n)*?\-\-\}\})'; + // (*COMMIT) stops the search when an opener has no closer: later openers cannot have one either. + return '(?\{\{\-\-(?:[\s\S]*?\-\-\}\}|(*COMMIT)(*FAIL)))'; } public function getTokenType(): TokenTypeEnum diff --git a/src/Languages/Css/Patterns/CssCommentPattern.php b/src/Languages/Css/Patterns/CssCommentPattern.php index af72ec6b..66114fa0 100644 --- a/src/Languages/Css/Patterns/CssCommentPattern.php +++ b/src/Languages/Css/Patterns/CssCommentPattern.php @@ -22,7 +22,8 @@ public function getPattern(): string { - return '/(?\/\*(.|\n)*?\*\/)/m'; + // (*COMMIT) stops the search when an opener has no closer: later openers cannot have one either. + return '/(?\/\*(?:[\s\S]*?\*\/|(*COMMIT)(*FAIL)))/m'; } public function getTokenType(): TokenTypeEnum diff --git a/src/Languages/Diff/Injections/DiffAdditionInjection.php b/src/Languages/Diff/Injections/DiffAdditionInjection.php index 2e89e2f8..34341308 100644 --- a/src/Languages/Diff/Injections/DiffAdditionInjection.php +++ b/src/Languages/Diff/Injections/DiffAdditionInjection.php @@ -8,8 +8,10 @@ use Tempest\Highlight\Injection; use Tempest\Highlight\Languages\Diff\DiffTokenType; use Tempest\Highlight\ParsedInjection; +use Tempest\Highlight\RequiresSubstring; use Tempest\Highlight\Tokens\Token; +#[RequiresSubstring('+')] class DiffAdditionInjection implements Injection { public function parse(string $content, Highlighter $highlighter): ParsedInjection diff --git a/src/Languages/Diff/Injections/DiffDeletionInjection.php b/src/Languages/Diff/Injections/DiffDeletionInjection.php index bc4b2253..c4c9dfe6 100644 --- a/src/Languages/Diff/Injections/DiffDeletionInjection.php +++ b/src/Languages/Diff/Injections/DiffDeletionInjection.php @@ -8,8 +8,10 @@ use Tempest\Highlight\Injection; use Tempest\Highlight\Languages\Diff\DiffTokenType; use Tempest\Highlight\ParsedInjection; +use Tempest\Highlight\RequiresSubstring; use Tempest\Highlight\Tokens\Token; +#[RequiresSubstring('-')] class DiffDeletionInjection implements Injection { public function parse(string $content, Highlighter $highlighter): ParsedInjection diff --git a/src/Languages/Dockerfile/Patterns/DoubleQuoteValuePattern.php b/src/Languages/Dockerfile/Patterns/DoubleQuoteValuePattern.php index 8dbebe0a..5101e8e9 100644 --- a/src/Languages/Dockerfile/Patterns/DoubleQuoteValuePattern.php +++ b/src/Languages/Dockerfile/Patterns/DoubleQuoteValuePattern.php @@ -17,7 +17,7 @@ public function getPattern(): string { - return '(?"(\\\"|.)*?")'; + return '(?"(?:[^"\\\\\n]++|\\\\"|\\\\)*+"|"[^\n]*\\\\")'; } public function getTokenType(): TokenTypeEnum diff --git a/src/Languages/Dockerfile/Patterns/SingleQuoteValuePattern.php b/src/Languages/Dockerfile/Patterns/SingleQuoteValuePattern.php index 3d5e93fc..50226426 100644 --- a/src/Languages/Dockerfile/Patterns/SingleQuoteValuePattern.php +++ b/src/Languages/Dockerfile/Patterns/SingleQuoteValuePattern.php @@ -16,7 +16,7 @@ public function getPattern(): string { - return "(?'(\\\'|.)*?')"; + return '(?\'(?:[^\'\\\\\n]++|\\\\\'|\\\\)*+\'|\'[^\n]*\\\\\')'; } public function getTokenType(): TokenTypeEnum diff --git a/src/Languages/Ellison/Injections/ParserInjection.php b/src/Languages/Ellison/Injections/ParserInjection.php index b60adad0..96ef981a 100644 --- a/src/Languages/Ellison/Injections/ParserInjection.php +++ b/src/Languages/Ellison/Injections/ParserInjection.php @@ -16,7 +16,7 @@ public function getPattern(): string { - return '(?(.|\n)*)'; + return '(?[\s\S]*)'; } public function parseContent(string $content, Highlighter $highlighter): string diff --git a/src/Languages/Gdscript/GdscriptLanguage.php b/src/Languages/Gdscript/GdscriptLanguage.php index 9e4fd79c..357d2944 100644 --- a/src/Languages/Gdscript/GdscriptLanguage.php +++ b/src/Languages/Gdscript/GdscriptLanguage.php @@ -9,11 +9,11 @@ use Tempest\Highlight\Languages\Gdscript\Patterns\AnnotationPattern; use Tempest\Highlight\Languages\Gdscript\Patterns\AsTypePattern; use Tempest\Highlight\Languages\Gdscript\Patterns\ClassNamePattern; +use Tempest\Highlight\Languages\Gdscript\Patterns\CombinedGdscriptKeywordPattern; use Tempest\Highlight\Languages\Gdscript\Patterns\DoubleQuoteValuePattern; use Tempest\Highlight\Languages\Gdscript\Patterns\ExtendsPattern; use Tempest\Highlight\Languages\Gdscript\Patterns\FunctionCallPattern; use Tempest\Highlight\Languages\Gdscript\Patterns\FunctionNamePattern; -use Tempest\Highlight\Languages\Gdscript\Patterns\KeywordPattern; use Tempest\Highlight\Languages\Gdscript\Patterns\OperatorPattern; use Tempest\Highlight\Languages\Gdscript\Patterns\PropertyAccessPattern; use Tempest\Highlight\Languages\Gdscript\Patterns\ReturnTypePattern; @@ -68,7 +68,6 @@ public function getPatterns(): array new OperatorPattern('+'), new OperatorPattern('-'), new OperatorPattern('*'), - new OperatorPattern('/'), new OperatorPattern('&'), new OperatorPattern('^'), new OperatorPattern('|'), @@ -76,42 +75,44 @@ public function getPatterns(): array new OperatorPattern('>'), new OperatorPattern('!'), - new KeywordPattern('if'), - new KeywordPattern('elif'), - new KeywordPattern('else'), - new KeywordPattern('for'), - new KeywordPattern('while'), - new KeywordPattern('match'), - new KeywordPattern('break'), - new KeywordPattern('continue'), - new KeywordPattern('pass'), - new KeywordPattern('return'), - new KeywordPattern('class'), - new KeywordPattern('class_name'), - new KeywordPattern('extends'), - new KeywordPattern('is'), - new KeywordPattern('not'), - new KeywordPattern('and'), - new KeywordPattern('or'), - new KeywordPattern('in'), - new KeywordPattern('as'), - new KeywordPattern('self'), - new KeywordPattern('signal'), - new KeywordPattern('func'), - new KeywordPattern('static'), - new KeywordPattern('const'), - new KeywordPattern('enum'), - new KeywordPattern('var'), - new KeywordPattern('breakpoint'), - new KeywordPattern('preload'), - new KeywordPattern('await'), - new KeywordPattern('yield'), - new KeywordPattern('assert'), - new KeywordPattern('super'), - new KeywordPattern('PI'), - new KeywordPattern('INF'), - new KeywordPattern('TAU'), - new KeywordPattern('NAN'), + new CombinedGdscriptKeywordPattern([ + 'if', + 'elif', + 'else', + 'for', + 'while', + 'match', + 'break', + 'continue', + 'pass', + 'return', + 'class', + 'class_name', + 'extends', + 'is', + 'not', + 'and', + 'or', + 'in', + 'as', + 'self', + 'signal', + 'func', + 'static', + 'const', + 'enum', + 'var', + 'breakpoint', + 'preload', + 'await', + 'yield', + 'assert', + 'super', + 'PI', + 'INF', + 'TAU', + 'NAN', + ]), new SinglelineCommentPattern(), diff --git a/src/Languages/Gdscript/Patterns/CombinedGdscriptKeywordPattern.php b/src/Languages/Gdscript/Patterns/CombinedGdscriptKeywordPattern.php new file mode 100644 index 00000000..7e848a94 --- /dev/null +++ b/src/Languages/Gdscript/Patterns/CombinedGdscriptKeywordPattern.php @@ -0,0 +1,50 @@ + strlen($b) <=> strlen($a)); + + $alternation = implode( + '|', + array_map( + static fn (string $keyword): string => preg_quote($keyword, '/'), + $keywords, + ), + ); + + $this->compiledPattern = "/\b(?(?:{$alternation}))\b/"; + } + + public function match(string $content): array + { + preg_match_all($this->compiledPattern, $content, $matches, PREG_OFFSET_CAPTURE); + + return $matches; + } + + public function getTokenType(): TokenTypeEnum + { + return TokenTypeEnum::KEYWORD; + } +} diff --git a/src/Languages/Gdscript/Patterns/SinglelineCommentPattern.php b/src/Languages/Gdscript/Patterns/SinglelineCommentPattern.php index 48db0391..f8cb3b3f 100644 --- a/src/Languages/Gdscript/Patterns/SinglelineCommentPattern.php +++ b/src/Languages/Gdscript/Patterns/SinglelineCommentPattern.php @@ -16,7 +16,7 @@ public function getPattern(): string { - return '(?#(.)*)'; + return '(?#.*)'; } public function getTokenType(): TokenTypeEnum diff --git a/src/Languages/Graphql/Patterns/GraphqlFieldPattern.php b/src/Languages/Graphql/Patterns/GraphqlFieldPattern.php index 184fbf2e..941ab7df 100644 --- a/src/Languages/Graphql/Patterns/GraphqlFieldPattern.php +++ b/src/Languages/Graphql/Patterns/GraphqlFieldPattern.php @@ -16,7 +16,7 @@ final class GraphqlFieldPattern implements Pattern public function getPattern(): string { - return '(?[_A-Za-z][_0-9A-Za-z]*)(?=\s*:)'; + return '(?[_A-Za-z][_0-9A-Za-z]*+)(?=\s*:)'; } public function getTokenType(): TokenTypeEnum diff --git a/src/Languages/Graphql/Patterns/GraphqlStringPattern.php b/src/Languages/Graphql/Patterns/GraphqlStringPattern.php index 92ee0f75..40e62849 100644 --- a/src/Languages/Graphql/Patterns/GraphqlStringPattern.php +++ b/src/Languages/Graphql/Patterns/GraphqlStringPattern.php @@ -16,7 +16,8 @@ final class GraphqlStringPattern implements Pattern public function getPattern(): string { - return '"(?([^"\\\\]|\\\\.)*)"'; + // An unclosed string would be rescanned from each escaped quote inside it, so skip past it + return '"(?(?:[^"\\\\]++|\\\\.)*+)(?:"|(*SKIP)(*FAIL))'; } public function getTokenType(): TokenTypeEnum diff --git a/src/Languages/Html/Injections/CssAttributeInHtmlInjection.php b/src/Languages/Html/Injections/CssAttributeInHtmlInjection.php index f53a3c38..f786d1e0 100644 --- a/src/Languages/Html/Injections/CssAttributeInHtmlInjection.php +++ b/src/Languages/Html/Injections/CssAttributeInHtmlInjection.php @@ -7,14 +7,16 @@ use Tempest\Highlight\Highlighter; use Tempest\Highlight\Injection; use Tempest\Highlight\IsInjection; +use Tempest\Highlight\RequiresSubstring; +#[RequiresSubstring('style="')] final readonly class CssAttributeInHtmlInjection implements Injection { use IsInjection; public function getPattern(): string { - return 'style="(?(.|\n)*?)"'; + return 'style="(?[\s\S]*?)"'; } public function parseContent(string $content, Highlighter $highlighter): string diff --git a/src/Languages/Html/Injections/CssInHtmlInjection.php b/src/Languages/Html/Injections/CssInHtmlInjection.php index 5f51cabc..0ff96b9a 100644 --- a/src/Languages/Html/Injections/CssInHtmlInjection.php +++ b/src/Languages/Html/Injections/CssInHtmlInjection.php @@ -7,14 +7,17 @@ use Tempest\Highlight\Highlighter; use Tempest\Highlight\Injection; use Tempest\Highlight\IsInjection; +use Tempest\Highlight\RequiresSubstring; +#[RequiresSubstring('')] final readonly class CssInHtmlInjection implements Injection { use IsInjection; public function getPattern(): string { - return '')] final class VueStyleScopedInjection implements Injection { use IsInjection; diff --git a/src/Languages/Vue/Injections/VueStyleScssInjection.php b/src/Languages/Vue/Injections/VueStyleScssInjection.php index b333ba3a..1182dac2 100644 --- a/src/Languages/Vue/Injections/VueStyleScssInjection.php +++ b/src/Languages/Vue/Injections/VueStyleScssInjection.php @@ -7,7 +7,9 @@ use Tempest\Highlight\Highlighter; use Tempest\Highlight\Injection; use Tempest\Highlight\IsInjection; +use Tempest\Highlight\RequiresSubstring; +#[RequiresSubstring('lang="s')] final class VueStyleScssInjection implements Injection { use IsInjection; diff --git a/src/Languages/Vue/Injections/VueTypeScriptInjection.php b/src/Languages/Vue/Injections/VueTypeScriptInjection.php index a9e45c17..77f67661 100644 --- a/src/Languages/Vue/Injections/VueTypeScriptInjection.php +++ b/src/Languages/Vue/Injections/VueTypeScriptInjection.php @@ -7,7 +7,9 @@ use Tempest\Highlight\Highlighter; use Tempest\Highlight\Injection; use Tempest\Highlight\IsInjection; +use Tempest\Highlight\RequiresSubstring; +#[RequiresSubstring('lang="ts"')] final class VueTypeScriptInjection implements Injection { use IsInjection; diff --git a/src/Languages/Xml/Patterns/XmlCommentPattern.php b/src/Languages/Xml/Patterns/XmlCommentPattern.php index 13ff8451..f17bcd26 100644 --- a/src/Languages/Xml/Patterns/XmlCommentPattern.php +++ b/src/Languages/Xml/Patterns/XmlCommentPattern.php @@ -26,7 +26,8 @@ public function getPattern(): string { - return '/(?\)/mU'; + // (*COMMIT) stops the search when an opener has no closer: later openers cannot have one either. + return '/(?\|(*COMMIT)(*FAIL)))/mU'; } public function getTokenType(): TokenTypeEnum diff --git a/src/Languages/Yaml/Patterns/YamlCommentPattern.php b/src/Languages/Yaml/Patterns/YamlCommentPattern.php index 0f5ab4d6..f3f9d81b 100644 --- a/src/Languages/Yaml/Patterns/YamlCommentPattern.php +++ b/src/Languages/Yaml/Patterns/YamlCommentPattern.php @@ -16,7 +16,7 @@ public function getPattern(): string { - return '(?\#(.)*)'; + return '(?\#.*)'; } public function getTokenType(): TokenTypeEnum diff --git a/src/RequiresSubstring.php b/src/RequiresSubstring.php new file mode 100644 index 00000000..18ac2371 --- /dev/null +++ b/src/RequiresSubstring.php @@ -0,0 +1,24 @@ +substrings = $substrings; + } +} diff --git a/src/Themes/InlineTheme.php b/src/Themes/InlineTheme.php index 437ca94f..7b91be9d 100644 --- a/src/Themes/InlineTheme.php +++ b/src/Themes/InlineTheme.php @@ -26,7 +26,7 @@ public function __construct(string $themePath) throw new Exception("No valid CSS file found at path {$themePath}"); } - preg_match_all('/(?[\w,\.\s\-]+){(?