html-minifier 4.0.0 → 5.0.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/LICENSE.txt +23 -0
- package/README.md +851 -110
- package/cli.js +991 -207
- package/dist/types/htmlminifier.d.ts +629 -0
- package/dist/types/htmlminifier.d.ts.map +1 -0
- package/dist/types/htmlparser.d.ts +28 -0
- package/dist/types/htmlparser.d.ts.map +1 -0
- package/dist/types/lib/attributes.d.ts +87 -0
- package/dist/types/lib/attributes.d.ts.map +1 -0
- package/dist/types/lib/constants.d.ts +421 -0
- package/dist/types/lib/constants.d.ts.map +1 -0
- package/dist/types/lib/content.d.ts +44 -0
- package/dist/types/lib/content.d.ts.map +1 -0
- package/dist/types/lib/elements.d.ts +48 -0
- package/dist/types/lib/elements.d.ts.map +1 -0
- package/dist/types/lib/fragments.d.ts +47 -0
- package/dist/types/lib/fragments.d.ts.map +1 -0
- package/dist/types/lib/option-definitions.d.ts +33 -0
- package/dist/types/lib/option-definitions.d.ts.map +1 -0
- package/dist/types/lib/options.d.ts +93 -0
- package/dist/types/lib/options.d.ts.map +1 -0
- package/dist/types/lib/unused-css.d.ts +76 -0
- package/dist/types/lib/unused-css.d.ts.map +1 -0
- package/dist/types/lib/urls.d.ts +15 -0
- package/dist/types/lib/urls.d.ts.map +1 -0
- package/dist/types/lib/utils.d.ts +132 -0
- package/dist/types/lib/utils.d.ts.map +1 -0
- package/dist/types/lib/whitespace.d.ts +97 -0
- package/dist/types/lib/whitespace.d.ts.map +1 -0
- package/dist/types/presets.d.ts +50 -0
- package/dist/types/presets.d.ts.map +1 -0
- package/dist/types/tokenchain.d.ts +36 -0
- package/dist/types/tokenchain.d.ts.map +1 -0
- package/html-minifier.schema.json +322 -0
- package/package.json +70 -58
- package/src/htmlminifier.js +2845 -1107
- package/src/htmlparser.js +1163 -452
- package/src/lib/attributes.js +1196 -0
- package/src/lib/constants.js +521 -0
- package/src/lib/content.js +237 -0
- package/src/lib/elements.js +268 -0
- package/src/lib/file-pool.js +193 -0
- package/src/lib/file-write.js +87 -0
- package/src/lib/fragments.js +340 -0
- package/src/lib/option-definitions.js +246 -0
- package/src/lib/options.js +841 -0
- package/src/lib/unused-css.js +499 -0
- package/src/lib/urls.js +146 -0
- package/src/lib/utils.js +1026 -0
- package/src/lib/whitespace.js +431 -0
- package/src/presets.js +58 -0
- package/src/tokenchain.js +142 -56
- package/LICENSE +0 -22
- package/sample-cli-config-file.conf +0 -40
- package/src/utils.js +0 -18
package/src/lib/utils.js
ADDED
|
@@ -0,0 +1,1026 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* General utility functions
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { quirksPublicIdPrefixes, quirksPublicIdPrefixesNoSystemId, quirksPublicIds, quirksSystemIds, RE_DOCTYPE, RE_XML_ESCAPE_ATTR, RE_XML_ESCAPE_TEXT } from './constants.js';
|
|
6
|
+
|
|
7
|
+
// Functions and other non-plain objects have no structure to compare (closures with the
|
|
8
|
+
// same source can behave differently), so they are told apart by identity
|
|
9
|
+
/** @type {WeakMap<object, number>} */
|
|
10
|
+
const objectIds = new WeakMap();
|
|
11
|
+
let objectIdLast = 0;
|
|
12
|
+
|
|
13
|
+
/** @param {object} obj */
|
|
14
|
+
function objectId(obj) {
|
|
15
|
+
let id = objectIds.get(obj);
|
|
16
|
+
if (id === undefined) {
|
|
17
|
+
id = ++objectIdLast;
|
|
18
|
+
objectIds.set(obj, id);
|
|
19
|
+
}
|
|
20
|
+
return id;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Stringify with sorted keys, for cache keys—values that can behave differently must not
|
|
25
|
+
* stringify alike, so unquoted markers stand in for what JSON cannot express
|
|
26
|
+
* @param {unknown} obj
|
|
27
|
+
* @returns {string}
|
|
28
|
+
*/
|
|
29
|
+
function stableStringify(obj) {
|
|
30
|
+
if (typeof obj === 'function') return 'function#' + objectId(obj);
|
|
31
|
+
if (obj == null || typeof obj !== 'object') return JSON.stringify(obj);
|
|
32
|
+
if (obj instanceof RegExp) return String(obj);
|
|
33
|
+
if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']';
|
|
34
|
+
const proto = Object.getPrototypeOf(obj);
|
|
35
|
+
if (proto !== Object.prototype && proto !== null) return 'object#' + objectId(obj);
|
|
36
|
+
const keys = Object.keys(obj).sort();
|
|
37
|
+
let out = '{';
|
|
38
|
+
for (let i = 0; i < keys.length; i++) {
|
|
39
|
+
const k = keys[i] ?? '';
|
|
40
|
+
out += JSON.stringify(k) + ':' + stableStringify(/** @type {Record<string, unknown>} */ (obj)[k]) + (i < keys.length - 1 ? ',' : '');
|
|
41
|
+
}
|
|
42
|
+
return out + '}';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// LRU cache for strings and promises
|
|
46
|
+
|
|
47
|
+
class LRU {
|
|
48
|
+
constructor(limit = 200) {
|
|
49
|
+
this.limit = limit;
|
|
50
|
+
this.gets = 0;
|
|
51
|
+
this.hits = 0;
|
|
52
|
+
/** @type {Map<string, unknown>} */
|
|
53
|
+
this.map = new Map();
|
|
54
|
+
}
|
|
55
|
+
/** @param {string} key */
|
|
56
|
+
get(key) {
|
|
57
|
+
this.gets++;
|
|
58
|
+
if (this.map.has(key)) {
|
|
59
|
+
this.hits++;
|
|
60
|
+
const v = this.map.get(key);
|
|
61
|
+
this.map.delete(key);
|
|
62
|
+
this.map.set(key, v);
|
|
63
|
+
return v;
|
|
64
|
+
}
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* @param {string} key
|
|
69
|
+
* @param {unknown} value
|
|
70
|
+
*/
|
|
71
|
+
set(key, value) {
|
|
72
|
+
if (this.map.has(key)) this.map.delete(key);
|
|
73
|
+
this.map.set(key, value);
|
|
74
|
+
if (this.map.size > this.limit) {
|
|
75
|
+
const first = this.map.keys().next().value;
|
|
76
|
+
if (first !== undefined) this.map.delete(first);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/** @param {string} key */
|
|
80
|
+
delete(key) { this.map.delete(key); }
|
|
81
|
+
/** @returns {{ gets: number, hits: number, size: number, limit: number }} */
|
|
82
|
+
stats() {
|
|
83
|
+
return { gets: this.gets, hits: this.hits, size: this.map.size, limit: this.limit };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Content longer than this (in UTF-16 code units, roughly bytes for typical CSS/JS/SVG) is
|
|
88
|
+
// minified normally but never stored in a minification cache—caps worst-case cache memory
|
|
89
|
+
// (entry count × this size) without affecting realistically sized inline content
|
|
90
|
+
const MAX_CACHE_ENTRY_SIZE = 1024 * 1024; // 1 MB
|
|
91
|
+
|
|
92
|
+
// 53-bit hash (cyrb53) for large-input cache keys, where a collision would hand one
|
|
93
|
+
// input another’s result; as fast as a 32-bit hash, whose collisions are within reach
|
|
94
|
+
|
|
95
|
+
/** @param {string} str */
|
|
96
|
+
function hashContent(str) {
|
|
97
|
+
let h1 = 0xdeadbeef;
|
|
98
|
+
let h2 = 0x41c6ce57;
|
|
99
|
+
for (let i = 0; i < str.length; i++) {
|
|
100
|
+
const code = str.charCodeAt(i);
|
|
101
|
+
h1 = Math.imul(h1 ^ code, 2654435761);
|
|
102
|
+
h2 = Math.imul(h2 ^ code, 1597334677);
|
|
103
|
+
}
|
|
104
|
+
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
|
105
|
+
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
|
106
|
+
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @param {string} value */
|
|
110
|
+
function uniqueId(value) {
|
|
111
|
+
let id;
|
|
112
|
+
do {
|
|
113
|
+
id = 'u' + crypto.randomUUID().replace(/-/g, '');
|
|
114
|
+
} while (~value.indexOf(id));
|
|
115
|
+
return id;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @type {Record<string, string>} */
|
|
119
|
+
const xmlEscapes = { '&': '&', '<': '<', ']]>': ']]>', '\t': '	', '\n': ' ', '\r': ' ' };
|
|
120
|
+
|
|
121
|
+
/** @param {string} match */
|
|
122
|
+
function escapeXMLMatch(match) {
|
|
123
|
+
return xmlEscapes[match] ?? match;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Write decoded text or an attribute value for an XML parser to read it the same
|
|
128
|
+
* @param {string} value - Text or attribute value, decoded
|
|
129
|
+
* @param {boolean} [attribute] - Whether the value is an attribute value
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
function escapeXML(value, attribute = false) {
|
|
133
|
+
return value.replace(attribute ? RE_XML_ESCAPE_ATTR : RE_XML_ESCAPE_TEXT, escapeXMLMatch);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Read text in foreign content as HTML does: CDATA sections hold text as written,
|
|
138
|
+
* while what surrounds them reads character references
|
|
139
|
+
* @param {string} text
|
|
140
|
+
* @param {(text: string) => string} decode - `decodeHTML`
|
|
141
|
+
* @returns {string}
|
|
142
|
+
*/
|
|
143
|
+
function decodeForeignText(text, decode) {
|
|
144
|
+
let out = '';
|
|
145
|
+
let pos = 0;
|
|
146
|
+
while (pos < text.length) {
|
|
147
|
+
const start = text.indexOf('<![CDATA[', pos);
|
|
148
|
+
if (start === -1) return out + decode(text.slice(pos));
|
|
149
|
+
out += decode(text.slice(pos, start));
|
|
150
|
+
// An unclosed section runs to the end
|
|
151
|
+
const end = text.indexOf(']]>', start + 9);
|
|
152
|
+
if (end === -1) return out + text.slice(start + 9);
|
|
153
|
+
out += text.slice(start + 9, end);
|
|
154
|
+
pos = end + 3;
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** @param {string} value */
|
|
160
|
+
function identity(value) {
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @param {unknown} value
|
|
166
|
+
* @returns {value is PromiseLike<any>}
|
|
167
|
+
*/
|
|
168
|
+
function isThenable(value) {
|
|
169
|
+
return value != null && typeof value === 'object' && typeof /** @type {any} */ (value).then === 'function';
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** @param {string} value */
|
|
173
|
+
function lowercase(value) {
|
|
174
|
+
return value.toLowerCase();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Option name to CLI flag name (names are ASCII, so no Unicode handling is needed)
|
|
178
|
+
/** @param {string} value */
|
|
179
|
+
function paramCase(value) {
|
|
180
|
+
return value.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Asynchronously replace matches in a string.
|
|
185
|
+
* @param {string} str - Input string
|
|
186
|
+
* @param {RegExp} regex - Regular expression with global flag
|
|
187
|
+
* @param {Function} asyncFn - Async function to process each match
|
|
188
|
+
* @returns {Promise<string>} Processed string
|
|
189
|
+
*/
|
|
190
|
+
async function replaceAsync(str, regex, asyncFn) {
|
|
191
|
+
/** @type {Promise<string>[]} */
|
|
192
|
+
const promises = [];
|
|
193
|
+
|
|
194
|
+
str.replace(regex, /** @returns {string} */ (match, ...args) => {
|
|
195
|
+
const promise = asyncFn(match, ...args);
|
|
196
|
+
promises.push(promise);
|
|
197
|
+
return match;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
const data = await Promise.all(promises);
|
|
201
|
+
let next = 0;
|
|
202
|
+
return str.replace(regex, () => data[next++] ?? '');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// String patterns to RegExp conversion (for JSON config support)
|
|
206
|
+
|
|
207
|
+
/** @param {string | RegExp} value */
|
|
208
|
+
function parseRegExp(value) {
|
|
209
|
+
if (typeof value === 'string') {
|
|
210
|
+
if (!value) return undefined; // Empty string = not configured
|
|
211
|
+
const match = value.match(/^\/(.+)\/([dgimsuvy]*)$/);
|
|
212
|
+
if (match) {
|
|
213
|
+
return new RegExp(match[1] ?? '', match[2] ?? '');
|
|
214
|
+
}
|
|
215
|
+
return new RegExp(value);
|
|
216
|
+
}
|
|
217
|
+
return value;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Regex source reading, shared by the analysis and the rewriting below
|
|
221
|
+
|
|
222
|
+
const RE_ASCII_LETTER = /^[a-zA-Z]$/;
|
|
223
|
+
const RE_HEX_PAIR = /^[0-9a-fA-F]{2}$/;
|
|
224
|
+
const RE_HEX_QUAD = /^[0-9a-fA-F]{4}$/;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* @param {string} source @param {number} index - Index of the opening `[`
|
|
228
|
+
* @param {boolean} [nested] - Whether the pattern carries `v`, where a class nests
|
|
229
|
+
* inside a class and the first `]` need not be the one that closes it
|
|
230
|
+
* @returns {number} Index just past the closing `]`
|
|
231
|
+
*/
|
|
232
|
+
function skipCharacterClass(source, index, nested = false) {
|
|
233
|
+
let i = index + 1;
|
|
234
|
+
let open = 1;
|
|
235
|
+
while (i < source.length && open > 0) {
|
|
236
|
+
const char = source[i];
|
|
237
|
+
if (char === '\\') i += tokenLength(source, i);
|
|
238
|
+
else {
|
|
239
|
+
if (nested && char === '[') open++;
|
|
240
|
+
else if (char === ']') open--;
|
|
241
|
+
i++;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return i;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Length of the token at `index`, so that a multi-character escape is read whole
|
|
249
|
+
* rather than leaving its tail to be mistaken for literals.
|
|
250
|
+
* @param {string} source @param {number} index
|
|
251
|
+
*/
|
|
252
|
+
function tokenLength(source, index) {
|
|
253
|
+
if (source[index] !== '\\') return 1;
|
|
254
|
+
const next = source[index + 1];
|
|
255
|
+
if (next === 'x' && RE_HEX_PAIR.test(source.slice(index + 2, index + 4))) return 4;
|
|
256
|
+
if (next === 'u') {
|
|
257
|
+
if (source[index + 2] === '{') {
|
|
258
|
+
const close = source.indexOf('}', index + 3);
|
|
259
|
+
if (close !== -1) return close - index + 1;
|
|
260
|
+
}
|
|
261
|
+
if (RE_HEX_QUAD.test(source.slice(index + 2, index + 6))) return 6;
|
|
262
|
+
}
|
|
263
|
+
if (next === 'c' && RE_ASCII_LETTER.test(source[index + 2] ?? '')) return 3;
|
|
264
|
+
// A `v` class holds string literals, which fold as strings or not at all
|
|
265
|
+
if (next === 'q' && source[index + 2] === '{') {
|
|
266
|
+
const close = source.indexOf('}', index + 3);
|
|
267
|
+
if (close !== -1) return close - index + 1;
|
|
268
|
+
}
|
|
269
|
+
// A property name or a group name is syntax, not text to fold
|
|
270
|
+
if ((next === 'p' || next === 'P') && source[index + 2] === '{') {
|
|
271
|
+
const close = source.indexOf('}', index + 3);
|
|
272
|
+
if (close !== -1) return close - index + 1;
|
|
273
|
+
}
|
|
274
|
+
if (next === 'k' && source[index + 2] === '<') {
|
|
275
|
+
const close = source.indexOf('>', index + 3);
|
|
276
|
+
if (close !== -1) return close - index + 1;
|
|
277
|
+
}
|
|
278
|
+
return 2;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ReDoS risk detection for user-supplied patterns
|
|
282
|
+
|
|
283
|
+
// Quantifier following an atom, with its optional lazy `?`; the captures hold
|
|
284
|
+
// the bounds of a `{n,m}` form, the upper one empty when the form is `{n,}`
|
|
285
|
+
const RE_QUANTIFIER = /(?:[*+?]|\{(\d+)(?:,(\d*))?\})\??/y;
|
|
286
|
+
|
|
287
|
+
// Bounds for the walk below: Patterns beyond either are judged risky unread,
|
|
288
|
+
// which keeps a pathological source from nesting the analysis into a stack
|
|
289
|
+
// overflow or making it rescan its groups once per level
|
|
290
|
+
const MAX_PATTERN_LENGTH = 10000;
|
|
291
|
+
const MAX_PATTERN_DEPTH = 50;
|
|
292
|
+
|
|
293
|
+
// A group that only wraps its body backtracks the way that body does, so
|
|
294
|
+
// `(?:a)` and `a` count as the same atom; lookarounds are left alone
|
|
295
|
+
const RE_TRANSPARENT_GROUP = /^\((?:\?:|\?<[^>=!][^>]*>)?([\s\S]*)\)$/;
|
|
296
|
+
|
|
297
|
+
// A quantifier of exactly one repetition, which is notation rather than shape
|
|
298
|
+
const RE_EXACT_ONE = /\{1(?:,1)?\}\??$/;
|
|
299
|
+
|
|
300
|
+
// An atom that matches without consuming, so what precedes it stays adjacent to
|
|
301
|
+
// what follows; a group that can match empty is left out, being rare enough that
|
|
302
|
+
// missing it costs less than reading every body for it
|
|
303
|
+
const RE_ZERO_WIDTH = /^(?:[$^]|\\[bB]|\((?:\?[=!]|\?<[=!]))/;
|
|
304
|
+
|
|
305
|
+
// How many repeats an atom may still be adjacent to across atoms matching empty;
|
|
306
|
+
// comparing against every one of them is what the bound keeps from turning
|
|
307
|
+
// quadratic on a pattern that is nothing but repeats
|
|
308
|
+
const MAX_REACHABLE = 50;
|
|
309
|
+
|
|
310
|
+
/** @param {string} atom - Atom as it reads in the source */
|
|
311
|
+
function unwrapAtom(atom) {
|
|
312
|
+
let inner = atom;
|
|
313
|
+
for (let level = 0; level < MAX_PATTERN_DEPTH; level++) {
|
|
314
|
+
const next = inner.replace(RE_EXACT_ONE, '').replace(RE_TRANSPARENT_GROUP, '$1');
|
|
315
|
+
if (next === inner) break;
|
|
316
|
+
inner = next;
|
|
317
|
+
}
|
|
318
|
+
return inner;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Two atoms that repeat unboundedly side by side split a run between them in as
|
|
322
|
+
// many ways as the run is long, whenever both can consume the same character.
|
|
323
|
+
// Comparing what they match, rather than how they are spelled, is what catches
|
|
324
|
+
// `[a]*a*` and `\w*\d*` alongside `a*a*`.
|
|
325
|
+
|
|
326
|
+
const MAX_CODE_POINT = 0x10FFFF;
|
|
327
|
+
|
|
328
|
+
/** @type {Record<string, [number, number][]>} */
|
|
329
|
+
const CLASS_ESCAPE_RANGES = {
|
|
330
|
+
d: [[0x30, 0x39]],
|
|
331
|
+
w: [[0x30, 0x39], [0x41, 0x5A], [0x5F, 0x5F], [0x61, 0x7A]],
|
|
332
|
+
s: [[0x09, 0x0D], [0x20, 0x20], [0xA0, 0xA0], [0x1680, 0x1680], [0x2000, 0x200A],
|
|
333
|
+
[0x2028, 0x2029], [0x202F, 0x202F], [0x205F, 0x205F], [0x3000, 0x3000], [0xFEFF, 0xFEFF]]
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
/** @type {Record<string, number>} */
|
|
337
|
+
const CONTROL_ESCAPE_CODES = { 0: 0x00, f: 0x0C, n: 0x0A, r: 0x0D, t: 0x09, v: 0x0B };
|
|
338
|
+
|
|
339
|
+
// An escape that stands for something other than one character of text
|
|
340
|
+
const RE_NON_CHARACTER_ESCAPE = /[bBdDkpPsSwW1-9]/;
|
|
341
|
+
|
|
342
|
+
/** @param {[number, number][]} ranges */
|
|
343
|
+
function complement(ranges) {
|
|
344
|
+
const sorted = [...ranges].sort((one, other) => one[0] - other[0]);
|
|
345
|
+
/** @type {[number, number][]} */
|
|
346
|
+
const out = [];
|
|
347
|
+
let next = 0;
|
|
348
|
+
for (const [low, high] of sorted) {
|
|
349
|
+
if (low > next) out.push([next, low - 1]);
|
|
350
|
+
next = Math.max(next, high + 1);
|
|
351
|
+
}
|
|
352
|
+
if (next <= MAX_CODE_POINT) out.push([next, MAX_CODE_POINT]);
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// `.` as a bare source reads it, without the `s` flag the source cannot carry
|
|
357
|
+
const DOT_RANGES = complement([[0x0A, 0x0A], [0x0D, 0x0D], [0x2028, 0x2029]]);
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* @param {string} token - One token, as `tokenLength` measures it
|
|
361
|
+
* @param {boolean} [inClass] - Whether the token sits inside a character class
|
|
362
|
+
* @returns {[number, number][] | null} What it matches, or `null` where it is not
|
|
363
|
+
* one character of text
|
|
364
|
+
*/
|
|
365
|
+
function tokenRanges(token, inClass = false) {
|
|
366
|
+
if (token[0] !== '\\') {
|
|
367
|
+
const code = token.codePointAt(0) ?? 0;
|
|
368
|
+
return String.fromCodePoint(code) === token ? [[code, code]] : null;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const kind = token[1] ?? '';
|
|
372
|
+
const named = CLASS_ESCAPE_RANGES[kind.toLowerCase()];
|
|
373
|
+
if (named) return kind === kind.toLowerCase() ? named : complement(named);
|
|
374
|
+
|
|
375
|
+
const control = CONTROL_ESCAPE_CODES[kind];
|
|
376
|
+
if (control !== undefined) return [[control, control]];
|
|
377
|
+
|
|
378
|
+
if (kind === 'x' || kind === 'u') {
|
|
379
|
+
const code = Number.parseInt(token[2] === '{' ? token.slice(3, -1) : token.slice(2), 16);
|
|
380
|
+
return Number.isNaN(code) || code > MAX_CODE_POINT ? null : [[code, code]];
|
|
381
|
+
}
|
|
382
|
+
if (kind === 'c') {
|
|
383
|
+
const code = token.charCodeAt(2) % 32;
|
|
384
|
+
return [[code, code]];
|
|
385
|
+
}
|
|
386
|
+
// `\b` asserts a word boundary on its own, and is a backspace inside a class
|
|
387
|
+
if (kind === 'b' && inClass) return [[0x08, 0x08]];
|
|
388
|
+
// `\q{…}` stands for whole strings, not for a character
|
|
389
|
+
if (token.startsWith('\\q{')) return null;
|
|
390
|
+
if (RE_NON_CHARACTER_ESCAPE.test(kind)) return null;
|
|
391
|
+
|
|
392
|
+
// The rest is punctuation escaped to be read as itself
|
|
393
|
+
const code = token.codePointAt(1) ?? 0;
|
|
394
|
+
return String.fromCodePoint(code) === token.slice(1) ? [[code, code]] : null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** @param {[number, number][] | null} ranges @returns {number | null} The one code point it holds */
|
|
398
|
+
function singleCode(ranges) {
|
|
399
|
+
const [range] = ranges ?? [];
|
|
400
|
+
return ranges?.length === 1 && range && range[0] === range[1] ? range[0] : null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* @param {string} source @param {number} i @param {boolean} nested - Whether the pattern carries `v`
|
|
405
|
+
* @returns {boolean} Whether a `v` class subtracts or intersects at the position
|
|
406
|
+
*/
|
|
407
|
+
function isSetOperator(source, i, nested) {
|
|
408
|
+
return nested && i >= 0 && (source.startsWith('--', i) || source.startsWith('&&', i));
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* @param {string} atom - Atom as it reads in the source, already unwrapped
|
|
413
|
+
* @param {boolean} [nested] - Whether the pattern carries `v`
|
|
414
|
+
* @param {number} [depth] - Class nesting level of this call
|
|
415
|
+
* @returns {[number, number][] | null} What it matches, or `null` where it is not
|
|
416
|
+
* one character of text, or one this does not read
|
|
417
|
+
*/
|
|
418
|
+
function atomRanges(atom, nested = false, depth = 0) {
|
|
419
|
+
if (depth > MAX_PATTERN_DEPTH) return null;
|
|
420
|
+
if (atom === '.') return DOT_RANGES;
|
|
421
|
+
if (atom[0] !== '[') return tokenRanges(atom);
|
|
422
|
+
if (!atom.endsWith(']')) return null;
|
|
423
|
+
|
|
424
|
+
let i = atom[1] === '^' ? 2 : 1;
|
|
425
|
+
const negated = i === 2;
|
|
426
|
+
const end = atom.length - 1;
|
|
427
|
+
/** @type {[number, number][]} */
|
|
428
|
+
let members = [];
|
|
429
|
+
// A `v` class subtracts or intersects where it does not unite, one operand at a time
|
|
430
|
+
let operator = '';
|
|
431
|
+
|
|
432
|
+
while (i < end) {
|
|
433
|
+
if (isSetOperator(atom, i, nested)) {
|
|
434
|
+
operator = atom.slice(i, i + 2);
|
|
435
|
+
i += 2;
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** @type {[number, number][] | null} */
|
|
440
|
+
let operand;
|
|
441
|
+
if (nested && atom[i] === '[') {
|
|
442
|
+
const close = skipCharacterClass(atom, i, true);
|
|
443
|
+
operand = atomRanges(atom.slice(i, close), true, depth + 1);
|
|
444
|
+
i = close;
|
|
445
|
+
} else {
|
|
446
|
+
const fromLength = tokenLength(atom, i);
|
|
447
|
+
operand = tokenRanges(atom.slice(i, i + fromLength), true);
|
|
448
|
+
i += fromLength;
|
|
449
|
+
// A dash right before the closing `]` is a member, not the start of a range,
|
|
450
|
+
// and so is the `--` that subtracts
|
|
451
|
+
if (operand && atom[i] === '-' && i + 1 < end && !isSetOperator(atom, i, nested)) {
|
|
452
|
+
const toLength = tokenLength(atom, i + 1);
|
|
453
|
+
// Only single characters bound a range; `\d-z` is not a range at all
|
|
454
|
+
const low = singleCode(operand);
|
|
455
|
+
const high = singleCode(tokenRanges(atom.slice(i + 1, i + 1 + toLength), true));
|
|
456
|
+
operand = low === null || high === null ? null : [[low, high]];
|
|
457
|
+
i += 1 + toLength;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
if (!operand) return null;
|
|
461
|
+
|
|
462
|
+
members = operator === '--' ? intersectRanges(members, complement(operand))
|
|
463
|
+
: operator === '&&' ? intersectRanges(members, operand)
|
|
464
|
+
: members.concat(operand);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return negated ? complement(members) : members;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** @param {[number, number][]} ranges @returns {[number, number][]} The same set, sorted and merged */
|
|
471
|
+
function mergeRanges(ranges) {
|
|
472
|
+
const sorted = [...ranges].sort((one, other) => one[0] - other[0]);
|
|
473
|
+
/** @type {[number, number][]} */
|
|
474
|
+
const out = [];
|
|
475
|
+
for (const [low, high] of sorted) {
|
|
476
|
+
const last = out[out.length - 1];
|
|
477
|
+
if (last && low <= last[1] + 1) last[1] = Math.max(last[1], high);
|
|
478
|
+
else out.push([low, high]);
|
|
479
|
+
}
|
|
480
|
+
return out;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* @param {[number, number][]} ranges @param {[number, number][]} other
|
|
485
|
+
* @returns {[number, number][]} What both match, merged so that repeated
|
|
486
|
+
* operations cannot multiply the pieces
|
|
487
|
+
*/
|
|
488
|
+
function intersectRanges(ranges, other) {
|
|
489
|
+
const one = mergeRanges(ranges);
|
|
490
|
+
const two = mergeRanges(other);
|
|
491
|
+
/** @type {[number, number][]} */
|
|
492
|
+
const out = [];
|
|
493
|
+
let i = 0;
|
|
494
|
+
let j = 0;
|
|
495
|
+
while (i < one.length && j < two.length) {
|
|
496
|
+
const [low, high] = /** @type {[number, number]} */ (one[i]);
|
|
497
|
+
const [otherLow, otherHigh] = /** @type {[number, number]} */ (two[j]);
|
|
498
|
+
const from = Math.max(low, otherLow);
|
|
499
|
+
const to = Math.min(high, otherHigh);
|
|
500
|
+
if (from <= to) out.push([from, to]);
|
|
501
|
+
if (high < otherHigh) i++;
|
|
502
|
+
else j++;
|
|
503
|
+
}
|
|
504
|
+
return out;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** @param {[number, number][]} ranges @param {[number, number][]} other */
|
|
508
|
+
function rangesIntersect(ranges, other) {
|
|
509
|
+
return ranges.some(([low, high]) => other.some(([otherLow, otherHigh]) =>
|
|
510
|
+
low <= otherHigh && otherLow <= high));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Embedding a pattern in a larger regex drops the flags it carried, so the two
|
|
514
|
+
// a source can carry on its own are rewritten into it
|
|
515
|
+
//
|
|
516
|
+
// @@ Replace with inline `(?i:…)` and `(?s:…)` modifiers once Node floor reaches 24
|
|
517
|
+
|
|
518
|
+
/** @param {string} char @returns {boolean} Whether the character has a single-character counterpart */
|
|
519
|
+
function foldsCase(char) {
|
|
520
|
+
const lower = char.toLowerCase();
|
|
521
|
+
const upper = char.toUpperCase();
|
|
522
|
+
return lower !== upper && lower.length === 1 && upper.length === 1;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** @param {string} char */
|
|
526
|
+
function isLower(char) {
|
|
527
|
+
return char === char.toLowerCase();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* @param {string} source - Regex source, assumed syntactically valid
|
|
532
|
+
* @param {boolean} [nested] - Whether the pattern carries `v`
|
|
533
|
+
*/
|
|
534
|
+
function expandDotAll(source, nested = false) {
|
|
535
|
+
let out = '';
|
|
536
|
+
let i = 0;
|
|
537
|
+
while (i < source.length) {
|
|
538
|
+
const char = source[i];
|
|
539
|
+
if (char === '\\') {
|
|
540
|
+
const length = tokenLength(source, i);
|
|
541
|
+
out += source.slice(i, i + length);
|
|
542
|
+
i += length;
|
|
543
|
+
} else if (char === '[') {
|
|
544
|
+
const end = skipCharacterClass(source, i, nested);
|
|
545
|
+
out += source.slice(i, end);
|
|
546
|
+
i = end;
|
|
547
|
+
} else if (char === '.') {
|
|
548
|
+
out += '[\\s\\S]';
|
|
549
|
+
i++;
|
|
550
|
+
} else {
|
|
551
|
+
out += char;
|
|
552
|
+
i++;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return out;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* @param {string} source - Regex source, assumed syntactically valid
|
|
560
|
+
* @param {boolean} [nested] - Whether the pattern carries `v`
|
|
561
|
+
*/
|
|
562
|
+
function foldCase(source, nested = false) {
|
|
563
|
+
let out = '';
|
|
564
|
+
let i = 0;
|
|
565
|
+
let open = 0;
|
|
566
|
+
|
|
567
|
+
while (i < source.length) {
|
|
568
|
+
const char = source[i] ?? '';
|
|
569
|
+
|
|
570
|
+
if (open === 0) {
|
|
571
|
+
if (char === '\\') {
|
|
572
|
+
const length = tokenLength(source, i);
|
|
573
|
+
out += source.slice(i, i + length);
|
|
574
|
+
i += length;
|
|
575
|
+
} else if (char === '[') {
|
|
576
|
+
open = 1;
|
|
577
|
+
out += char;
|
|
578
|
+
i++;
|
|
579
|
+
} else if (char === '(' && source.startsWith('(?<', i) &&
|
|
580
|
+
source[i + 3] !== '=' && source[i + 3] !== '!') {
|
|
581
|
+
// A capture group’s name is syntax, and folding it is a syntax error
|
|
582
|
+
const close = source.indexOf('>', i + 3);
|
|
583
|
+
const end = close === -1 ? i + 3 : close + 1;
|
|
584
|
+
out += source.slice(i, end);
|
|
585
|
+
i = end;
|
|
586
|
+
} else {
|
|
587
|
+
out += foldsCase(char) ? '[' + char.toLowerCase() + char.toUpperCase() + ']' : char;
|
|
588
|
+
i++;
|
|
589
|
+
}
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (nested && char === '[') {
|
|
594
|
+
open++;
|
|
595
|
+
out += char;
|
|
596
|
+
i++;
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (char === ']') {
|
|
601
|
+
open--;
|
|
602
|
+
out += char;
|
|
603
|
+
i++;
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (isSetOperator(source, i, nested)) {
|
|
608
|
+
out += source.slice(i, i + 2);
|
|
609
|
+
i += 2;
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const fromLength = tokenLength(source, i);
|
|
614
|
+
const from = source.slice(i, i + fromLength);
|
|
615
|
+
const afterFrom = i + fromLength;
|
|
616
|
+
|
|
617
|
+
// A range needs the other case’s range beside it—only where both ends are
|
|
618
|
+
// ASCII letters, the one span whose two cases are contiguous and parallel
|
|
619
|
+
// (`ÿ` uppercases to `Ÿ`, 150 code points past where its range ends)
|
|
620
|
+
if (source[afterFrom] === '-' && afterFrom + 1 < source.length && source[afterFrom + 1] !== ']' && !isSetOperator(source, afterFrom, nested)) {
|
|
621
|
+
const toLength = tokenLength(source, afterFrom + 1);
|
|
622
|
+
const to = source.slice(afterFrom + 1, afterFrom + 1 + toLength);
|
|
623
|
+
out += RE_ASCII_LETTER.test(from) && RE_ASCII_LETTER.test(to) && isLower(from) === isLower(to)
|
|
624
|
+
? from + '-' + to + (isLower(from)
|
|
625
|
+
? from.toUpperCase() + '-' + to.toUpperCase()
|
|
626
|
+
: from.toLowerCase() + '-' + to.toLowerCase())
|
|
627
|
+
: from + '-' + to;
|
|
628
|
+
i = afterFrom + 1 + toLength;
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// A set operand is one character, so both cases go into a class of their own
|
|
633
|
+
const folded = from.toLowerCase() + from.toUpperCase();
|
|
634
|
+
out += fromLength !== 1 || !foldsCase(from) ? from
|
|
635
|
+
: isSetOperator(source, afterFrom, nested) || isSetOperator(source, i - 2, nested) ? '[' + folded + ']'
|
|
636
|
+
: folded;
|
|
637
|
+
i = afterFrom;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return out;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Whether a source anchors anywhere, so that `m` would move where it matches
|
|
645
|
+
* @param {string} source @param {boolean} [nested] - Whether the pattern carries `v`
|
|
646
|
+
*/
|
|
647
|
+
function hasAnchor(source, nested = false) {
|
|
648
|
+
let i = 0;
|
|
649
|
+
while (i < source.length) {
|
|
650
|
+
if (source[i] === '\\') i += tokenLength(source, i);
|
|
651
|
+
// Inside a class `^` negates and `$` is a member, so neither anchors there
|
|
652
|
+
else if (source[i] === '[') i = skipCharacterClass(source, i, nested);
|
|
653
|
+
else if (source[i] === '^' || source[i] === '$') return true;
|
|
654
|
+
else i++;
|
|
655
|
+
}
|
|
656
|
+
return false;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// A property escape or a code point escape, both of which read as literal text
|
|
660
|
+
// where the flag that gives them meaning is gone
|
|
661
|
+
const RE_UNICODE_ESCAPE = /\\[pP]\{|\\u\{/;
|
|
662
|
+
|
|
663
|
+
// The characters `u` folds by Unicode rules and a source without it does not:
|
|
664
|
+
// `/s/iu` matches `\u017F` and `/k/iu` matches `\u212A`, where neither does on its
|
|
665
|
+
// own. Characters past the BMP fold this way, too, and are caught as astral first.
|
|
666
|
+
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]/;
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* Which flag changes what a source matches, so that embedding the source where the
|
|
670
|
+
* flag cannot follow would silently match something else. `u` and `v` only narrow
|
|
671
|
+
* what syntax is legal, so a source valid under either stays valid without it—the
|
|
672
|
+
* difference never surfaces as a syntax error, and this stands in for the one that
|
|
673
|
+
* would otherwise be raised.
|
|
674
|
+
* @param {RegExp} pattern
|
|
675
|
+
* @returns {'u' | 'v' | 'm' | null} The flag the source depends on, or `null` where
|
|
676
|
+
* every flag it carries would leave the source matching the same
|
|
677
|
+
*/
|
|
678
|
+
function lostFlag(pattern) {
|
|
679
|
+
const { source, unicode, unicodeSets } = pattern;
|
|
680
|
+
// `m` moves where `^` and `$` match, and only matters where the source has one
|
|
681
|
+
if (pattern.multiline && hasAnchor(source, unicodeSets)) return 'm';
|
|
682
|
+
if (!unicode && !unicodeSets) return null;
|
|
683
|
+
const flag = unicodeSets ? 'v' : 'u';
|
|
684
|
+
if (RE_UNICODE_ESCAPE.test(source)) return flag;
|
|
685
|
+
// Case folding under `i` follows Unicode rules only while the flag is there. A
|
|
686
|
+
// character can be written literally or as an escape, so the source is read
|
|
687
|
+
// token by token rather than scanned for the characters themselves; `singleCode`
|
|
688
|
+
// leaves out `\d` and friends, which fold alike with the flag and without it.
|
|
689
|
+
if (pattern.ignoreCase) {
|
|
690
|
+
for (let i = 0; i < source.length;) {
|
|
691
|
+
const length = tokenLength(source, i);
|
|
692
|
+
const code = singleCode(tokenRanges(source.slice(i, i + length), true));
|
|
693
|
+
if (code !== null && RE_UNICODE_FOLDING.test(String.fromCodePoint(code))) return flag;
|
|
694
|
+
i += length;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
// Past the BMP a character is one code point with the flag, two units without,
|
|
698
|
+
// which is what a quantifier or a class beside it would go on to read wrongly
|
|
699
|
+
for (const char of source) {
|
|
700
|
+
if ((char.codePointAt(0) ?? 0) > 0xFFFF) return flag;
|
|
701
|
+
}
|
|
702
|
+
if (!unicodeSets) return null;
|
|
703
|
+
|
|
704
|
+
// `v` alone lets a class nest, subtract, intersect, and hold whole strings
|
|
705
|
+
let i = 0;
|
|
706
|
+
while (i < source.length) {
|
|
707
|
+
if (source[i] === '\\') {
|
|
708
|
+
i += tokenLength(source, i);
|
|
709
|
+
continue;
|
|
710
|
+
}
|
|
711
|
+
if (source[i] !== '[') {
|
|
712
|
+
i++;
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
const end = skipCharacterClass(source, i, true);
|
|
716
|
+
for (let j = i + 1; j < end - 1;) {
|
|
717
|
+
if (source[j] === '\\') {
|
|
718
|
+
if (source.startsWith('\\q{', j)) return 'v';
|
|
719
|
+
j += tokenLength(source, j);
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
if (source[j] === '[' || isSetOperator(source, j, true)) return 'v';
|
|
723
|
+
j++;
|
|
724
|
+
}
|
|
725
|
+
i = end;
|
|
726
|
+
}
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* A pattern’s source, rewritten to match the way its own flags make it match, for
|
|
732
|
+
* embedding in a larger regex that cannot carry them. `i` and `s` fit into a
|
|
733
|
+
* source; `m`, `u`, and `v` do not, and are left to the pattern it joins. Where
|
|
734
|
+
* `i` cannot be written in—a backreference, a range outside ASCII, a range that
|
|
735
|
+
* spans letters without being one—the source stands as it is, matching less than
|
|
736
|
+
* the pattern would rather than more.
|
|
737
|
+
* @param {RegExp} pattern
|
|
738
|
+
* @returns {string}
|
|
739
|
+
*/
|
|
740
|
+
function embedSource(pattern) {
|
|
741
|
+
let source = pattern.source;
|
|
742
|
+
if (pattern.dotAll) source = expandDotAll(source, pattern.unicodeSets);
|
|
743
|
+
if (pattern.ignoreCase) source = foldCase(source, pattern.unicodeSets);
|
|
744
|
+
return source;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* @typedef {{text: string, ranges: [number, number][] | null}} Repeat - An unbounded
|
|
749
|
+
* repeat, as it reads and as the set of characters it matches
|
|
750
|
+
*/
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* @param {Repeat[]} reachable - Repeats that could still sit adjacent to what comes next
|
|
754
|
+
* @param {Repeat} repeat
|
|
755
|
+
* @returns {boolean} Whether the two can consume the same character, and so split
|
|
756
|
+
* the same run of input between them
|
|
757
|
+
*/
|
|
758
|
+
function splitsWith(reachable, repeat) {
|
|
759
|
+
return reachable.some(earlier => earlier.text === repeat.text ||
|
|
760
|
+
(!!repeat.ranges && !!earlier.ranges && rangesIntersect(repeat.ranges, earlier.ranges)));
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* @param {Repeat[]} into
|
|
765
|
+
* @param {Repeat[]} repeats
|
|
766
|
+
* @returns {void} Adds the repeats, keeping only the most recent ones the walk
|
|
767
|
+
* still compares against
|
|
768
|
+
*/
|
|
769
|
+
function collectReachable(into, repeats) {
|
|
770
|
+
for (const repeat of repeats) {
|
|
771
|
+
into.push(repeat);
|
|
772
|
+
if (into.length > MAX_REACHABLE) into.shift();
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Walk a regex source for the shapes whose backtracking blows up: an unlimited
|
|
778
|
+
* quantifier over a group that itself contains a variable quantifier (`(a+)+`,
|
|
779
|
+
* `(a?)+`) or alternates, and two unbounded repeats that can consume the same
|
|
780
|
+
* character with only atoms matching empty between them (`.*.*`, `[a]*a*`,
|
|
781
|
+
* `\w*\d*`, `a*b*a*`, `a*(b*)a*`, `(a*)a*`). A lone unlimited quantifier stays
|
|
782
|
+
* linear, so `[\s\S]*?` up to a literal terminator passes.
|
|
783
|
+
* @param {string} source - Regex source, assumed syntactically valid
|
|
784
|
+
* @param {number} [depth] - Group nesting level of this call
|
|
785
|
+
* @param {boolean} [nested] - Whether the pattern carries `v`
|
|
786
|
+
* @returns {{risky: boolean, varies: boolean, alternates: boolean, deep: boolean,
|
|
787
|
+
* empty: boolean, leading: Repeat[], trailing: Repeat[]}} What the walk found,
|
|
788
|
+
* with the repeats that reach the source’s start and end for a caller to compare
|
|
789
|
+
* against what stands beside it
|
|
790
|
+
*/
|
|
791
|
+
function analyzeQuantifiers(source, depth = 0, nested = false) {
|
|
792
|
+
if (depth > MAX_PATTERN_DEPTH) return { risky: true, varies: true, alternates: true, deep: true, empty: true, leading: [], trailing: [] };
|
|
793
|
+
|
|
794
|
+
let risky = false;
|
|
795
|
+
let varies = false;
|
|
796
|
+
let alternates = false;
|
|
797
|
+
let deep = false;
|
|
798
|
+
// Whether every atom of the branch being read matches empty, and whether any
|
|
799
|
+
// branch before it did
|
|
800
|
+
let branchEmpty = true;
|
|
801
|
+
let anyBranchEmpty = false;
|
|
802
|
+
// The unbounded repeats that could still sit adjacent to what comes next, most
|
|
803
|
+
// recent last; an atom matching empty leaves the ones before it reachable
|
|
804
|
+
/** @type {Repeat[]} */
|
|
805
|
+
const reachable = [];
|
|
806
|
+
// The repeats a caller can reach from either end, gathered across branches
|
|
807
|
+
/** @type {Repeat[]} */
|
|
808
|
+
const leading = [];
|
|
809
|
+
/** @type {Repeat[]} */
|
|
810
|
+
const trailing = [];
|
|
811
|
+
/** @type {Repeat[]} */
|
|
812
|
+
let branchLeading = [];
|
|
813
|
+
let i = 0;
|
|
814
|
+
|
|
815
|
+
while (i < source.length) {
|
|
816
|
+
const start = i;
|
|
817
|
+
const char = source[i];
|
|
818
|
+
/** @type {ReturnType<typeof analyzeQuantifiers> | null} */
|
|
819
|
+
let group = null;
|
|
820
|
+
|
|
821
|
+
if (char === '|') {
|
|
822
|
+
// Alternatives are separate expressions, so nothing carries across
|
|
823
|
+
alternates = true;
|
|
824
|
+
anyBranchEmpty ||= branchEmpty;
|
|
825
|
+
branchEmpty = true;
|
|
826
|
+
collectReachable(leading, branchLeading);
|
|
827
|
+
collectReachable(trailing, reachable);
|
|
828
|
+
branchLeading = [];
|
|
829
|
+
reachable.length = 0;
|
|
830
|
+
i++;
|
|
831
|
+
continue;
|
|
832
|
+
} else if (char === '\\') {
|
|
833
|
+
i += tokenLength(source, i);
|
|
834
|
+
} else if (char === '[') {
|
|
835
|
+
i = skipCharacterClass(source, i, nested);
|
|
836
|
+
} else if (char === '(') {
|
|
837
|
+
let open = 1;
|
|
838
|
+
i++;
|
|
839
|
+
while (i < source.length && open > 0) {
|
|
840
|
+
const inner = source[i];
|
|
841
|
+
if (inner === '\\') {
|
|
842
|
+
i += tokenLength(source, i);
|
|
843
|
+
} else if (inner === '[') {
|
|
844
|
+
i = skipCharacterClass(source, i, nested);
|
|
845
|
+
} else {
|
|
846
|
+
if (inner === '(') open++;
|
|
847
|
+
else if (inner === ')') open--;
|
|
848
|
+
i++;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
// Drop the group prefix (`?:`, `?=`, `?<name>`, …) before reading the body
|
|
852
|
+
group = analyzeQuantifiers(source.slice(start + 1, i - 1).replace(/^\?(?:[:=!]|<[=!]|<[^>]*>)/, ''), depth + 1, nested);
|
|
853
|
+
} else {
|
|
854
|
+
i++;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const atom = source.slice(start, i);
|
|
858
|
+
RE_QUANTIFIER.lastIndex = i;
|
|
859
|
+
const quantifier = RE_QUANTIFIER.exec(source);
|
|
860
|
+
const upper = quantifier?.[2];
|
|
861
|
+
const repeats = !!quantifier && (quantifier[0][0] === '*' || quantifier[0][0] === '+' || upper === '');
|
|
862
|
+
// A count that can vary—anything but `{n}` and its `{n,n}` spelling—makes
|
|
863
|
+
// the group ambiguous about how much it consumes, which multiplies under an
|
|
864
|
+
// unlimited repeat
|
|
865
|
+
const exact = !!quantifier && quantifier[0][0] === '{' &&
|
|
866
|
+
(upper === undefined || (upper !== '' && Number(upper) === Number(quantifier[1])));
|
|
867
|
+
const variable = !!quantifier && !exact;
|
|
868
|
+
// An atom matches empty where it is zero-width, where its quantifier may
|
|
869
|
+
// repeat none, or where a group body matches empty however often it repeats—
|
|
870
|
+
// `(b*)`, carrying the quantifier inside the group rather than on it
|
|
871
|
+
const least = quantifier && (quantifier[0][0] === '+' ? 1
|
|
872
|
+
: quantifier[0][0] === '{' ? Number(quantifier[1]) : 0);
|
|
873
|
+
const body = RE_ZERO_WIDTH.test(atom) || (!!group && group.empty);
|
|
874
|
+
const nullable = body || (!!quantifier && least === 0);
|
|
875
|
+
if (quantifier) i = RE_QUANTIFIER.lastIndex;
|
|
876
|
+
|
|
877
|
+
// A lookaround is atomic: It backtracks nothing, so what it holds neither
|
|
878
|
+
// reaches out of it nor is reached into
|
|
879
|
+
const opaque = RE_ZERO_WIDTH.test(atom);
|
|
880
|
+
if (group) {
|
|
881
|
+
if (group.risky || (repeats && (group.varies || group.alternates))) risky = true;
|
|
882
|
+
if (group.varies) varies = true;
|
|
883
|
+
// A group alternates whether the `|` sits at its top level or deeper
|
|
884
|
+
if (group.alternates) alternates = true;
|
|
885
|
+
if (group.deep) deep = true;
|
|
886
|
+
// A group is no wall: What it repeats at its start splits the same run as
|
|
887
|
+
// what stands unbounded before it
|
|
888
|
+
if (!opaque && group.leading.some(repeat => splitsWith(reachable, repeat))) risky = true;
|
|
889
|
+
}
|
|
890
|
+
if (variable) varies = true;
|
|
891
|
+
const text = unwrapAtom(atom);
|
|
892
|
+
const ranges = atomRanges(text, nested);
|
|
893
|
+
if (repeats && splitsWith(reachable, { text, ranges })) risky = true;
|
|
894
|
+
|
|
895
|
+
// Whatever a caller could reach before this atom, it reaches this one, too
|
|
896
|
+
if (branchEmpty) {
|
|
897
|
+
if (repeats) collectReachable(branchLeading, [{ text, ranges }]);
|
|
898
|
+
if (group && !opaque) collectReachable(branchLeading, group.leading);
|
|
899
|
+
}
|
|
900
|
+
// Nothing reaches past an atom that has to consume something
|
|
901
|
+
if (!nullable) {
|
|
902
|
+
reachable.length = 0;
|
|
903
|
+
branchEmpty = false;
|
|
904
|
+
}
|
|
905
|
+
if (group && !opaque) collectReachable(reachable, group.trailing);
|
|
906
|
+
if (repeats) collectReachable(reachable, [{ text, ranges }]);
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
collectReachable(leading, branchLeading);
|
|
910
|
+
collectReachable(trailing, reachable);
|
|
911
|
+
|
|
912
|
+
return { risky, varies, alternates, deep, empty: anyBranchEmpty || branchEmpty, leading, trailing };
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* @param {RegExp | string} pattern - Pattern to judge, or a bare source to read
|
|
917
|
+
* as it stands
|
|
918
|
+
* @returns {string | null} What makes the pattern a backtracking risk, phrased
|
|
919
|
+
* to follow the pattern itself, or `null` where it is none
|
|
920
|
+
*/
|
|
921
|
+
function describeQuantifierRisk(pattern) {
|
|
922
|
+
const source = typeof pattern === 'string' ? pattern : pattern.source;
|
|
923
|
+
// A pattern too big to read is refused for that, not for a shape nobody saw;
|
|
924
|
+
// its own length is what counts, not the length folding case inflates it to
|
|
925
|
+
if (source.length > MAX_PATTERN_LENGTH) {
|
|
926
|
+
return `runs past ${MAX_PATTERN_LENGTH.toLocaleString()} characters, too long to analyze for catastrophic backtracking—shorten it, or split it into several patterns`;
|
|
927
|
+
}
|
|
928
|
+
// `i` and `s` change what the source matches, so they are written into it
|
|
929
|
+
// before the shapes are read
|
|
930
|
+
const analysis = typeof pattern === 'string'
|
|
931
|
+
? analyzeQuantifiers(source)
|
|
932
|
+
: analyzeQuantifiers(embedSource(pattern), 0, pattern.unicodeSets);
|
|
933
|
+
if (analysis.deep) {
|
|
934
|
+
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`;
|
|
935
|
+
}
|
|
936
|
+
return analysis.risky
|
|
937
|
+
? 'compounds quantifiers or alternation in a way that may cause ReDoS—bound the repetition (e.g., `{0,1000}`) instead'
|
|
938
|
+
: null;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Find the index of the `>` that closes an opening tag, correctly skipping
|
|
943
|
+
* over quoted attribute values (which may contain `>`).
|
|
944
|
+
* @param {string} html
|
|
945
|
+
* @param {number} pos - Start position (just after the tag name)
|
|
946
|
+
* @param {number} [limit] - Position to stop the search at (exclusive)
|
|
947
|
+
* @returns {number} Index of the closing `>`, or -1 if not found
|
|
948
|
+
*/
|
|
949
|
+
function findTagEnd(html, pos, limit = html.length) {
|
|
950
|
+
let i = pos;
|
|
951
|
+
while (i < limit) {
|
|
952
|
+
const ch = html[i];
|
|
953
|
+
if (ch === '>') return i;
|
|
954
|
+
if (ch === '"' || ch === "'") {
|
|
955
|
+
const q = ch;
|
|
956
|
+
i++;
|
|
957
|
+
while (i < limit && html[i] !== q) i++;
|
|
958
|
+
}
|
|
959
|
+
i++;
|
|
960
|
+
}
|
|
961
|
+
return -1;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* A package that resolves but fails to load—e.g., a native binding built for
|
|
966
|
+
* another platform—is not one another `npm install` fixes, so name what happened
|
|
967
|
+
* rather than the install command
|
|
968
|
+
*
|
|
969
|
+
* @param {string} label - Minifier name as it should read in the message
|
|
970
|
+
* @param {string} specifier - Package that failed to load
|
|
971
|
+
* @param {unknown} cause - Error the import rejected with
|
|
972
|
+
* @returns {string} Message describing the failure
|
|
973
|
+
*/
|
|
974
|
+
function describeDependencyFailure(label, specifier, cause) {
|
|
975
|
+
const err = /** @type {any} */ (cause);
|
|
976
|
+
const notInstalled =
|
|
977
|
+
err?.code === 'ERR_MODULE_NOT_FOUND' && String(err?.message).includes(`'${specifier}'`);
|
|
978
|
+
if (notInstalled) {
|
|
979
|
+
return `The ${label} minifier requires ${specifier} to be installed.\n` +
|
|
980
|
+
`Install it with: npm install ${specifier}`;
|
|
981
|
+
}
|
|
982
|
+
return `The ${label} minifier could not load ${specifier}: ` +
|
|
983
|
+
(cause instanceof Error ? cause.message : String(cause));
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Whether a doctype sets quirks mode, per the HTML parser (limited-quirks mode counts as no-quirks,
|
|
987
|
+
// as it only differs in line height calculation)
|
|
988
|
+
// https://html.spec.whatwg.org/multipage/parsing.html#the-initial-insertion-mode
|
|
989
|
+
/** @param {string} doctype */
|
|
990
|
+
function isQuirksDoctype(doctype) {
|
|
991
|
+
const match = RE_DOCTYPE.exec(doctype);
|
|
992
|
+
if (!match) return true;
|
|
993
|
+
const [, name = '', publicQuoted, systemQuoted, systemOnlyQuoted, rest] = match;
|
|
994
|
+
const systemId = (systemQuoted ?? systemOnlyQuoted)?.slice(1, -1).toLowerCase();
|
|
995
|
+
// The force-quirks flag
|
|
996
|
+
if (systemId === undefined && rest !== '>') return true;
|
|
997
|
+
if (name.toLowerCase() !== 'html') return true;
|
|
998
|
+
if (systemId !== undefined && quirksSystemIds.has(systemId)) return true;
|
|
999
|
+
if (publicQuoted === undefined) return false;
|
|
1000
|
+
const publicId = publicQuoted.slice(1, -1).toLowerCase();
|
|
1001
|
+
return quirksPublicIds.has(publicId) ||
|
|
1002
|
+
quirksPublicIdPrefixes.some(prefix => publicId.startsWith(prefix)) ||
|
|
1003
|
+
(!systemId && quirksPublicIdPrefixesNoSystemId.some(prefix => publicId.startsWith(prefix)));
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
export {
|
|
1007
|
+
stableStringify,
|
|
1008
|
+
findTagEnd,
|
|
1009
|
+
LRU,
|
|
1010
|
+
MAX_CACHE_ENTRY_SIZE,
|
|
1011
|
+
hashContent,
|
|
1012
|
+
uniqueId,
|
|
1013
|
+
escapeXML,
|
|
1014
|
+
decodeForeignText,
|
|
1015
|
+
identity,
|
|
1016
|
+
isQuirksDoctype,
|
|
1017
|
+
isThenable,
|
|
1018
|
+
lowercase,
|
|
1019
|
+
paramCase,
|
|
1020
|
+
replaceAsync,
|
|
1021
|
+
parseRegExp,
|
|
1022
|
+
embedSource,
|
|
1023
|
+
lostFlag,
|
|
1024
|
+
describeQuantifierRisk,
|
|
1025
|
+
describeDependencyFailure
|
|
1026
|
+
};
|