Skip to content

Commit 1755662

Browse files
authored
Optimize inline link destination parsing (#1141)
1 parent b9f5e1a commit 1755662

4 files changed

Lines changed: 38 additions & 11 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) princi
99
### Changed
1010
- Improved performance of reading single characters from multibyte lines
1111
- Improved performance of locating the next non-space character on lines without tabs
12+
- Optimized `Cursor::advanceToNextNonSpaceOrNewline()` to scan the line in place instead of copying everything left in the block on every call
13+
- 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
1214

1315
### Fixed
1416
- 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)

src/Parser/Cursor.php

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -469,13 +469,22 @@ public function advanceToNextNonSpaceOrNewline(): int
469469
return 0;
470470
}
471471

472-
$matches = [];
473-
\preg_match('/^ *(?:\n *)?/', $this->getRemainder(), $matches, \PREG_OFFSET_CAPTURE);
472+
// A partially-consumed tab leaves the cursor sitting on the tab itself, which the check
473+
// above has already returned on, so only real spaces and newlines reach this point and no
474+
// tab expansion is needed.
475+
//
476+
// Spaces and newlines are single-byte ASCII characters which can never appear inside a
477+
// multibyte UTF-8 sequence, so the run is measured at the byte level and each byte
478+
// consumed is exactly one character. Scanning the line in place keeps the cost of each
479+
// call proportional to the run it consumes, rather than to the length of everything left
480+
// in the block, which is what building the remainder first charged for.
481+
$byteOffset = $this->isMultibyte ? $this->byteOffset($this->currentPosition) : $this->currentPosition;
474482

475-
// [0][0] contains the matched text
476-
// [0][1] contains the index of that match
477-
\assert(isset($matches[0]));
478-
$increment = $matches[0][1] + \strlen($matches[0][0]);
483+
$increment = \strspn($this->line, ' ', $byteOffset);
484+
if (($this->line[$byteOffset + $increment] ?? '') === "\n") {
485+
$increment++;
486+
$increment += \strspn($this->line, ' ', $byteOffset + $increment);
487+
}
479488

480489
$this->advanceBy($increment);
481490

src/Util/LinkParserHelper.php

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,12 +93,19 @@ public static function parsePartialLinkTitle(Cursor $cursor, string $endDelimite
9393

9494
private static function manuallyParseLinkDestination(Cursor $cursor): ?string
9595
{
96-
$remainder = $cursor->getRemainder();
96+
// The destination always ends at the first whitespace or unbalanced ")", so scan the line
97+
// in place from the cursor rather than materializing the remainder: the cost of finding it
98+
// should follow the length of the destination, not the length of everything left in the
99+
// block. A partially-consumed tab needs no special handling - getRemainder() would expand
100+
// it into leading spaces and the scan would stop on the first one, exactly as it stops on
101+
// the tab itself here.
102+
$line = $cursor->getLine();
103+
$start = $cursor->getBytePosition();
97104
$openParens = 0;
98-
$len = \strlen($remainder);
105+
$len = \strlen($line) - $start;
99106
for ($i = 0; $i < $len; $i++) {
100-
$c = $remainder[$i];
101-
if ($c === '\\' && $i + 1 < $len && RegexHelper::isEscapable($remainder[$i + 1])) {
107+
$c = $line[$start + $i];
108+
if ($c === '\\' && $i + 1 < $len && RegexHelper::isEscapable($line[$start + $i + 1])) {
102109
$i++;
103110
} elseif ($c === '(') {
104111
$openParens++;
@@ -125,7 +132,7 @@ private static function manuallyParseLinkDestination(Cursor $cursor): ?string
125132
return null;
126133
}
127134

128-
$destination = \substr($remainder, 0, $i);
135+
$destination = \substr($line, $start, $i);
129136
$cursor->advanceBy(\mb_strlen($destination, 'UTF-8'));
130137

131138
return $destination;

tests/pathological/test.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,15 @@
188188
'input' => static fn($n) => \str_repeat('[a](b', $n),
189189
'expected' => static fn($n) => '<p>' . \str_repeat('[a](b', $n) . '</p>',
190190
],
191+
'Unclosed inline links with whitespace after the paren' => [
192+
// The space is load-bearing: it routes each "(" through the whitespace skip in
193+
// Cursor::advanceToNextNonSpaceOrNewline() as well as the destination scan in
194+
// LinkParserHelper. Every other case here puts a non-space directly after the "(", so the
195+
// destination scan is the only one they cover.
196+
'sizes' => [10_000, 50_000, 200_000],
197+
'input' => static fn($n) => \str_repeat('[a]( b', $n),
198+
'expected' => static fn($n) => '<p>' . \str_repeat('[a]( b', $n) . '</p>',
199+
],
191200
'Unclosed inline links (3)' => [
192201
'ref' => 'https://github.com/commonmark/commonmark.js/issues/129',
193202
'sizes' => [1_000, 10_000, 100_000],

0 commit comments

Comments
 (0)