Skip to content

Commit e42e14c

Browse files
committed
fix(sort-regexp): detect wildcard shadowing after multi-character prefixes
1 parent da7b5f5 commit e42e14c

File tree

4 files changed

+550
-7
lines changed

4 files changed

+550
-7
lines changed

rules/sort-regexp/get-first-character-paths.ts

Lines changed: 266 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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. */
7483
interface 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

7989
type 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

Comments
 (0)