Skip to content

Commit e14f15f

Browse files
authored
Publish Cursor::matchInPlace() and deprecate the anchored RegexHelper constants (#1147)
1 parent cff6984 commit e14f15f

6 files changed

Lines changed: 41 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,18 @@ Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) princi
88

99
### Added
1010
- Added a new `table_of_contents/max_placeholder_entries` option to limit how many table of contents entries a document may render across all of its placeholders (#1134)
11+
- Added `Cursor::matchInPlace()`, which matches a regular expression at the cursor's position within the line using PCRE's native offset semantics instead of copying the remainder (#1145)
12+
- `\G` anchors at the cursor, `^` anchors at the start of the line, and lookbehinds and `\b` see the characters actually preceding the cursor; this keeps scanning loops linear and enables left-context assertions that `match()` cannot express
13+
- Added `RegexHelper::PARTIAL_LINK_TITLE_UNANCHORED` and `RegexHelper::PARTIAL_LINK_DESTINATION_BRACES`, unanchored fragments so each call site can supply its own anchor
1114

1215
### Changed
1316
- Changed the `TableOfContents` extension to render the table of contents once and share it across all placeholders instead of cloning it into each one (#1134)
1417
- A custom renderer registered for the `TableOfContents` node is no longer called once per placeholder, so it must return the same markup each time it is called for a given document (#1134)
1518
- The first placeholder receives the table of contents itself, so a document still contains a `TableOfContents` node for listeners which locate and reposition it (#1143)
1619

20+
### Deprecated
21+
- Deprecated `RegexHelper::PARTIAL_LINK_TITLE` and `RegexHelper::REGEX_LINK_DESTINATION_BRACES`; use the unanchored variants with an explicit anchor instead
22+
1723
## [2.9.2] - 2026-08-10
1824

1925
This release fixes a regression introduced in 2.9.0 which changed the behavior of `Cursor::match()` for certain regular expression patterns.

docs/2.x/customization/cursor.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,33 @@ You can then call any of the following methods to parse the string within that `
5757
| `advanceToNextNonSpaceOrTab()` | Advances forward past all spaces and tabs found, returning the number of such characters found |
5858
| `advanceToNextNonSpaceOrNewline()` | Advances forward past all spaces and newlines found, returning the number of such characters found |
5959
| `advanceToEnd()` | Advances the position to the very end of the string, returning the number of such characters passed |
60-
| `match(string $regex)` | Attempts to match the given `$regex`; returns `null` if matching fails, otherwise it advances past and returns the matched text |
60+
| `match(string $regex)` | Attempts to match the given `$regex` against the remainder; returns `null` if matching fails, otherwise it advances past and returns the matched text |
61+
| `matchInPlace(string $regex)` | Like `match()`, but matches at the cursor's position within the whole line instead of copying the remainder; see below |
6162
| `getPreviousText()` | Returns the text that was just advanced through during the last `advance__()` or `match()` operation |
6263
| `getRemainder()` | Returns the contents of the string from the current position through the end of the string |
6364
| `isBlank()` | Returns whether the remainder is blank (we're at the end or only space characters remain) |
6465
| `isAtEnd()` | Returns whether the cursor has reached the end of the string |
6566
| `saveState()` | Encapsulates the current state of the cursor into an `array` in case you need to `restoreState()` later |
6667
| `restoreState($state)` | Pass the result of `saveState()` back into here to restore the original state of the `Cursor` |
6768
| `getLine()` | Returns the entire string (not taking the position into account) |
69+
70+
## Regular Expression Matching
71+
72+
The `Cursor` offers two ways to match a regular expression at the current position. They differ in what the pattern is matched against:
73+
74+
- **`match()`** copies the remainder and matches against that copy, so the subject begins at the cursor. `^` and `\A` anchor at the cursor, and constructs which examine what precedes the match position (lookbehinds, `\b`) see the start of the subject there — never the actual preceding characters.
75+
- **`matchInPlace()`** (available since 2.10) matches against the whole line starting at the cursor's position, using PCRE's native offset semantics. `\G` anchors at the cursor, `^` means the start of the line, and lookbehinds and `\b` see the characters actually preceding the cursor.
76+
77+
`matchInPlace()` has two advantages:
78+
79+
- It avoids copying the remainder, so repeated calls (such as a scanning loop) stay fast instead of paying for a copy of everything left in the line on every call.
80+
- Because the text before the cursor stays visible to the pattern, it can answer contextual questions `match()` cannot — for example, `/(?<!\w)@\w+/` only matching a mention when the preceding character isn't a word character.
81+
82+
One exception: when the cursor has partially consumed a tab, no position within the line can represent it, so `matchInPlace()` falls back to matching a copy of the remainder with the leftover tab expanded into spaces. `\G` still anchors at the cursor there, but the text before the cursor is not visible to the pattern in that state.
83+
84+
To migrate a pattern from `match()` to `matchInPlace()`, replace its leading `^` (or `\A`) with `\G`, and double-check that any `\b` or lookbehind still means what you want now that it can see the preceding text:
85+
86+
```php
87+
$cursor->match('/^#+/'); // anchors at the cursor
88+
$cursor->matchInPlace('/\G#+/'); // equivalent, without copying the remainder
89+
```

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,13 @@ 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/', ' ', $code) ?? '';
59+
$c = \str_replace("\n", ' ', $code);
6060

6161
if (
6262
$c !== '' &&
6363
$c[0] === ' ' &&
6464
\substr($c, -1, 1) === ' ' &&
65-
\preg_match('/[^ ]/', $c)
65+
\strspn($c, ' ') !== \strlen($c)
6666
) {
6767
$c = \substr($c, 1, -1);
6868
}

src/Parser/Cursor.php

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -607,9 +607,6 @@ public function match(string $regex): ?string
607607
* spaces; "\G" still anchors at the cursor there, but the line content before it is not
608608
* visible in that case.
609609
*
610-
* @internal Planned to become public API in 2.10; until then the name and contract may
611-
* change without notice.
612-
*
613610
* @psalm-param non-empty-string $regex
614611
*/
615612
public function matchInPlace(string $regex): ?string

src/Util/RegexHelper.php

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,14 @@ final class RegexHelper
6262
public const PARTIAL_HTMLBLOCKOPEN = '<(?:' . self::PARTIAL_BLOCKTAGNAME . '(?:[\s\/>]|$)' . '|' .
6363
'\/' . self::PARTIAL_BLOCKTAGNAME . '(?:[\s>]|$)' . '|' . '[?!])';
6464
/**
65-
* @internal Unanchored so each call site can supply its own anchor ("^" against a detached
66-
* string, "\G" at a cursor position). PARTIAL_LINK_TITLE is composed from this
67-
* and must keep its value, so only this fragment should be used in new code.
65+
* Unanchored so each call site can supply its own anchor: "^" against a detached string,
66+
* or "\G" at a cursor position (see Cursor::matchInPlace()).
6867
*/
6968
public const PARTIAL_LINK_TITLE_UNANCHORED = '(?:"(' . self::PARTIAL_ESCAPED_CHAR . '|[^"\x00])*+"' .
7069
'|' . '\'(' . self::PARTIAL_ESCAPED_CHAR . '|[^\'\x00])*+\'' .
7170
'|' . '\((' . self::PARTIAL_ESCAPED_CHAR . '|[^()\x00])*+\))';
72-
public const PARTIAL_LINK_TITLE = '^' . self::PARTIAL_LINK_TITLE_UNANCHORED;
71+
/** @deprecated since 2.10; use {@link RegexHelper::PARTIAL_LINK_TITLE_UNANCHORED} with an explicit anchor instead */
72+
public const PARTIAL_LINK_TITLE = '^' . self::PARTIAL_LINK_TITLE_UNANCHORED;
7373

7474
public const REGEX_PUNCTUATION = '/^[\p{P}\p{S}]/u';
7575
public const REGEX_UNSAFE_PROTOCOL = '/^(?:javascript|vbscript|file|data):/i';
@@ -80,13 +80,12 @@ final class RegexHelper
8080
public const REGEX_UNICODE_WHITESPACE_CHAR = '/^\pZ|\s/u';
8181
public const REGEX_THEMATIC_BREAK = '/^(?:(?:\*[ \t]*){3,}|(?:_[ \t]*){3,}|(?:-[ \t]*){3,})$/';
8282
/**
83-
* @internal Unanchored so each call site can supply its own anchor ("^" against a detached
84-
* string, "\G" at a cursor position). REGEX_LINK_DESTINATION_BRACES is composed
85-
* from this and must keep its value, so only this fragment should be used in new
86-
* code.
83+
* Unanchored so each call site can supply its own anchor: "^" against a detached string,
84+
* or "\G" at a cursor position (see Cursor::matchInPlace()).
8785
*/
8886
public const PARTIAL_LINK_DESTINATION_BRACES = '(?:<(?:[^<>\\n\\\\\\x00]|\\\\.)*>)';
89-
public const REGEX_LINK_DESTINATION_BRACES = '/^' . self::PARTIAL_LINK_DESTINATION_BRACES . '/';
87+
/** @deprecated since 2.10; use {@link RegexHelper::PARTIAL_LINK_DESTINATION_BRACES} with an explicit anchor instead */
88+
public const REGEX_LINK_DESTINATION_BRACES = '/^' . self::PARTIAL_LINK_DESTINATION_BRACES . '/';
9089

9190
/**
9291
* @psalm-pure

tests/unit/Util/RegexHelperTest.php

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -456,9 +456,8 @@ public static function dataForTestIsLinkPotentiallyUnsafe(): iterable
456456
}
457457

458458
/**
459-
* These public constants are now composed from internal unanchored fragments (which the
460-
* cursor-based call sites anchor with "\G" instead). The public values are part of the BC
461-
* surface and must not change before 3.0, so pin them to their exact historical values.
459+
* The deprecated anchored constants remain part of the BC surface until 3.0 and must keep
460+
* their exact historical values, so pin them here.
462461
*/
463462
public function testAnchoredConstantsKeepTheirHistoricalValues(): void
464463
{

0 commit comments

Comments
 (0)