@davidwells/extract-deps 0.1.11 → 0.1.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidwells/extract-deps",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Get imports and requires from a codebase without AST",
5
5
  "main": "src/index.js",
6
6
  "exports": {
package/src/dep-graph.js CHANGED
@@ -3,9 +3,82 @@ const path = require('path')
3
3
  const fs = require('fs')
4
4
  const { pathToFileURL, fileURLToPath } = require('url')
5
5
  const { resolve: resolveImport } = require('@dual-bundle/import-meta-resolve')
6
- const { extractDeps } = require('./index')
6
+ const { extractDepsShared: extractDeps } = require('./index')
7
7
  const { isBuiltIn } = require('./utils')
8
8
 
9
+ /**
10
+ * Parsed deps per file, keyed on mtime + size: callers walk the same shared
11
+ * files for every entry point (and a walk parses each import target twice:
12
+ * to verify the importer's names, then to recurse). Results are read-only.
13
+ * @type {Map<string, { key: string, deps: ReturnType<typeof extractDeps> }>}
14
+ */
15
+ const parseCache = new Map()
16
+
17
+ /**
18
+ * @param {string} filePath
19
+ * @returns {ReturnType<typeof extractDeps>}
20
+ */
21
+ function readDeps(filePath) {
22
+ const stat = fs.statSync(filePath)
23
+ const key = `${stat.mtimeMs}:${stat.size}`
24
+ const cached = parseCache.get(filePath)
25
+ if (cached && cached.key === key) return cached.deps
26
+ const deps = extractDeps(fs.readFileSync(filePath, 'utf8'))
27
+ parseCache.set(filePath, { key, deps })
28
+ return deps
29
+ }
30
+
31
+ /** @type {Map<string, string | null>} `${baseDir}\0${importPath}` -> resolved file (or null) */
32
+ const relativeResolveCache = new Map()
33
+ /** @type {Map<string, { url: string } | { error: Error }>} `${parentDir}\0${specifier}` -> package resolution */
34
+ const packageResolveCache = new Map()
35
+ /** @type {Map<string, string>} path -> realpath */
36
+ const realpathCache = new Map()
37
+
38
+ /**
39
+ * fs.realpathSync, memoized per path.
40
+ * @param {string} filePath
41
+ * @returns {string}
42
+ */
43
+ function realpath(filePath) {
44
+ let real = realpathCache.get(filePath)
45
+ if (real === undefined) {
46
+ real = fs.realpathSync(filePath)
47
+ realpathCache.set(filePath, real)
48
+ }
49
+ return real
50
+ }
51
+
52
+ /**
53
+ * resolveImport (import-meta-resolve) memoized per importing directory: a
54
+ * bare specifier resolves the same from every file in one directory.
55
+ * @param {string} specifier
56
+ * @param {string} parentPath - Importing file
57
+ * @returns {string} resolved URL
58
+ */
59
+ function resolvePackageImport(specifier, parentPath) {
60
+ const key = `${path.dirname(parentPath)}\0${specifier}`
61
+ let cached = packageResolveCache.get(key)
62
+ if (!cached) {
63
+ try {
64
+ cached = { url: resolveImport(specifier, pathToFileURL(parentPath).href) }
65
+ } catch (error) {
66
+ cached = { error: /** @type {Error} */ (error) }
67
+ }
68
+ packageResolveCache.set(key, cached)
69
+ }
70
+ if ('error' in cached) throw cached.error
71
+ return cached.url
72
+ }
73
+
74
+ /** Drop memoized parses and resolutions (e.g. between runs in a long-lived process). */
75
+ function clearDepGraphCache() {
76
+ parseCache.clear()
77
+ relativeResolveCache.clear()
78
+ packageResolveCache.clear()
79
+ realpathCache.clear()
80
+ }
81
+
9
82
  function fsExistsSync(path) {
10
83
  try {
11
84
  fs.accessSync(path, fs.constants.F_OK)
@@ -30,6 +103,21 @@ function isFileSync(filePath) {
30
103
  * @returns {string|null} - Resolved file path or null if not found
31
104
  */
32
105
  function resolveImportPath(baseDir, importPath) {
106
+ const key = `${baseDir}\0${importPath}`
107
+ let resolved = relativeResolveCache.get(key)
108
+ if (resolved === undefined) {
109
+ resolved = resolveImportPathUncached(baseDir, importPath)
110
+ relativeResolveCache.set(key, resolved)
111
+ }
112
+ return resolved
113
+ }
114
+
115
+ /**
116
+ * @param {string} baseDir
117
+ * @param {string} importPath
118
+ * @returns {string|null}
119
+ */
120
+ function resolveImportPathUncached(baseDir, importPath) {
33
121
  const possibleExtensions = ['.js', '.mjs', '.ts', '.json', '']
34
122
 
35
123
  // Try direct file resolution (must be a file, not directory)
@@ -198,7 +286,7 @@ function buildGraphRecursively(
198
286
  visited = new Set(),
199
287
  ) {
200
288
  // Avoid infinite loops
201
- const normalizedPath = fs.realpathSync(filePath)
289
+ const normalizedPath = realpath(filePath)
202
290
  if (visited.has(normalizedPath)) {
203
291
  return
204
292
  }
@@ -210,8 +298,7 @@ function buildGraphRecursively(
210
298
  }
211
299
 
212
300
  // Read and extract deps from this file
213
- const content = fs.readFileSync(filePath, 'utf8')
214
- const deps = extractDeps(content)
301
+ const deps = readDeps(filePath)
215
302
 
216
303
  // Collect all relative dependencies
217
304
  const relativeDeps = []
@@ -255,15 +342,14 @@ function buildGraphRecursively(
255
342
 
256
343
  // Add edge to graph if building
257
344
  if (graph !== null) {
258
- const normalizedResolvedPath = fs.realpathSync(resolvedPath)
345
+ const normalizedResolvedPath = realpath(resolvedPath)
259
346
  graph.get(normalizedPath).push(normalizedResolvedPath)
260
347
  }
261
348
 
262
349
  // Verify imports if verification is enabled
263
350
  if (verify) {
264
351
  // Extract deps from the imported file
265
- const targetContent = fs.readFileSync(resolvedPath, 'utf8')
266
- const targetDeps = extractDeps(targetContent)
352
+ const targetDeps = readDeps(resolvedPath)
267
353
 
268
354
  // Verify the imported values are exported
269
355
  verifyImportedValuesExist(dep, targetDeps, resolvedPath, filePath, errors, throwOnFirstError)
@@ -282,8 +368,6 @@ function buildGraphRecursively(
282
368
 
283
369
  // Verify node_modules dependencies if enabled (skip built-ins)
284
370
  if (verifyNodeModules && nodeModuleDeps.length > 0) {
285
- const parentURL = pathToFileURL(filePath).href
286
-
287
371
  for (const dep of nodeModuleDeps) {
288
372
  const { from, line } = dep
289
373
 
@@ -295,7 +379,7 @@ function buildGraphRecursively(
295
379
  let resolvedPath
296
380
  try {
297
381
  // resolveImport throws if file doesn't exist
298
- const resolved = resolveImport(from, parentURL)
382
+ const resolved = resolvePackageImport(from, filePath)
299
383
  resolvedPath = fileURLToPath(resolved)
300
384
  } catch (error) {
301
385
  const err = new Error(
@@ -313,8 +397,7 @@ function buildGraphRecursively(
313
397
 
314
398
  // Verify import/export styles match (only needed for ESM imports)
315
399
  if (dep.isESM) {
316
- const targetContent = fs.readFileSync(resolvedPath, 'utf8')
317
- const targetDeps = extractDeps(targetContent)
400
+ const targetDeps = readDeps(resolvedPath)
318
401
  verifyImportedValuesExist(dep, targetDeps, resolvedPath, filePath, errors, throwOnFirstError)
319
402
  }
320
403
  }
@@ -470,5 +553,6 @@ if (require.main === module) {
470
553
 
471
554
  module.exports = {
472
555
  depGraph,
473
- resolveDepPaths
556
+ resolveDepPaths,
557
+ clearDepGraphCache
474
558
  }
@@ -41,9 +41,11 @@ function getCjsExports(code) {
41
41
  // values) keeps working off the same indices.
42
42
  const sanitizedCode = stripCommentsAndStrings(code)
43
43
 
44
- const assignmentExports = getModuleExportsAssignment(code, sanitizedCode)
45
- const modulePropertyExports = getModuleExportsProperty(code, sanitizedCode)
46
- const propertyExports = getExportsProperty(code, sanitizedCode)
44
+ // Their regexes need `module.exports` / `exports` in the sanitized text.
45
+ const hasModuleExports = sanitizedCode.includes('module.exports')
46
+ const assignmentExports = hasModuleExports ? getModuleExportsAssignment(code, sanitizedCode) : []
47
+ const modulePropertyExports = hasModuleExports ? getModuleExportsProperty(code, sanitizedCode) : []
48
+ const propertyExports = sanitizedCode.includes('exports') ? getExportsProperty(code, sanitizedCode) : []
47
49
 
48
50
  // Quick pre-checks to avoid expensive regex operations when not needed
49
51
  const objectAssignExports = sanitizedCode.includes('Object.assign')
@@ -3,7 +3,7 @@ const { getNames, extractExportName, stripComments, stripStringContents } = requ
3
3
  // https://regex101.com/r/example/1
4
4
  const EXPORT_DEFAULT_REGEX = /export\s+default\s+(?:(?:async\s+)?function\s+([\w$]+)|class\s+([\w$]+)|([\w$]+|\([^)]*\)\s*=>|{[\s\S]*?}|\[[\s\S]*?\]|'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`|[\w$]+(?:\s*[+\-*/%]\s*[\w$]+)*))/gm
5
5
  // https://regex101.com/r/example/2
6
- const EXPORT_NAMED_REGEX = /export\s+(?:const|let|var|function|class|async\s+function)\s+([\w$]+)/gm
6
+ const EXPORT_NAMED_REGEX = /export\s+(?:const\s+enum|enum|const|let|var|function|class|async\s+function)\s+([\w$]+)/gm
7
7
  // https://regex101.com/r/example/3
8
8
  const EXPORT_NAMED_DECLARATION_REGEX = /export\s*{([^}]+)}/gm
9
9
  // https://regex101.com/r/example/4
@@ -16,6 +16,8 @@ const EXPORT_TYPE_REGEX = /export\s+(?:type|interface)\s+([\w$]+)/gm
16
16
  const EXPORT_ASSERTION_REGEX = /export\s+(?:["'\s]*([\w*{}\n\r\t, ]+)from\s*)?["'\s](.*[@\w_-]+)["'\s]+(assert|with)\s*\{([^}]+)\};?$/gm
17
17
 
18
18
  function getExports(code = '', options = {}) {
19
+ // Every pattern below needs the word `export`; stripping never adds one.
20
+ if (!code.includes('export')) return []
19
21
  // Comments first, then strings — order matters. Running stripStringContents
20
22
  // alone would mis-pair an apostrophe inside a JSDoc comment (`Netlify's …`)
21
23
  // with a later quote and shift subsequent backtick pairing, which could
@@ -303,4 +305,4 @@ module.exports = {
303
305
  getNamespaceExports,
304
306
  getTypeExports,
305
307
  getAssertionExports
306
- }
308
+ }
@@ -37,6 +37,8 @@ const DYNAMIC_IMPORT_REGEX = /(?:(const|var|let)\s+([\w*{}\n\r\t,:= $]+)\s*=\s*)
37
37
  const ASSERTION_IMPORT_REGEX = /(?:^|\n)\s*import\s+(?:["'\s]*([\w*{}\n\r\t, $]+)from\s*)?["'\s]+([^"'`\n;\s]+)\s*["']\s+(assert|with)\s*\{([^}]+)\}/gm
38
38
 
39
39
  function getImports(code = '', options = {}) {
40
+ // Every pattern below needs the word `import`; stripping never adds one.
41
+ if (!code.includes('import')) return []
40
42
  // Strip comments FIRST so apostrophes/backticks inside JSDoc text get
41
43
  // removed before `stripStringContents` looks for string literals.
42
44
  // (Running the two in the opposite order let comment chars masquerade as
@@ -38,11 +38,51 @@ function getDynamicRequires(code) {
38
38
  return results
39
39
  }
40
40
 
41
+ // Sticky copy of REQUIRE_REGEX: matches only at an exact start position.
42
+ const REQUIRE_REGEX_AT = new RegExp(REQUIRE_REGEX.source, 'my')
43
+ // Every char REQUIRE_REGEX can consume before its literal `require(`.
44
+ const REQUIRE_PREFIX_CHAR = /[\w\s*{}:=,$]/
45
+
46
+ /**
47
+ * Same result as `REQUIRE_REGEX.exec` with `lastIndex = from`, but only
48
+ * attempts start positions that can reach a `require(` literal. Scanning
49
+ * every position is quadratic on long lines because of the leading
50
+ * `(?!.*await\s+import)` lookahead.
51
+ *
52
+ * A match starting at `s` must contain `require(` at some `p >= s` with
53
+ * every char in [s, p) matching REQUIRE_PREFIX_CHAR. `(` is not in that set,
54
+ * so the match uses the first `require(` at or after `s`. For each
55
+ * `require(` at `p`, the only viable starts are therefore [q, p], where q
56
+ * walks back from p over prefix chars. Trying those in ascending order finds
57
+ * the same leftmost match.
58
+ * @param {string} text
59
+ * @param {number} from
60
+ */
61
+ function execRequireFrom(text, from) {
62
+ let searchFrom = from
63
+ let tried = from
64
+ while (true) {
65
+ const requireIndex = text.indexOf('require(', searchFrom)
66
+ if (requireIndex === -1) return null
67
+ let start = requireIndex
68
+ while (start > tried && REQUIRE_PREFIX_CHAR.test(text[start - 1])) start--
69
+ for (; start <= requireIndex; start++) {
70
+ REQUIRE_REGEX_AT.lastIndex = start
71
+ const match = REQUIRE_REGEX_AT.exec(text)
72
+ if (match) return match
73
+ }
74
+ tried = requireIndex + 1
75
+ searchFrom = requireIndex + 1
76
+ }
77
+ }
78
+
41
79
  // https://regex101.com/r/sR1TIP/1
42
80
  function getStaticRequires(targetText = '') {
43
81
  let requireStatements = []
44
82
  var result;
45
- while((result = REQUIRE_REGEX.exec(targetText)) !== null) {
83
+ let searchStart = 0
84
+ while((result = execRequireFrom(targetText, searchStart)) !== null) {
85
+ searchStart = result.index + result[0].length
46
86
  const [ line, varType, names, from ] = result
47
87
  // console.log('getStaticRequires result', result)
48
88
  const importLocation = from
@@ -65,6 +105,8 @@ function getStaticRequires(targetText = '') {
65
105
  }
66
106
 
67
107
  function getRequires(code) {
108
+ // Both scanners need the word `require`; stripping never adds one.
109
+ if (!code.includes('require')) return []
68
110
  // Strip comments first, then strings — order matters. `stripComments`
69
111
  // alone would mis-eat `//` inside strings; running it first instead means
70
112
  // comment chars (incl. apostrophes/backticks inside JSDoc) disappear
package/src/index.js CHANGED
@@ -16,7 +16,35 @@ const {
16
16
  // https://github.com/ganeshkbhat/get-imports/blob/main/index.js
17
17
  // Alt get imports https://github.com/beenotung/fix-esm-import-path/blob/master/fix-esm-import-path.js#L186
18
18
 
19
+ // Parse results keyed by source text. Extraction is a pure function of the
20
+ // code, and dep-graph walks re-visit the same shared modules once per entry
21
+ // point, so repeat parses dominate runtime without this. Bounded FIFO so a
22
+ // long-lived process scanning many files doesn't grow without limit.
23
+ const PARSE_CACHE_MAX = 2000
24
+ const parseCache = new Map()
25
+
26
+ /**
27
+ * Cached extraction. Returns a shared object — callers must treat it as
28
+ * read-only. Use `extractDeps` for a private copy.
29
+ * @param {string} code
30
+ */
31
+ function extractDepsShared(code) {
32
+ let result = parseCache.get(code)
33
+ if (result === undefined) {
34
+ result = parseDeps(code)
35
+ if (parseCache.size >= PARSE_CACHE_MAX) {
36
+ parseCache.delete(parseCache.keys().next().value)
37
+ }
38
+ parseCache.set(code, result)
39
+ }
40
+ return result
41
+ }
42
+
19
43
  function extractDeps(code, filePath = '') {
44
+ return structuredClone(extractDepsShared(code))
45
+ }
46
+
47
+ function parseDeps(code) {
20
48
  const imports = getImports(code)
21
49
  //console.log('imports', imports)
22
50
  const requires = getRequires(code)
@@ -94,6 +122,7 @@ const { execSync, spawnSync } = require('child_process')
94
122
 
95
123
  module.exports = {
96
124
  extractDeps,
125
+ extractDepsShared,
97
126
  /* syntax detection */
98
127
  hasESMSyntax,
99
128
  hasCJSSyntax,
package/src/utils.js CHANGED
@@ -1,22 +1,6 @@
1
1
  const { inspectParameters, getParametersNames } = require('./vendor/inspect-parameters-declaration')
2
2
  const builtinModules = require('module').builtinModules
3
3
 
4
- const COMMENT_RE = /\/\*[\s\S]*?\*\/|\/\/.*(?:\r?\n|$)/g
5
- const PROTOCOL_PLACEHOLDER = '__PROTO_SLASH__'
6
-
7
- // Single-quoted and double-quoted strings can't contain unescaped newlines
8
- // (per JS spec — they'd need `\n` escapes or `\<NL>` line continuations). The
9
- // `[^"\\\n]` / `[^'\\\n]` char classes enforce that, so an unbalanced quote
10
- // followed by a newline doesn't sweep up the next several lines as "string".
11
- // Template literals (`` `...` ``) DO span newlines, so their inner class only
12
- // excludes backtick and backslash. Escape sequences (`\.`) are allowed in all
13
- // three so escaped quotes don't prematurely end the match.
14
- // Note: this treats `${...}` interpolations as part of the string body — code
15
- // inside `${...}` (rare, but possible: `${import('./x.js')}`) gets blanked too.
16
- // Acceptable for the change-detection use case; a full tokenizer would be
17
- // needed to preserve interpolations.
18
- const STRING_LITERAL_RE = /"(?:[^"\\\n]|\\[\s\S])*"|'(?:[^'\\\n]|\\[\s\S])*'|`(?:[^`\\]|\\[\s\S])*`/g
19
-
20
4
  // Strings whose bodies contain these tokens are treated as embedded code
21
5
  // samples (template strings, generator helpers, fenced docs) and get blanked.
22
6
  // Plain module specifiers like `'lodash'` or `'./foo.js'` don't contain any
@@ -38,8 +22,9 @@ const EMBEDDED_CODE_RE = /\b(?:import|export|require|module\.exports)\b/
38
22
  * Without this pass, regex-based extractors falsely match `import`, `export`,
39
23
  * and `require` keywords that appear inside code samples embedded in strings.
40
24
  *
41
- * Run this BEFORE `stripComments` — the existing comment regex would otherwise
42
- * mis-strip `//` and `/* *\/` sequences that occur inside strings.
25
+ * Run after `stripComments` in the import/require/export extractors. The
26
+ * comment scanner already skips strings and regex literals, so this pass can
27
+ * focus on embedded-code strings without risking `//` in URLs or regexes.
43
28
  *
44
29
  * Caveat: `${...}` interpolations inside template literals are treated as
45
30
  * part of the body, so a runtime `${import('./x.js')}` inside a template
@@ -50,32 +35,64 @@ const EMBEDDED_CODE_RE = /\b(?:import|export|require|module\.exports)\b/
50
35
  * @returns {string}
51
36
  */
52
37
  function stripStringContents(code) {
53
- return code.replace(STRING_LITERAL_RE, (match) => {
38
+ let output = ''
39
+ let i = 0
40
+ const special = /[/"'`]/g
41
+
42
+ while (i < code.length) {
43
+ // Copy the run of ordinary characters up to the next / or quote in one go.
44
+ special.lastIndex = i
45
+ const next = special.exec(code)
46
+ const stop = next ? next.index : code.length
47
+ if (stop > i) {
48
+ output += code.slice(i, stop)
49
+ i = stop
50
+ if (i >= code.length) break
51
+ }
52
+ const char = code[i]
53
+
54
+ if (char === '/' && isRegexLiteralStart(code, i)) {
55
+ const end = readRegexLiteral(code, i)
56
+ output += code.slice(i, end)
57
+ i = end
58
+ continue
59
+ }
60
+
61
+ if (char !== '"' && char !== "'" && char !== '`') {
62
+ output += char
63
+ i++
64
+ continue
65
+ }
66
+
67
+ const end = readQuoted(code, i, char)
68
+ const match = code.slice(i, end)
69
+ if (match.length < 2 || match[match.length - 1] !== char) {
70
+ output += char
71
+ i++
72
+ continue
73
+ }
74
+
54
75
  const body = match.slice(1, -1)
55
76
  if (!EMBEDDED_CODE_RE.test(body)) {
56
77
  // Plain string — leave it alone so module specifiers stay extractable.
57
- return match
78
+ output += match
79
+ i = end
80
+ continue
58
81
  }
59
82
  const opener = match[0]
60
83
  const closer = match[match.length - 1]
61
84
  const middle = body.replace(/[^\n]/g, ' ')
62
- return opener + middle + closer
63
- })
64
- }
85
+ output += opener + middle + closer
86
+ i = end
87
+ }
65
88
 
66
- // Unified regex matching whichever comes first at each position: block
67
- // comment, line comment, single-quoted string, double-quoted string, or
68
- // template literal. Matching left-to-right means a comment that starts
69
- // first consumes its body — including any apostrophes or backticks inside
70
- // — before they can start a spurious string match a few lines later. That
71
- // solves the JSDoc-apostrophe-shifts-backtick-pairing class of bugs that
72
- // arose when comments and strings were stripped in two separate passes.
73
- const COMMENT_OR_STRING_RE = /\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:[^"\\\n]|\\[\s\S])*"|'(?:[^'\\\n]|\\[\s\S])*'|`(?:[^`\\]|\\[\s\S])*`/g
89
+ return output
90
+ }
74
91
 
75
92
  /**
76
- * Single-regex pass that blanks comment bodies and code-bearing
77
- * string/template bodies together. Solves two bugs that occurred when the
78
- * string-strip and comment-strip ran in separate passes:
93
+ * Blank comment bodies and code-bearing string/template bodies together.
94
+ * Solves two bugs that occurred when the string-strip and comment-strip ran
95
+ * as naive regex passes:
79
96
  *
80
97
  * - Apostrophes inside JSDoc comments (e.g. `* Netlify's \`...`) were
81
98
  * mis-paired with later quotes, which shifted subsequent backtick
@@ -83,6 +100,8 @@ const COMMENT_OR_STRING_RE = /\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:[^"\\\n]|\\[\s\S])
83
100
  * later in the same file.
84
101
  * - `//` and `/* *\/` sequences inside string bodies were mis-stripped
85
102
  * as comments.
103
+ * - `//` inside regex literals such as `/^https?:\/\//i` was mis-stripped
104
+ * as a line comment.
86
105
  *
87
106
  * Length is preserved (each consumed char replaced by a space or newline
88
107
  * kept verbatim) so downstream character indices stay valid.
@@ -99,38 +118,177 @@ const COMMENT_OR_STRING_RE = /\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:[^"\\\n]|\\[\s\S])
99
118
  * @returns {string}
100
119
  */
101
120
  function stripCommentsAndStrings(code) {
102
- return code.replace(COMMENT_OR_STRING_RE, (match) => {
103
- const first = match[0]
104
- // Comments (line or block) are fully blanked — they have no extractable
105
- // content. Newlines are preserved so line counts hold.
106
- if (first === '/') {
107
- return match.replace(/[^\n]/g, ' ')
121
+ return stripStringContents(stripCommentsImpl(code, { preserveLength: true }))
122
+ }
123
+
124
+ const REGEX_PREFIX_KEYWORDS = new Set([
125
+ 'case',
126
+ 'delete',
127
+ 'do',
128
+ 'else',
129
+ 'in',
130
+ 'instanceof',
131
+ 'of',
132
+ 'return',
133
+ 'throw',
134
+ 'typeof',
135
+ 'void',
136
+ 'yield',
137
+ 'await',
138
+ ])
139
+
140
+ function isRegexLiteralStart(code, slashIndex) {
141
+ let i = slashIndex - 1
142
+ while (i >= 0 && /\s/.test(code[i])) i--
143
+ if (i < 0) return true
144
+
145
+ const previousChar = code[i]
146
+ if ('([{=,:;!&|?+-*~^<>'.includes(previousChar)) return true
147
+
148
+ // Same result as `code.slice(0, i + 1).match(/([A-Za-z_$][\w$]*)$/)[1]`
149
+ // without copying and re-scanning the whole prefix on every `/` (quadratic
150
+ // on large files): the leftmost match starts at the first non-digit of the
151
+ // trailing run of [\w$] chars.
152
+ let start = i
153
+ while (start >= 0 && isIdentifierChar(code.charCodeAt(start))) start--
154
+ start++
155
+ while (start <= i && isDigit(code.charCodeAt(start))) start++
156
+ if (start > i) return false
157
+ return REGEX_PREFIX_KEYWORDS.has(code.slice(start, i + 1))
158
+ }
159
+
160
+ function isDigit(charCode) {
161
+ return charCode >= 48 && charCode <= 57
162
+ }
163
+
164
+ // [\w$] — ASCII letters, digits, underscore, dollar
165
+ function isIdentifierChar(charCode) {
166
+ return (
167
+ (charCode >= 97 && charCode <= 122) ||
168
+ (charCode >= 65 && charCode <= 90) ||
169
+ isDigit(charCode) ||
170
+ charCode === 95 ||
171
+ charCode === 36
172
+ )
173
+ }
174
+
175
+ function readQuoted(code, start, quote) {
176
+ let i = start + 1
177
+ while (i < code.length) {
178
+ if (code[i] === '\\') {
179
+ i += 2
180
+ continue
108
181
  }
109
- // Strings/templates — only blank if the body references code keywords;
110
- // otherwise leave the literal intact so `'lodash'` etc. remain readable.
111
- const body = match.slice(1, -1)
112
- if (!EMBEDDED_CODE_RE.test(body)) {
113
- return match
182
+ if (code[i] === quote) return i + 1
183
+ if (quote !== '`' && (code[i] === '\n' || code[i] === '\r')) return i
184
+ i++
185
+ }
186
+ return i
187
+ }
188
+
189
+ function readRegexLiteral(code, start) {
190
+ let i = start + 1
191
+ let inCharacterClass = false
192
+
193
+ while (i < code.length) {
194
+ const char = code[i]
195
+ if (char === '\\') {
196
+ i += 2
197
+ continue
114
198
  }
115
- return first + body.replace(/[^\n]/g, ' ') + first
116
- })
199
+ if (char === '[') {
200
+ inCharacterClass = true
201
+ i++
202
+ continue
203
+ }
204
+ if (char === ']') {
205
+ inCharacterClass = false
206
+ i++
207
+ continue
208
+ }
209
+ if (char === '/' && !inCharacterClass) {
210
+ i++
211
+ while (i < code.length && /[a-z]/i.test(code[i])) i++
212
+ return i
213
+ }
214
+ if (char === '\n' || char === '\r') return i
215
+ i++
216
+ }
217
+
218
+ return i
219
+ }
220
+
221
+ function appendSkippedComment(output, commentText, preserveLength) {
222
+ if (!preserveLength) {
223
+ return output + commentText.replace(/[^\n\r]/g, '')
224
+ }
225
+ return output + commentText.replace(/[^\n\r]/g, ' ')
226
+ }
227
+
228
+ function stripCommentsImpl(code, { preserveLength = false } = {}) {
229
+ let output = ''
230
+ let i = 0
231
+ const special = /[/"'`]/g
232
+
233
+ while (i < code.length) {
234
+ // Copy the run of ordinary characters up to the next / or quote in one go.
235
+ special.lastIndex = i
236
+ const found = special.exec(code)
237
+ const stop = found ? found.index : code.length
238
+ if (stop > i) {
239
+ output += code.slice(i, stop)
240
+ i = stop
241
+ if (i >= code.length) break
242
+ }
243
+ const char = code[i]
244
+ const next = code[i + 1]
245
+
246
+ if (char === '"' || char === "'" || char === '`') {
247
+ const end = readQuoted(code, i, char)
248
+ output += code.slice(i, end)
249
+ i = end
250
+ continue
251
+ }
252
+
253
+ if (char === '/' && next === '/') {
254
+ let end = i + 2
255
+ while (end < code.length && code[end] !== '\n' && code[end] !== '\r') end++
256
+ output = appendSkippedComment(output, code.slice(i, end), preserveLength)
257
+ i = end
258
+ continue
259
+ }
260
+
261
+ if (char === '/' && next === '*') {
262
+ let end = code.indexOf('*/', i + 2)
263
+ end = end === -1 ? code.length : end + 2
264
+ output = appendSkippedComment(output, code.slice(i, end), preserveLength)
265
+ i = end
266
+ continue
267
+ }
268
+
269
+ if (char === '/' && isRegexLiteralStart(code, i)) {
270
+ const end = readRegexLiteral(code, i)
271
+ output += code.slice(i, end)
272
+ i = end
273
+ continue
274
+ }
275
+
276
+ output += char
277
+ i++
278
+ }
279
+
280
+ return output
117
281
  }
118
282
 
119
283
  /**
120
- * Strip comments from code, preserving :// in URLs
284
+ * Strip comments from code while skipping strings, templates, and regex
285
+ * literals. Regex literals can legally contain `//`, for example `/https?:\/\//`,
286
+ * and must not be treated as line comments.
121
287
  * @param {string} code
122
288
  * @returns {string}
123
289
  */
124
290
  function stripComments(code) {
125
- const hasProtocol = code.indexOf('://') > -1
126
- if (hasProtocol) {
127
- code = code.replace(/:\/\//g, PROTOCOL_PLACEHOLDER)
128
- }
129
- code = code.replace(COMMENT_RE, '')
130
- if (hasProtocol) {
131
- code = code.replace(/__PROTO_SLASH__/g, '://')
132
- }
133
- return code
291
+ return stripCommentsImpl(code)
134
292
  }
135
293
 
136
294
  /**
@@ -351,4 +509,4 @@ module.exports = {
351
509
  isRelativeImportWithQuotes,
352
510
  getNames,
353
511
  extractExportName
354
- }
512
+ }