Skip to content

Commit 58a181a

Browse files
authored
Match single quotes in one O(n) pass instead of an O(n^2) scan (#249)
buildSingleQuoteMatchCache() collected potential openers and closers into two lists, then matched each closer against the opener list with a nested scan - O(closers x openers), which degraded badly on quote-heavy text. Fold the classification and matching into a single forward pass: push each opener onto a stack and, at a closer, pop the top opener. The stack top is always the largest-index still-open opener, so popping it reproduces the former nearest-preceding-unmatched-opener pairing exactly, now in O(n). Behavior is unchanged - the smart-quote semantics (including the official conformance corpus) are identical; only the cost changes. Also drops the unused findMatchingSingleQuoteCloser() relic of the old approach.
1 parent 5cf4c4d commit 58a181a

2 files changed

Lines changed: 54 additions & 89 deletions

File tree

src/Parser/InlineParser.php

Lines changed: 12 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -1369,74 +1369,6 @@ protected function parseSmartQuote(string $text, int $pos, string $quote): strin
13691369
return $this->closeSingleQuote;
13701370
}
13711371

1372-
/**
1373-
* Find a matching single quote closer for a potential opener at $pos
1374-
*
1375-
* Returns the position of the closer if found, null otherwise.
1376-
* Uses a matching algorithm similar to emphasis - potential openers and closers
1377-
* are matched from innermost pairs outward.
1378-
*/
1379-
protected function findMatchingSingleQuoteCloser(string $text, int $openerPos): ?int
1380-
{
1381-
$length = strlen($text);
1382-
1383-
// Collect all potential openers and closers after this position
1384-
$openers = [$openerPos];
1385-
$closers = [];
1386-
1387-
for ($i = $openerPos + 1; $i < $length; $i++) {
1388-
if ($text[$i] !== "'") {
1389-
continue;
1390-
}
1391-
1392-
$prevChar = $text[$i - 1] ?? ' ';
1393-
$nextChar = $text[$i + 1] ?? ' ';
1394-
$prevIsSpace = ctype_space($prevChar);
1395-
// Closer can be followed by space, punctuation, or end of string
1396-
$nextIsSpaceOrPunct = ctype_space($nextChar) || $i === $length - 1
1397-
|| preg_match('/^[\p{P}\p{S}]/u', $nextChar);
1398-
1399-
// Skip quotes before digits (always apostrophe)
1400-
if (ctype_digit($nextChar)) {
1401-
continue;
1402-
}
1403-
1404-
// Skip quotes after ] or )
1405-
if ($prevChar === ']' || $prevChar === ')') {
1406-
continue;
1407-
}
1408-
1409-
$nextIsSpace = ctype_space($nextChar);
1410-
if ($prevIsSpace && !$nextIsSpace) {
1411-
// Could be opener (after space, before non-space)
1412-
$openers[] = $i;
1413-
} elseif (!$prevIsSpace && $nextIsSpaceOrPunct) {
1414-
// Could be closer (after non-space, before space/punct)
1415-
$closers[] = $i;
1416-
} elseif (!$prevIsSpace) {
1417-
// Mid-word quote (like Jane's) - typically apostrophe
1418-
continue;
1419-
}
1420-
}
1421-
1422-
// Now match openers with closers, innermost first
1423-
// For each closer, find the nearest preceding unmatched opener
1424-
$matched = [];
1425-
foreach ($closers as $closer) {
1426-
for ($j = count($openers) - 1; $j >= 0; $j--) {
1427-
$opener = $openers[$j];
1428-
if ($opener < $closer && !isset($matched[$opener])) {
1429-
$matched[$opener] = $closer;
1430-
1431-
break;
1432-
}
1433-
}
1434-
}
1435-
1436-
// Return the closer for our position, if any
1437-
return $matched[$openerPos] ?? null;
1438-
}
1439-
14401372
/**
14411373
* Build a cache of all single quote opener→closer matches for the text.
14421374
*
@@ -1453,10 +1385,14 @@ protected function buildSingleQuoteMatchCache(string $text): array
14531385
}
14541386

14551387
$length = strlen($text);
1456-
$openers = [];
1457-
$closers = [];
1388+
$matched = [];
1389+
$openerStack = [];
14581390

1459-
// Single pass: collect all potential openers and closers
1391+
// Single forward pass: classify each quote and pair a closer with the
1392+
// innermost still-open opener via a stack. The stack top is always the
1393+
// largest-index unmatched opener seen so far, so popping it reproduces
1394+
// the former "nearest preceding unmatched opener" pairing in O(n)
1395+
// instead of the previous O(n²) closer-by-opener scan.
14601396
for ($i = 0; $i < $length; $i++) {
14611397
if ($text[$i] !== "'") {
14621398
continue;
@@ -1493,28 +1429,15 @@ protected function buildSingleQuoteMatchCache(string $text): array
14931429
}
14941430

14951431
if ($prevIsSpace && !$nextIsSpace) {
1496-
// Potential opener
1497-
$openers[] = $i;
1498-
} elseif (!$prevIsSpace && $nextIsSpaceOrPunct) {
1499-
// Potential closer
1500-
$closers[] = $i;
1432+
// Potential opener - push onto the stack of open quotes
1433+
$openerStack[] = $i;
1434+
} elseif (!$prevIsSpace && $nextIsSpaceOrPunct && $openerStack) {
1435+
// Potential closer - pair with the innermost unmatched opener
1436+
$matched[array_pop($openerStack)] = $i;
15011437
}
15021438
// Mid-word quotes are skipped (apostrophes)
15031439
}
15041440

1505-
// Match openers with closers, innermost first
1506-
$matched = [];
1507-
foreach ($closers as $closer) {
1508-
for ($j = count($openers) - 1; $j >= 0; $j--) {
1509-
$opener = $openers[$j];
1510-
if ($opener < $closer && !isset($matched[$opener])) {
1511-
$matched[$opener] = $closer;
1512-
1513-
break;
1514-
}
1515-
}
1516-
}
1517-
15181441
return $matched;
15191442
}
15201443

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Djot\Test\TestCase\Parser;
6+
7+
use Djot\DjotConverter;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use PHPUnit\Framework\TestCase;
10+
11+
/**
12+
* Locks the single-quote opener/closer pairing produced by
13+
* InlineParser::buildSingleQuoteMatchCache(), so the O(n) stack-based
14+
* matching keeps the exact behavior of the former O(n^2) scan.
15+
*/
16+
class SingleQuoteMatchingTest extends TestCase
17+
{
18+
/**
19+
* @return array<string, array{0: string, 1: string}>
20+
*/
21+
public static function quoteProvider(): array
22+
{
23+
return [
24+
// A balanced pair becomes an open + close curly quote.
25+
'matched pair' => ["'hello'", "\u{2018}hello\u{2019}"],
26+
// A flanking opener with no later closer stays an apostrophe.
27+
'lone opener is apostrophe' => ['say \'what', "say \u{2019}what"],
28+
// Nested pairs match innermost-first (inner pair closes before outer).
29+
'nested pairs' => ["'a 'b' c'", "\u{2018}a \u{2018}b\u{2019} c\u{2019}"],
30+
// Mid-word apostrophe is untouched; the following pair still matches.
31+
'apostrophe then pair' => ["it's a 'test'", "it\u{2019}s a \u{2018}test\u{2019}"],
32+
];
33+
}
34+
35+
#[DataProvider('quoteProvider')]
36+
public function testSingleQuotePairing(string $input, string $expected): void
37+
{
38+
$html = (new DjotConverter())->convert($input);
39+
40+
$this->assertStringContainsString($expected, $html);
41+
}
42+
}

0 commit comments

Comments
 (0)