@@ -640,6 +640,200 @@ const matchRuleSetCondition = (condition, resource) => {
640640 }
641641} ;
642642
643+ // Cap on the spellings one pattern is expanded into, so a pattern built from
644+ // many alternations stays cheap to probe.
645+ const MAX_EXPANDED_SPELLINGS = 64 ;
646+
647+ // A rule naming an extension is short; a pattern generated from a list of paths
648+ // runs to kilobytes and costs more to expand than the answer is worth.
649+ const MAX_EXPANDED_PATTERN_LENGTH = 256 ;
650+
651+ /**
652+ * Index of the `]` closing the character class opened at `start`, or the end of
653+ * the source when it is unterminated.
654+ * @param {string } source regexp source
655+ * @param {number } start index of the opening `[`
656+ * @returns {number } index of the closing `]`
657+ */
658+ const findCharacterClassEnd = ( source , start ) => {
659+ for ( let i = start + 1 ; i < source . length ; i ++ ) {
660+ if ( source [ i ] === "\\" ) i ++ ;
661+ else if ( source [ i ] === "]" ) return i ;
662+ }
663+ return source . length ;
664+ } ;
665+
666+ /**
667+ * Index of the `)` closing the group opened at `start`, skipping nested groups
668+ * and character classes, or the end of the source when it is unterminated.
669+ * @param {string } source regexp source
670+ * @param {number } start index of the opening `(`
671+ * @returns {number } index of the closing `)`
672+ */
673+ const findGroupEnd = ( source , start ) => {
674+ let depth = 0 ;
675+ for ( let i = start ; i < source . length ; i ++ ) {
676+ const char = source [ i ] ;
677+ if ( char === "\\" ) i ++ ;
678+ else if ( char === "[" ) i = findCharacterClassEnd ( source , i ) ;
679+ else if ( char === "(" ) depth ++ ;
680+ else if ( char === ")" && -- depth === 0 ) return i ;
681+ }
682+ return source . length ;
683+ } ;
684+
685+ /**
686+ * Splits a group body on its top-level `|`, skipping nested groups and
687+ * character classes.
688+ * @param {string } body group body without the enclosing parentheses
689+ * @returns {string[] } the alternatives
690+ */
691+ const splitAlternatives = ( body ) => {
692+ const alternatives = [ ] ;
693+ let depth = 0 ;
694+ let start = 0 ;
695+ for ( let i = 0 ; i < body . length ; i ++ ) {
696+ const char = body [ i ] ;
697+ if ( char === "\\" ) {
698+ i ++ ;
699+ } else if ( char === "[" ) {
700+ i = findCharacterClassEnd ( body , i ) ;
701+ } else if ( char === "(" ) {
702+ depth ++ ;
703+ } else if ( char === ")" ) {
704+ depth -- ;
705+ } else if ( char === "|" && depth === 0 ) {
706+ alternatives . push ( body . slice ( start , i ) ) ;
707+ start = i + 1 ;
708+ }
709+ }
710+ alternatives . push ( body . slice ( start ) ) ;
711+ return alternatives ;
712+ } ;
713+
714+ /**
715+ * The part of a group body that the match spells — past a `?:` or `?<name>`
716+ * prefix, and nothing at all for a lookaround, which spells nothing.
717+ * @param {string } body group body without the enclosing parentheses
718+ * @returns {string | undefined } the spelled part, or `undefined` for a lookaround
719+ */
720+ const groupContent = ( body ) => {
721+ if ( ! body . startsWith ( "?" ) ) return body ;
722+ if ( body . startsWith ( "?:" ) ) return body . slice ( 2 ) ;
723+ const named = / ^ \? < [ ^ = ! ] [ ^ > ] * > / . exec ( body ) ;
724+ return named ? body . slice ( named [ 0 ] . length ) : undefined ;
725+ } ;
726+
727+ /**
728+ * Rewrites the first group, word-character class or optional atom
729+ * in the source into the spellings it can match, leaving the rest as written.
730+ * @param {string } source regexp source
731+ * @returns {string[] | undefined } the rewritten sources, or `undefined` when the source holds no such construct
732+ */
733+ const expandFirstConstruct = ( source ) => {
734+ for ( let i = 0 ; i < source . length ; i ++ ) {
735+ const char = source [ i ] ;
736+ let end = i + 1 ;
737+ /** @type {string[] | undefined } */
738+ let spellings ;
739+ if ( char === "\\" ) {
740+ end = i + 2 ;
741+ } else if ( char === "[" ) {
742+ const close = findCharacterClassEnd ( source , i ) ;
743+ end = close + 1 ;
744+ const body = source . slice ( i + 1 , close ) ;
745+ // only a plain list of word characters spells out safely (no range,
746+ // no negation, no escape)
747+ if ( / ^ \w + $ / . test ( body ) ) spellings = [ ...body ] ;
748+ } else if ( char === "(" ) {
749+ const close = findGroupEnd ( source , i ) ;
750+ end = close + 1 ;
751+ const content = groupContent ( source . slice ( i + 1 , close ) ) ;
752+ if ( content !== undefined ) spellings = splitAlternatives ( content ) ;
753+ }
754+ // `?` makes the atom optional, so its absence is a spelling as well
755+ if ( source [ end ] === "?" ) {
756+ spellings = [ ...( spellings || [ source . slice ( i , end ) ] ) , "" ] ;
757+ end += 1 ;
758+ }
759+ if ( spellings ) {
760+ return spellings . map (
761+ ( spelling ) => source . slice ( 0 , i ) + spelling + source . slice ( end )
762+ ) ;
763+ }
764+ i = end - 1 ;
765+ }
766+ } ;
767+
768+ /**
769+ * Rewrites the first brace alternative in a glob pattern (e.g. `*.{css,scss}`)
770+ * into the spellings it can match.
771+ * @param {string } pattern glob pattern
772+ * @returns {string[] | undefined } the rewritten patterns, or `undefined` when the pattern holds no brace
773+ */
774+ const expandFirstBrace = ( pattern ) => {
775+ const open = pattern . indexOf ( "{" ) ;
776+ const close = open === - 1 ? - 1 : pattern . indexOf ( "}" , open ) ;
777+ if ( close === - 1 ) return ;
778+ return pattern
779+ . slice ( open + 1 , close )
780+ . split ( "," )
781+ . map (
782+ ( alternative ) =>
783+ pattern . slice ( 0 , open ) + alternative + pattern . slice ( close + 1 )
784+ ) ;
785+ } ;
786+
787+ /**
788+ * Whether `probe` occurs in `pattern` as a subsequence. Expanding a pattern only
789+ * ever drops characters, so a probe failing this is spelled by none of its
790+ * spellings and the expansion is not worth starting.
791+ * @param {string } pattern pattern to search
792+ * @param {string } probe probe to find
793+ * @returns {boolean } whether the probe is a subsequence of the pattern
794+ */
795+ const containsSubsequence = ( pattern , probe ) => {
796+ let found = 0 ;
797+ for ( let i = 0 ; i < pattern . length && found < probe . length ; i ++ ) {
798+ if ( pattern [ i ] === probe [ found ] ) found ++ ;
799+ }
800+ return found === probe . length ;
801+ } ;
802+
803+ /**
804+ * Whether any spelling of the pattern contains the probe, rewriting one
805+ * construct at a time until none is left or `MAX_EXPANDED_SPELLINGS` is reached.
806+ * @param {string } pattern pattern to expand
807+ * @param {string } probe probe the spellings are searched for
808+ * @param {(pattern: string) => string[] | undefined } expandFirst rewrites the first expandable construct
809+ * @returns {boolean } whether a spelling contains the probe
810+ */
811+ const someSpellingContains = ( pattern , probe , expandFirst ) => {
812+ if ( pattern . includes ( probe ) ) return true ;
813+ if ( pattern . length > MAX_EXPANDED_PATTERN_LENGTH ) return false ;
814+ if ( ! containsSubsequence ( pattern , probe ) ) return false ;
815+ let spellings = [ pattern ] ;
816+ for ( ; ; ) {
817+ /** @type {string[] } */
818+ const expanded = [ ] ;
819+ for ( const spelling of spellings ) {
820+ const rewritten = expandFirst ( spelling ) ;
821+ // every spelling was searched as it was produced, so one with nothing
822+ // left to rewrite is settled
823+ if ( ! rewritten ) continue ;
824+ for ( const alternative of rewritten ) {
825+ if ( alternative . includes ( probe ) ) return true ;
826+ // an alternative that dropped the probe's characters expands no
827+ // further into it
828+ if ( containsSubsequence ( alternative , probe ) ) expanded . push ( alternative ) ;
829+ }
830+ if ( expanded . length > MAX_EXPANDED_SPELLINGS ) return false ;
831+ }
832+ if ( expanded . length === 0 ) return false ;
833+ spellings = expanded ;
834+ }
835+ } ;
836+
643837/**
644838 * @param {EXPECTED_ANY } glob glob pattern(s)
645839 * @param {string } extension extension including the dot (e.g. `".css"`)
@@ -648,7 +842,10 @@ const matchRuleSetCondition = (condition, resource) => {
648842const globReferencesExtension = ( glob , extension ) => {
649843 // a `!` pattern subtracts the extension instead of targeting it
650844 if ( typeof glob === "string" ) {
651- return ! glob . startsWith ( "!" ) && glob . includes ( extension ) ;
845+ return (
846+ ! glob . startsWith ( "!" ) &&
847+ someSpellingContains ( glob , extension , expandFirstBrace )
848+ ) ;
652849 }
653850 return (
654851 Array . isArray ( glob ) &&
@@ -660,12 +857,20 @@ const globReferencesExtension = (glob, extension) => {
660857 * Whether any regexp or glob in the condition references the extension probe
661858 * (e.g. `\.css`), i.e. the rule clearly targets that extension even if the
662859 * generic sample path doesn't match its (possibly filename-scoped) pattern.
860+ * The extension may be spelled by an alternation (`\.(css|scss)$`) or a
861+ * character class (`\.[jt]sx?$`), so those are expanded before probing.
663862 * @param {EXPECTED_ANY } condition condition
664863 * @param {string } extProbe escaped extension probe (e.g. `"\\.css"`)
665864 * @returns {boolean } whether a regexp in the condition targets the extension
666865 */
667866const conditionReferencesExtension = ( condition , extProbe ) => {
668- if ( condition instanceof RegExp ) return condition . source . includes ( extProbe ) ;
867+ if ( condition instanceof RegExp ) {
868+ // an `/i` rule spells the extension in any case; the probe is lower case
869+ const source = condition . ignoreCase
870+ ? condition . source . toLowerCase ( )
871+ : condition . source ;
872+ return someSpellingContains ( source , extProbe , expandFirstConstruct ) ;
873+ }
669874 if ( Array . isArray ( condition ) ) {
670875 return condition . some ( ( c ) => conditionReferencesExtension ( c , extProbe ) ) ;
671876 }
0 commit comments