Skip to content

Commit d28bc4e

Browse files
colinodellclaude
andauthored
Fix 2.9.0 regression: restore Cursor::match() remainder semantics (#1146)
* Restore remainder semantics in Cursor::match(), moving in-place matching to an internal method with native offset semantics Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add pathological cases covering attribute lists at and beyond the backtrack-limit boundary Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Drop the inert m modifiers from BacktickParser's patterns Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1755662 commit d28bc4e

14 files changed

Lines changed: 277 additions & 71 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) princi
1313
- Optimized inline link destination parsing to scan the line in place, so its cost follows the length of the destination rather than the length of everything left in the block
1414

1515
### Fixed
16+
- Fixed a regression introduced in 2.9.0 where `Cursor::match()` treated text before the cursor as part of the match subject (#1145). Patterns were matched against the whole line at an offset, which silently changed the meaning of `\b`, `\B`, `\A`, lookbehinds, a `^` anywhere other than the very start of the pattern, and a leading `^` combined with the `m` modifier. `match()` once again matches against the remainder, exactly as it did in 2.8; the core parsers keep the optimized in-place matching via a new internal method with PCRE's native offset semantics, anchoring their patterns at the cursor with `\G`
1617
- Fixed heading permalinks rendered with `aria-hidden="true"` remaining in the keyboard tab order; they are now also given `tabindex="-1"`, as a focusable element removed from the accessibility tree has no accessible name to announce when focused (WCAG 4.1.2)
1718
- Fixed cloning a node breaking the link from the original node's children back to their parent, silently corrupting the document that node belonged to; detaching or inserting around those children afterwards could drop nodes from the tree
1819
- Fixed cloned nodes sharing their `data` with the node they were cloned from, so that setting an attribute on either one also set it on the other

src/Extension/Attributes/Util/AttributesHelper.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
final class AttributesHelper
2525
{
2626
private const SINGLE_ATTRIBUTE = '\s*([.]-?[_a-z][^\s.}]*|[#][^\s}]+|' . RegexHelper::PARTIAL_ATTRIBUTENAME . RegexHelper::PARTIAL_ATTRIBUTEVALUESPEC . ')\s*';
27-
private const ATTRIBUTE_LIST = '/^{:?(' . self::SINGLE_ATTRIBUTE . ')+}/i';
27+
private const ATTRIBUTE_LIST = '/\G{:?(' . self::SINGLE_ATTRIBUTE . ')+}/i';
2828

2929
/**
3030
* PCRE's `\s` matches the form feed that PHP's default trim charlist omits, so the
@@ -53,7 +53,7 @@ public static function parseAttributes(Cursor $cursor): array
5353
// matching individual attributes since they won't need to look ahead for the closing '}'
5454
// while dealing with the fact that attributes can technically contain curly braces.
5555
// So we'll just match the start and end braces up front.
56-
$attributeExpression = $cursor->match(self::ATTRIBUTE_LIST);
56+
$attributeExpression = $cursor->matchInPlace(self::ATTRIBUTE_LIST);
5757
if ($attributeExpression === null) {
5858
$cursor->restoreState($state);
5959

@@ -66,7 +66,7 @@ public static function parseAttributes(Cursor $cursor): array
6666

6767
/** @var array<string, mixed> $attributes */
6868
$attributes = [];
69-
while ($attribute = \trim((string) $attributeCursor->match('/^' . self::SINGLE_ATTRIBUTE . '/i'), self::WHITESPACE)) {
69+
while ($attribute = \trim((string) $attributeCursor->matchInPlace('/\G' . self::SINGLE_ATTRIBUTE . '/i'), self::WHITESPACE)) {
7070
if ($attribute[0] === '#') {
7171
$attributes['id'] = \substr($attribute, 1);
7272

src/Extension/CommonMark/Parser/Block/FencedCodeStartParser.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public function tryStart(Cursor $cursor, MarkdownParserStateInterface $parserSta
2727
}
2828

2929
$indent = $cursor->getIndent();
30-
$fence = $cursor->match('/^[ \t]*(?:`{3,}+(?!.*`)|~{3,})/');
30+
$fence = $cursor->matchInPlace('/\G[ \t]*(?:`{3,}+(?!.*`)|~{3,})/');
3131
if ($fence === null) {
3232
return BlockStart::none();
3333
}

src/Extension/CommonMark/Parser/Inline/BacktickParser.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ public function parse(InlineParserContext $inlineContext): bool
5656
if ($this->findMatchingTicks(\strlen($ticks), $cursor)) {
5757
$code = $cursor->getSubstring($currentPosition, $cursor->getPosition() - $currentPosition - \strlen($ticks));
5858

59-
$c = \preg_replace('/\n/m', ' ', $code) ?? '';
59+
$c = \preg_replace('/\n/', ' ', $code) ?? '';
6060

6161
if (
6262
$c !== '' &&
@@ -110,7 +110,7 @@ private function findMatchingTicks(int $openTickLength, Cursor $cursor): bool
110110
return false;
111111
}
112112

113-
while ($ticks = $cursor->match('/`{1,' . self::MAX_BACKTICKS . '}/m')) {
113+
while ($ticks = $cursor->matchInPlace('/`{1,' . self::MAX_BACKTICKS . '}/')) {
114114
$numTicks = \strlen($ticks);
115115

116116
// Did we find the closer?

src/Extension/DescriptionList/Parser/DescriptionStartParser.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public function tryStart(Cursor $cursor, MarkdownParserStateInterface $parserSta
2929
}
3030

3131
$cursor->advanceToNextNonSpaceOrTab();
32-
if ($cursor->match('/^:[ \t]+/') === null) {
32+
if ($cursor->matchInPlace('/\G:[ \t]+/') === null) {
3333
return BlockStart::none();
3434
}
3535

src/Extension/FrontMatter/FrontMatterParser.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ final class FrontMatterParser implements FrontMatterParserInterface
2323
/** @psalm-readonly */
2424
private FrontMatterDataParserInterface $frontMatterParser;
2525

26-
private const REGEX_FRONT_MATTER = '/^---\\R.*?\\R---\\R/s';
26+
private const REGEX_FRONT_MATTER = '/\G---\\R.*?\\R---\\R/s';
2727

2828
public function __construct(FrontMatterDataParserInterface $frontMatterParser)
2929
{
@@ -38,7 +38,7 @@ public function parse(string $markdownContent): MarkdownInputWithFrontMatter
3838
$cursor = new Cursor($markdownContent);
3939

4040
// Locate the front matter
41-
$frontMatter = $cursor->match(self::REGEX_FRONT_MATTER);
41+
$frontMatter = $cursor->matchInPlace(self::REGEX_FRONT_MATTER);
4242
if ($frontMatter === null) {
4343
return new MarkdownInputWithFrontMatter($markdownContent);
4444
}
@@ -53,7 +53,7 @@ public function parse(string $markdownContent): MarkdownInputWithFrontMatter
5353
$data = $this->frontMatterParser->parse($frontMatter);
5454

5555
// Advance through any remaining newlines which separated the front matter from the Markdown text
56-
$trailingNewlines = $cursor->match('/^\R+/');
56+
$trailingNewlines = $cursor->matchInPlace('/\G\R+/');
5757

5858
// Calculate how many lines the Markdown is offset from the front matter by counting the number of newlines
5959
// Don't forget to add 1 because we stripped one out when trimming the trailing delims

src/Extension/Table/TableStartParser.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ private static function parseSeparator(Cursor $cursor): array
107107
$cursor->advanceBy(1);
108108
}
109109

110-
if ($cursor->match('/^-+/') === null) {
110+
if ($cursor->matchInPlace('/\G-+/') === null) {
111111
// Need at least one dash
112112
return [];
113113
}

src/Extension/TableOfContents/TableOfContentsPlaceholderParser.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ public function tryStart(Cursor $cursor, MarkdownParserStateInterface $parserSta
5858
}
5959

6060
// The placeholder must be the only thing on the line
61-
if ($cursor->match('/^' . \preg_quote($placeholder, '/') . '$/') === null) {
61+
if ($cursor->matchInPlace('/\G' . \preg_quote($placeholder, '/') . '$/') === null) {
6262
return BlockStart::none();
6363
}
6464

src/Parser/Cursor.php

Lines changed: 65 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -538,94 +538,107 @@ public function isAtEnd(): bool
538538
}
539539

540540
/**
541-
* Try to match a regular expression
541+
* Try to match a regular expression against the remainder of the line
542+
*
543+
* The subject begins at the cursor: text before the cursor is invisible to the pattern,
544+
* so "^" and "\A" anchor at the cursor, and constructs which examine what precedes the
545+
* match position (lookbehinds, "\b", "\B") see the start of a subject there rather than
546+
* the characters actually preceding the cursor.
542547
*
543548
* Returns the matching text and advances to the end of that match
544549
*
545550
* @psalm-param non-empty-string $regex
546551
*/
547552
public function match(string $regex): ?string
548553
{
549-
// When a tab has been partially consumed the remainder is reconstructed with the
550-
// leftover tab expanded into spaces, so matching must run against that reconstructed
551-
// string rather than the raw line. This is rare; use the copy-based path to preserve
552-
// the exact column arithmetic.
553-
if ($this->partiallyConsumedTab) {
554-
return $this->matchViaRemainder($regex);
555-
}
556-
557-
// Match against the persistent line at the current byte offset instead of allocating a
558-
// fresh copy of the remainder on every call. A leading "^" is rewritten to "\G" so the
559-
// pattern still anchors to the cursor - a bare "^" only matches at the true start of the
560-
// subject when a non-zero offset is supplied. Patterns that intentionally scan ahead
561-
// (e.g. the backtick closer search) carry no leading "^" and are left untouched. This
562-
// keeps repeated match() calls - such as that backtick scan - linear rather than O(n^2),
563-
// since each call no longer copies the entire remaining line.
564-
if ($regex[1] === '^') {
565-
$regex = $regex[0] . '\\G' . \substr($regex, 2);
566-
}
567-
568-
$bytePosition = $this->isMultibyte ? $this->byteOffset($this->currentPosition) : $this->currentPosition;
554+
$subject = $this->getRemainder();
569555

570-
if (! \preg_match($regex, $this->line, $matches, \PREG_OFFSET_CAPTURE, $bytePosition)) {
556+
if (! \preg_match($regex, $subject, $matches, \PREG_OFFSET_CAPTURE)) {
571557
return null;
572558
}
573559

574-
// $matches[0][0] contains the matched text; $matches[0][1] is its absolute byte offset in the line.
560+
// $matches[0][0] contains the matched text; $matches[0][1] is its byte offset in the subject.
575561
if ($this->isMultibyte) {
576-
// Convert the byte offset to a character advance relative to the cursor. The scanned gap
577-
// is only the distance from the cursor to the match (zero for anchored patterns), never
578-
// the whole line, so this stays linear across repeated calls.
579-
$offset = \mb_strlen(\substr($this->line, $bytePosition, $matches[0][1] - $bytePosition), 'UTF-8');
562+
$offset = \mb_strlen(\substr($subject, 0, $matches[0][1]), 'UTF-8');
580563
$matchLength = \mb_strlen($matches[0][0], 'UTF-8');
581564
} else {
582-
$offset = $matches[0][1] - $this->currentPosition;
565+
$offset = $matches[0][1];
583566
$matchLength = \strlen($matches[0][0]);
584567
}
585568

586-
$this->advanceBy($offset + $matchLength);
569+
$advance = $offset + $matchLength;
570+
571+
// The remainder we matched against had any partially-consumed tab expanded into spaces,
572+
// so those columns must be advanced by column instead of by character.
573+
if ($this->partiallyConsumedTab) {
574+
$charsToTab = 4 - ($this->column % 4);
575+
if ($advance < $charsToTab) {
576+
$this->advanceBy($advance, true);
577+
578+
return $matches[0][0];
579+
}
580+
581+
$this->advanceBy($charsToTab, true);
582+
$advance -= $charsToTab;
583+
}
584+
585+
$this->advanceBy($advance);
587586

588587
return $matches[0][0];
589588
}
590589

591590
/**
592-
* Slow path for match() used only when a tab has been partially consumed: match against a
593-
* freshly-built remainder whose leftover tab is expanded into spaces, advancing by columns
594-
* across that expansion. Kept separate so the common case avoids the remainder allocation.
591+
* Try to match a regular expression at the cursor's position within the line, without
592+
* copying the remainder
593+
*
594+
* Matches with PCRE's native offset semantics: the whole line is the subject, and matching
595+
* starts at the cursor. "\G" anchors at the cursor; "^" anchors at the true start of the
596+
* line (or after newlines under the "m" modifier); lookbehinds, "\b", and "\B" see the
597+
* characters actually preceding the cursor. This differs from match(), whose subject begins
598+
* at the cursor - a pattern written for match() migrates by replacing its leading "^" (or
599+
* "\A") with "\G".
600+
*
601+
* Because no copy of the remainder is made, repeated calls stay linear: match() copies
602+
* everything left in the line on every call, so scanning loops (such as the backtick closer
603+
* search) would otherwise cost O(n^2).
604+
*
605+
* When a tab has been partially consumed, no position within the line can represent the
606+
* cursor, so this falls back to matching the remainder with the leftover tab expanded into
607+
* spaces; "\G" still anchors at the cursor there, but the line content before it is not
608+
* visible in that case.
609+
*
610+
* @internal Planned to become public API in 2.10; until then the name and contract may
611+
* change without notice.
595612
*
596613
* @psalm-param non-empty-string $regex
597614
*/
598-
private function matchViaRemainder(string $regex): ?string
615+
public function matchInPlace(string $regex): ?string
599616
{
600-
$subject = $this->getRemainder();
617+
// A partially-consumed tab means the remainder differs from the underlying line (the
618+
// leftover tab expands into spaces), so no byte offset can represent the cursor.
619+
if ($this->partiallyConsumedTab) {
620+
return $this->match($regex);
621+
}
601622

602-
if (! \preg_match($regex, $subject, $matches, \PREG_OFFSET_CAPTURE)) {
623+
$bytePosition = $this->isMultibyte ? $this->byteOffset($this->currentPosition) : $this->currentPosition;
624+
625+
if (! \preg_match($regex, $this->line, $matches, \PREG_OFFSET_CAPTURE, $bytePosition)) {
603626
return null;
604627
}
605628

629+
// $matches[0][0] contains the matched text; $matches[0][1] is its absolute byte offset in the line.
606630
if ($this->isMultibyte) {
607-
$offset = \mb_strlen(\substr($subject, 0, $matches[0][1]), 'UTF-8');
631+
// Convert the byte offset to a character advance relative to the cursor. The scanned gap
632+
// is only the distance from the cursor to the match (zero for anchored patterns), never
633+
// the whole line, so this stays linear across repeated calls.
634+
$offset = \mb_strlen(\substr($this->line, $bytePosition, $matches[0][1] - $bytePosition), 'UTF-8');
608635
$matchLength = \mb_strlen($matches[0][0], 'UTF-8');
609636
} else {
610-
$offset = $matches[0][1];
637+
$offset = $matches[0][1] - $this->currentPosition;
611638
$matchLength = \strlen($matches[0][0]);
612639
}
613640

614-
$advance = $offset + $matchLength;
615-
616-
// The remainder we matched against had the partially-consumed tab expanded into spaces,
617-
// so those columns must be advanced by column instead of by character.
618-
$charsToTab = 4 - ($this->column % 4);
619-
if ($advance < $charsToTab) {
620-
$this->advanceBy($advance, true);
621-
622-
return $matches[0][0];
623-
}
624-
625-
$this->advanceBy($charsToTab, true);
626-
$advance -= $charsToTab;
627-
628-
$this->advanceBy($advance);
641+
$this->advanceBy($offset + $matchLength);
629642

630643
return $matches[0][0];
631644
}

src/Util/LinkParserHelper.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public static function parseLinkDestination(Cursor $cursor): ?string
4646

4747
public static function parseLinkLabel(Cursor $cursor): int
4848
{
49-
$match = $cursor->match('/^\[(?:[^\\\\\[\]]|\\\\.){0,1000}\]/');
49+
$match = $cursor->matchInPlace('/\G\[(?:[^\\\\\[\]]|\\\\.){0,1000}\]/');
5050
if ($match === null) {
5151
return 0;
5252
}
@@ -62,7 +62,7 @@ public static function parseLinkLabel(Cursor $cursor): int
6262

6363
public static function parsePartialLinkLabel(Cursor $cursor): ?string
6464
{
65-
return $cursor->match('/^(?:[^\\\\\[\]]++|\\\\.?)*+/');
65+
return $cursor->matchInPlace('/\G(?:[^\\\\\[\]]++|\\\\.?)*+/');
6666
}
6767

6868
/**
@@ -72,7 +72,7 @@ public static function parsePartialLinkLabel(Cursor $cursor): ?string
7272
*/
7373
public static function parseLinkTitle(Cursor $cursor): ?string
7474
{
75-
if ($title = $cursor->match('/' . RegexHelper::PARTIAL_LINK_TITLE . '/')) {
75+
if ($title = $cursor->matchInPlace('/\G' . RegexHelper::PARTIAL_LINK_TITLE_UNANCHORED . '/')) {
7676
// Chop off quotes from title and unescape
7777
return RegexHelper::unescape(\substr($title, 1, -1));
7878
}
@@ -84,7 +84,7 @@ public static function parsePartialLinkTitle(Cursor $cursor, string $endDelimite
8484
{
8585
$endDelimiter = \preg_quote($endDelimiter, '/');
8686
$regex = \sprintf('/(%s|[^%s\x00])*(?:%s)?/', RegexHelper::PARTIAL_ESCAPED_CHAR, $endDelimiter, $endDelimiter);
87-
if (($partialTitle = $cursor->match($regex)) === null) {
87+
if (($partialTitle = $cursor->matchInPlace($regex)) === null) {
8888
return null;
8989
}
9090

@@ -156,7 +156,7 @@ private static function parseDestinationBraces(Cursor $cursor): ?string
156156
self::$lastCursor = \WeakReference::create($cursor);
157157
}
158158

159-
if ($res = $cursor->match(RegexHelper::REGEX_LINK_DESTINATION_BRACES)) {
159+
if ($res = $cursor->matchInPlace('/\G' . RegexHelper::PARTIAL_LINK_DESTINATION_BRACES . '/')) {
160160
self::$lastCursorLacksClosingBrace = false;
161161

162162
// Chop off surrounding <..>:

0 commit comments

Comments
 (0)