html-minifier-next 8.0.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -547,6 +547,14 @@ For partial HTML fragments (such as template includes, SSI fragments, or closing
547
547
 
548
548
  To validate complete HTML markup, use [the W3C validator](https://validator.w3.org/) or one of [several validator packages](https://meiert.com/blog/html-validator-packages/).
549
549
 
550
+ ### Regex options and flags
551
+
552
+ `customAttrAssign` and `customAttrSurround` patterns are merged into one attribute pattern which carries no flags of its own. `i` and `s` are written into each pattern’s source instead, so they survive the merge. `u`, `v`, and `m` cannot be, and none of them fails loudly when dropped: `u` and `v` only narrow what syntax is legal, so a source valid under either stays valid without it and quietly matches something else—a dropped `u` leaves `\p{L}` matching the literal text `p{L}`—while a dropped `m` leaves `^` and `$` matching at the ends of the input rather than of each line.
553
+
554
+ A pattern is therefore refused with an error where the flag changes what its source matches—a property or code point escape, a character past the BMP, a character `i` folds by Unicode rules only while `u` is there (`/s/iu` matches `\u017F`, `/k/iu` matches `\u212A`), a `v` class that nests, subtracts, intersects, or holds strings, or—under `m`—an anchor whose meaning moves. A flag the source does not depend on, as in `/x=/u`, is left alone.
555
+
556
+ Patterns given as strings, in a configuration file or on the command line, may be written either bare (`ng-class`) or delimited with flags (`/ng-class/i`).
557
+
550
558
  ## Security
551
559
 
552
560
  ### ReDoS protection
@@ -555,7 +563,11 @@ You can use `ignoreCustomFragments` to hand HTML Minifier Next a regular express
555
563
 
556
564
  * Matching without backtracking: A pattern that wraps an any-character or negated-class body in literal delimiters—`<%[\s\S]*?%>` or `\{\{[^}]*?\}\}`, and every other shape below—is matched by scanning for those delimiters in linear time, with no regular expression involved. Patterns of other shapes run as regular expressions, one per pattern, so each keeps its own flags.
557
565
 
558
- * Pattern detection: HMN warns about the shapes that backtrack catastrophically—an unlimited quantifier over a group that itself contains a quantifier that can vary (`(a+)+`, `(a?)+`) or alternation (`(a|b)*`), and the same atom repeated unboundedly twice in a row (`.*.*`). A fixed count does not vary, so `(?:a{4})+` passes. These are also shapes a linear scan cannot stand in for. `strictCustomFragments` refuses them with an error instead, which is worth enabling where the patterns or the input are not entirely under your control. A pattern longer than 10,000 characters or nested more than 50 groups deep is judged risky without being analyzed further, so that reading the pattern cannot itself become the expensive step.
566
+ * Pattern detection: HMN warns about the shapes that backtrack catastrophically—an unlimited quantifier over a group that itself contains a quantifier that can vary (`(a+)+`, `(a?)+`) or alternation (`(a|b)*`), and two unbounded repeats that can consume the same character with only atoms matching empty between them (`.*.*`, `[a]*a*`, `\w*\d*`, `a*b*a*`).
567
+
568
+ A group is no wall: It counts by what its body can match, and repeats meet across its boundary, so `\s*(\w*)\s*` and `(a*)a*` are flagged like `\s*\w*\s*` and `a*a*`. A lookaround backtracks nothing, so `(?=a*)a*` passes. A fixed count does not vary, so `(?:a{4})+` passes; repeats that share no character leave nothing ambiguous to split, so `\s*\S*` passes. A pattern is read the way its own flags make it match, so `/.*\n*/s` and `/[a]*A*/i` are flagged where those same sources without the flags are not. Under `v`, a class that nests reads as the union it is, while one that subtracts (`--`) or intersects (`&&`) is left unread and passes.
569
+
570
+ These are also shapes a linear scan cannot stand in for. `strictCustomFragments` refuses them with an error instead, which is worth enabling where the patterns or the input are not entirely under your control. A pattern longer than 10,000 characters or nested more than 50 groups deep is judged risky without being analyzed further, so that reading the pattern cannot itself become the expensive step. The check reads shapes, not languages: It warns about the common ones rather than proving a pattern linear, and misses repeats that overlap only across whole subexpressions (`(ab)*(abab)*`). **Treat a pattern that passes as unflagged, not as vetted.**
559
571
 
560
572
  * Input length limits: The `maxInputLength` option allows you to set a maximum input size to prevent processing of excessively large inputs that could cause performance issues.
561
573
 
@@ -579,7 +591,8 @@ ignoreCustomFragments: [
579
591
  ignoreCustomFragments: [
580
592
  /<%(\s|\S)*?%>/, // Unlimited quantifier over an alternating group
581
593
  /\{\{([^}]+)+\}\}/, // Nested unlimited quantifiers
582
- /<!--[\s\S]*[\s\S]*-->/ // The same atom repeated unboundedly twice
594
+ /<!--[\s\S]*[\s\S]*-->/, // Two unbounded repeats in a row
595
+ /<%\w*\d*%>/ // Two unbounded repeats over overlapping sets
583
596
  ]
584
597
  ```
585
598
 
@@ -1 +1 @@
1
- {"version":3,"file":"options.d.ts","sourceRoot":"","sources":["../../../src/lib/options.js"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"options.d.ts","sourceRoot":"","sources":["../../../src/lib/options.js"],"names":[],"mappings":"AACA,OAAO,EAAE,GAAG,EAAwI,MAAM,YAAY,CAAC;AAQnK,OAAQ,KAAA,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAA;AASrE,YAA4D,UAAU,GAA5D;IAAC,WAAW,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAAC,MAAM,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;CAAC,CAAY;AAOtE,YAA6C,SAAS,GAA5C;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAC,CAAW;AAUtD,YAgBG,gBAAgB,GAhBT,IAAI,CAAC,eAAe,EAAE,QAAQ,GAAG,uBAAuB,GAAG,mBAAmB,GAAG,sBAAsB,GAAG,KAAK,GAAG,WAAW,GAAG,UAAU,GAAG,YAAY,GAAG,WAAW,GAAG,iBAAiB,CAAC,GAAG;IACvM,IAAI,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IAC/B,GAAG,EAAE,CAAC,OAAO,EAAE,GAAG,KAAK,OAAO,CAAC;IAC/B,oBAAoB,EAAE,MAAM,EAAE,CAAC;IAC/B,qBAAqB,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,EAAE,SAAS,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,KAAK,OAAO,CAAC;IAC7G,iBAAiB,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,EAAE,SAAS,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,KAAK,OAAO,CAAC;IACzG,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,UAAU,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC3F,QAAQ,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,OAAO,EAAE,QAAQ,CAAC,EAAE,OAAO,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC3F,UAAU,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IACvD,SAAS,EAAE,CAAC,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,IAAI,CAAC;IACrE,eAAe,EAAE;QAAC,QAAQ,EAAE,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC;QAAC,OAAO,EAAE,OAAO,CAAA;KAAC,GAAG,IAAI,CAAC;IAC7E,UAAU,CAAC,EAAE,UAAU,CAAC;IACxB,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACtC,QAAQ,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,oBAAoB,CAAC,EAAE,OAAO,CAAA;CAC/B,CAAkB;AA1CtB,2EAA2E;AAI3E;;;;;;GAMG;AAEH;;;;;GAKG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAIH,wCAAwC;AACxC,iBAAS,qBAAqB,CAAC,OAAO,EAD1B,gBAC0B,WAUrC;AAwCD;;;;GAIG;AACH,QAAA,MAAM,cAAc,iBAJT,eAAe,mGACf;IAAC,eAAe,CAAC,EAAE,WAAW,SAAS,CAAC;IAAC,SAAS,CAAC,EAAE,WAAW,SAAS,CAAC;IAAC,MAAM,CAAC,EAAE,WAAW,SAAS,CAAC;IAAC,OAAO,CAAC,EAAE,WAAW,SAAS,CAAC;IAAC,cAAc,CAAC,EAAE,GAAG,GAAG,SAAS,CAAC;IAAC,aAAa,CAAC,EAAE,GAAG,GAAG,SAAS,CAAC;IAAC,cAAc,CAAC,EAAE,GAAG,GAAG,SAAS,CAAA;CAAC,KAC5O,gBAsiBZ,CAAC;AAIF,OAAO,EACL,qBAAqB,EACrB,cAAc,EACf,CAAC"}
@@ -2,7 +2,7 @@
2
2
  * Collect the class names and IDs a document references.
3
3
  *
4
4
  * Style sheet contents are excluded, so that a style sheet never counts as
5
- * evidence for its own selectors. An `iframe srcdoc` value is not scanned
5
+ * evidence for its own selectors. An `iframe srcdoc` value is not scanned,
6
6
  * either: It holds a document of its own, minified against its own symbol set.
7
7
  * Over-collecting is safe here (a symbol wrongly considered used is merely
8
8
  * kept), under-collecting is not.
@@ -51,6 +51,17 @@ declare function lowercase(value: string): string;
51
51
  declare function replaceAsync(str: string, regex: RegExp, asyncFn: Function): Promise<string>;
52
52
  /** @param {string | RegExp} value */
53
53
  declare function parseRegExp(value: string | RegExp): RegExp | undefined;
54
+ /**
55
+ * Which flag changes what a source matches, so that embedding the source where the
56
+ * flag cannot follow would silently match something else. `u` and `v` only narrow
57
+ * what syntax is legal, so a source valid under either stays valid without it—the
58
+ * difference never surfaces as a syntax error, and this stands in for the one that
59
+ * would otherwise be raised.
60
+ * @param {RegExp} pattern
61
+ * @returns {'u' | 'v' | 'm' | null} The flag the source depends on, or `null` where
62
+ * every flag it carries would leave the source matching the same
63
+ */
64
+ declare function lostFlag(pattern: RegExp): 'u' | 'v' | 'm' | null;
54
65
  /**
55
66
  * A pattern’s source, rewritten to match the way its own flags make it match, for
56
67
  * embedding in a larger regex that cannot carry them. `i` and `s` fit into a
@@ -62,12 +73,17 @@ declare function parseRegExp(value: string | RegExp): RegExp | undefined;
62
73
  * @returns {string}
63
74
  */
64
75
  declare function embedSource(pattern: RegExp): string;
76
+ export type Repeat = {
77
+ text: string;
78
+ ranges: [number, number][] | null;
79
+ };
65
80
  /**
66
- * @param {string} source - Regex source to judge
81
+ * @param {RegExp | string} pattern - Pattern to judge, or a bare source to read
82
+ * as it stands
67
83
  * @returns {string | null} What makes the pattern a backtracking risk, phrased
68
84
  * to follow the pattern itself, or `null` where it is none
69
85
  */
70
- declare function describeQuantifierRisk(source: string): string | null;
86
+ declare function describeQuantifierRisk(pattern: RegExp | string): string | null;
71
87
  /**
72
88
  * Find the index of the `>` that closes an opening tag, correctly skipping
73
89
  * over quoted attribute values (which may contain `>`).
@@ -76,5 +92,5 @@ declare function describeQuantifierRisk(source: string): string | null;
76
92
  * @returns {number} Index of the closing `>`, or -1 if not found
77
93
  */
78
94
  declare function findTagEnd(html: string, pos: number): number;
79
- export { stableStringify, findTagEnd, LRU, MAX_CACHE_ENTRY_SIZE, hashContent, uniqueId, identity, isThenable, lowercase, replaceAsync, parseRegExp, embedSource, describeQuantifierRisk };
95
+ export { stableStringify, findTagEnd, LRU, MAX_CACHE_ENTRY_SIZE, hashContent, uniqueId, identity, isThenable, lowercase, replaceAsync, parseRegExp, embedSource, lostFlag, describeQuantifierRisk };
80
96
  //# sourceMappingURL=utils.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../../src/lib/utils.js"],"names":[],"mappings":"AAEA;;;GAGG;AACH,iBAAS,eAAe,CAAC,GAAG,EAHjB,OAGiB,GAFf,MAAM,CAYlB;AAID,cAAM,GAAG;IAEA,KAAK;IACL,IAAI;IACJ,IAAI;IACT,mCAAmC;IAC9B,GAAG,EADG,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC;IAJjC,YAAY,KAAK,SAAM,EAMtB;IACD,0BAA0B;IAC1B,GAAG,CAAC,GAAG,EADK,MACL,WAUN;IACD;;;OAGG;IACH,GAAG,CAAC,GAAG,EAHI,MAGJ,EAAE,KAAK,EAFH,OAEG,QAOb;IACD,0BAA0B;IAC1B,MAAM,CAAC,GAAG,EADE,MACF,QAA2B;IACrC,6EAA6E;IAC7E,KAAK,IADS;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAGxE;CACF;AAKD,QAAA,MAAM,oBAAoB,QAAc,CAAC;AAIzC,0BAA0B;AAC1B,iBAAS,WAAW,CAAC,GAAG,EADZ,MACY,UAOvB;AAID,4BAA4B;AAC5B,iBAAS,QAAQ,CAAC,KAAK,EADX,MACW,UAMtB;AAID,4BAA4B;AAC5B,iBAAS,QAAQ,CAAC,KAAK,EADX,MACW,UAEtB;AAED;;;GAGG;AACH,iBAAS,UAAU,CAAC,KAAK,EAHd,OAGc,GAFZ,KAAK,IAAI,WAAW,CAAC,GAAG,CAAC,CAIrC;AAED,4BAA4B;AAC5B,iBAAS,SAAS,CAAC,KAAK,EADZ,MACY,UAEvB;AAID;;;;;;GAMG;AACH,iBAAe,YAAY,CAAC,GAAG,EALpB,MAKoB,EAAE,KAAK,EAJ3B,MAI2B,EAAE,OAAO,UAAA,GAFlC,OAAO,CAAC,MAAM,CAAC,CAe3B;AAID,qCAAqC;AACrC,iBAAS,WAAW,CAAC,KAAK,EADd,MAAM,GAAG,MACK,sBAUzB;AAqLD;;;;;;;;;GASG;AACH,iBAAS,WAAW,CAAC,OAAO,EAHjB,MAGiB,GAFf,MAAM,CAOlB;AAyFD;;;;GAIG;AACH,iBAAS,sBAAsB,CAAC,MAAM,EAJ3B,MAI2B,GAHzB,MAAM,GAAG,IAAI,CAezB;AAED;;;;;;GAMG;AACH,iBAAS,UAAU,CAAC,IAAI,EAJb,MAIa,EAAE,GAAG,EAHlB,MAGkB,GAFhB,MAAM,CAelB;AAID,OAAO,EACL,eAAe,EACf,UAAU,EACV,GAAG,EACH,oBAAoB,EACpB,WAAW,EACX,QAAQ,EACR,QAAQ,EACR,UAAU,EACV,SAAS,EACT,YAAY,EACZ,WAAW,EACX,WAAW,EACX,sBAAsB,EACvB,CAAC"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../../src/lib/utils.js"],"names":[],"mappings":"AAEA;;;GAGG;AACH,iBAAS,eAAe,CAAC,GAAG,EAHjB,OAGiB,GAFf,MAAM,CAYlB;AAID,cAAM,GAAG;IAEA,KAAK;IACL,IAAI;IACJ,IAAI;IACT,mCAAmC;IAC9B,GAAG,EADG,GAAG,CAAC,MAAM,EAAE,OAAO,CAAC;IAJjC,YAAY,KAAK,SAAM,EAMtB;IACD,0BAA0B;IAC1B,GAAG,CAAC,GAAG,EADK,MACL,WAUN;IACD;;;OAGG;IACH,GAAG,CAAC,GAAG,EAHI,MAGJ,EAAE,KAAK,EAFH,OAEG,QAOb;IACD,0BAA0B;IAC1B,MAAM,CAAC,GAAG,EADE,MACF,QAA2B;IACrC,6EAA6E;IAC7E,KAAK,IADS;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAGxE;CACF;AAKD,QAAA,MAAM,oBAAoB,QAAc,CAAC;AAIzC,0BAA0B;AAC1B,iBAAS,WAAW,CAAC,GAAG,EADZ,MACY,UAOvB;AAID,4BAA4B;AAC5B,iBAAS,QAAQ,CAAC,KAAK,EADX,MACW,UAMtB;AAID,4BAA4B;AAC5B,iBAAS,QAAQ,CAAC,KAAK,EADX,MACW,UAEtB;AAED;;;GAGG;AACH,iBAAS,UAAU,CAAC,KAAK,EAHd,OAGc,GAFZ,KAAK,IAAI,WAAW,CAAC,GAAG,CAAC,CAIrC;AAED,4BAA4B;AAC5B,iBAAS,SAAS,CAAC,KAAK,EADZ,MACY,UAEvB;AAID;;;;;;GAMG;AACH,iBAAe,YAAY,CAAC,GAAG,EALpB,MAKoB,EAAE,KAAK,EAJ3B,MAI2B,EAAE,OAAO,UAAA,GAFlC,OAAO,CAAC,MAAM,CAAC,CAe3B;AAID,qCAAqC;AACrC,iBAAS,WAAW,CAAC,KAAK,EADd,MAAM,GAAG,MACK,sBAUzB;AAyYD;;;;;;;;;GASG;AACH,iBAAS,QAAQ,CAAC,OAAO,EAJd,MAIc,GAHZ,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,IAAI,CAqDlC;AAED;;;;;;;;;GASG;AACH,iBAAS,WAAW,CAAC,OAAO,EAHjB,MAGiB,GAFf,MAAM,CAOlB;AAGE,YAA6D,MAAM,GAAzD;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,IAAI,CAAA;CAAC,CAE7D;AAqKD;;;;;GAKG;AACH,iBAAS,sBAAsB,CAAC,OAAO,EAL5B,MAAM,GAAG,MAKmB,GAH1B,MAAM,GAAG,IAAI,CAqBzB;AAED;;;;;;GAMG;AACH,iBAAS,UAAU,CAAC,IAAI,EAJb,MAIa,EAAE,GAAG,EAHlB,MAGkB,GAFhB,MAAM,CAelB;AAID,OAAO,EACL,eAAe,EACf,UAAU,EACV,GAAG,EACH,oBAAoB,EACpB,WAAW,EACX,QAAQ,EACR,QAAQ,EACR,UAAU,EACV,SAAS,EACT,YAAY,EACZ,WAAW,EACX,WAAW,EACX,QAAQ,EACR,sBAAsB,EACvB,CAAC"}
package/package.json CHANGED
@@ -95,5 +95,5 @@
95
95
  },
96
96
  "type": "module",
97
97
  "types": "dist/types/htmlminifier.d.ts",
98
- "version": "8.0.0"
98
+ "version": "8.1.0"
99
99
  }
@@ -225,7 +225,7 @@ function replaceCustomFragments(value, fragments, replacer) {
225
225
  ? previous
226
226
  : ('open' in fragment ? scan(fragment, i, from, false) : run(fragment, from, false));
227
227
  found[i] = match;
228
- // Nothing ahead now means nothing ahead later either
228
+ // Nothing ahead now means nothing ahead later, either
229
229
  if (!match) exhausted[i] = true;
230
230
  }
231
231
 
@@ -1,5 +1,5 @@
1
1
  import { createUrlMinifier } from './urls.js';
2
- import { LRU, MAX_CACHE_ENTRY_SIZE, stableStringify, hashContent, identity, lowercase, replaceAsync, parseRegExp, describeQuantifierRisk } from './utils.js';
2
+ import { LRU, MAX_CACHE_ENTRY_SIZE, stableStringify, hashContent, identity, lowercase, replaceAsync, parseRegExp, describeQuantifierRisk, lostFlag } from './utils.js';
3
3
  import { RE_TRAILING_SEMICOLON } from './constants.js';
4
4
  import { canCollapseWhitespace, canTrimWhitespace } from './whitespace.js';
5
5
  import { wrapCSS, unwrapCSS } from './content.js';
@@ -602,6 +602,25 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
602
602
  }
603
603
  });
604
604
 
605
+ // The parser merges these into one attribute pattern, and a merged pattern
606
+ // carries no flags of its own. `i` and `s` are written into the source instead,
607
+ // but `u` and `v` change how a source reads and cannot be: dropped, `\p{L}`
608
+ // stops being a property escape and matches the literal text `p{L}`, silently
609
+ // and without failing to compile. Refusing them beats matching the wrong thing.
610
+ /** @type {[string, RegExp[]][]} */
611
+ const merged = [
612
+ ['customAttrAssign', options.customAttrAssign || []],
613
+ ['customAttrSurround', (options.customAttrSurround || []).flat()]
614
+ ];
615
+ for (const [key, patterns] of merged) {
616
+ for (const re of patterns) {
617
+ if (!(re instanceof RegExp)) continue;
618
+ const flag = lostFlag(re);
619
+ if (!flag) continue;
620
+ throw new Error(`HTML Minifier Next: \`${key}\` pattern \`/${re.source}/${re.flags}\` carries \`${flag}\`, which the merged attribute pattern cannot carry—rewrite it without \`${flag}\``);
621
+ }
622
+ }
623
+
605
624
  // Fragments that compound quantifiers or alternation under unbounded repetition
606
625
  // are the shapes that backtrack catastrophically, and they are also the ones a
607
626
  // linear scan cannot stand in for; so are patterns too long or too deeply
@@ -610,7 +629,7 @@ const processOptions = (inputOptions, { getLightningCSS, getTerser, getSwc, getS
610
629
  // fragments have exactly that shape, so the check flagging it would mean
611
630
  // warning about the defaults themselves.
612
631
  for (const re of options.ignoreCustomFragments || []) {
613
- const risk = describeQuantifierRisk(re.source);
632
+ const risk = describeQuantifierRisk(re);
614
633
  if (!risk) continue;
615
634
  const problem = `Custom fragment \`/${re.source}/\` ${risk}`;
616
635
  if (options.strictCustomFragments) {
@@ -170,7 +170,7 @@ function findRawTextElements(haystack, tagName) {
170
170
  * Collect the class names and IDs a document references.
171
171
  *
172
172
  * Style sheet contents are excluded, so that a style sheet never counts as
173
- * evidence for its own selectors. An `iframe srcdoc` value is not scanned
173
+ * evidence for its own selectors. An `iframe srcdoc` value is not scanned,
174
174
  * either: It holds a document of its own, minified against its own symbol set.
175
175
  * Over-collecting is safe here (a symbol wrongly considered used is merely
176
176
  * kept), under-collecting is not.
package/src/lib/utils.js CHANGED
@@ -145,6 +145,67 @@ function parseRegExp(value) {
145
145
  return value;
146
146
  }
147
147
 
148
+ // Regex source reading, shared by the analysis and the rewriting below
149
+
150
+ const RE_ASCII_LETTER = /^[a-zA-Z]$/;
151
+ const RE_HEX_PAIR = /^[0-9a-fA-F]{2}$/;
152
+ const RE_HEX_QUAD = /^[0-9a-fA-F]{4}$/;
153
+
154
+ /**
155
+ * @param {string} source @param {number} index - Index of the opening `[`
156
+ * @param {boolean} [nested] - Whether the pattern carries `v`, where a class nests
157
+ * inside a class and the first `]` need not be the one that closes it
158
+ * @returns {number} Index just past the closing `]`
159
+ */
160
+ function skipCharacterClass(source, index, nested = false) {
161
+ let i = index + 1;
162
+ let open = 1;
163
+ while (i < source.length && open > 0) {
164
+ const char = source[i];
165
+ if (char === '\\') i += tokenLength(source, i);
166
+ else {
167
+ if (nested && char === '[') open++;
168
+ else if (char === ']') open--;
169
+ i++;
170
+ }
171
+ }
172
+ return i;
173
+ }
174
+
175
+ /**
176
+ * Length of the token at `index`, so that a multi-character escape is read whole
177
+ * rather than leaving its tail to be mistaken for literals.
178
+ * @param {string} source @param {number} index
179
+ */
180
+ function tokenLength(source, index) {
181
+ if (source[index] !== '\\') return 1;
182
+ const next = source[index + 1];
183
+ if (next === 'x' && RE_HEX_PAIR.test(source.slice(index + 2, index + 4))) return 4;
184
+ if (next === 'u') {
185
+ if (source[index + 2] === '{') {
186
+ const close = source.indexOf('}', index + 3);
187
+ if (close !== -1) return close - index + 1;
188
+ }
189
+ if (RE_HEX_QUAD.test(source.slice(index + 2, index + 6))) return 6;
190
+ }
191
+ if (next === 'c' && RE_ASCII_LETTER.test(source[index + 2] ?? '')) return 3;
192
+ // A `v` class holds string literals, which fold as strings or not at all
193
+ if (next === 'q' && source[index + 2] === '{') {
194
+ const close = source.indexOf('}', index + 3);
195
+ if (close !== -1) return close - index + 1;
196
+ }
197
+ // A property name or a group name is syntax, not text to fold
198
+ if ((next === 'p' || next === 'P') && source[index + 2] === '{') {
199
+ const close = source.indexOf('}', index + 3);
200
+ if (close !== -1) return close - index + 1;
201
+ }
202
+ if (next === 'k' && source[index + 2] === '<') {
203
+ const close = source.indexOf('>', index + 3);
204
+ if (close !== -1) return close - index + 1;
205
+ }
206
+ return 2;
207
+ }
208
+
148
209
  // ReDoS risk detection for user-supplied patterns
149
210
 
150
211
  // Quantifier following an atom, with its optional lazy `?`; the captures hold
@@ -164,14 +225,15 @@ const RE_TRANSPARENT_GROUP = /^\((?:\?:|\?<[^>=!][^>]*>)?([\s\S]*)\)$/;
164
225
  // A quantifier of exactly one repetition, which is notation rather than shape
165
226
  const RE_EXACT_ONE = /\{1(?:,1)?\}\??$/;
166
227
 
167
- /** @param {string} source @param {number} index - Index of the opening `[` */
168
- function skipCharacterClass(source, index) {
169
- let i = index + 1;
170
- while (i < source.length && source[i] !== ']') {
171
- i += source[i] === '\\' ? 2 : 1;
172
- }
173
- return i + 1;
174
- }
228
+ // An atom that matches without consuming, so what precedes it stays adjacent to
229
+ // what follows; a group that can match empty is left out, being rare enough that
230
+ // missing it costs less than reading every body for it
231
+ const RE_ZERO_WIDTH = /^(?:[$^]|\\[bB]|\((?:\?[=!]|\?<[=!]))/;
232
+
233
+ // How many repeats an atom may still be adjacent to across atoms matching empty;
234
+ // comparing against every one of them is what the bound keeps from turning
235
+ // quadratic on a pattern that is nothing but repeats
236
+ const MAX_REACHABLE = 50;
175
237
 
176
238
  /** @param {string} atom - Atom as it reads in the source */
177
239
  function unwrapAtom(atom) {
@@ -184,15 +246,156 @@ function unwrapAtom(atom) {
184
246
  return inner;
185
247
  }
186
248
 
249
+ // Two atoms that repeat unboundedly side by side split a run between them in as
250
+ // many ways as the run is long, whenever both can consume the same character.
251
+ // Comparing what they match, rather than how they are spelled, is what catches
252
+ // `[a]*a*` and `\w*\d*` alongside `a*a*`.
253
+
254
+ const MAX_CODE_POINT = 0x10FFFF;
255
+
256
+ /** @type {Record<string, [number, number][]>} */
257
+ const CLASS_ESCAPE_RANGES = {
258
+ d: [[0x30, 0x39]],
259
+ w: [[0x30, 0x39], [0x41, 0x5A], [0x5F, 0x5F], [0x61, 0x7A]],
260
+ s: [[0x09, 0x0D], [0x20, 0x20], [0xA0, 0xA0], [0x1680, 0x1680], [0x2000, 0x200A],
261
+ [0x2028, 0x2029], [0x202F, 0x202F], [0x205F, 0x205F], [0x3000, 0x3000], [0xFEFF, 0xFEFF]]
262
+ };
263
+
264
+ /** @type {Record<string, number>} */
265
+ const CONTROL_ESCAPE_CODES = { 0: 0x00, f: 0x0C, n: 0x0A, r: 0x0D, t: 0x09, v: 0x0B };
266
+
267
+ // An escape that stands for something other than one character of text
268
+ const RE_NON_CHARACTER_ESCAPE = /[bBdDkpPsSwW1-9]/;
269
+
270
+ /** @param {[number, number][]} ranges */
271
+ function complement(ranges) {
272
+ const sorted = [...ranges].sort((one, other) => one[0] - other[0]);
273
+ /** @type {[number, number][]} */
274
+ const out = [];
275
+ let next = 0;
276
+ for (const [low, high] of sorted) {
277
+ if (low > next) out.push([next, low - 1]);
278
+ next = Math.max(next, high + 1);
279
+ }
280
+ if (next <= MAX_CODE_POINT) out.push([next, MAX_CODE_POINT]);
281
+ return out;
282
+ }
283
+
284
+ // `.` as a bare source reads it, without the `s` flag the source cannot carry
285
+ const DOT_RANGES = complement([[0x0A, 0x0A], [0x0D, 0x0D], [0x2028, 0x2029]]);
286
+
287
+ /**
288
+ * @param {string} token - One token, as `tokenLength` measures it
289
+ * @param {boolean} [inClass] - Whether the token sits inside a character class
290
+ * @returns {[number, number][] | null} What it matches, or `null` where it is not
291
+ * one character of text
292
+ */
293
+ function tokenRanges(token, inClass = false) {
294
+ if (token[0] !== '\\') {
295
+ const code = token.codePointAt(0) ?? 0;
296
+ return String.fromCodePoint(code) === token ? [[code, code]] : null;
297
+ }
298
+
299
+ const kind = token[1] ?? '';
300
+ const named = CLASS_ESCAPE_RANGES[kind.toLowerCase()];
301
+ if (named) return kind === kind.toLowerCase() ? named : complement(named);
302
+
303
+ const control = CONTROL_ESCAPE_CODES[kind];
304
+ if (control !== undefined) return [[control, control]];
305
+
306
+ if (kind === 'x' || kind === 'u') {
307
+ const code = Number.parseInt(token[2] === '{' ? token.slice(3, -1) : token.slice(2), 16);
308
+ return Number.isNaN(code) || code > MAX_CODE_POINT ? null : [[code, code]];
309
+ }
310
+ if (kind === 'c') {
311
+ const code = token.charCodeAt(2) % 32;
312
+ return [[code, code]];
313
+ }
314
+ // `\b` asserts a word boundary on its own, and is a backspace inside a class
315
+ if (kind === 'b' && inClass) return [[0x08, 0x08]];
316
+ // `\q{…}` stands for whole strings, not for a character
317
+ if (token.startsWith('\\q{')) return null;
318
+ if (RE_NON_CHARACTER_ESCAPE.test(kind)) return null;
319
+
320
+ // The rest is punctuation escaped to be read as itself
321
+ const code = token.codePointAt(1) ?? 0;
322
+ return String.fromCodePoint(code) === token.slice(1) ? [[code, code]] : null;
323
+ }
324
+
325
+ /** @param {[number, number][] | null} ranges @returns {number | null} The one code point it holds */
326
+ function singleCode(ranges) {
327
+ const [range] = ranges ?? [];
328
+ return ranges?.length === 1 && range && range[0] === range[1] ? range[0] : null;
329
+ }
330
+
331
+ /**
332
+ * @param {string} atom - Atom as it reads in the source, already unwrapped
333
+ * @param {boolean} [nested] - Whether the pattern carries `v`
334
+ * @param {number} [depth] - Class nesting level of this call
335
+ * @returns {[number, number][] | null} What it matches, or `null` where it is not
336
+ * one character of text, or one this does not read
337
+ */
338
+ function atomRanges(atom, nested = false, depth = 0) {
339
+ if (depth > MAX_PATTERN_DEPTH) return null;
340
+ if (atom === '.') return DOT_RANGES;
341
+ if (atom[0] !== '[') return tokenRanges(atom);
342
+ if (!atom.endsWith(']')) return null;
343
+
344
+ let i = atom[1] === '^' ? 2 : 1;
345
+ const negated = i === 2;
346
+ const end = atom.length - 1;
347
+ /** @type {[number, number][]} */
348
+ const members = [];
349
+
350
+ while (i < end) {
351
+ // A `v` class nests, and nesting alone is a union to read through
352
+ if (nested && atom[i] === '[') {
353
+ const close = skipCharacterClass(atom, i, true);
354
+ const inner = atomRanges(atom.slice(i, close), true, depth + 1);
355
+ if (!inner) return null;
356
+ members.push(...inner);
357
+ i = close;
358
+ continue;
359
+ }
360
+ // Subtraction and intersection are not unions, and are left unread
361
+ //
362
+ // @@ Read `--` and `&&` as set difference and intersection
363
+ // (so that a `v` class built is compared rather than passed unjudged)
364
+ if (nested && (atom.startsWith('--', i) || atom.startsWith('&&', i))) return null;
365
+
366
+ const fromLength = tokenLength(atom, i);
367
+ const from = tokenRanges(atom.slice(i, i + fromLength), true);
368
+ if (!from) return null;
369
+ i += fromLength;
370
+
371
+ // A dash right before the closing `]` is a member, not the start of a range
372
+ if (atom[i] !== '-' || i + 1 >= end) {
373
+ members.push(...from);
374
+ continue;
375
+ }
376
+ const toLength = tokenLength(atom, i + 1);
377
+ // Only single characters bound a range; `\d-z` is not a range at all
378
+ const low = singleCode(from);
379
+ const high = singleCode(tokenRanges(atom.slice(i + 1, i + 1 + toLength), true));
380
+ if (low === null || high === null) return null;
381
+ members.push([low, high]);
382
+ i += 1 + toLength;
383
+ }
384
+
385
+ return negated ? complement(members) : members;
386
+ }
387
+
388
+ /** @param {[number, number][]} ranges @param {[number, number][]} other */
389
+ function rangesIntersect(ranges, other) {
390
+ return ranges.some(([low, high]) => other.some(([otherLow, otherHigh]) =>
391
+ low <= otherHigh && otherLow <= high));
392
+ }
393
+
187
394
  // Embedding a pattern in a larger regex drops the flags it carried, so the two
188
395
  // a source can carry on its own are rewritten into it
189
396
  //
190
397
  // @@ Replace with inline `(?i:…)` and `(?s:…)` modifiers once Node floor reaches 24
191
398
 
192
- const RE_ASCII_LETTER = /^[a-zA-Z]$/;
193
- const RE_HEX_PAIR = /^[0-9a-fA-F]{2}$/;
194
- const RE_HEX_QUAD = /^[0-9a-fA-F]{4}$/;
195
-
196
399
  /** @param {string} char @returns {boolean} Whether the character has a single-character counterpart */
197
400
  function foldsCase(char) {
198
401
  const lower = char.toLowerCase();
@@ -206,36 +409,10 @@ function isLower(char) {
206
409
  }
207
410
 
208
411
  /**
209
- * Length of the token at `index`, so that a multi-character escape is read whole
210
- * rather than leaving its tail to be mistaken for literals.
211
- * @param {string} source @param {number} index
412
+ * @param {string} source - Regex source, assumed syntactically valid
413
+ * @param {boolean} [nested] - Whether the pattern carries `v`
212
414
  */
213
- function tokenLength(source, index) {
214
- if (source[index] !== '\\') return 1;
215
- const next = source[index + 1];
216
- if (next === 'x' && RE_HEX_PAIR.test(source.slice(index + 2, index + 4))) return 4;
217
- if (next === 'u') {
218
- if (source[index + 2] === '{') {
219
- const close = source.indexOf('}', index + 3);
220
- if (close !== -1) return close - index + 1;
221
- }
222
- if (RE_HEX_QUAD.test(source.slice(index + 2, index + 6))) return 6;
223
- }
224
- if (next === 'c' && RE_ASCII_LETTER.test(source[index + 2] ?? '')) return 3;
225
- // A property name or a group name is syntax, not text to fold
226
- if ((next === 'p' || next === 'P') && source[index + 2] === '{') {
227
- const close = source.indexOf('}', index + 3);
228
- if (close !== -1) return close - index + 1;
229
- }
230
- if (next === 'k' && source[index + 2] === '<') {
231
- const close = source.indexOf('>', index + 3);
232
- if (close !== -1) return close - index + 1;
233
- }
234
- return 2;
235
- }
236
-
237
- /** @param {string} source - Regex source, assumed syntactically valid */
238
- function expandDotAll(source) {
415
+ function expandDotAll(source, nested = false) {
239
416
  let out = '';
240
417
  let i = 0;
241
418
  while (i < source.length) {
@@ -245,7 +422,7 @@ function expandDotAll(source) {
245
422
  out += source.slice(i, i + length);
246
423
  i += length;
247
424
  } else if (char === '[') {
248
- const end = skipCharacterClass(source, i);
425
+ const end = skipCharacterClass(source, i, nested);
249
426
  out += source.slice(i, end);
250
427
  i = end;
251
428
  } else if (char === '.') {
@@ -259,22 +436,25 @@ function expandDotAll(source) {
259
436
  return out;
260
437
  }
261
438
 
262
- /** @param {string} source - Regex source, assumed syntactically valid */
263
- function foldCase(source) {
439
+ /**
440
+ * @param {string} source - Regex source, assumed syntactically valid
441
+ * @param {boolean} [nested] - Whether the pattern carries `v`
442
+ */
443
+ function foldCase(source, nested = false) {
264
444
  let out = '';
265
445
  let i = 0;
266
- let inClass = false;
446
+ let open = 0;
267
447
 
268
448
  while (i < source.length) {
269
449
  const char = source[i] ?? '';
270
450
 
271
- if (!inClass) {
451
+ if (open === 0) {
272
452
  if (char === '\\') {
273
453
  const length = tokenLength(source, i);
274
454
  out += source.slice(i, i + length);
275
455
  i += length;
276
456
  } else if (char === '[') {
277
- inClass = true;
457
+ open = 1;
278
458
  out += char;
279
459
  i++;
280
460
  } else if (char === '(' && source.startsWith('(?<', i) &&
@@ -291,8 +471,15 @@ function foldCase(source) {
291
471
  continue;
292
472
  }
293
473
 
474
+ if (nested && char === '[') {
475
+ open++;
476
+ out += char;
477
+ i++;
478
+ continue;
479
+ }
480
+
294
481
  if (char === ']') {
295
- inClass = false;
482
+ open--;
296
483
  out += char;
297
484
  i++;
298
485
  continue;
@@ -324,6 +511,93 @@ function foldCase(source) {
324
511
  return out;
325
512
  }
326
513
 
514
+ /**
515
+ * Whether a source anchors anywhere, so that `m` would move where it matches
516
+ * @param {string} source @param {boolean} [nested] - Whether the pattern carries `v`
517
+ */
518
+ function hasAnchor(source, nested = false) {
519
+ let i = 0;
520
+ while (i < source.length) {
521
+ if (source[i] === '\\') i += tokenLength(source, i);
522
+ // Inside a class `^` negates and `$` is a member, so neither anchors there
523
+ else if (source[i] === '[') i = skipCharacterClass(source, i, nested);
524
+ else if (source[i] === '^' || source[i] === '$') return true;
525
+ else i++;
526
+ }
527
+ return false;
528
+ }
529
+
530
+ // A property escape or a code point escape, both of which read as literal text
531
+ // where the flag that gives them meaning is gone
532
+ const RE_UNICODE_ESCAPE = /\\[pP]\{|\\u\{/;
533
+
534
+ // The characters `u` folds by Unicode rules and a source without it does not:
535
+ // `/s/iu` matches `\u017F` and `/k/iu` matches `\u212A`, where neither does on its
536
+ // own. Characters past the BMP fold this way, too, and are caught as astral first.
537
+ const RE_UNICODE_FOLDING = /[\u004B\u0053\u006B\u0073\u00C5\u00DF\u00E5\u017F\u0398\u03A9\u03B8\u03C9\u03D1\u03F4\u1E9E\u1F80-\u1FAF\u1FB3\u1FBC\u1FC3\u1FCC\u1FF3\u1FFC\u2126\u212A\u212B]/;
538
+
539
+ /**
540
+ * Which flag changes what a source matches, so that embedding the source where the
541
+ * flag cannot follow would silently match something else. `u` and `v` only narrow
542
+ * what syntax is legal, so a source valid under either stays valid without it—the
543
+ * difference never surfaces as a syntax error, and this stands in for the one that
544
+ * would otherwise be raised.
545
+ * @param {RegExp} pattern
546
+ * @returns {'u' | 'v' | 'm' | null} The flag the source depends on, or `null` where
547
+ * every flag it carries would leave the source matching the same
548
+ */
549
+ function lostFlag(pattern) {
550
+ const { source, unicode, unicodeSets } = pattern;
551
+ // `m` moves where `^` and `$` match, and only matters where the source has one
552
+ if (pattern.multiline && hasAnchor(source, unicodeSets)) return 'm';
553
+ if (!unicode && !unicodeSets) return null;
554
+ const flag = unicodeSets ? 'v' : 'u';
555
+ if (RE_UNICODE_ESCAPE.test(source)) return flag;
556
+ // Case folding under `i` follows Unicode rules only while the flag is there. A
557
+ // character can be written literally or as an escape, so the source is read
558
+ // token by token rather than scanned for the characters themselves; `singleCode`
559
+ // leaves out `\d` and friends, which fold alike with the flag and without it.
560
+ if (pattern.ignoreCase) {
561
+ for (let i = 0; i < source.length;) {
562
+ const length = tokenLength(source, i);
563
+ const code = singleCode(tokenRanges(source.slice(i, i + length), true));
564
+ if (code !== null && RE_UNICODE_FOLDING.test(String.fromCodePoint(code))) return flag;
565
+ i += length;
566
+ }
567
+ }
568
+ // Past the BMP a character is one code point with the flag, two units without,
569
+ // which is what a quantifier or a class beside it would go on to read wrongly
570
+ for (const char of source) {
571
+ if ((char.codePointAt(0) ?? 0) > 0xFFFF) return flag;
572
+ }
573
+ if (!unicodeSets) return null;
574
+
575
+ // `v` alone lets a class nest, subtract, intersect, and hold whole strings
576
+ let i = 0;
577
+ while (i < source.length) {
578
+ if (source[i] === '\\') {
579
+ i += tokenLength(source, i);
580
+ continue;
581
+ }
582
+ if (source[i] !== '[') {
583
+ i++;
584
+ continue;
585
+ }
586
+ const end = skipCharacterClass(source, i, true);
587
+ for (let j = i + 1; j < end - 1;) {
588
+ if (source[j] === '\\') {
589
+ if (source.startsWith('\\q{', j)) return 'v';
590
+ j += tokenLength(source, j);
591
+ continue;
592
+ }
593
+ if (source[j] === '[' || source.startsWith('--', j) || source.startsWith('&&', j)) return 'v';
594
+ j++;
595
+ }
596
+ i = end;
597
+ }
598
+ return null;
599
+ }
600
+
327
601
  /**
328
602
  * A pattern’s source, rewritten to match the way its own flags make it match, for
329
603
  * embedding in a larger regex that cannot carry them. `i` and `s` fit into a
@@ -336,30 +610,77 @@ function foldCase(source) {
336
610
  */
337
611
  function embedSource(pattern) {
338
612
  let source = pattern.source;
339
- if (pattern.dotAll) source = expandDotAll(source);
340
- if (pattern.ignoreCase) source = foldCase(source);
613
+ if (pattern.dotAll) source = expandDotAll(source, pattern.unicodeSets);
614
+ if (pattern.ignoreCase) source = foldCase(source, pattern.unicodeSets);
341
615
  return source;
342
616
  }
343
617
 
618
+ /**
619
+ * @typedef {{text: string, ranges: [number, number][] | null}} Repeat - An unbounded
620
+ * repeat, as it reads and as the set of characters it matches
621
+ */
622
+
623
+ /**
624
+ * @param {Repeat[]} reachable - Repeats that could still sit adjacent to what comes next
625
+ * @param {Repeat} repeat
626
+ * @returns {boolean} Whether the two can consume the same character, and so split
627
+ * the same run of input between them
628
+ */
629
+ function splitsWith(reachable, repeat) {
630
+ return reachable.some(earlier => earlier.text === repeat.text ||
631
+ (!!repeat.ranges && !!earlier.ranges && rangesIntersect(repeat.ranges, earlier.ranges)));
632
+ }
633
+
634
+ /**
635
+ * @param {Repeat[]} into
636
+ * @param {Repeat[]} repeats
637
+ * @returns {void} Adds the repeats, keeping only the most recent ones the walk
638
+ * still compares against
639
+ */
640
+ function collectReachable(into, repeats) {
641
+ for (const repeat of repeats) {
642
+ into.push(repeat);
643
+ if (into.length > MAX_REACHABLE) into.shift();
644
+ }
645
+ }
646
+
344
647
  /**
345
648
  * Walk a regex source for the shapes whose backtracking blows up: an unlimited
346
649
  * quantifier over a group that itself contains a variable quantifier (`(a+)+`,
347
- * `(a?)+`) or alternates, and the same atom repeated unboundedly twice in a
348
- * row. A lone unlimited quantifier stays linear, so `[\s\S]*?` up to a literal
349
- * terminator passes.
650
+ * `(a?)+`) or alternates, and two unbounded repeats that can consume the same
651
+ * character with only atoms matching empty between them (`.*.*`, `[a]*a*`,
652
+ * `\w*\d*`, `a*b*a*`, `a*(b*)a*`, `(a*)a*`). A lone unlimited quantifier stays
653
+ * linear, so `[\s\S]*?` up to a literal terminator passes.
350
654
  * @param {string} source - Regex source, assumed syntactically valid
351
655
  * @param {number} [depth] - Group nesting level of this call
352
- * @returns {{risky: boolean, varies: boolean, alternates: boolean, deep: boolean}}
656
+ * @param {boolean} [nested] - Whether the pattern carries `v`
657
+ * @returns {{risky: boolean, varies: boolean, alternates: boolean, deep: boolean,
658
+ * empty: boolean, leading: Repeat[], trailing: Repeat[]}} What the walk found,
659
+ * with the repeats that reach the source’s start and end for a caller to compare
660
+ * against what stands beside it
353
661
  */
354
- function analyzeQuantifiers(source, depth = 0) {
355
- if (depth > MAX_PATTERN_DEPTH) return { risky: true, varies: true, alternates: true, deep: true };
662
+ function analyzeQuantifiers(source, depth = 0, nested = false) {
663
+ if (depth > MAX_PATTERN_DEPTH) return { risky: true, varies: true, alternates: true, deep: true, empty: true, leading: [], trailing: [] };
356
664
 
357
665
  let risky = false;
358
666
  let varies = false;
359
667
  let alternates = false;
360
668
  let deep = false;
361
- /** @type {{text: string, repeats: boolean} | null} */
362
- let previous = null;
669
+ // Whether every atom of the branch being read matches empty, and whether any
670
+ // branch before it did
671
+ let branchEmpty = true;
672
+ let anyBranchEmpty = false;
673
+ // The unbounded repeats that could still sit adjacent to what comes next, most
674
+ // recent last; an atom matching empty leaves the ones before it reachable
675
+ /** @type {Repeat[]} */
676
+ const reachable = [];
677
+ // The repeats a caller can reach from either end, gathered across branches
678
+ /** @type {Repeat[]} */
679
+ const leading = [];
680
+ /** @type {Repeat[]} */
681
+ const trailing = [];
682
+ /** @type {Repeat[]} */
683
+ let branchLeading = [];
363
684
  let i = 0;
364
685
 
365
686
  while (i < source.length) {
@@ -371,22 +692,27 @@ function analyzeQuantifiers(source, depth = 0) {
371
692
  if (char === '|') {
372
693
  // Alternatives are separate expressions, so nothing carries across
373
694
  alternates = true;
374
- previous = null;
695
+ anyBranchEmpty ||= branchEmpty;
696
+ branchEmpty = true;
697
+ collectReachable(leading, branchLeading);
698
+ collectReachable(trailing, reachable);
699
+ branchLeading = [];
700
+ reachable.length = 0;
375
701
  i++;
376
702
  continue;
377
703
  } else if (char === '\\') {
378
- i += 2;
704
+ i += tokenLength(source, i);
379
705
  } else if (char === '[') {
380
- i = skipCharacterClass(source, i);
706
+ i = skipCharacterClass(source, i, nested);
381
707
  } else if (char === '(') {
382
708
  let open = 1;
383
709
  i++;
384
710
  while (i < source.length && open > 0) {
385
711
  const inner = source[i];
386
712
  if (inner === '\\') {
387
- i += 2;
713
+ i += tokenLength(source, i);
388
714
  } else if (inner === '[') {
389
- i = skipCharacterClass(source, i);
715
+ i = skipCharacterClass(source, i, nested);
390
716
  } else {
391
717
  if (inner === '(') open++;
392
718
  else if (inner === ')') open--;
@@ -394,7 +720,7 @@ function analyzeQuantifiers(source, depth = 0) {
394
720
  }
395
721
  }
396
722
  // Drop the group prefix (`?:`, `?=`, `?<name>`, …) before reading the body
397
- group = analyzeQuantifiers(source.slice(start + 1, i - 1).replace(/^\?(?:[:=!]|<[=!]|<[^>]*>)/, ''), depth + 1);
723
+ group = analyzeQuantifiers(source.slice(start + 1, i - 1).replace(/^\?(?:[:=!]|<[=!]|<[^>]*>)/, ''), depth + 1, nested);
398
724
  } else {
399
725
  i++;
400
726
  }
@@ -410,35 +736,71 @@ function analyzeQuantifiers(source, depth = 0) {
410
736
  const exact = !!quantifier && quantifier[0][0] === '{' &&
411
737
  (upper === undefined || (upper !== '' && Number(upper) === Number(quantifier[1])));
412
738
  const variable = !!quantifier && !exact;
739
+ // An atom matches empty where it is zero-width, where its quantifier may
740
+ // repeat none, or where a group body matches empty however often it repeats—
741
+ // `(b*)`, carrying the quantifier inside the group rather than on it
742
+ const least = quantifier && (quantifier[0][0] === '+' ? 1
743
+ : quantifier[0][0] === '{' ? Number(quantifier[1]) : 0);
744
+ const body = RE_ZERO_WIDTH.test(atom) || (!!group && group.empty);
745
+ const nullable = body || (!!quantifier && least === 0);
413
746
  if (quantifier) i = RE_QUANTIFIER.lastIndex;
414
747
 
748
+ // A lookaround is atomic: It backtracks nothing, so what it holds neither
749
+ // reaches out of it nor is reached into
750
+ const opaque = RE_ZERO_WIDTH.test(atom);
415
751
  if (group) {
416
752
  if (group.risky || (repeats && (group.varies || group.alternates))) risky = true;
417
753
  if (group.varies) varies = true;
418
754
  // A group alternates whether the `|` sits at its top level or deeper
419
755
  if (group.alternates) alternates = true;
420
756
  if (group.deep) deep = true;
757
+ // A group is no wall: What it repeats at its start splits the same run as
758
+ // what stands unbounded before it
759
+ if (!opaque && group.leading.some(repeat => splitsWith(reachable, repeat))) risky = true;
421
760
  }
422
761
  if (variable) varies = true;
423
762
  const text = unwrapAtom(atom);
424
- if (repeats && previous?.repeats && previous.text === text) risky = true;
425
- previous = { text, repeats };
763
+ const ranges = atomRanges(text, nested);
764
+ if (repeats && splitsWith(reachable, { text, ranges })) risky = true;
765
+
766
+ // Whatever a caller could reach before this atom, it reaches this one, too
767
+ if (branchEmpty) {
768
+ if (repeats) collectReachable(branchLeading, [{ text, ranges }]);
769
+ if (group && !opaque) collectReachable(branchLeading, group.leading);
770
+ }
771
+ // Nothing reaches past an atom that has to consume something
772
+ if (!nullable) {
773
+ reachable.length = 0;
774
+ branchEmpty = false;
775
+ }
776
+ if (group && !opaque) collectReachable(reachable, group.trailing);
777
+ if (repeats) collectReachable(reachable, [{ text, ranges }]);
426
778
  }
427
779
 
428
- return { risky, varies, alternates, deep };
780
+ collectReachable(leading, branchLeading);
781
+ collectReachable(trailing, reachable);
782
+
783
+ return { risky, varies, alternates, deep, empty: anyBranchEmpty || branchEmpty, leading, trailing };
429
784
  }
430
785
 
431
786
  /**
432
- * @param {string} source - Regex source to judge
787
+ * @param {RegExp | string} pattern - Pattern to judge, or a bare source to read
788
+ * as it stands
433
789
  * @returns {string | null} What makes the pattern a backtracking risk, phrased
434
790
  * to follow the pattern itself, or `null` where it is none
435
791
  */
436
- function describeQuantifierRisk(source) {
437
- // A pattern too big to read is refused for that, not for a shape nobody saw
792
+ function describeQuantifierRisk(pattern) {
793
+ const source = typeof pattern === 'string' ? pattern : pattern.source;
794
+ // A pattern too big to read is refused for that, not for a shape nobody saw;
795
+ // its own length is what counts, not the length folding case inflates it to
438
796
  if (source.length > MAX_PATTERN_LENGTH) {
439
797
  return `runs past ${MAX_PATTERN_LENGTH.toLocaleString()} characters, too long to analyze for catastrophic backtracking—shorten it, or split it into several patterns`;
440
798
  }
441
- const analysis = analyzeQuantifiers(source);
799
+ // `i` and `s` change what the source matches, so they are written into it
800
+ // before the shapes are read
801
+ const analysis = typeof pattern === 'string'
802
+ ? analyzeQuantifiers(source)
803
+ : analyzeQuantifiers(embedSource(pattern), 0, pattern.unicodeSets);
442
804
  if (analysis.deep) {
443
805
  return `nests groups more than ${MAX_PATTERN_DEPTH.toLocaleString()} deep, too deep to analyze for catastrophic backtracking—flatten it, or split it into several patterns`;
444
806
  }
@@ -484,5 +846,6 @@ export {
484
846
  replaceAsync,
485
847
  parseRegExp,
486
848
  embedSource,
849
+ lostFlag,
487
850
  describeQuantifierRisk
488
851
  };