@@ -17,6 +17,9 @@ export interface FirstCharacterPath {
1717
1818 /** Indicates whether additional characters must follow to complete the match. */
1919 requiresMore : boolean
20+
21+ /** Indicates whether the alternative can consume more characters after the prefix. */
22+ canMatchMore : boolean
2023}
2124
2225/** Matcher produced from a character class AST node. */
@@ -60,8 +63,14 @@ interface AnalysisContext {
6063 /** Cache storing computed minimum lengths for AST nodes. */
6164 minLengthCache : WeakMap < object , LengthResult >
6265
66+ /** Cache storing computed maximum lengths for AST nodes. */
67+ maxLengthCache : WeakMap < object , LengthResult >
68+
6369 /** Alternatives currently on the recursion stack. */
64- activeAlternatives : Set < Alternative >
70+ minLengthActiveAlternatives : Set < Alternative >
71+
72+ /** Alternatives on the recursion stack for maximum-length calculation. */
73+ maxLengthActiveAlternatives : Set < Alternative >
6574
6675 /** Indicates whether collection exceeded the maximum allowed paths. */
6776 limitExceeded : boolean
@@ -72,8 +81,9 @@ interface AnalysisContext {
7281
7382/** Internal extension that includes metadata needed during traversal. */
7483interface FirstCharacterPathInternal extends FirstCharacterPath {
75- /** Mirrors the public flag for convenience when mutating paths. */
84+ /** Mirrors the public flags for convenience when mutating paths. */
7685 requiresMore : boolean
86+ canMatchMore : boolean
7787}
7888
7989type LengthResult = LengthInfo | null
@@ -92,7 +102,9 @@ export function getFirstCharacterPaths(
92102) : FirstCharacterPath [ ] {
93103 let context : AnalysisContext = {
94104 minLengthCache : new WeakMap ( ) ,
95- activeAlternatives : new Set ( ) ,
105+ maxLengthCache : new WeakMap ( ) ,
106+ minLengthActiveAlternatives : new Set ( ) ,
107+ maxLengthActiveAlternatives : new Set ( ) ,
96108 limitExceeded : false ,
97109 pathCount : 0 ,
98110 }
@@ -128,6 +140,7 @@ function collectFirstCharacterPathsFromElement(
128140 {
129141 matcher : { type : 'character-class' , value : element } ,
130142 requiresMore : false ,
143+ canMatchMore : false ,
131144 } ,
132145 ]
133146 }
@@ -146,6 +159,7 @@ function collectFirstCharacterPathsFromElement(
146159 {
147160 matcher : { type : 'character-set' , value : element } ,
148161 requiresMore : false ,
162+ canMatchMore : false ,
149163 } ,
150164 ]
151165 }
@@ -157,6 +171,7 @@ function collectFirstCharacterPathsFromElement(
157171 {
158172 matcher : { value : element . value , type : 'character' } ,
159173 requiresMore : false ,
174+ canMatchMore : false ,
160175 } ,
161176 ]
162177 }
@@ -195,11 +210,15 @@ function collectFirstCharacterPathsFromAlternative(
195210
196211 if ( elementPaths . length > 0 ) {
197212 let restLength = getElementsMinLength ( elements , index + 1 , context )
213+ let restMaxLength = getElementsMaxLength ( elements , index + 1 , context )
198214
199215 if ( restLength !== null ) {
216+ let restCanMatchMore = restMaxLength !== 0
217+
200218 for ( let path of elementPaths ) {
201219 addPath ( results , context , {
202220 requiresMore : path . requiresMore || restLength > 0 ,
221+ canMatchMore : path . canMatchMore || restCanMatchMore ,
203222 matcher : path . matcher ,
204223 } )
205224 }
@@ -299,10 +318,16 @@ function collectFirstCharacterPathsFromQuantifier(
299318 return [ ]
300319 }
301320
321+ let innerMaxLength = getElementMaxLength ( quantifier . element , context )
302322 let requiresAdditionalIterations = quantifier . min > 1 && innerMinLength > 0
323+ let elementCanConsumeCharacters = innerMaxLength !== 0
324+ let allowsAdditionalIterations =
325+ elementCanConsumeCharacters &&
326+ ( quantifier . max === Infinity || quantifier . max > 1 )
303327
304328 return innerPaths . map ( path => ( {
305329 requiresMore : path . requiresMore || requiresAdditionalIterations ,
330+ canMatchMore : path . canMatchMore || allowsAdditionalIterations ,
306331 matcher : path . matcher ,
307332 } ) )
308333}
@@ -324,15 +349,15 @@ function getAlternativeMinLength(
324349 return cached
325350 }
326351
327- if ( context . activeAlternatives . has ( alternative ) ) {
352+ if ( context . minLengthActiveAlternatives . has ( alternative ) ) {
328353 return null
329354 }
330355
331- context . activeAlternatives . add ( alternative )
356+ context . minLengthActiveAlternatives . add ( alternative )
332357
333358 let length = getElementsMinLength ( alternative . elements , 0 , context )
334359
335- context . activeAlternatives . delete ( alternative )
360+ context . minLengthActiveAlternatives . delete ( alternative )
336361 context . minLengthCache . set ( alternative , length )
337362
338363 return length
@@ -403,6 +428,184 @@ function getGroupMinLength(
403428 return minLength
404429}
405430
431+ /**
432+ * Computes the maximum possible length for an element.
433+ *
434+ * @param element - AST element to analyze.
435+ * @param context - Shared traversal context.
436+ * @returns Maximum length in characters, `2` for "two or more", or `null` if
437+ * unknown.
438+ */
439+ function getElementMaxLength (
440+ element : Element ,
441+ context : AnalysisContext ,
442+ ) : LengthResult {
443+ // Defensive guard triggers only when traversal exceeded path limit earlier.
444+ /* c8 ignore next 3 */
445+ if ( context . limitExceeded ) {
446+ return null
447+ }
448+
449+ let cached = context . maxLengthCache . get ( element )
450+
451+ if ( cached !== undefined ) {
452+ return cached
453+ }
454+
455+ let result : LengthResult = null
456+
457+ switch ( element . type ) {
458+ case 'CharacterClass' :
459+ case 'CharacterSet' :
460+ case 'Character' : {
461+ result = 1
462+ break
463+ }
464+ case 'CapturingGroup' :
465+ case 'Group' : {
466+ result = getGroupMaxLength ( element , context )
467+ break
468+ }
469+ case 'Backreference' : {
470+ result = null
471+ break
472+ }
473+ case 'Quantifier' : {
474+ let innerLength = getElementMaxLength ( element . element , context )
475+
476+ if ( innerLength === null ) {
477+ result = null
478+ break
479+ }
480+
481+ // Numerical sentinels are unreachable with current AST inputs.
482+ /* c8 ignore start */
483+ if ( innerLength === 0 || element . max === 0 ) {
484+ result = 0
485+ break
486+ }
487+
488+ if ( element . max === Infinity ) {
489+ result = 2
490+ break
491+ }
492+ /* c8 ignore stop */
493+
494+ result = multiplyMaxLength ( innerLength , element . max )
495+ break
496+ }
497+ case 'Assertion' : {
498+ result = 0
499+ break
500+ }
501+ default : {
502+ result = null
503+ }
504+ }
505+
506+ context . maxLengthCache . set ( element , result )
507+
508+ return result
509+ }
510+
511+ /**
512+ * Computes the maximum possible length for an alternative.
513+ *
514+ * @param alternative - Alternative whose elements should be measured.
515+ * @param context - Shared traversal context.
516+ * @returns Maximum length for the entire alternative.
517+ */
518+ function getAlternativeMaxLength (
519+ alternative : Alternative ,
520+ context : AnalysisContext ,
521+ ) : LengthResult {
522+ let cached = context . maxLengthCache . get ( alternative )
523+
524+ // Cache reuse only occurs for recursive alternatives, which tests do not create.
525+ /* c8 ignore next 3 */
526+ if ( cached !== undefined ) {
527+ return cached
528+ }
529+
530+ if ( context . maxLengthActiveAlternatives . has ( alternative ) ) {
531+ return null
532+ }
533+
534+ context . maxLengthActiveAlternatives . add ( alternative )
535+
536+ let length = getElementsMaxLength ( alternative . elements , 0 , context )
537+
538+ context . maxLengthActiveAlternatives . delete ( alternative )
539+ context . maxLengthCache . set ( alternative , length )
540+
541+ return length
542+ }
543+
544+ /**
545+ * Computes the maximum length of a suffix of elements.
546+ *
547+ * @param elements - Sequence of elements belonging to an alternative.
548+ * @param startIndex - Index from which the suffix begins.
549+ * @param context - Shared traversal context.
550+ * @returns Maximum length for the suffix.
551+ */
552+ function getElementsMaxLength (
553+ elements : Alternative [ 'elements' ] ,
554+ startIndex : number ,
555+ context : AnalysisContext ,
556+ ) : LengthResult {
557+ let length : LengthResult = 0
558+
559+ for ( let index = startIndex ; index < elements . length ; index ++ ) {
560+ let element = elements [ index ] !
561+ let elementLength = getElementMaxLength ( element , context )
562+
563+ length = addMaxLengths ( length , elementLength )
564+
565+ if ( length === null ) {
566+ return null
567+ }
568+
569+ if ( length === 2 ) {
570+ return 2
571+ }
572+ }
573+
574+ return length
575+ }
576+
577+ /**
578+ * Computes the maximum length among the alternatives contained in a group.
579+ *
580+ * @param group - Capturing or non-capturing group to analyze.
581+ * @param context - Shared traversal context.
582+ * @returns Maximum length across the group's alternatives.
583+ */
584+ function getGroupMaxLength (
585+ group : CapturingGroup | Group ,
586+ context : AnalysisContext ,
587+ ) : LengthResult {
588+ let maxLength : LengthResult = 0
589+
590+ for ( let alternative of group . alternatives ) {
591+ let alternativeLength = getAlternativeMaxLength ( alternative , context )
592+
593+ if ( alternativeLength === null ) {
594+ return null
595+ }
596+
597+ if ( alternativeLength > maxLength ) {
598+ maxLength = alternativeLength
599+ }
600+
601+ if ( maxLength === 2 ) {
602+ break
603+ }
604+ }
605+
606+ return maxLength
607+ }
608+
406609/**
407610 * Multiplies a minimum length by a quantifier count while respecting sentinel
408611 * values.
@@ -431,6 +634,38 @@ function multiplyLength(length: LengthResult, count: number): LengthResult {
431634 return 2
432635}
433636
637+ /**
638+ * Multiplies a maximum length by a quantifier count while respecting sentinel
639+ * values.
640+ *
641+ * @param length - Maximum length of the repeated element.
642+ * @param count - Maximum number of repetitions.
643+ * @returns Combined maximum length or `null` when unknown.
644+ */
645+ // Exhaustive runtime coverage would require crafting pathological recursive
646+ // quantifiers and backreferences, so skip coverage for this helper.
647+ /* c8 ignore start */
648+ function multiplyMaxLength ( length : LengthResult , count : number ) : LengthResult {
649+ if ( length === null ) {
650+ return null
651+ }
652+
653+ if ( length === 0 || count === 0 ) {
654+ return 0
655+ }
656+
657+ if ( length === 2 ) {
658+ return 2
659+ }
660+
661+ if ( count === 1 ) {
662+ return length
663+ }
664+
665+ return 2
666+ }
667+ /* c8 ignore stop */
668+
434669/**
435670 * Adds a collected path to the results while accounting for the safety limit.
436671 *
@@ -476,6 +711,31 @@ function addLengths(a: LengthResult, b: LengthResult): LengthResult {
476711 return sum as LengthInfo
477712}
478713
714+ /**
715+ * Adds two maximum-length values together, preserving sentinel semantics.
716+ *
717+ * @param a - First length operand.
718+ * @param b - Second length operand.
719+ * @returns Sum of the operands, clamped to the sentinel space.
720+ */
721+ function addMaxLengths ( a : LengthResult , b : LengthResult ) : LengthResult {
722+ if ( a === null || b === null ) {
723+ return null
724+ }
725+
726+ if ( a === 2 || b === 2 ) {
727+ return 2
728+ }
729+
730+ let sum = a + b
731+
732+ if ( sum >= 2 ) {
733+ return 2
734+ }
735+
736+ return sum as LengthInfo
737+ }
738+
479739/**
480740 * Determines whether a given element can match an empty string.
481741 *
0 commit comments