Skip to content

perf: ~32% faster highlighting, fix silent token loss, memory and gutter bugs - #236

Closed
ArtMin96 wants to merge 15 commits into
tempestphp:mainfrom
ArtMin96:perf/engine-optimizations
Closed

ArtMin96 wants to merge 15 commits into
tempestphp:mainfrom
ArtMin96:perf/engine-optimizations

Conversation

@ArtMin96

@ArtMin96 ArtMin96 commented Oct 20, 2026 •

Copy link
Copy Markdown

Highlighting is about 32% faster across the 35 bench fixtures (Twig 63%, Blade 40%). Output is byte-identical except where it was wrong. Profiling turned up a number of bugs, most of them also on main, and they are fixed here with a failing test each.

Performance

Change Why
Escape::html is one strtr pass It ran nine str_replace passes over the whole output. The keys don't overlap, so one pass gives the same result.
Twig and GDScript keywords use one regex each They ran one preg_match_all per keyword (38 and 36). Whole-word keywords give the same tokens from one alternation.
Built-in patterns are compiled once per language "/{$pattern}/" and getPattern() were rebuilt on every call, including nested parses. Custom Pattern classes still go through match().
New #[RequiresSubstring] attribute Injections and patterns declare a literal every match needs, and are skipped when the content lacks it. Many injections run, often inside nested parses, on content where nothing can match. The attribute isn't inherited, so subclasses are never skipped by surprise.
Tokens sort on one packed integer key array_multisort over four arrays was the peak memory point.

Bug fixes

Bug On main Now
Language caches keyed by spl_object_id Same Twig input renders differently depending on what was parsed before Stable (WeakMap)
Comment or block over ~50KB Highlighting silently lost (PCRE JIT stack) Highlighted
String literal over ~6KB on one line Every later string of that quote type loses highlighting Highlighted
100KB of unclosed {- markers 21ms, because PCRE gave up. An earlier commit in this PR made it 648ms 16ms
2MB PHP file at memory_limit=128M Out of memory 86MB peak
Foo:: after a 50K-char identifier 451ms 15ms
function f($new) $<keyword>new</keyword> (lookbehind compiled to an end anchor) Plain variable
Python and, case _ Never highlighted (missing |) and is a keyword; _ only where Python reads it as a wildcard
Reused withGutter() highlighter Shows +/- marks from the previous block Marks reset per parse
Gutter with invalid UTF-8 input Fatal TypeError Renders
{:class:...:} with many class names Markup cache grows without bound Only enum types are cached
RubyLexer Keeps the last input in a static property for the process lifetime Held per language instance
PhpGenericTypeInjection One regex per type name; some names corrupt earlier markup One constant regex

Benchmarks

phpbench with Xdebug off. main, the first commit set and this branch ran alternately, and the faster of two rounds counts.

Fixture main This PR
twig 724.6µs 270.7µs -63%
blade 555.0µs 331.5µs -40%
svelte 530.3µs 387.0µs -27%
python 237.8µs 176.5µs -26%
javascript 152.2µs 117.2µs -23%
php 393.7µs 311.5µs -21%
all 35 5207.9µs 3518.4µs -32%

Every language got faster. On 50KB inputs Twig went from 15.0 to 4.8ms, Blade from 12.2 to 5.2ms and PHP from 7.1 to 4.8ms. EdgeCaseBench covers the pathological inputs above.

Behaviour changes

Gutter marks now reset after each parse. Marks added with addIcon/addClass before calling parse() still apply to that parse. Ruby patterns accept an optional shared RubyLexer, and the default stays as before.

Verification

I snapshotted 8,388 outputs from main: every fixture parsed as every language, three themes, gutter on and off, 30KB inputs and adversarial snippets. The only differences are the fixes above. Running the branch with skipping disabled gives identical output over that corpus and 180k fuzzed inputs. Every regex rewrite was fuzzed against the original with no differences, apart from the intended PhpGenericTypeInjection fix. There are 1,252 tests; phpstan and php-cs-fixer are clean, and each commit passes on its own.

Left for later

Swift's recursive patterns and Markdown emphasis still fail on very long lines. Svelte and Vue highlight some content twice, Blade echo and multiple <script> blocks match greedily, and Python f(a = 1) gets no argument token. Custom classes with many distinct names are still quadratic, PhpHeredocInjection builds a regex per language name, and blocks of about 1MB hit pcre.backtrack_limit.

Escape::html ran nine sequential str_replace passes over the full rendered
output. The escape keys and token keys are disjoint and strtr never rescans
replaced text, so one strtr pass gives identical output at about a third
of the cost.
Twig tags and GDScript ran one preg_match_all per keyword (38 and 36
patterns). Every keyword is a whole word bounded by \b, so a single
alternation yields exactly the same tokens with one regex pass.
…guages

The pattern and injection caches were keyed by spl_object_id. Injections
created a new language object for every match, so the caches missed on
each nested parse, and a freed object's id could be reused by a different
language, which then got the stale patterns.

Store the caches in a WeakMap keyed by the language instance, and let the
four injections that built a language per match reuse one instance.
…ting

Patterns used (.|\n)*? to match across lines. PCRE handles the alternation
group with one backtracking frame per character, so a block of roughly
50KB or more exhausts the JIT stack. preg_match_all then returns false and
the block is silently left unhighlighted.

[\s\S] matches the same characters as (.|\n) without the group, so the
output is unchanged and the size limit is gone.
@ArtMin96
ArtMin96 marked this pull request as draft October 20, 2026 08:53
…ctions

Lets a pattern or injection declare substrings it needs, so the
highlighter can skip it when the content contains none of them.
…-8 errors

Unclosed {- and similar markers made the marker injections rescan to the
end of the content for every opener. 100KB of them took ~650ms. Matching
now stops after the last valid closer, and Addition/Deletion build their
output in one pass instead of one substr_replace per marker.

A reused withGutter() highlighter kept line marks from the previous
parse, and content with invalid UTF-8 made the gutter throw a TypeError
because preg_split returned false. Marks are now cleared per parse, and
a byte-level split with the same line breaks is used as a fallback.

The Base markers also declare the substrings they need, so the
highlighter can skip them.
…bugs

Lazy (\\"|.)*? string patterns and (.)* comment patterns exhausted the PCRE
JIT stack on a single line of ~6KB, after which every later match of the
same pattern was silently lost. The rewrites match the same text without
per-character backtracking frames.

Also:
- PHP keywords: (?<!\$) in a double-quoted PHP string compiled to the end
  anchor (?<!$), so $new in function parameters got a keyword span.
- Python: a missing | meant 'and' and '_' were never keywords.
- StaticClassCallPattern was quadratic on long identifiers.
- RubyLexer kept the last input in a static property for the life of
  the process; it is now held per language instance.
- Comment patterns stop at an unclosed opener with (*COMMIT) instead of
  rescanning to the end (quadratic without JIT).
…mory

- Built-in patterns that use IsPattern::match() get their final regex
  and token type resolved once per language instead of on every parse.
- Patterns and injections marked with #[RequiresSubstring] are skipped
  when the content contains none of the substrings.
- Tokens from one pattern are cloned from a prototype, and render and
  grouping loops do less work per token.
- Tokens are sorted on one packed integer key instead of array_multisort
  over four arrays. A 2MB PHP file no longer runs out of memory at 128M.
- RenderTokens only caches markup for enum token types, keyed by class
  and case. Custom class names grew the cache without bound, and a
  custom TokenType returning a built-in value got the wrong markup.

Adds EdgeCaseBench for the pathological inputs.
Injections declare the literal every match needs, so the highlighter
skips them on content that cannot match. PhpGenericTypeInjection now
uses one constant pattern instead of building a regex per type name,
which also stops type names from corrupting earlier markup. Injection
regexes stop at an unclosed opener with (*COMMIT).
@ArtMin96 ArtMin96 changed the title perf: faster highlighting, fix stale language cache and large-block loss perf: ~32% faster highlighting, fix silent token loss, memory and gutter bugs Oct 20, 2026
@ArtMin96
ArtMin96 marked this pull request as ready for review October 20, 2026 11:02
@github-actions

github-actions Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

Benchmark Results

Comparison of perf/engine-optimizations against main (3d96482ee77208c8e445d4f68667918c7f440f62).

Open to see the benchmark results
benchmark subject set revs its mem_peak mode rstdev
HighlighterBench benchParse apache 100 5 2.107mb +2.45% 39.210215μs -25.90% ±1.25% +270.21%
HighlighterBench benchParse bash 100 5 2.140mb +2.07% 114.126654μs -24.06% ±0.23% -75.56%
HighlighterBench benchParse bbcode 100 5 2.095mb +2.31% 39.432368μs -27.00% ±0.48% -21.70%
HighlighterBench benchParse blade 100 5 2.779mb -7.16% 473.959354μs -34.47% ±0.85% +112.84%
HighlighterBench benchParse css 100 5 2.127mb +2.13% 78.995597μs -22.66% ±0.61% +34.22%
HighlighterBench benchParse diff 100 5 2.130mb +2.42% 59.579315μs -12.76% ±1.31% +239.64%
HighlighterBench benchParse dockerfile 100 5 2.099mb +2.37% 52.198924μs -18.49% ±0.76% +200.31%
HighlighterBench benchParse dotenv 100 5 2.111mb +2.28% 41.945029μs -26.27% ±0.50% -62.55%
HighlighterBench benchParse gdscript 100 5 2.207mb +1.92% 183.638806μs -23.71% ±0.68% +39.87%
HighlighterBench benchParse graphql 100 5 2.127mb +2.46% 84.547573μs -22.83% ±0.81% -29.34%
HighlighterBench benchParse html 100 5 2.130mb +2.61% 64.302348μs -22.64% ±1.04% +16.00%
HighlighterBench benchParse http 100 5 2.099mb +2.40% 11.168239μs -34.49% ±0.60% -27.59%
HighlighterBench benchParse ini 100 5 2.115mb +2.28% 47.002583μs -25.25% ±0.63% +218.24%
HighlighterBench benchParse javascript 100 5 2.195mb +1.77% 161.584677μs -17.02% ±2.40% +418.19%
HighlighterBench benchParse json 100 5 2.171mb +2.31% 135.689863μs -21.59% ±0.16% -70.28%
HighlighterBench benchParse markdown 100 5 2.117mb +2.48% 42.570137μs -22.86% ±1.27% +211.10%
HighlighterBench benchParse nginx 100 5 2.181mb +1.54% 147.253327μs -24.42% ±0.48% -32.63%
HighlighterBench benchParse php 100 5 2.474mb +2.51% 420.177456μs -23.44% ±1.08% +158.71%
HighlighterBench benchParse python 100 5 2.374mb +3.47% 242.720372μs -21.96% ±0.66% +148.27%
HighlighterBench benchParse ruby 100 5 2.416mb +1.81% 147.267906μs -14.17% ±0.78% +39.72%
HighlighterBench benchParse scss 100 5 2.142mb +2.13% 105.586380μs -20.66% ±0.56% -2.53%
HighlighterBench benchParse sema 100 5 2.311mb +2.54% 127.418650μs -18.85% ±0.74% +159.64%
HighlighterBench benchParse sql 100 5 2.141mb +2.48% 91.226791μs -19.50% ±0.54% +111.08%
HighlighterBench benchParse svelte 100 5 2.418mb +2.74% 562.194012μs -25.10% ±0.64% +45.10%
HighlighterBench benchParse swift 100 5 2.189mb +1.71% 142.900489μs -21.38% ±0.84% +21.42%
HighlighterBench benchParse terminal 100 5 2.136mb +2.07% 103.768982μs -22.45% ±0.67% +23.18%
HighlighterBench benchParse terraform 100 5 2.141mb +2.17% 98.765108μs -21.06% ±0.45% -35.74%
HighlighterBench benchParse typescript 100 5 2.571mb -4.34% 144.272798μs -19.09% ±0.68% +13.27%
HighlighterBench benchParse txt 100 5 2.070mb +2.05% 1.917847μs -69.40% ±2.27% +274.38%
HighlighterBench benchParse twig 100 5 19.249mb -88.14% 413.254658μs -62.12% ±0.78% +36.86%
HighlighterBench benchParse vue 100 5 2.426mb +3.44% 226.138650μs -19.80% ±1.05% +363.45%
HighlighterBench benchParse xml 100 5 2.146mb +2.04% 101.865421μs -22.97% ±0.51% +67.27%
HighlighterBench benchParse yaml 100 5 2.202mb +1.89% 181.550646μs -23.79% ±0.55% -34.40%
HighlighterBench benchParse jsx 100 5 2.138mb +2.63% 28.149609μs -23.33% ±0.58% -17.44%
HighlighterBench benchParse tsx 100 5 2.225mb +2.52% 42.846849μs -19.80% ±0.65% -20.54%
EdgeCaseBench benchUnclosedMarkers 3 3 ERR 12.339870ms +0.00% ±1.84% +0.00%
EdgeCaseBench benchLongStringLiteral 5 3 ERR 0.618382ms +0.00% ±1.08% +0.00%
EdgeCaseBench benchOneMegabytePhp 1 3 ERR 205.509656ms +0.00% ±0.47% +0.00%

Generated by phpbench against commit 6be53a0

@brendt
brendt requested a review from xHeaven October 22, 2026 08:31
@brendt

brendt commented Oct 22, 2026

Copy link
Copy Markdown
Member

New #[RequiresSubstring] attribute

Is this a requirement for all injections or an opt-in performance boost?

@ArtMin96

Copy link
Copy Markdown
Author

Is this a requirement for all injections or an opt-in performance boost?

#[RequiresSubstring] is opt-in. It only speeds things up, and no injection or pattern has to use it.

How the engine treats it (src/Highlighter.php:336-347):

  • getRequiredSubstrings() returns null when a class has no attribute, or has one with no arguments.
  • With null, the checks at src/Highlighter.php:227 and :258 never skip the class. It runs on every parse, exactly as on main.
  • When the attribute lists substrings, the class is skipped only if the content contains none of them. Matching is case-sensitive.
  • The engine reads the attribute from the concrete class only, so a subclass doesn't inherit it. A third-party class that extends one of ours is never skipped unless it adds the attribute itself.

@ArtMin96

Copy link
Copy Markdown
Author

@brendt (*COMMIT) is a PCRE control verb. It stops a regex from retrying at later positions once an opener such as /* has been found without a closer. I added it in round 2: 4ef0675 for patterns, ebf9682 for injections. It's in 10 places, for example src/Languages/Php/Patterns/MultilineSingleDocCommentPattern.php:18.

What it does. Take /\/\*(*COMMIT)[\s\S]*?\*\//. The regex finds /* and passes (*COMMIT). If no */ follows, the whole preg_match_all ends there. Without it, PCRE moves forward one character and tries again from the next /*. Each retry scans to the end of the input again, so the cost grows with the square of the input size.

Why it's safe. Every regex where I used it has a closer that doesn't depend on the opener (*/, ?>, </script>, the heredoc label). If the first unclosed opener has no closer after it, no later opener can have one either. Stopping early gives the same result. The output matched over the 8,388-case snapshot and the fuzz runs.

Measured on 20,000 unclosed /* (100KB):

                          with (*COMMIT)    without
JIT on  (default)         0.22 ms           0.31 ms
JIT off (pcre.jit=0)      0.61 ms           6,061 ms

Match results were identical in every case.

With JIT on, the speed gain is small: PCRE's JIT already handles this input well. The verb protects servers where JIT is off. Some hosts disable it, and PHP falls back to the interpreter when JIT can't allocate memory. On those servers a single unclosed comment in pasted code can take seconds to render.

@xHeaven xHeaven left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This message is generated by gpt-6.1-sol on behalf of xHeaven. It may contain errors—verify important details.

Requesting changes on ebf9682c7dd34ae769f754afee41c768ee6218ee (compared with 3d96482ee77208c8e445d4f68667918c7f440f62).

The performance improvements hold up in local testing: all 35 fixtures improved, with a 30.4% reduction in their summed execution times. The full suite passed (1,197 tests / 1,712 assertions), as did PHPStan and the style check. The additional review covered all 114 changed files and included focused tests, differential probes, lifecycle checks, and CommonMark integration.

Four independently reproduced regressions remain, detailed inline:

  • P2: packed sort keys lose precision under SORT_NUMERIC, changing token precedence on very large inputs.
  • P2: unterminated GraphQL strings containing escaped quotes trigger quadratic processing.
  • P3: compiled regexes and token types become stale when mutable built-in or certain custom patterns are reconfigured.
  • P3: ordinary Python _ identifiers before a colon receive wildcard keyword styling.

Please also add dedicated behavioral tests for CombinedGdscriptKeywordPattern and CombinedTwigKeywordPattern. Both require constructor arguments, so the attribute-based pattern test discovery does not exercise them. Include multiple matches, near misses, identifier boundaries, and capture offsets.

Local verification used PHP 8.5.12. PHP 8.4 passed hosted CI, but was not rerun locally. The generated and differential coverage is bounded; the inline comments state the conditions for each failure.

Comment thread src/Tokens/GroupTokens.php Outdated
Comment thread src/Languages/Graphql/Patterns/GraphqlStringPattern.php Outdated
Comment thread src/Tokens/ParseTokens.php
Comment thread src/Languages/Python/Patterns/PyKeywordPattern.php Outdated
@brendt

brendt commented Oct 22, 2026

Copy link
Copy Markdown
Member

@ArtMin96 are you ok looking at Mark's feedback, or want me to take a look?

@ArtMin96

Copy link
Copy Markdown
Author

@brendt I’ll check them all and apply the fixes that are valid. I’ll do it tomorrow.

@brendt

brendt commented Oct 22, 2026

Copy link
Copy Markdown
Member

Thanks!

Compiled regexes were reused for any IsPattern class under the
Tempest\Highlight namespace. KeywordPattern::caseInsensitive() and
custom classes in that namespace could change their pattern between
parses, and the old regex kept being used.

Freeze only classes whose file lives in this package's src directory
and whose properties are all readonly. OperatorPattern and
TwigKeywordPattern never change after construction, so their
properties are now readonly and they keep the fast path.
SORT_NUMERIC compares integers as doubles. Packed keys above 2^53,
reached from inputs of about 95MB, could compare equal, so a shorter
token stayed ahead of a longer one starting at the same offset.
The default flag compares two integers exactly.
An unclosed string with escaped quotes took quadratic time: after the
string failed, matching restarted at every escaped quote inside it
and scanned the same text again. A 262KB input took about 2 seconds.

Restarts inside the scanned body always fail the same way, so
(*SKIP) resumes after it. Fuzzing 200k inputs against the previous
regex found no difference in matches.
Restoring the missing alternation also enabled the _ branch wherever
a colon follows, so parameters, annotated names, dict keys, lambdas
and slices styled _ as a keyword. Require a preceding "case" so only
the wildcard in "case _:" matches.
Both patterns take their keyword list in the constructor, so the
PatternTest attribute discovery cannot build them. Cover multiple
matches with offsets, word boundaries, case, duplicates, the empty
list, and equality with one pattern per keyword on the bench fixture.
@ArtMin96
ArtMin96 requested a review from xHeaven October 23, 2026 07:32

@xHeaven xHeaven left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This message is generated by gpt-6.1-sol on behalf of xHeaven. It may contain errors—verify important details.

Requesting changes on 98ce35e302af2eaa60240288dc2dc67fde9a84d1.

The packed-key precision, GraphQL rescanning, and mutable-pattern caching findings are fixed, and the combined-keyword behavioral tests have been added. The Python underscore fix remains partial: the two inline comments cover a new multiple-whitespace omission and a remaining false positive after a comment ending in case.

Reviewed all 13 files changed since ebf9682, including independent standards and spec passes. Local verification passed: 1,228 repository tests / 1,749 assertions, PHPStan, and the style check. Both additional public-interface Python regression probes fail as described inline. Hosted checks, including PHP 8.4 and the performance check, are green. Local runs used PHP 8.5.12; generated probes are bounded.

Comment thread src/Languages/Python/Patterns/PyKeywordPattern.php Outdated
Comment thread src/Languages/Python/Patterns/PyKeywordPattern.php Outdated
The `(?<=\bcase\s)_` lookbehind missed `case  _:` and `case \t_:`, took
its context from a comment ending in "case", and ignored wildcards in
or, sequence, star, class and mapping patterns.

PyCaseWildcardPattern scans `case` clauses whose enclosing block is a
`match` statement (or the top level of a snippet) and marks `_` names
in the pattern, skipping strings, comments, guards and bodies. Its
results match the wildcards Python's ast reports across 2,467
generated programs.
@ArtMin96

Copy link
Copy Markdown
Author

Pushed. While I was on the _ wildcard I made it cover the rest of the pattern syntax too: or, sequence, star, class and mapping patterns (case 1 | _:, case [x, *_]:, case Point(x=_):) are highlighted now, and _ in guards, case bodies, strings, comments and Color._ stays plain. I checked it against ast on about 2,500 generated programs and the positions match.

OperatorPattern and TwigKeywordPattern also got readonly properties, so they keep the compiled path.

@xHeaven xHeaven left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This message is generated by an AI agent on behalf of @xHeaven. It may contain errors—verify important details.

Follow-up on 6be53a001e8b33ec9f0d74efc19879705b13f10e, compared with 98ce35e.

The two previous Python findings are fixed: multiple whitespace characters after case now work, and a comment ending in case no longer turns an ordinary annotation into a wildcard. The separate scanner also adds guarded and nested-pattern coverage.

Standards

All four changed files were reviewed independently. No separate design-smell finding. The identifier-boundary and multiline requirements in AGENTS.md remain incomplete in the two scanner paths described inline.

Spec

The claim that _ is styled only where Python reads it as a wildcard still has three confirmed P3 exceptions: ordinary annotations are accepted as clauses, ordinary multiline expressions can erase match context, and continued attribute/field names are styled as wildcards. Details and valid Python reproductions are inline. Source text remains intact.

Verification: 745 focused repository tests / 813 assertions passed; scoped PHPStan and the four-file dry-run style check passed. Ninety additional valid Python cases matched AST wildcard offsets. Three additional public-interface regression tests fail as described inline; the ordinary-annotation test passes at the previous head. All hosted checks, including PHP 8.4 and the performance check, are green. The Python fixture benchmark completed successfully on both heads with similar timings.

Local verification used PHP 8.5.12. Full PHPUnit, full static analysis, and all-language benchmarks were not repeated for this four-file update; generated coverage is bounded. Standards: two boundary/multiline failures, no independent smell. Spec: three P3 findings.

continue;
}

if ($char === ':' && $depth === 0) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This message is generated by an AI agent on behalf of @xHeaven. It may contain errors—verify important details.

[P3] Distinguish ordinary annotations from case clauses

The scanner accepts the annotation's colon as a case delimiter and marks an ordinary subscript identifier as a wildcard:

case = {}
_: int = 0
case[_]: int = 1

Actual final line: case[<span class="hl-keyword">_</span>]: …. Expected: case[_]: …, with _ plain. This is a module-level annotated assignment, not a match clause. case(_).value: int = 1 also incorrectly highlights the call argument.

The dictionary example both parses and executes successfully in Python. A public Highlighter::parse() regression test passes at 98ce35e and fails here. Please distinguish these ordinary statements before accepting a top-level colon, and add rendered annotation negatives alongside the fragment tests.


$indent = $this->indentWidth(substr($content, $lineStart, $indentLength));

while ($blocks !== [] && $blocks[array_key_last($blocks)][0] >= $indent) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This message is generated by an AI agent on behalf of @xHeaven. It may contain errors—verify important details.

[P3] Preserve match context across ordinary expression continuations

Only continued headers and strings are excluded from indentation tracking. A continued ordinary expression can therefore pop the enclosing match block:

match x:
    case 0:
        y = (
0
        )
    case _:
        pass

This is valid Python: indentation inside parentheses does not change the surrounding block. Actual final clause: case _:. Expected: case <span class="hl-keyword">_</span>:, as at 98ce35e.

The unindented 0 is treated as a statement, removes the match frame, and leaves the later case under a non-match frame. Please track ordinary bracket/backslash continuations before changing the indentation stack, and add a rendered regression. Confirmed with Python ast.parse() and through the public highlighter.


if ($word === 'if' && $depth === 0) {
$inPattern = false;
} elseif ($inPattern && $word === '_' && $previous !== '.' && ! preg_match('/\G[ \t\f]*(?:=(?!=)|\.)/', $content, $nameOrAttribute, 0, $next)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

This message is generated by an AI agent on behalf of @xHeaven. It may contain errors—verify important details.

[P3] Skip logical whitespace when distinguishing names from wildcards

The lookahead skips only horizontal whitespace before . or =, but valid patterns can continue those constructs across lines:

match x:
    case {
        _
        .key: _
    }:
        pass

Actual: both underscores receive hl-keyword. Expected: only the mapping value does; the first underscore is the name in the attribute expression _.key. The first underscore was correctly plain at 98ce35e.

The same issue affects a class-pattern field name split before =, for example case Point(_ followed by a newline and =value):. That underscore names a field and must remain plain. Please account for legal newlines, comments, and continuations when checking the next significant token. Both examples pass Python ast.parse() and reproduce through Highlighter::parse().

@brendt

brendt commented Oct 23, 2026

Copy link
Copy Markdown
Member

@ArtMin96 on second thought, I would like this work to be split into separate PRs. It's too much to be able to confidently review.

@brendt brendt closed this Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants