@@ -17,7 +17,7 @@ final class Lexer
1717 private const PATTERN_BLOCKQUOTE = '/^((?:>[ \t]*)++)(.*)/ ' ;
1818 private const PATTERN_UNORDERED_LIST = '/^( *)[-*+]\s+(.+)/ ' ;
1919 private const PATTERN_ORDERED_LIST = '/^( *)\d+\.\s+(.+)/ ' ;
20- private const PATTERN_HORIZONTAL_RULE = '/^(-{3,}|\*{3,}|_{3,})\s *$/ ' ;
20+ private const PATTERN_HORIZONTAL_RULE = '/^[ \t]{0,3}([-*_])([ \t]*\1){2,}[ \t] *$/ ' ;
2121 private const PATTERN_LINK_DEFINITION = '/^\[([^\]\[]+)\]:\s+(?:<((?:[^<> \\\\\n]| \\\\.)*)>|(\S+))(?:\s+(?:"((?:[^" \\\\]| \\\\.)*)"| \'((?:[^ \'\\\\]| \\\\.)*) \'|\(((?:[^() \\\\]| \\\\.)*)\)))?$/ ' ;
2222 /** Matches a standalone title line (CommonMark §4.7 multiline link ref definition). */
2323 private const PATTERN_STANDALONE_TITLE = '/^(?:"((?:[^" \\\\]| \\\\.)*)"| \'((?:[^ \'\\\\]| \\\\.)*) \'|\(((?:[^() \\\\]| \\\\.)*)\))\s*$/ ' ;
@@ -28,7 +28,6 @@ final class Lexer
2828 private const PATTERN_COLUMNS_OPEN = '/^:::\s*columns\s*$/i ' ;
2929 private const PATTERN_COLUMNS_CLOSE = '/^:::$/ ' ;
3030 private const PATTERN_COLUMNS_SEP = '/^\|\|\|$/ ' ;
31- private const PATTERN_INDENTED_CODE = '/^( |\t)(.*)/s ' ;
3231 private const PATTERN_FOOTNOTE_DEF = '/^\[\^([A-Za-z0-9_-]{1,50})\]:\s+(.+)$/ ' ;
3332
3433 /**
@@ -105,7 +104,8 @@ public function tokenize(string $markdown): array
105104 $ footnoteBodyLines = [];
106105
107106 foreach ($ lines as $ raw ) {
108- $ line = rtrim ($ raw , "\r" );
107+ $ line = rtrim ($ raw , "\r" );
108+ $ expanded = $ this ->expandTabs ($ line );
109109
110110 // Collect lines for an in-progress HTML block.
111111 // The block ends on the first blank line (CommonMark §4.6 type 6/7).
@@ -316,11 +316,11 @@ public function tokenize(string $markdown): array
316316
317317 // Indented code block drain (continuation).
318318 if ($ inIndentedBlock ) {
319- if (preg_match (self :: PATTERN_INDENTED_CODE , $ line , $ m )
319+ if (preg_match (' /^ (.*)$/s ' , $ expanded , $ m )
320320 && !preg_match (self ::PATTERN_UNORDERED_LIST , $ line )
321321 && !preg_match (self ::PATTERN_ORDERED_LIST , $ line )
322322 ) {
323- $ indentedLines = [...$ indentedLines , ...$ pendingBlanks , $ m [ 2 ] ];
323+ $ indentedLines = [...$ indentedLines , ...$ pendingBlanks , $ this -> stripLeadingColumns ( $ line , 4 ) ];
324324 $ pendingBlanks = [];
325325 continue ;
326326 }
@@ -343,12 +343,12 @@ public function tokenize(string $markdown): array
343343 // and only when the line is not a list item — list items with leading spaces
344344 // are handled by matchLine() via PATTERN_UNORDERED_LIST / PATTERN_ORDERED_LIST).
345345 if (!$ hadPendingLines
346- && preg_match (self :: PATTERN_INDENTED_CODE , $ line , $ m )
346+ && preg_match (' /^ (.*)$/s ' , $ expanded , $ m )
347347 && !preg_match (self ::PATTERN_UNORDERED_LIST , $ line )
348348 && !preg_match (self ::PATTERN_ORDERED_LIST , $ line )
349349 ) {
350350 $ inIndentedBlock = true ;
351- $ indentedLines = [$ m [ 2 ] ];
351+ $ indentedLines = [$ this -> stripLeadingColumns ( $ line , 4 ) ];
352352 $ pendingBlanks = [];
353353 continue ;
354354 }
@@ -457,6 +457,80 @@ private function extractTaskChecked(string $content): array
457457 return [$ content , null ];
458458 }
459459
460+ /**
461+ * Expand tab characters to spaces using 4-column tab stops (CommonMark §2.1).
462+ *
463+ * @param int $startCol Column position of the first character of $line (default 0).
464+ */
465+ private function expandTabs (string $ line , int $ startCol = 0 ): string
466+ {
467+ $ out = '' ;
468+ $ col = $ startCol ;
469+ $ len = strlen ($ line );
470+ for ($ i = 0 ; $ i < $ len ; $ i ++) {
471+ $ ch = $ line [$ i ];
472+ if ($ ch === "\t" ) {
473+ $ spaces = 4 - ($ col % 4 );
474+ $ out .= str_repeat (' ' , $ spaces );
475+ $ col += $ spaces ;
476+ } else {
477+ $ out .= $ ch ;
478+ $ col ++;
479+ }
480+ }
481+ return $ out ;
482+ }
483+
484+ /**
485+ * Return the suffix of $line after consuming exactly $cols columns,
486+ * prepending any overshoot spaces when a tab spans the boundary.
487+ */
488+ private function stripLeadingColumns (string $ line , int $ cols ): string
489+ {
490+ $ col = 0 ;
491+ $ len = strlen ($ line );
492+ for ($ i = 0 ; $ i < $ len ; $ i ++) {
493+ if ($ col >= $ cols ) {
494+ return substr ($ line , $ i );
495+ }
496+ $ ch = $ line [$ i ];
497+ if ($ ch === "\t" ) {
498+ $ tabStop = 4 - ($ col % 4 );
499+ $ newCol = $ col + $ tabStop ;
500+ if ($ newCol > $ cols ) {
501+ $ surplus = $ newCol - $ cols ;
502+ return str_repeat (' ' , $ surplus ) . substr ($ line , $ i + 1 );
503+ }
504+ $ col = $ newCol ;
505+ } else {
506+ $ col ++;
507+ }
508+ }
509+ return '' ;
510+ }
511+
512+ /**
513+ * Strip blockquote markers from an already-expanded line.
514+ *
515+ * Implements CommonMark §5.1: each `>` consumes one mandatory character plus one
516+ * optional space. Operates on the expanded form so tab overshoot is already resolved.
517+ */
518+ private function stripBlockquoteMarkers (string $ expandedLine , int $ level ): string
519+ {
520+ $ pos = 0 ;
521+ $ len = strlen ($ expandedLine );
522+ for ($ l = 0 ; $ l < $ level ; $ l ++) {
523+ if ($ pos < $ len && $ expandedLine [$ pos ] === '> ' ) {
524+ $ pos ++;
525+ }
526+ // Consume at most one optional space following the marker.
527+ if ($ pos < $ len && $ expandedLine [$ pos ] === ' ' ) {
528+ $ pos ++;
529+ }
530+ }
531+ return substr ($ expandedLine , $ pos );
532+ }
533+
460534 private function matchLine (string $ line ): Token
461535 {
462536 if ($ line === '' || ctype_space ($ line )) {
@@ -477,10 +551,16 @@ private function matchLine(string $line): Token
477551 }
478552
479553 if (preg_match (self ::PATTERN_BLOCKQUOTE , $ line , $ m )) {
554+ // Compute inner content from the expanded line using the CommonMark §5.1 rule:
555+ // each '>' marker consumes the '>' character and optionally one space.
556+ // Operating on the expanded form ensures tabs in the prefix are correctly handled.
557+ $ level = substr_count ($ m [1 ], '> ' );
558+ $ expandedLine = $ this ->expandTabs ($ line );
559+ $ innerContent = $ this ->stripBlockquoteMarkers ($ expandedLine , $ level );
480560 return new Token (
481561 TokenType::BLOCKQUOTE ,
482- trim ( $ m [ 2 ]) ,
483- ['level ' => substr_count ( $ m [ 1 ], ' > ' ) ],
562+ $ innerContent ,
563+ ['level ' => $ level ],
484564 );
485565 }
486566
0 commit comments