Skip to content

Commit 1432326

Browse files
fix: read the extension out of a path-scoped rule's alternation (#21946)
* fix: read the extension out of a path-scoped rule's alternation `hasRuleForResource` matched the extension probe (`\.css`) as a literal substring of a rule's regexp source, so a path-scoped rule spelling its extensions in an alternation or a character class — `/[\\/]src[\\/].*\.(ts|tsx)$/`, `/\.[jt]sx?$/`, `glob: "src/**/*.{css,scss}"` — went undetected and left `experiments.typescript` on next to the user's loader, which then rejects `.tsx` outright. Expand those constructs into the spellings they can match before probing. * fix: read a named group and an `i` rule's case in the extension probe A named capture group's body was skipped along with the lookarounds, and the probe compared a lower-case extension against the source of a rule that may spell it in any case, so `/[\\/]src[\\/].*\.(?<ext>css|scss)$/` and `/[\\/]src[\\/].*\.(CSS|SCSS)$/i` stayed undetected. * test: build the named-group regexp at runtime `tsc -p tsconfig.types.test.json` targets ES2017, which rejects a named capture group written as a literal. * docs: tighten the group-body helper's description * perf: leave a pattern generated from a list of paths unexpanded The test262 harness builds one `test` regexp out of ~200 file paths, and the subsequence guard passes on it: the probe's characters all occur somewhere in 9KB. Expanding it unwrapped 205 groups one at a time, rescanning and reallocating the whole source each round — 165ms per config resolution, once per compilation, which is what stalled the test262 job. A rule naming an extension is short, so past 256 characters only the spelling the pattern carries outright is read. * test: hold the new comment to the two-line limit
1 parent 9ab5580 commit 1432326

9 files changed

Lines changed: 431 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack": patch
3+
---
4+
5+
Read the extension out of a path-scoped rule's alternation, class or braces.

‎lib/rules/RuleSetCompiler.js‎

Lines changed: 207 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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) => {
648842
const 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
*/
667866
const 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
}

‎test/Defaults.unittest.js‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5825,6 +5825,41 @@ describe("experiments.css/html/asyncWebAssembly auto", () => {
58255825
).toEqual({ css: "auto", html: "auto", asyncWebAssembly: true });
58265826
});
58275827

5828+
it("reads the extension out of a path-scoped alternation or class", () => {
5829+
// The sample path the detection probes with doesn't match a path-scoped
5830+
// rule, so the extension has to be read out of the pattern itself.
5831+
expect(
5832+
resolve({
5833+
module: {
5834+
rules: [
5835+
{
5836+
test: /[\\/]src[\\/].*\.(css|scss)$/i,
5837+
use: ["style-loader", "css-loader"]
5838+
}
5839+
]
5840+
}
5841+
})
5842+
).toEqual({ css: false, html: "auto", asyncWebAssembly: true });
5843+
expect(
5844+
resolve({
5845+
module: {
5846+
rules: [
5847+
{ glob: "src/**/*.{css,scss}", use: ["style-loader", "css-loader"] }
5848+
]
5849+
}
5850+
})
5851+
).toEqual({ css: false, html: "auto", asyncWebAssembly: true });
5852+
expect(
5853+
resolve({
5854+
module: {
5855+
rules: [
5856+
{ test: /\/templates\/.*\.(html|htm)$/i, use: ["html-loader"] }
5857+
]
5858+
}
5859+
})
5860+
).toEqual({ css: "auto", html: false, asyncWebAssembly: true });
5861+
});
5862+
58285863
it("stays lenient about include/exclude narrowing", () => {
58295864
// A loader scoped to `include: /src/` already handles those files today, so
58305865
// the built-in type must stay off even for a resource outside that scope.
@@ -5955,12 +5990,36 @@ describe("experiments.typescript auto", () => {
59555990
).toBe(false);
59565991
});
59575992

5993+
it("keeps typescript off for a path-scoped ts-loader rule", () => {
5994+
// A rule the sample path can't match still names `.ts` in its alternation
5995+
// or class — leaving the built-in support on would reject `.tsx` outright.
5996+
expect(
5997+
resolve({
5998+
module: {
5999+
rules: [{ test: /\/components\/.*\.(ts|tsx)$/, use: ["ts-loader"] }]
6000+
}
6001+
})
6002+
).toBe(false);
6003+
expect(
6004+
resolve({
6005+
module: { rules: [{ test: /[\\/]src[\\/].*\.[jt]sx?$/, use: ["swc"] }] }
6006+
})
6007+
).toBe(false);
6008+
});
6009+
59586010
it("ignores loaders registered for unrelated extensions", () => {
59596011
expect(
59606012
resolve({
59616013
module: { rules: [{ test: /\.js$/i, use: ["babel-loader"] }] }
59626014
})
59636015
).toBe(true);
6016+
expect(
6017+
resolve({
6018+
module: {
6019+
rules: [{ test: /[\\/]src[\\/].*\.(js|mjs)$/, use: ["babel-loader"] }]
6020+
}
6021+
})
6022+
).toBe(true);
59646023
});
59656024

59666025
it("keeps typescript off when the Node.js TypeScript API is unavailable", () => {

0 commit comments

Comments
 (0)