From bbf46c302f4f2632e1d4b76c52101c8ed8c469c2 Mon Sep 17 00:00:00 2001 From: JounQin Date: Wed, 13 Mar 2024 19:17:48 +0800 Subject: [PATCH] feat: migrate named rule and related usage (#44) --- .changeset/rotten-glasses-ring.md | 5 + .markdownlint.json | 10 - .markdownlintignore | 2 - babel.config.js | 9 +- package.json | 2 + src/ExportMap.js | 967 --------------- src/ExportMap.ts | 1090 +++++++++++++++++ src/index.ts | 6 +- src/rules/default.js | 4 +- src/rules/export.js | 2 +- src/rules/named.js | 175 --- src/rules/named.ts | 232 ++++ src/rules/namespace.js | 10 +- src/rules/no-cycle.js | 7 +- src/rules/no-deprecated.js | 6 +- src/rules/no-named-as-default-member.js | 9 +- src/rules/no-named-as-default.js | 4 +- src/rules/no-unresolved.ts | 7 +- src/rules/no-unused-modules.js | 9 +- src/rules/unambiguous.js | 4 +- src/types.ts | 52 +- src/utils/ignore.d.ts | 15 - src/utils/ignore.js | 75 -- src/utils/ignore.ts | 71 ++ src/utils/is-core-module.js | 4 - src/utils/is-core-module.ts | 4 + .../{module-require.js => module-require.ts} | 21 +- src/utils/parse.d.ts | 9 - src/utils/parse.js | 193 --- src/utils/parse.ts | 172 +++ src/utils/resolve.ts | 59 +- src/utils/types.d.ts | 9 - src/utils/unambiguous.d.ts | 7 - src/utils/{unambiguous.js => unambiguous.ts} | 10 +- src/utils/visit.d.ts | 10 - src/utils/visit.js | 30 - src/utils/visit.ts | 41 + test/cli.spec.js | 7 +- test/core/getExports.spec.js | 19 +- test/core/ignore.spec.js | 51 +- test/core/parse.spec.js | 2 +- yarn.lock | 17 + 42 files changed, 1803 insertions(+), 1635 deletions(-) create mode 100644 .changeset/rotten-glasses-ring.md delete mode 100644 .markdownlint.json delete mode 100644 .markdownlintignore delete mode 100644 src/ExportMap.js create mode 100644 src/ExportMap.ts delete mode 100644 src/rules/named.js create mode 100644 src/rules/named.ts delete mode 100644 src/utils/ignore.d.ts delete mode 100644 src/utils/ignore.js create mode 100644 src/utils/ignore.ts delete mode 100644 src/utils/is-core-module.js create mode 100644 src/utils/is-core-module.ts rename src/utils/{module-require.js => module-require.ts} (61%) delete mode 100644 src/utils/parse.d.ts delete mode 100644 src/utils/parse.js create mode 100644 src/utils/parse.ts delete mode 100644 src/utils/types.d.ts delete mode 100644 src/utils/unambiguous.d.ts rename src/utils/{unambiguous.js => unambiguous.ts} (74%) delete mode 100644 src/utils/visit.d.ts delete mode 100644 src/utils/visit.js create mode 100644 src/utils/visit.ts diff --git a/.changeset/rotten-glasses-ring.md b/.changeset/rotten-glasses-ring.md new file mode 100644 index 000000000..031b8563e --- /dev/null +++ b/.changeset/rotten-glasses-ring.md @@ -0,0 +1,5 @@ +--- +"eslint-plugin-import-x": patch +--- + +feat: migrate named rule and related usage diff --git a/.markdownlint.json b/.markdownlint.json deleted file mode 100644 index d179615f4..000000000 --- a/.markdownlint.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "line-length": false, - "ul-indent": { - "start_indent": 1, - "start_indented": true - }, - "ul-style": { - "style": "dash" - } -} diff --git a/.markdownlintignore b/.markdownlintignore deleted file mode 100644 index 6ed5b5b6e..000000000 --- a/.markdownlintignore +++ /dev/null @@ -1,2 +0,0 @@ -CHANGELOG.md -node_modules diff --git a/babel.config.js b/babel.config.js index 44396b262..a0a37f30a 100644 --- a/babel.config.js +++ b/babel.config.js @@ -28,7 +28,14 @@ module.exports = { overrides: [ { include: '**/*.ts', - presets: ['@babel/typescript'], + presets: [ + [ + '@babel/typescript', + { + allowDeclareFields: true, + }, + ], + ], }, ], } diff --git a/package.json b/package.json index 29a871ab9..dc225cacb 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,8 @@ "@changesets/cli": "^2.27.1", "@eslint/import-test-order-redirect-scoped": "link:./test/fixtures/order-redirect-scoped", "@test-scope/some-module": "link:./test/fixtures/symlinked-module", + "@types/debug": "^4.1.12", + "@types/doctrine": "^0.0.9", "@types/eslint": "^8.56.5", "@types/jest": "^29.5.12", "@types/json-schema": "^7.0.15", diff --git a/src/ExportMap.js b/src/ExportMap.js deleted file mode 100644 index 57cf12ccd..000000000 --- a/src/ExportMap.js +++ /dev/null @@ -1,967 +0,0 @@ -import fs from 'fs' -import { resolve as pathResolve } from 'path' - -import doctrine from 'doctrine' - -import debug from 'debug' - -import { SourceCode } from 'eslint' - -import parse from './utils/parse' -import visit from './utils/visit' -import { relative, resolve } from './utils/resolve' -import isIgnored, { hasValidExtension } from './utils/ignore' - -import { hashObject } from './utils/hash' -import * as unambiguous from './utils/unambiguous' - -import { getTsconfig } from 'get-tsconfig' - -const log = debug('eslint-plugin-import-x:ExportMap') - -const exportCache = new Map() -const tsconfigCache = new Map() - -export default class ExportMap { - constructor(path) { - this.path = path - this.namespace = new Map() - // todo: restructure to key on path, value is resolver + map of names - this.reexports = new Map() - /** - * star-exports - * @type {Set} of () => ExportMap - */ - this.dependencies = new Set() - /** - * dependencies of this module that are not explicitly re-exported - * @type {Map} from path = () => ExportMap - */ - this.imports = new Map() - this.errors = [] - /** - * type {'ambiguous' | 'Module' | 'Script'} - */ - this.parseGoal = 'ambiguous' - } - - get hasDefault() { - return this.get('default') != null - } // stronger than this.has - - get size() { - let size = this.namespace.size + this.reexports.size - this.dependencies.forEach(dep => { - const d = dep() - // CJS / ignored dependencies won't exist (#717) - if (d == null) { - return - } - size += d.size - }) - return size - } - - /** - * Note that this does not check explicitly re-exported names for existence - * in the base namespace, but it will expand all `export * from '...'` exports - * if not found in the explicit namespace. - * @param {string} name - * @return {Boolean} true if `name` is exported by this module. - */ - has(name) { - if (this.namespace.has(name)) { - return true - } - if (this.reexports.has(name)) { - return true - } - - // default exports must be explicitly re-exported (#328) - if (name !== 'default') { - for (const dep of this.dependencies) { - const innerMap = dep() - - // todo: report as unresolved? - if (!innerMap) { - continue - } - - if (innerMap.has(name)) { - return true - } - } - } - - return false - } - - /** - * ensure that imported name fully resolves. - * @param {string} name - * @return {{ found: boolean, path: ExportMap[] }} - */ - hasDeep(name) { - if (this.namespace.has(name)) { - return { found: true, path: [this] } - } - - if (this.reexports.has(name)) { - const reexports = this.reexports.get(name) - const imported = reexports.getImport() - - // if import is ignored, return explicit 'null' - if (imported == null) { - return { found: true, path: [this] } - } - - // safeguard against cycles, only if name matches - if (imported.path === this.path && reexports.local === name) { - return { found: false, path: [this] } - } - - const deep = imported.hasDeep(reexports.local) - deep.path.unshift(this) - - return deep - } - - // default exports must be explicitly re-exported (#328) - if (name !== 'default') { - for (const dep of this.dependencies) { - const innerMap = dep() - if (innerMap == null) { - return { found: true, path: [this] } - } - // todo: report as unresolved? - if (!innerMap) { - continue - } - - // safeguard against cycles - if (innerMap.path === this.path) { - continue - } - - const innerValue = innerMap.hasDeep(name) - if (innerValue.found) { - innerValue.path.unshift(this) - return innerValue - } - } - } - - return { found: false, path: [this] } - } - - get(name) { - if (this.namespace.has(name)) { - return this.namespace.get(name) - } - - if (this.reexports.has(name)) { - const reexports = this.reexports.get(name) - const imported = reexports.getImport() - - // if import is ignored, return explicit 'null' - if (imported == null) { - return null - } - - // safeguard against cycles, only if name matches - if (imported.path === this.path && reexports.local === name) { - return undefined - } - - return imported.get(reexports.local) - } - - // default exports must be explicitly re-exported (#328) - if (name !== 'default') { - for (const dep of this.dependencies) { - const innerMap = dep() - // todo: report as unresolved? - if (!innerMap) { - continue - } - - // safeguard against cycles - if (innerMap.path === this.path) { - continue - } - - const innerValue = innerMap.get(name) - if (innerValue !== undefined) { - return innerValue - } - } - } - - return undefined - } - - forEach(callback, thisArg) { - this.namespace.forEach((v, n) => { - callback.call(thisArg, v, n, this) - }) - - this.reexports.forEach((reexports, name) => { - const reexported = reexports.getImport() - // can't look up meta for ignored re-exports (#348) - callback.call( - thisArg, - reexported && reexported.get(reexports.local), - name, - this, - ) - }) - - this.dependencies.forEach(dep => { - const d = dep() - // CJS / ignored dependencies won't exist (#717) - if (d == null) { - return - } - - d.forEach((v, n) => { - if (n !== 'default') { - callback.call(thisArg, v, n, this) - } - }) - }) - } - - // todo: keys, values, entries? - - reportErrors(context, declaration) { - const msg = this.errors - .map(e => `${e.message} (${e.lineNumber}:${e.column})`) - .join(', ') - context.report({ - node: declaration.source, - message: `Parse errors in imported module '${declaration.source.value}': ${msg}`, - }) - } -} - -/** - * parse docs from the first node that has leading comments - */ -function captureDoc(source, docStyleParsers, ...nodes) { - const metadata = {} - - // 'some' short-circuits on first 'true' - nodes.some(n => { - try { - let leadingComments - - // n.leadingComments is legacy `attachComments` behavior - if ('leadingComments' in n) { - leadingComments = n.leadingComments - } else if (n.range) { - leadingComments = source.getCommentsBefore(n) - } - - if (!leadingComments || leadingComments.length === 0) { - return false - } - - for (const name in docStyleParsers) { - const doc = docStyleParsers[name](leadingComments) - if (doc) { - metadata.doc = doc - } - } - - return true - } catch (err) { - return false - } - }) - - return metadata -} - -const availableDocStyleParsers = { - jsdoc: captureJsDoc, - tomdoc: captureTomDoc, -} - -/** - * parse JSDoc from leading comments - * @param {object[]} comments - * @return {{ doc: object }} - */ -function captureJsDoc(comments) { - let doc - - // capture XSDoc - comments.forEach(comment => { - // skip non-block comments - if (comment.type !== 'Block') { - return - } - try { - doc = doctrine.parse(comment.value, { unwrap: true }) - } catch (err) { - /* don't care, for now? maybe add to `errors?` */ - } - }) - - return doc -} - -/** - * parse TomDoc section from comments - */ -function captureTomDoc(comments) { - // collect lines up to first paragraph break - const lines = [] - for (let i = 0; i < comments.length; i++) { - const comment = comments[i] - if (comment.value.match(/^\s*$/)) { - break - } - lines.push(comment.value.trim()) - } - - // return doctrine-like object - const statusMatch = lines - .join(' ') - .match(/^(Public|Internal|Deprecated):\s*(.+)/) - if (statusMatch) { - return { - description: statusMatch[2], - tags: [ - { - title: statusMatch[1].toLowerCase(), - description: statusMatch[2], - }, - ], - } - } -} - -const supportedImportTypes = new Set([ - 'ImportDefaultSpecifier', - 'ImportNamespaceSpecifier', -]) - -ExportMap.get = function (source, context) { - const path = resolve(source, context) - if (path == null) { - return null - } - - return ExportMap.for(childContext(path, context)) -} - -ExportMap.for = function (context) { - const { path } = context - - const cacheKey = context.cacheKey || hashObject(context).digest('hex') - let exportMap = exportCache.get(cacheKey) - - // return cached ignore - if (exportMap === null) { - return null - } - - const stats = fs.statSync(path) - if (exportMap != null) { - // date equality check - if (exportMap.mtime - stats.mtime === 0) { - return exportMap - } - // future: check content equality? - } - - // check valid extensions first - if (!hasValidExtension(path, context)) { - exportCache.set(cacheKey, null) - return null - } - - // check for and cache ignore - if (isIgnored(path, context)) { - log('ignored path due to ignore settings:', path) - exportCache.set(cacheKey, null) - return null - } - - const content = fs.readFileSync(path, { encoding: 'utf8' }) - - // check for and cache unambiguous modules - if (!unambiguous.test(content)) { - log('ignored path due to unambiguous regex:', path) - exportCache.set(cacheKey, null) - return null - } - - log('cache miss', cacheKey, 'for path', path) - exportMap = ExportMap.parse(path, content, context) - - // ambiguous modules return null - if (exportMap == null) { - log('ignored path due to ambiguous parse:', path) - exportCache.set(cacheKey, null) - return null - } - - exportMap.mtime = stats.mtime - - exportCache.set(cacheKey, exportMap) - return exportMap -} - -ExportMap.parse = function (path, content, context) { - const m = new ExportMap(path) - const isEsModuleInteropTrue = isEsModuleInterop() - - let ast - let visitorKeys - try { - const result = parse(path, content, context) - ast = result.ast - visitorKeys = result.visitorKeys - } catch (err) { - m.errors.push(err) - return m // can't continue - } - - m.visitorKeys = visitorKeys - - let hasDynamicImports = false - - function processDynamicImport(source) { - hasDynamicImports = true - if (source.type !== 'Literal') { - return null - } - const p = remotePath(source.value) - if (p == null) { - return null - } - const importedSpecifiers = new Set() - importedSpecifiers.add('ImportNamespaceSpecifier') - const getter = thunkFor(p, context) - m.imports.set(p, { - getter, - declarations: new Set([ - { - source: { - // capturing actual node reference holds full AST in memory! - value: source.value, - loc: source.loc, - }, - importedSpecifiers, - dynamic: true, - }, - ]), - }) - } - - visit(ast, visitorKeys, { - ImportExpression(node) { - processDynamicImport(node.source) - }, - CallExpression(node) { - if (node.callee.type === 'Import') { - processDynamicImport(node.arguments[0]) - } - }, - }) - - const unambiguouslyESM = unambiguous.isModule(ast) - if (!unambiguouslyESM && !hasDynamicImports) { - return null - } - - const docstyle = (context.settings && - context.settings['import-x/docstyle']) || ['jsdoc'] - const docStyleParsers = {} - docstyle.forEach(style => { - docStyleParsers[style] = availableDocStyleParsers[style] - }) - - // attempt to collect module doc - if (ast.comments) { - ast.comments.some(c => { - if (c.type !== 'Block') { - return false - } - try { - const doc = doctrine.parse(c.value, { unwrap: true }) - if (doc.tags.some(t => t.title === 'module')) { - m.doc = doc - return true - } - } catch (err) { - /* ignore */ - } - return false - }) - } - - const namespaces = new Map() - - function remotePath(value) { - return relative(value, path, context.settings) - } - - function resolveImport(value) { - const rp = remotePath(value) - if (rp == null) { - return null - } - return ExportMap.for(childContext(rp, context)) - } - - function getNamespace(identifier) { - if (!namespaces.has(identifier.name)) { - return - } - - return function () { - return resolveImport(namespaces.get(identifier.name)) - } - } - - function addNamespace(object, identifier) { - const nsfn = getNamespace(identifier) - if (nsfn) { - Object.defineProperty(object, 'namespace', { get: nsfn }) - } - - return object - } - - function processSpecifier(s, n, m) { - const nsource = n.source && n.source.value - const exportMeta = {} - let local - - switch (s.type) { - case 'ExportDefaultSpecifier': - if (!nsource) { - return - } - local = 'default' - break - case 'ExportNamespaceSpecifier': - m.namespace.set( - s.exported.name, - Object.defineProperty(exportMeta, 'namespace', { - get() { - return resolveImport(nsource) - }, - }), - ) - return - case 'ExportAllDeclaration': - m.namespace.set( - s.exported.name || s.exported.value, - addNamespace(exportMeta, s.source.value), - ) - return - case 'ExportSpecifier': - if (!n.source) { - m.namespace.set( - s.exported.name || s.exported.value, - addNamespace(exportMeta, s.local), - ) - return - } - // else falls through - default: - local = s.local.name - break - } - - // todo: JSDoc - m.reexports.set(s.exported.name, { - local, - getImport: () => resolveImport(nsource), - }) - } - - function captureDependencyWithSpecifiers(n) { - // import type { Foo } (TS and Flow); import typeof { Foo } (Flow) - const declarationIsType = - n.importKind === 'type' || n.importKind === 'typeof' - // import './foo' or import {} from './foo' (both 0 specifiers) is a side effect and - // shouldn't be considered to be just importing types - let specifiersOnlyImportingTypes = n.specifiers.length > 0 - const importedSpecifiers = new Set() - n.specifiers.forEach(specifier => { - if (specifier.type === 'ImportSpecifier') { - importedSpecifiers.add( - specifier.imported.name || specifier.imported.value, - ) - } else if (supportedImportTypes.has(specifier.type)) { - importedSpecifiers.add(specifier.type) - } - - // import { type Foo } (Flow); import { typeof Foo } (Flow) - specifiersOnlyImportingTypes = - specifiersOnlyImportingTypes && - (specifier.importKind === 'type' || specifier.importKind === 'typeof') - }) - captureDependency( - n, - declarationIsType || specifiersOnlyImportingTypes, - importedSpecifiers, - ) - } - - function captureDependency( - { source }, - isOnlyImportingTypes, - importedSpecifiers = new Set(), - ) { - if (source == null) { - return null - } - - const p = remotePath(source.value) - if (p == null) { - return null - } - - const declarationMetadata = { - // capturing actual node reference holds full AST in memory! - source: { value: source.value, loc: source.loc }, - isOnlyImportingTypes, - importedSpecifiers, - } - - const existing = m.imports.get(p) - if (existing != null) { - existing.declarations.add(declarationMetadata) - return existing.getter - } - - const getter = thunkFor(p, context) - m.imports.set(p, { getter, declarations: new Set([declarationMetadata]) }) - return getter - } - - const source = makeSourceCode(content, ast) - - function isEsModuleInterop() { - const parserOptions = context.parserOptions || {} - let tsconfigRootDir = parserOptions.tsconfigRootDir - const project = parserOptions.project - const cacheKey = hashObject({ - tsconfigRootDir, - project, - }).digest('hex') - let tsConfig = tsconfigCache.get(cacheKey) - if (typeof tsConfig === 'undefined') { - tsconfigRootDir = tsconfigRootDir || process.cwd() - let tsconfigResult - if (project) { - const projects = Array.isArray(project) ? project : [project] - for (const project of projects) { - tsconfigResult = getTsconfig( - project === true - ? context.filename - : pathResolve(tsconfigRootDir, project), - ) - if (tsconfigResult) { - break - } - } - } else { - tsconfigResult = getTsconfig(tsconfigRootDir) - } - tsConfig = (tsconfigResult && tsconfigResult.config) || null - tsconfigCache.set(cacheKey, tsConfig) - } - - return tsConfig && tsConfig.compilerOptions - ? tsConfig.compilerOptions.esModuleInterop - : false - } - - ast.body.forEach(function (n) { - if (n.type === 'ExportDefaultDeclaration') { - const exportMeta = captureDoc(source, docStyleParsers, n) - if (n.declaration.type === 'Identifier') { - addNamespace(exportMeta, n.declaration) - } - m.namespace.set('default', exportMeta) - return - } - - if (n.type === 'ExportAllDeclaration') { - const getter = captureDependency(n, n.exportKind === 'type') - if (getter) { - m.dependencies.add(getter) - } - if (n.exported) { - processSpecifier(n, n.exported, m) - } - return - } - - // capture namespaces in case of later export - if (n.type === 'ImportDeclaration') { - captureDependencyWithSpecifiers(n) - - const ns = n.specifiers.find(s => s.type === 'ImportNamespaceSpecifier') - if (ns) { - namespaces.set(ns.local.name, n.source.value) - } - return - } - - if (n.type === 'ExportNamedDeclaration') { - captureDependencyWithSpecifiers(n) - - // capture declaration - if (n.declaration != null) { - switch (n.declaration.type) { - case 'FunctionDeclaration': - case 'ClassDeclaration': - case 'TypeAlias': // flowtype with @babel/eslint-parser - case 'InterfaceDeclaration': - case 'DeclareFunction': - case 'TSDeclareFunction': - case 'TSEnumDeclaration': - case 'TSTypeAliasDeclaration': - case 'TSInterfaceDeclaration': - case 'TSAbstractClassDeclaration': - case 'TSModuleDeclaration': - m.namespace.set( - n.declaration.id.name, - captureDoc(source, docStyleParsers, n), - ) - break - case 'VariableDeclaration': - n.declaration.declarations.forEach(d => { - recursivePatternCapture(d.id, id => - m.namespace.set( - id.name, - captureDoc(source, docStyleParsers, d, n), - ), - ) - }) - break - default: - } - } - - n.specifiers.forEach(s => processSpecifier(s, n, m)) - } - - const exports = ['TSExportAssignment'] - if (isEsModuleInteropTrue) { - exports.push('TSNamespaceExportDeclaration') - } - - // This doesn't declare anything, but changes what's being exported. - if (exports.includes(n.type)) { - const exportedName = - n.type === 'TSNamespaceExportDeclaration' - ? (n.id || n.name).name - : (n.expression && n.expression.name) || - (n.expression.id && n.expression.id.name) || - null - const declTypes = [ - 'VariableDeclaration', - 'ClassDeclaration', - 'TSDeclareFunction', - 'TSEnumDeclaration', - 'TSTypeAliasDeclaration', - 'TSInterfaceDeclaration', - 'TSAbstractClassDeclaration', - 'TSModuleDeclaration', - ] - const exportedDecls = ast.body.filter( - ({ type, id, declarations }) => - declTypes.includes(type) && - ((id && id.name === exportedName) || - (declarations && - declarations.find(d => d.id.name === exportedName))), - ) - if (exportedDecls.length === 0) { - // Export is not referencing any local declaration, must be re-exporting - m.namespace.set('default', captureDoc(source, docStyleParsers, n)) - return - } - if ( - isEsModuleInteropTrue && // esModuleInterop is on in tsconfig - !m.namespace.has('default') // and default isn't added already - ) { - m.namespace.set('default', {}) // add default export - } - exportedDecls.forEach(decl => { - if (decl.type === 'TSModuleDeclaration') { - if (decl.body && decl.body.type === 'TSModuleDeclaration') { - m.namespace.set( - decl.body.id.name, - captureDoc(source, docStyleParsers, decl.body), - ) - } else if (decl.body && decl.body.body) { - decl.body.body.forEach(moduleBlockNode => { - // Export-assignment exports all members in the namespace, - // explicitly exported or not. - const namespaceDecl = - moduleBlockNode.type === 'ExportNamedDeclaration' - ? moduleBlockNode.declaration - : moduleBlockNode - - if (!namespaceDecl) { - // TypeScript can check this for us; we needn't - } else if (namespaceDecl.type === 'VariableDeclaration') { - namespaceDecl.declarations.forEach(d => - recursivePatternCapture(d.id, id => - m.namespace.set( - id.name, - captureDoc( - source, - docStyleParsers, - decl, - namespaceDecl, - moduleBlockNode, - ), - ), - ), - ) - } else { - m.namespace.set( - namespaceDecl.id.name, - captureDoc(source, docStyleParsers, moduleBlockNode), - ) - } - }) - } - } else { - // Export as default - m.namespace.set('default', captureDoc(source, docStyleParsers, decl)) - } - }) - } - }) - - if ( - isEsModuleInteropTrue && // esModuleInterop is on in tsconfig - m.namespace.size > 0 && // anything is exported - !m.namespace.has('default') // and default isn't added already - ) { - m.namespace.set('default', {}) // add default export - } - - if (unambiguouslyESM) { - m.parseGoal = 'Module' - } - return m -} - -/** - * The creation of this closure is isolated from other scopes - * to avoid over-retention of unrelated variables, which has - * caused memory leaks. See #1266. - */ -function thunkFor(p, context) { - return () => ExportMap.for(childContext(p, context)) -} - -/** - * Traverse a pattern/identifier node, calling 'callback' - * for each leaf identifier. - * @param {node} pattern - * @param {Function} callback - * @return {void} - */ -export function recursivePatternCapture(pattern, callback) { - switch (pattern.type) { - case 'Identifier': // base case - callback(pattern) - break - - case 'ObjectPattern': - pattern.properties.forEach(p => { - if (p.type === 'ExperimentalRestProperty' || p.type === 'RestElement') { - callback(p.argument) - return - } - recursivePatternCapture(p.value, callback) - }) - break - - case 'ArrayPattern': - pattern.elements.forEach(element => { - if (element == null) { - return - } - if ( - element.type === 'ExperimentalRestProperty' || - element.type === 'RestElement' - ) { - callback(element.argument) - return - } - recursivePatternCapture(element, callback) - }) - break - - case 'AssignmentPattern': - callback(pattern.left) - break - default: - } -} - -let parserOptionsHash = '' -let prevParserOptions = '' -let settingsHash = '' -let prevSettings = '' -/** - * don't hold full context object in memory, just grab what we need. - * also calculate a cacheKey, where parts of the cacheKey hash are memoized - */ -function childContext(path, context) { - const { settings, parserOptions, parserPath } = context - - if (JSON.stringify(settings) !== prevSettings) { - settingsHash = hashObject({ settings }).digest('hex') - prevSettings = JSON.stringify(settings) - } - - if (JSON.stringify(parserOptions) !== prevParserOptions) { - parserOptionsHash = hashObject({ parserOptions }).digest('hex') - prevParserOptions = JSON.stringify(parserOptions) - } - - return { - cacheKey: - String(parserPath) + parserOptionsHash + settingsHash + String(path), - settings, - parserOptions, - parserPath, - path, - filename: - typeof context.getPhysicalFilename === 'function' - ? context.getPhysicalFilename() - : context.physicalFilename != null - ? context.physicalFilename - : typeof context.getFilename === 'function' - ? context.getFilename() - : context.filename, - } -} - -/** - * sometimes legacy support isn't _that_ hard... right? - */ -function makeSourceCode(text, ast) { - if (SourceCode.length > 1) { - // ESLint 3 - return new SourceCode(text, ast) - } else { - // ESLint 4, 5 - return new SourceCode({ text, ast }) - } -} diff --git a/src/ExportMap.ts b/src/ExportMap.ts new file mode 100644 index 000000000..89651848d --- /dev/null +++ b/src/ExportMap.ts @@ -0,0 +1,1090 @@ +import fs from 'fs' +import { resolve as pathResolve } from 'path' + +import debug from 'debug' +import doctrine, { Annotation } from 'doctrine' +import { AST, SourceCode } from 'eslint' +import { TsConfigJsonResolved, getTsconfig } from 'get-tsconfig' + +import { parse } from './utils/parse' +import { visit } from './utils/visit' +import { relative, resolve } from './utils/resolve' +import { hasValidExtension, ignore } from './utils/ignore' +import { hashObject } from './utils/hash' +import { + isMaybeUnambiguousModule, + isUnambiguousModule, +} from './utils/unambiguous' + +import type { + ChildContext, + DocStyle, + ExportDefaultSpecifier, + ExportNamespaceSpecifier, + ParseError, + RuleContext, +} from './types' +import { TSESLint, TSESTree } from '@typescript-eslint/utils' + +const log = debug('eslint-plugin-import-x:ExportMap') + +const exportCache = new Map() + +const tsconfigCache = new Map() + +export type DocStyleParsers = Record< + DocStyle, + (comments: TSESTree.Comment[]) => Annotation | undefined +> + +export interface DeclarationMetadata { + source: Pick + importedSpecifiers?: Set + dynamic?: boolean + isOnlyImportingTypes?: boolean +} + +export class ExportMap { + static for(context: ChildContext) { + const { path } = context + + const cacheKey = context.cacheKey || hashObject(context).digest('hex') + let exportMap = exportCache.get(cacheKey) + + // return cached ignore + if (exportMap === null) { + return null + } + + const stats = fs.statSync(path) + if (exportMap != null) { + // date equality check + if (exportMap.mtime.valueOf() - stats.mtime.valueOf() === 0) { + return exportMap + } + // future: check content equality? + } + + // check valid extensions first + if (!hasValidExtension(path, context)) { + exportCache.set(cacheKey, null) + return null + } + + // check for and cache ignore + if (ignore(path, context)) { + log('ignored path due to ignore settings:', path) + exportCache.set(cacheKey, null) + return null + } + + const content = fs.readFileSync(path, { encoding: 'utf8' }) + + // check for and cache unambiguous modules + if (!isMaybeUnambiguousModule(content)) { + log('ignored path due to unambiguous regex:', path) + exportCache.set(cacheKey, null) + return null + } + + log('cache miss', cacheKey, 'for path', path) + exportMap = ExportMap.parse(path, content, context) + + // ambiguous modules return null + if (exportMap == null) { + log('ignored path due to ambiguous parse:', path) + exportCache.set(cacheKey, null) + return null + } + + exportMap.mtime = stats.mtime + + exportCache.set(cacheKey, exportMap) + + return exportMap + } + + static get(source: string, context: RuleContext) { + const path = resolve(source, context) + if (path == null) { + return null + } + + return ExportMap.for(childContext(path, context)) + } + + static parse(path: string, content: string, context: ChildContext) { + const m = new ExportMap(path) + const isEsModuleInteropTrue = isEsModuleInterop() + + let ast: TSESTree.Program + let visitorKeys: TSESLint.SourceCode.VisitorKeys | null + try { + ;({ ast, visitorKeys } = parse(path, content, context)) + } catch (err) { + m.errors.push(err as ParseError) + return m // can't continue + } + + m.visitorKeys = visitorKeys + + let hasDynamicImports = false + + function processDynamicImport(source: TSESTree.CallExpressionArgument) { + hasDynamicImports = true + if (source.type !== 'Literal') { + return null + } + const p = remotePath(source.value as string) + if (p == null) { + return null + } + const getter = thunkFor(p, context) + m.imports.set(p, { + getter, + declarations: new Set([ + { + source: { + // capturing actual node reference holds full AST in memory! + value: source.value, + loc: source.loc, + }, + importedSpecifiers: new Set(['ImportNamespaceSpecifier']), + dynamic: true, + }, + ]), + }) + } + + visit(ast, visitorKeys, { + ImportExpression(node) { + processDynamicImport((node as TSESTree.ImportExpression).source) + }, + CallExpression(_node) { + const node = _node as TSESTree.CallExpression + // @ts-expect-error - legacy parser type + if (node.callee.type === 'Import') { + processDynamicImport(node.arguments[0]) + } + }, + }) + + const unambiguouslyESM = isUnambiguousModule(ast) + if (!unambiguouslyESM && !hasDynamicImports) { + return null + } + + const docStyles = (context.settings && + context.settings['import-x/docstyle']) || ['jsdoc'] + + const docStyleParsers = {} as DocStyleParsers + + docStyles.forEach(style => { + docStyleParsers[style] = availableDocStyleParsers[style] + }) + + // attempt to collect module doc + if (ast.comments) { + ast.comments.some(c => { + if (c.type !== 'Block') { + return false + } + try { + const doc = doctrine.parse(c.value, { unwrap: true }) + if (doc.tags.some(t => t.title === 'module')) { + m.doc = doc + return true + } + } catch (err) { + /* ignore */ + } + return false + }) + } + + const namespaces = new Map() + + function remotePath(value: string) { + return relative(value, path, context.settings) + } + + function resolveImport(value: string) { + const rp = remotePath(value) + if (rp == null) { + return null + } + return ExportMap.for(childContext(rp, context)) + } + + function getNamespace(identifier: TSESTree.Identifier) { + if (!namespaces.has(identifier.name)) { + return + } + + return function () { + return resolveImport(namespaces.get(identifier.name)) + } + } + + function addNamespace(object: object, identifier: TSESTree.Identifier) { + const nsfn = getNamespace(identifier) + if (nsfn) { + Object.defineProperty(object, 'namespace', { get: nsfn }) + } + return object + } + + function processSpecifier( + s: + | TSESTree.ExportAllDeclaration + | TSESTree.ExportSpecifier + | ExportDefaultSpecifier + | ExportNamespaceSpecifier, + n: TSESTree.Identifier | TSESTree.ProgramStatement, + m: ExportMap, + ) { + const nsource = ('source' in n && + n.source && + (n.source as TSESTree.StringLiteral).value) as string + + const exportMeta = {} + + let local: string + + switch (s.type) { + case 'ExportDefaultSpecifier': + if (!nsource) { + return + } + local = 'default' + break + case 'ExportNamespaceSpecifier': + m.namespace.set( + s.exported.name, + Object.defineProperty(exportMeta, 'namespace', { + get() { + return resolveImport(nsource) + }, + }), + ) + return + case 'ExportAllDeclaration': + m.namespace.set( + s.exported!.name || + // @ts-expect-error - legacy parser type + s.exported!.value, + addNamespace( + exportMeta, + // @ts-expect-error -- FIXME: no idea yet + s.source.value, + ), + ) + return + case 'ExportSpecifier': + if (!('source' in n && n.source)) { + m.namespace.set( + s.exported.name || + // @ts-expect-error - legacy parser type + s.exported.value, + addNamespace(exportMeta, s.local), + ) + return + } + // else falls through + default: { + if ('local' in s) { + local = s.local.name + } else { + throw new Error('Unknown export specifier type') + } + break + } + } + + if ('exported' in s) { + // todo: JSDoc + m.reexports.set(s.exported.name, { + local, + getImport: () => resolveImport(nsource), + }) + } + } + + function captureDependencyWithSpecifiers( + n: TSESTree.ImportDeclaration | TSESTree.ExportNamedDeclaration, + ) { + // import type { Foo } (TS and Flow); import typeof { Foo } (Flow) + const declarationIsType = + 'importKind' in n && + (n.importKind === 'type' || + // @ts-expect-error - flow type + n.importKind === 'typeof') + // import './foo' or import {} from './foo' (both 0 specifiers) is a side effect and + // shouldn't be considered to be just importing types + let specifiersOnlyImportingTypes = n.specifiers.length > 0 + const importedSpecifiers = new Set() + n.specifiers.forEach(specifier => { + if (specifier.type === 'ImportSpecifier') { + importedSpecifiers.add( + specifier.imported.name || + // @ts-expect-error - legacy parser type + specifier.imported.value, + ) + } else if (supportedImportTypes.has(specifier.type)) { + importedSpecifiers.add(specifier.type) + } + + // import { type Foo } (TypeScript/Flow); import { typeof Foo } (Flow) + specifiersOnlyImportingTypes = + specifiersOnlyImportingTypes && + 'importKind' in specifier && + (specifier.importKind === 'type' || + // @ts-expect-error - flow type + specifier.importKind === 'typeof') + }) + captureDependency( + n, + declarationIsType || specifiersOnlyImportingTypes, + importedSpecifiers, + ) + } + + function captureDependency( + { + source, + }: + | TSESTree.ExportAllDeclaration + | TSESTree.ImportDeclaration + | TSESTree.ExportNamedDeclaration, + isOnlyImportingTypes: boolean, + importedSpecifiers = new Set(), + ) { + if (source == null) { + return null + } + + const p = remotePath(source.value) + if (p == null) { + return null + } + + const declarationMetadata: DeclarationMetadata = { + // capturing actual node reference holds full AST in memory! + source: { + value: source.value, + loc: source.loc, + }, + isOnlyImportingTypes, + importedSpecifiers, + } + + const existing = m.imports.get(p) + if (existing != null) { + existing.declarations.add(declarationMetadata) + return existing.getter + } + + const getter = thunkFor(p, context) + m.imports.set(p, { getter, declarations: new Set([declarationMetadata]) }) + return getter + } + + const source = makeSourceCode(content, ast) + + function isEsModuleInterop() { + const parserOptions = context.parserOptions || {} + let tsconfigRootDir = parserOptions.tsconfigRootDir + const project = parserOptions.project + const cacheKey = hashObject({ + tsconfigRootDir, + project, + }).digest('hex') + let tsConfig = tsconfigCache.get(cacheKey) + if (typeof tsConfig === 'undefined') { + tsconfigRootDir = tsconfigRootDir || process.cwd() + let tsconfigResult + if (project) { + const projects = Array.isArray(project) ? project : [project] + for (const project of projects) { + tsconfigResult = getTsconfig( + project === true + ? context.filename + : pathResolve(tsconfigRootDir, project), + ) + if (tsconfigResult) { + break + } + } + } else { + tsconfigResult = getTsconfig(tsconfigRootDir) + } + tsConfig = (tsconfigResult && tsconfigResult.config) || null + tsconfigCache.set(cacheKey, tsConfig) + } + + return tsConfig && tsConfig.compilerOptions + ? tsConfig.compilerOptions.esModuleInterop + : false + } + + ast.body.forEach(function (n) { + if (n.type === 'ExportDefaultDeclaration') { + const exportMeta = captureDoc(source, docStyleParsers, n) + if (n.declaration.type === 'Identifier') { + addNamespace(exportMeta, n.declaration) + } + m.namespace.set('default', exportMeta) + return + } + + if (n.type === 'ExportAllDeclaration') { + const getter = captureDependency(n, n.exportKind === 'type') + if (getter) { + m.dependencies.add(getter) + } + if (n.exported) { + processSpecifier(n, n.exported, m) + } + return + } + + // capture namespaces in case of later export + if (n.type === 'ImportDeclaration') { + captureDependencyWithSpecifiers(n) + + const ns = n.specifiers.find(s => s.type === 'ImportNamespaceSpecifier') + if (ns) { + namespaces.set(ns.local.name, n.source.value) + } + return + } + + if (n.type === 'ExportNamedDeclaration') { + captureDependencyWithSpecifiers(n) + + // capture declaration + if (n.declaration != null) { + switch (n.declaration.type) { + case 'FunctionDeclaration': + case 'ClassDeclaration': + /* eslint-disable no-fallthrough */ + // @ts-expect-error - flowtype with @babel/eslint-parser + case 'TypeAlias': + // @ts-expect-error - legacy parser type + case 'InterfaceDeclaration': + // @ts-expect-error - legacy parser type + case 'DeclareFunction': + case 'TSDeclareFunction': + case 'TSEnumDeclaration': + case 'TSTypeAliasDeclaration': + case 'TSInterfaceDeclaration': + // @ts-expect-error - legacy parser type + case 'TSAbstractClassDeclaration': + case 'TSModuleDeclaration': + m.namespace.set( + (n.declaration.id as TSESTree.Identifier).name, + captureDoc(source, docStyleParsers, n), + ) + break + /* eslint-enable no-fallthrough */ + case 'VariableDeclaration': + n.declaration.declarations.forEach(d => { + recursivePatternCapture(d.id, id => + m.namespace.set( + (id as TSESTree.Identifier).name, + captureDoc(source, docStyleParsers, d, n), + ), + ) + }) + break + default: + } + } + + n.specifiers.forEach(s => processSpecifier(s, n, m)) + } + + const exports = ['TSExportAssignment'] + if (isEsModuleInteropTrue) { + exports.push('TSNamespaceExportDeclaration') + } + + // This doesn't declare anything, but changes what's being exported. + if (exports.includes(n.type)) { + const exportedName = + n.type === 'TSNamespaceExportDeclaration' + ? ( + n.id || + // @ts-expect-error - legacy parser type + n.name + ).name + : ('expression' in n && + n.expression && + (('name' in n.expression && n.expression.name) || + ('id' in n.expression && + n.expression.id && + n.expression.id.name))) || + null + const declTypes = [ + 'VariableDeclaration', + 'ClassDeclaration', + 'TSDeclareFunction', + 'TSEnumDeclaration', + 'TSTypeAliasDeclaration', + 'TSInterfaceDeclaration', + 'TSAbstractClassDeclaration', + 'TSModuleDeclaration', + ] + const exportedDecls = ast.body.filter(node => { + return ( + declTypes.includes(node.type) && + (('id' in node && + node.id && + 'name' in node.id && + node.id.name === exportedName) || + ('declarations' in node && + node.declarations.find( + d => 'name' in d.id && d.id.name === exportedName, + ))) + ) + }) + if (exportedDecls.length === 0) { + // Export is not referencing any local declaration, must be re-exporting + m.namespace.set('default', captureDoc(source, docStyleParsers, n)) + return + } + if ( + isEsModuleInteropTrue && // esModuleInterop is on in tsconfig + !m.namespace.has('default') // and default isn't added already + ) { + m.namespace.set('default', {}) // add default export + } + exportedDecls.forEach(decl => { + if (decl.type === 'TSModuleDeclaration') { + if (decl.body && decl.body.type === 'TSModuleDeclaration') { + m.namespace.set( + (decl.body.id as TSESTree.Identifier).name, + captureDoc(source, docStyleParsers, decl.body), + ) + } else if (decl.body && decl.body.body) { + decl.body.body.forEach(moduleBlockNode => { + // Export-assignment exports all members in the namespace, + // explicitly exported or not. + const namespaceDecl = + moduleBlockNode.type === 'ExportNamedDeclaration' + ? moduleBlockNode.declaration + : moduleBlockNode + + if (!namespaceDecl) { + // TypeScript can check this for us; we needn't + } else if (namespaceDecl.type === 'VariableDeclaration') { + namespaceDecl.declarations.forEach(d => + recursivePatternCapture(d.id, id => + m.namespace.set( + (id as TSESTree.Identifier).name, + captureDoc( + source, + docStyleParsers, + decl, + namespaceDecl, + moduleBlockNode, + ), + ), + ), + ) + } else if ('id' in namespaceDecl) { + m.namespace.set( + (namespaceDecl.id as TSESTree.Identifier).name, + captureDoc(source, docStyleParsers, moduleBlockNode), + ) + } + }) + } + } else { + // Export as default + m.namespace.set( + 'default', + captureDoc(source, docStyleParsers, decl), + ) + } + }) + } + }) + + if ( + isEsModuleInteropTrue && // esModuleInterop is on in tsconfig + m.namespace.size > 0 && // anything is exported + !m.namespace.has('default') // and default isn't added already + ) { + m.namespace.set('default', {}) // add default export + } + + if (unambiguouslyESM) { + m.parseGoal = 'Module' + } + return m + } + + namespace = new Map() + + // todo: restructure to key on path, value is resolver + map of names + reexports = new Map< + string, + { + local: string + getImport(): ExportMap | null + } + >() + + /** + * star-exports + */ + dependencies = new Set<() => ExportMap | null>() + + /** + * dependencies of this module that are not explicitly re-exported + */ + imports = new Map< + string, + { + getter: () => ExportMap | null + declarations: Set + } + >() + + errors: ParseError[] = [] + + parseGoal: 'ambiguous' | 'Module' | 'Script' = 'ambiguous' + + private declare visitorKeys: TSESLint.SourceCode.VisitorKeys | null + + private declare mtime: Date + + private declare doc: Annotation + + constructor(public path: string) {} + + get hasDefault() { + return this.get('default') != null + } // stronger than this.has + + get size() { + let size = this.namespace.size + this.reexports.size + this.dependencies.forEach(dep => { + const d = dep() + // CJS / ignored dependencies won't exist (#717) + if (d == null) { + return + } + size += d.size + }) + return size + } + + /** + * Note that this does not check explicitly re-exported names for existence + * in the base namespace, but it will expand all `export * from '...'` exports + * if not found in the explicit namespace. + * @return true if `name` is exported by this module. + */ + has(name: string): boolean { + if (this.namespace.has(name)) { + return true + } + if (this.reexports.has(name)) { + return true + } + + // default exports must be explicitly re-exported (#328) + if (name !== 'default') { + for (const dep of this.dependencies) { + const innerMap = dep() + + // todo: report as unresolved? + if (!innerMap) { + continue + } + + if (innerMap.has(name)) { + return true + } + } + } + + return false + } + + /** + * ensure that imported name fully resolves. + */ + hasDeep(name: string): { found: boolean; path: ExportMap[] } { + if (this.namespace.has(name)) { + return { found: true, path: [this] } + } + + if (this.reexports.has(name)) { + const reexports = this.reexports.get(name)! + const imported = reexports.getImport() + + // if import is ignored, return explicit 'null' + if (imported == null) { + return { found: true, path: [this] } + } + + // safeguard against cycles, only if name matches + if (imported.path === this.path && reexports.local === name) { + return { found: false, path: [this] } + } + + const deep = imported.hasDeep(reexports.local) + deep.path.unshift(this) + + return deep + } + + // default exports must be explicitly re-exported (#328) + if (name !== 'default') { + for (const dep of this.dependencies) { + const innerMap = dep() + if (innerMap == null) { + return { found: true, path: [this] } + } + // todo: report as unresolved? + if (!innerMap) { + continue + } + + // safeguard against cycles + if (innerMap.path === this.path) { + continue + } + + const innerValue = innerMap.hasDeep(name) + if (innerValue.found) { + innerValue.path.unshift(this) + return innerValue + } + } + } + + return { found: false, path: [this] } + } + + get(name: string): T | null | undefined { + if (this.namespace.has(name)) { + return this.namespace.get(name) + } + + if (this.reexports.has(name)) { + const reexports = this.reexports.get(name)! + const imported = reexports.getImport() + + // if import is ignored, return explicit 'null' + if (imported == null) { + return null + } + + // safeguard against cycles, only if name matches + if (imported.path === this.path && reexports.local === name) { + return undefined + } + + return imported.get(reexports.local) + } + + // default exports must be explicitly re-exported (#328) + if (name !== 'default') { + for (const dep of this.dependencies) { + const innerMap = dep() + // todo: report as unresolved? + if (!innerMap) { + continue + } + + // safeguard against cycles + if (innerMap.path === this.path) { + continue + } + + const innerValue = innerMap.get(name) + if (innerValue !== undefined) { + return innerValue as T + } + } + } + + return undefined + } + + forEach( + callback: (value: unknown, name: string, map: ExportMap) => void, + thisArg?: unknown, + ) { + this.namespace.forEach((v, n) => { + callback.call(thisArg, v, n, this) + }) + + this.reexports.forEach((reexports, name) => { + const reexported = reexports.getImport() + // can't look up meta for ignored re-exports (#348) + callback.call(thisArg, reexported?.get(reexports.local), name, this) + }) + + this.dependencies.forEach(dep => { + const d = dep() + // CJS / ignored dependencies won't exist (#717) + if (d == null) { + return + } + + d.forEach((v, n) => { + if (n !== 'default') { + callback.call(thisArg, v, n, this) + } + }) + }) + } + + // todo: keys, values, entries? + + reportErrors( + context: RuleContext, + declaration: { source: TSESTree.Literal }, + ) { + const msg = this.errors + .map(err => `${err.message} (${err.lineNumber}:${err.column})`) + .join(', ') + context.report({ + node: declaration.source, + // @ts-expect-error - report without messageId + message: `Parse errors in imported module '${declaration.source.value}': ${msg}`, + }) + } +} + +/** + * parse docs from the first node that has leading comments + */ +function captureDoc( + source: SourceCode, + docStyleParsers: DocStyleParsers, + ...nodes: TSESTree.Node[] +) { + const metadata: { + doc?: Annotation + } = {} + + // 'some' short-circuits on first 'true' + nodes.some(n => { + try { + let leadingComments: TSESTree.Comment[] | undefined + + // n.leadingComments is legacy `attachComments` behavior + if ('leadingComments' in n && Array.isArray(n.leadingComments)) { + leadingComments = n.leadingComments as TSESTree.Comment[] + } else if (n.range) { + leadingComments = ( + source as unknown as TSESLint.SourceCode + ).getCommentsBefore(n) + } + + if (!leadingComments || leadingComments.length === 0) { + return false + } + + for (const parser of Object.values(docStyleParsers)) { + const doc = parser(leadingComments) + if (doc) { + metadata.doc = doc + } + } + + return true + } catch { + return false + } + }) + + return metadata +} + +const availableDocStyleParsers = { + jsdoc: captureJsDoc, + tomdoc: captureTomDoc, +} + +/** + * parse JSDoc from leading comments + */ +function captureJsDoc(comments: TSESTree.Comment[]) { + let doc: Annotation | undefined + + // capture XSDoc + comments.forEach(comment => { + // skip non-block comments + if (comment.type !== 'Block') { + return + } + try { + doc = doctrine.parse(comment.value, { unwrap: true }) + } catch (err) { + /* don't care, for now? maybe add to `errors?` */ + } + }) + + return doc +} + +/** + * parse TomDoc section from comments + */ +function captureTomDoc(comments: TSESTree.Comment[]): Annotation | undefined { + // collect lines up to first paragraph break + const lines = [] + for (let i = 0; i < comments.length; i++) { + const comment = comments[i] + if (comment.value.match(/^\s*$/)) { + break + } + lines.push(comment.value.trim()) + } + + // return doctrine-like object + const statusMatch = lines + .join(' ') + .match(/^(Public|Internal|Deprecated):\s*(.+)/) + if (statusMatch) { + return { + description: statusMatch[2], + tags: [ + { + title: statusMatch[1].toLowerCase(), + description: statusMatch[2], + }, + ], + } + } +} + +const supportedImportTypes = new Set([ + 'ImportDefaultSpecifier', + 'ImportNamespaceSpecifier', +]) + +/** + * The creation of this closure is isolated from other scopes + * to avoid over-retention of unrelated variables, which has + * caused memory leaks. See #1266. + */ +function thunkFor(p: string, context: RuleContext | ChildContext) { + return () => ExportMap.for(childContext(p, context)) +} + +/** + * Traverse a pattern/identifier node, calling 'callback' + * for each leaf identifier. + */ +export function recursivePatternCapture( + pattern: TSESTree.Node, + callback: (node: TSESTree.DestructuringPattern) => void, +) { + switch (pattern.type) { + case 'Identifier': // base case + callback(pattern) + break + + case 'ObjectPattern': + pattern.properties.forEach(p => { + if ( + // @ts-expect-error - legacy experimental + p.type === 'ExperimentalRestProperty' || + p.type === 'RestElement' + ) { + callback(p.argument) + return + } + recursivePatternCapture(p.value, callback) + }) + break + + case 'ArrayPattern': + pattern.elements.forEach(element => { + if (element == null) { + return + } + if ( + // @ts-expect-error - legacy experimental + element.type === 'ExperimentalRestProperty' || + element.type === 'RestElement' + ) { + callback(element.argument) + return + } + recursivePatternCapture(element, callback) + }) + break + + case 'AssignmentPattern': + callback(pattern.left) + break + default: + } +} + +let parserOptionsHash = '' +let prevParserOptions = '' +let settingsHash = '' +let prevSettings = '' + +/** + * don't hold full context object in memory, just grab what we need. + * also calculate a cacheKey, where parts of the cacheKey hash are memoized + */ +function childContext( + path: string, + context: RuleContext | ChildContext, +): ChildContext { + const { settings, parserOptions, parserPath } = context + + if (JSON.stringify(settings) !== prevSettings) { + settingsHash = hashObject({ settings }).digest('hex') + prevSettings = JSON.stringify(settings) + } + + if (JSON.stringify(parserOptions) !== prevParserOptions) { + parserOptionsHash = hashObject({ parserOptions }).digest('hex') + prevParserOptions = JSON.stringify(parserOptions) + } + + return { + cacheKey: + String(parserPath) + parserOptionsHash + settingsHash + String(path), + settings, + parserOptions, + parserPath, + path, + filename: + 'getPhysicalFilename' in context && + typeof context.getPhysicalFilename === 'function' + ? context.getPhysicalFilename() + : 'physicalFilename' in context && context.physicalFilename != null + ? (context.physicalFilename as string) + : 'getFilename' in context && + typeof context.getFilename === 'function' + ? context.getFilename() + : (('filename' in context && context.filename) as string), + } +} + +/** + * sometimes legacy support isn't _that_ hard... right? + */ +function makeSourceCode(text: string, ast: TSESTree.Program) { + if (SourceCode.length > 1) { + // ESLint 3 + return new SourceCode(text, ast as AST.Program) + } + + // ESLint 4+ + return new SourceCode({ text, ast: ast as AST.Program }) +} diff --git a/src/index.ts b/src/index.ts index 279a2b59c..5631f8a75 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,13 @@ +import type { TSESLint } from '@typescript-eslint/utils' + import type { PluginConfig } from './types' import noUnresolved from './rules/no-unresolved' -import { TSESLint } from '@typescript-eslint/utils' +import named from './rules/named' export const rules = { 'no-unresolved': noUnresolved, - named: require('./rules/named'), + named, default: require('./rules/default'), namespace: require('./rules/namespace'), 'no-namespace': require('./rules/no-namespace'), diff --git a/src/rules/default.js b/src/rules/default.js index 6d345b49b..ce7dea0d9 100644 --- a/src/rules/default.js +++ b/src/rules/default.js @@ -1,4 +1,4 @@ -import Exports from '../ExportMap' +import { ExportMap } from '../ExportMap' import { docsUrl } from '../docs-url' module.exports = { @@ -22,7 +22,7 @@ module.exports = { if (!defaultSpecifier) { return } - const imports = Exports.get(node.source.value, context) + const imports = ExportMap.get(node.source.value, context) if (imports == null) { return } diff --git a/src/rules/export.js b/src/rules/export.js index b36ad4846..ac0892005 100644 --- a/src/rules/export.js +++ b/src/rules/export.js @@ -1,4 +1,4 @@ -import ExportMap, { recursivePatternCapture } from '../ExportMap' +import { ExportMap, recursivePatternCapture } from '../ExportMap' import { docsUrl } from '../docs-url' /* diff --git a/src/rules/named.js b/src/rules/named.js deleted file mode 100644 index afae724d4..000000000 --- a/src/rules/named.js +++ /dev/null @@ -1,175 +0,0 @@ -import * as path from 'path' -import Exports from '../ExportMap' -import { docsUrl } from '../docs-url' - -module.exports = { - meta: { - type: 'problem', - docs: { - category: 'Static analysis', - description: - 'Ensure named imports correspond to a named export in the remote file.', - url: docsUrl('named'), - }, - schema: [ - { - type: 'object', - properties: { - commonjs: { - type: 'boolean', - }, - }, - additionalProperties: false, - }, - ], - }, - - create(context) { - const options = context.options[0] || {} - - function checkSpecifiers(key, type, node) { - // ignore local exports and type imports/exports - if ( - node.source == null || - node.importKind === 'type' || - node.importKind === 'typeof' || - node.exportKind === 'type' - ) { - return - } - - if (!node.specifiers.some(im => im.type === type)) { - return // no named imports/exports - } - - const imports = Exports.get(node.source.value, context) - if (imports == null || imports.parseGoal === 'ambiguous') { - return - } - - if (imports.errors.length) { - imports.reportErrors(context, node) - return - } - - node.specifiers.forEach(function (im) { - if ( - im.type !== type || - // ignore type imports - im.importKind === 'type' || - im.importKind === 'typeof' - ) { - return - } - - const name = im[key].name || im[key].value - - const deepLookup = imports.hasDeep(name) - - if (!deepLookup.found) { - if (deepLookup.path.length > 1) { - const deepPath = deepLookup.path - .map(i => - path.relative( - path.dirname( - context.getPhysicalFilename - ? context.getPhysicalFilename() - : context.getFilename(), - ), - i.path, - ), - ) - .join(' -> ') - - context.report(im[key], `${name} not found via ${deepPath}`) - } else { - context.report( - im[key], - `${name} not found in '${node.source.value}'`, - ) - } - } - }) - } - - function checkRequire(node) { - if ( - !options.commonjs || - node.type !== 'VariableDeclarator' || - // return if it's not an object destructure or it's an empty object destructure - !node.id || - node.id.type !== 'ObjectPattern' || - node.id.properties.length === 0 || - // return if there is no call expression on the right side - !node.init || - node.init.type !== 'CallExpression' - ) { - return - } - - const call = node.init - const [source] = call.arguments - const variableImports = node.id.properties - const variableExports = Exports.get(source.value, context) - - if ( - // return if it's not a commonjs require statement - call.callee.type !== 'Identifier' || - call.callee.name !== 'require' || - call.arguments.length !== 1 || - // return if it's not a string source - source.type !== 'Literal' || - variableExports == null || - variableExports.parseGoal === 'ambiguous' - ) { - return - } - - if (variableExports.errors.length) { - variableExports.reportErrors(context, node) - return - } - - variableImports.forEach(function (im) { - if (im.type !== 'Property' || !im.key || im.key.type !== 'Identifier') { - return - } - - const deepLookup = variableExports.hasDeep(im.key.name) - - if (!deepLookup.found) { - if (deepLookup.path.length > 1) { - const deepPath = deepLookup.path - .map(i => - path.relative(path.dirname(context.getFilename()), i.path), - ) - .join(' -> ') - - context.report(im.key, `${im.key.name} not found via ${deepPath}`) - } else { - context.report( - im.key, - `${im.key.name} not found in '${source.value}'`, - ) - } - } - }) - } - - return { - ImportDeclaration: checkSpecifiers.bind( - null, - 'imported', - 'ImportSpecifier', - ), - - ExportNamedDeclaration: checkSpecifiers.bind( - null, - 'local', - 'ExportSpecifier', - ), - - VariableDeclarator: checkRequire, - } - }, -} diff --git a/src/rules/named.ts b/src/rules/named.ts new file mode 100644 index 000000000..0f39cc17e --- /dev/null +++ b/src/rules/named.ts @@ -0,0 +1,232 @@ +import path from 'path' + +import type { TSESTree } from '@typescript-eslint/utils' + +import { ExportMap } from '../ExportMap' +import { createRule } from '../utils' +import { ModuleOptions } from '../utils/moduleVisitor' + +type MessageId = 'notFound' | 'notFoundDeep' + +export = createRule<[ModuleOptions], MessageId>({ + name: 'named', + meta: { + type: 'problem', + docs: { + category: 'Static analysis', + description: + 'Ensure named imports correspond to a named export in the remote file.', + recommended: 'error', + }, + messages: { + notFound: "{{name}} not found in '{{path}}'", + notFoundDeep: '{{name}} not found via {{deepPath}}', + }, + schema: [ + { + type: 'object', + properties: { + commonjs: { + type: 'boolean', + }, + }, + additionalProperties: false, + }, + ], + }, + defaultOptions: [{}], + create(context) { + const options = context.options[0] || {} + + function checkSpecifiers( + key: 'imported' | 'local', + type: 'ImportSpecifier' | 'ExportSpecifier', + node: TSESTree.ImportDeclaration | TSESTree.ExportNamedDeclaration, + ) { + // ignore local exports and type imports/exports + if ( + node.source == null || + ('importKind' in node && + (node.importKind === 'type' || + // @ts-expect-error - flow type + node.importKind === 'typeof')) || + ('exportKind' in node && node.exportKind === 'type') + ) { + return + } + + if (!node.specifiers.some(im => im.type === type)) { + return // no named imports/exports + } + + const imports = ExportMap.get(node.source.value, context) + if (imports == null || imports.parseGoal === 'ambiguous') { + return + } + + if (imports.errors.length) { + imports.reportErrors(context, node) + return + } + + node.specifiers.forEach(function (im) { + if ( + im.type !== type || + // ignore type imports + ('importKind' in im && + (im.importKind === 'type' || + // @ts-expect-error - flow type + im.importKind === 'typeof')) + ) { + return + } + + /** + * @see im is @see TSESTree.ExportSpecifier or @see TSESTree.ImportSpecifier + */ + // @ts-expect-error - it sucks, see above + const imNode = im[key] as TSESTree.Identifier + + const name = + imNode.name || + // @ts-expect-error - old version ast + (imNode.value as string) + + const deepLookup = imports.hasDeep(name) + + if (!deepLookup.found) { + if (deepLookup.path.length > 1) { + const deepPath = deepLookup.path + .map(i => + path.relative( + path.dirname( + context.getPhysicalFilename + ? context.getPhysicalFilename() + : context.getFilename(), + ), + i.path, + ), + ) + .join(' -> ') + + context.report({ + node: imNode, + messageId: 'notFoundDeep', + data: { + name, + deepPath, + }, + }) + } else { + context.report({ + node: imNode, + messageId: 'notFound', + data: { + name, + path: node.source.value, + }, + }) + } + } + }) + } + + return { + ImportDeclaration: checkSpecifiers.bind( + null, + 'imported', + 'ImportSpecifier', + ), + + ExportNamedDeclaration: checkSpecifiers.bind( + null, + 'local', + 'ExportSpecifier', + ), + + VariableDeclarator(node) { + if ( + !options.commonjs || + node.type !== 'VariableDeclarator' || + // return if it's not an object destructure or it's an empty object destructure + !node.id || + node.id.type !== 'ObjectPattern' || + node.id.properties.length === 0 || + // return if there is no call expression on the right side + !node.init || + node.init.type !== 'CallExpression' + ) { + return + } + + const call = node.init + const source = call.arguments[0] as TSESTree.StringLiteral + + const variableImports = node.id.properties + const variableExports = ExportMap.get(source.value, context) + + if ( + // return if it's not a commonjs require statement + call.callee.type !== 'Identifier' || + call.callee.name !== 'require' || + call.arguments.length !== 1 || + // return if it's not a string source + source.type !== 'Literal' || + variableExports == null || + variableExports.parseGoal === 'ambiguous' + ) { + return + } + + if (variableExports.errors.length) { + variableExports.reportErrors( + context, + // @ts-expect-error - FIXME: no idea yet + node, + ) + return + } + + variableImports.forEach(function (im) { + if ( + im.type !== 'Property' || + !im.key || + im.key.type !== 'Identifier' + ) { + return + } + + const deepLookup = variableExports.hasDeep(im.key.name) + + if (!deepLookup.found) { + if (deepLookup.path.length > 1) { + const deepPath = deepLookup.path + .map(i => + path.relative(path.dirname(context.getFilename()), i.path), + ) + .join(' -> ') + + context.report({ + node: im.key, + messageId: 'notFoundDeep', + data: { + name: im.key.name, + deepPath, + }, + }) + } else { + context.report({ + node: im.key, + messageId: 'notFound', + data: { + name: im.key.name, + path: source.value, + }, + }) + } + } + }) + }, + } + }, +}) diff --git a/src/rules/namespace.js b/src/rules/namespace.js index b6f13f9a6..72d5d94c9 100644 --- a/src/rules/namespace.js +++ b/src/rules/namespace.js @@ -1,5 +1,5 @@ import declaredScope from '../utils/declaredScope' -import Exports from '../ExportMap' +import { ExportMap } from '../ExportMap' import { importDeclaration } from '../import-declaration' import { docsUrl } from '../docs-url' @@ -12,7 +12,7 @@ function processBodyStatement(context, namespaces, declaration) { return } - const imports = Exports.get(declaration.source.value, context) + const imports = ExportMap.get(declaration.source.value, context) if (imports == null) { return null } @@ -100,7 +100,7 @@ module.exports = { ExportNamespaceSpecifier(namespace) { const declaration = importDeclaration(context) - const imports = Exports.get(declaration.source.value, context) + const imports = ExportMap.get(declaration.source.value, context) if (imports == null) { return null } @@ -146,7 +146,7 @@ module.exports = { const namepath = [dereference.object.name] // while property is namespace and parent is member expression, keep validating while ( - namespace instanceof Exports && + namespace instanceof ExportMap && dereference.type === 'MemberExpression' ) { if (dereference.computed) { @@ -197,7 +197,7 @@ module.exports = { // DFS traverse child namespaces function testKey(pattern, namespace, path = [init.name]) { - if (!(namespace instanceof Exports)) { + if (!(namespace instanceof ExportMap)) { return } diff --git a/src/rules/no-cycle.js b/src/rules/no-cycle.js index 94bda96bb..b8f7b1656 100644 --- a/src/rules/no-cycle.js +++ b/src/rules/no-cycle.js @@ -1,10 +1,9 @@ /** - * @fileOverview Ensures that no imported module imports the linted module. - * @author Ben Mosher + * Ensures that no imported module imports the linted module. */ import { resolve } from '../utils/resolve' -import Exports from '../ExportMap' +import { ExportMap } from '../ExportMap' import { isExternalModule } from '../core/importType' import { moduleVisitor, makeOptionsSchema } from '../utils/moduleVisitor' import { docsUrl } from '../docs-url' @@ -90,7 +89,7 @@ module.exports = { return // ignore type imports } - const imported = Exports.get(sourceNode.value, context) + const imported = ExportMap.get(sourceNode.value, context) if (imported == null) { return // no-unresolved territory diff --git a/src/rules/no-deprecated.js b/src/rules/no-deprecated.js index b33107062..d1e91edfa 100644 --- a/src/rules/no-deprecated.js +++ b/src/rules/no-deprecated.js @@ -1,5 +1,5 @@ import declaredScope from '../utils/declaredScope' -import Exports from '../ExportMap' +import { ExportMap } from '../ExportMap' import { docsUrl } from '../docs-url' function message(deprecation) { @@ -38,7 +38,7 @@ module.exports = { return } // local export, ignore - const imports = Exports.get(node.source.value, context) + const imports = ExportMap.get(node.source.value, context) if (imports == null) { return } @@ -148,7 +148,7 @@ module.exports = { const namepath = [dereference.object.name] // while property is namespace and parent is member expression, keep validating while ( - namespace instanceof Exports && + namespace instanceof ExportMap && dereference.type === 'MemberExpression' ) { // ignore computed parts for now diff --git a/src/rules/no-named-as-default-member.js b/src/rules/no-named-as-default-member.js index cf3058ef2..8d36e7281 100644 --- a/src/rules/no-named-as-default-member.js +++ b/src/rules/no-named-as-default-member.js @@ -1,10 +1,7 @@ /** - * @fileoverview Rule to warn about potentially confused use of name exports - * @author Desmond Brand - * @copyright 2016 Desmond Brand. All rights reserved. - * See LICENSE in root directory for full license. + * Rule to warn about potentially confused use of name exports */ -import Exports from '../ExportMap' +import { ExportMap } from '../ExportMap' import { importDeclaration } from '../import-declaration' import { docsUrl } from '../docs-url' @@ -36,7 +33,7 @@ module.exports = { return { ImportDefaultSpecifier(node) { const declaration = importDeclaration(context) - const exportMap = Exports.get(declaration.source.value, context) + const exportMap = ExportMap.get(declaration.source.value, context) if (exportMap == null) { return } diff --git a/src/rules/no-named-as-default.js b/src/rules/no-named-as-default.js index 8a4ffbbfe..31b023c2b 100644 --- a/src/rules/no-named-as-default.js +++ b/src/rules/no-named-as-default.js @@ -1,4 +1,4 @@ -import Exports from '../ExportMap' +import { ExportMap } from '../ExportMap' import { importDeclaration } from '../import-declaration' import { docsUrl } from '../docs-url' @@ -23,7 +23,7 @@ module.exports = { const declaration = importDeclaration(context) - const imports = Exports.get(declaration.source.value, context) + const imports = ExportMap.get(declaration.source.value, context) if (imports == null) { return } diff --git a/src/rules/no-unresolved.ts b/src/rules/no-unresolved.ts index eb21a3bb6..6f8fe0a96 100644 --- a/src/rules/no-unresolved.ts +++ b/src/rules/no-unresolved.ts @@ -22,7 +22,7 @@ type Options = [ }, ] -type MessageId = 'unresolved' | 'casing-mismatch' +type MessageId = 'unresolved' | 'casingMismatch' export = createRule({ name: 'no-unresolved', @@ -36,7 +36,7 @@ export = createRule({ }, messages: { unresolved: "Unable to resolve path to module '{{module}}'.", - 'casing-mismatch': + casingMismatch: "Casing of '{{module}}' does not match the underlying filesystem.", }, schema: [ @@ -51,7 +51,6 @@ export = createRule({ caseSensitive: true, }, ], - create(context) { const options = context.options[0] || {} @@ -90,7 +89,7 @@ export = createRule({ ) { context.report({ node: source, - messageId: 'casing-mismatch', + messageId: 'casingMismatch', data: { module: source.value, }, diff --git a/src/rules/no-unused-modules.js b/src/rules/no-unused-modules.js index 34aa51d9b..7ed436596 100644 --- a/src/rules/no-unused-modules.js +++ b/src/rules/no-unused-modules.js @@ -1,16 +1,15 @@ /** - * @fileOverview Ensures that modules contain exports and/or all + * Ensures that modules contain exports and/or all * modules are consumed within other modules. - * @author René Fermann */ import { getFileExtensions } from '../utils/ignore' import { resolve } from '../utils/resolve' -import visit from '../utils/visit' +import { visit } from '../utils/visit' import { dirname, join } from 'path' import { readPkgUp } from '../utils/readPkgUp' -import Exports, { recursivePatternCapture } from '../ExportMap' +import { ExportMap, recursivePatternCapture } from '../ExportMap' import { docsUrl } from '../docs-url' let FileEnumerator @@ -205,7 +204,7 @@ const prepareImportsAndExports = (srcFiles, context) => { srcFiles.forEach(file => { const exports = new Map() const imports = new Map() - const currentExports = Exports.get(file, context) + const currentExports = ExportMap.get(file, context) if (currentExports) { const { dependencies, diff --git a/src/rules/unambiguous.js b/src/rules/unambiguous.js index a7e231b54..9f4a673b0 100644 --- a/src/rules/unambiguous.js +++ b/src/rules/unambiguous.js @@ -3,7 +3,7 @@ * @author Ben Mosher */ -import { isModule } from '../utils/unambiguous' +import { isUnambiguousModule } from '../utils/unambiguous' import { docsUrl } from '../docs-url' module.exports = { @@ -26,7 +26,7 @@ module.exports = { return { Program(ast) { - if (!isModule(ast)) { + if (!isUnambiguousModule(ast)) { context.report({ node: ast, message: 'This module could be parsed as a valid script.', diff --git a/src/types.ts b/src/types.ts index c599f8e1d..ad5f4e6b3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,7 +3,7 @@ import type { KebabCase, LiteralUnion } from 'type-fest' import type { ResolveOptions } from 'enhanced-resolve' import type { PluginName } from './utils' -import { TSESLint } from '@typescript-eslint/utils' +import { TSESLint, TSESTree } from '@typescript-eslint/utils' export interface NodeResolverOptions { extensions?: readonly string[] @@ -18,14 +18,20 @@ export interface WebpackResolverOptions { argv?: Record } +export type FileExtension = `.${string}` + +export type DocStyle = 'jsdoc' | 'tomdoc' + export interface ImportSettings { cache?: { lifetime: number | '∞' | 'Infinity' } coreModules?: string[] - extensions?: ReadonlyArray<`.${string}`> + docstyle?: DocStyle[] + extensions?: readonly FileExtension[] externalModuleFolders?: string[] - parsers?: boolean | Record + ignore?: string[] + parsers?: Record resolve?: NodeResolverOptions resolver?: | LiteralUnion<'node' | 'typescript' | 'webpack', string> @@ -49,3 +55,43 @@ export interface PluginConfig extends TSESLint.Linter.Config { settings?: PluginSettings rules?: Record<`${PluginName}/${string}`, TSESLint.Linter.RuleEntry> } + +export interface RuleContext< + TMessageIds extends string = string, + TOptions extends readonly unknown[] = readonly unknown[], +> extends Omit, 'settings'> { + languageOptions?: { + parser: TSESLint.Linter.ParserModule + parserOptions: TSESLint.ParserOptions + } + settings: PluginSettings +} + +export interface ChildContext { + cacheKey: string + settings: PluginSettings + parserPath: string + parserOptions: TSESLint.ParserOptions + path: string + filename: string +} + +export interface ParseError extends Error { + lineNumber: number + column: number +} + +// eslint-disable-next-line @typescript-eslint/ban-types +export type CustomESTreeNode = Omit< + TSESTree.BaseNode, + 'type' +> & { + type: Type +} & T + +export type ExportDefaultSpecifier = CustomESTreeNode<'ExportDefaultSpecifier'> + +export type ExportNamespaceSpecifier = CustomESTreeNode< + 'ExportNamespaceSpecifier', + { exported: TSESTree.Identifier } +> diff --git a/src/utils/ignore.d.ts b/src/utils/ignore.d.ts deleted file mode 100644 index 6e97a88e6..000000000 --- a/src/utils/ignore.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { Rule } from 'eslint' -import type { ESLintSettings, Extension } from './types' - -declare function ignore(path: string, context: Rule.RuleContext): boolean - -declare function getFileExtensions(settings: ESLintSettings): Set - -declare function hasValidExtension( - path: string, - context: Rule.RuleContext, -): path is `${string}${Extension}` - -export default ignore - -export { getFileExtensions, hasValidExtension } diff --git a/src/utils/ignore.js b/src/utils/ignore.js deleted file mode 100644 index 507830dd5..000000000 --- a/src/utils/ignore.js +++ /dev/null @@ -1,75 +0,0 @@ -'use strict' - -exports.__esModule = true - -const extname = require('path').extname - -const log = require('debug')('eslint-plugin-import-x:utils:ignore') - -// one-shot memoized -/** @type {Set} */ let cachedSet -/** @type {import('./types').ESLintSettings} */ let lastSettings - -/** @type {(context: import('eslint').Rule.RuleContext) => Set} */ -function validExtensions(context) { - if (cachedSet && context.settings === lastSettings) { - return cachedSet - } - - lastSettings = context.settings - cachedSet = makeValidExtensionSet(context.settings) - return cachedSet -} - -/** @type {import('./ignore').getFileExtensions} */ -function makeValidExtensionSet(settings) { - // start with explicit JS-parsed extensions - /** @type {Set} */ - const exts = new Set(settings['import-x/extensions'] || ['.js']) - - // all alternate parser extensions are also valid - if ('import-x/parsers' in settings) { - for (const parser in settings['import-x/parsers']) { - const parserSettings = settings['import-x/parsers'][parser] - if (!Array.isArray(parserSettings)) { - throw new TypeError(`"settings" for ${parser} must be an array`) - } - parserSettings.forEach(ext => exts.add(ext)) - } - } - - return exts -} -exports.getFileExtensions = makeValidExtensionSet - -/** @type {import('./ignore').default} */ -exports.default = function ignore(path, context) { - // check extension whitelist first (cheap) - if (!hasValidExtension(path, context)) { - return true - } - - if (!('import-x/ignore' in context.settings)) { - return false - } - const ignoreStrings = context.settings['import-x/ignore'] - - for (let i = 0; i < ignoreStrings.length; i++) { - const regex = new RegExp(ignoreStrings[i]) - if (regex.test(path)) { - log(`ignoring ${path}, matched pattern /${ignoreStrings[i]}/`) - return true - } - } - - return false -} - -/** @type {import('./ignore').hasValidExtension} */ -function hasValidExtension(path, context) { - // eslint-disable-next-line no-extra-parens - return validExtensions(context).has( - /** @type {import('./types').Extension} */ (extname(path)), - ) -} -exports.hasValidExtension = hasValidExtension diff --git a/src/utils/ignore.ts b/src/utils/ignore.ts new file mode 100644 index 000000000..876e5f319 --- /dev/null +++ b/src/utils/ignore.ts @@ -0,0 +1,71 @@ +import { extname } from 'path' + +import debug from 'debug' + +import type { ChildContext, FileExtension, PluginSettings } from '../types' + +const log = debug('eslint-plugin-import-x:utils:ignore') + +// one-shot memoized +let cachedSet: Set +let lastSettings: PluginSettings + +function validExtensions(context: ChildContext) { + if (cachedSet && context.settings === lastSettings) { + return cachedSet + } + + lastSettings = context.settings + cachedSet = getFileExtensions(context.settings) + return cachedSet +} + +export function getFileExtensions(settings: PluginSettings) { + // start with explicit JS-parsed extensions + const exts = new Set( + settings['import-x/extensions'] || ['.js'], + ) + + // all alternate parser extensions are also valid + if ('import-x/parsers' in settings) { + for (const parser in settings['import-x/parsers']) { + const parserSettings = settings['import-x/parsers'][parser] + if (!Array.isArray(parserSettings)) { + throw new TypeError(`"settings" for ${parser} must be an array`) + } + parserSettings.forEach(ext => exts.add(ext)) + } + } + + return exts +} + +export function ignore(path: string, context: ChildContext) { + // check extension whitelist first (cheap) + if (!hasValidExtension(path, context)) { + return true + } + + const ignoreStrings = context.settings['import-x/ignore'] + + if (!ignoreStrings?.length) { + return false + } + + for (let i = 0; i < ignoreStrings.length; i++) { + const regex = new RegExp(ignoreStrings[i]) + if (regex.test(path)) { + log(`ignoring ${path}, matched pattern /${ignoreStrings[i]}/`) + return true + } + } + + return false +} + +export function hasValidExtension( + path: string, + context: ChildContext, +): path is `${string}${FileExtension}` { + return validExtensions(context).has(extname(path) as FileExtension) +} diff --git a/src/utils/is-core-module.js b/src/utils/is-core-module.js deleted file mode 100644 index a9855218c..000000000 --- a/src/utils/is-core-module.js +++ /dev/null @@ -1,4 +0,0 @@ -import { builtinModules } from 'module' - -export const isCoreModule = pkg => - builtinModules.includes(pkg.startsWith('node:') ? pkg.slice(5) : pkg) diff --git a/src/utils/is-core-module.ts b/src/utils/is-core-module.ts new file mode 100644 index 000000000..8c2640bee --- /dev/null +++ b/src/utils/is-core-module.ts @@ -0,0 +1,4 @@ +import Module from 'module' + +export const isCoreModule = (pkg: string) => + Module.builtinModules.includes(pkg.startsWith('node:') ? pkg.slice(5) : pkg) diff --git a/src/utils/module-require.js b/src/utils/module-require.ts similarity index 61% rename from src/utils/module-require.js rename to src/utils/module-require.ts index 05e2501b4..2b168f643 100644 --- a/src/utils/module-require.js +++ b/src/utils/module-require.ts @@ -1,13 +1,8 @@ -'use strict' - -exports.__esModule = true - -const Module = require('module') -const path = require('path') +import Module from 'module' +import path from 'path' // borrowed from @babel/eslint-parser -/** @type {(filename: string) => Module} */ -function createModule(filename) { +function createModule(filename: string) { const mod = new Module(filename) mod.filename = filename // @ts-expect-error _nodeModulesPaths are undocumented @@ -15,8 +10,7 @@ function createModule(filename) { return mod } -/** @type {import('./module-require').default} */ -exports.default = function moduleRequire(p) { +export function moduleRequire(p: string): T { try { // attempt to get espree relative to eslint const eslintPath = require.resolve('eslint') @@ -24,15 +18,14 @@ exports.default = function moduleRequire(p) { // @ts-expect-error _resolveFilename is undocumented return require(Module._resolveFilename(p, eslintModule)) } catch (err) { - /* ignore */ + // } try { // try relative to entry point - // @ts-expect-error TODO: figure out what this is - return require.main.require(p) + return require.main!.require(p) } catch (err) { - /* ignore */ + // } // finally, try from here diff --git a/src/utils/parse.d.ts b/src/utils/parse.d.ts deleted file mode 100644 index e116aeb38..000000000 --- a/src/utils/parse.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { AST, Rule } from 'eslint' - -declare function parse( - path: string, - content: string, - context: Rule.RuleContext, -): AST.Program | null | undefined - -export default parse diff --git a/src/utils/parse.js b/src/utils/parse.js deleted file mode 100644 index 4a9877af6..000000000 --- a/src/utils/parse.js +++ /dev/null @@ -1,193 +0,0 @@ -'use strict' - -exports.__esModule = true - -/** @typedef {`.${string}`} Extension */ -/** @typedef {NonNullable & { 'import-x/extensions'?: Extension[], 'import-x/parsers'?: { [k: string]: Extension[] }, 'import-x/cache'?: { lifetime: number | '∞' | 'Infinity' } }} ESLintSettings */ - -const moduleRequire = require('./module-require').default -const extname = require('path').extname -const fs = require('fs') - -const log = require('debug')('eslint-plugin-import-x:parse') - -/** @type {(parserPath: NonNullable) => unknown} */ -function getBabelEslintVisitorKeys(parserPath) { - if (parserPath.endsWith('index.js')) { - const hypotheticalLocation = parserPath.replace( - 'index.js', - 'visitor-keys.js', - ) - if (fs.existsSync(hypotheticalLocation)) { - const keys = moduleRequire(hypotheticalLocation) - return keys.default || keys - } - } - return null -} - -/** @type {(parserPath: import('eslint').Rule.RuleContext['parserPath'], parserInstance: { VisitorKeys: unknown }, parsedResult?: { visitorKeys?: unknown }) => unknown} */ -function keysFromParser(parserPath, parserInstance, parsedResult) { - // Exposed by @typescript-eslint/parser and @babel/eslint-parser - if (parsedResult && parsedResult.visitorKeys) { - return parsedResult.visitorKeys - } - if (typeof parserPath === 'string' && /.*espree.*/.test(parserPath)) { - return parserInstance.VisitorKeys - } - if ( - typeof parserPath === 'string' && - /.*@babel\/eslint-parser.*/.test(parserPath) - ) { - return getBabelEslintVisitorKeys(parserPath) - } - return null -} - -// this exists to smooth over the unintentional breaking change in v2.7. -// TODO, semver-major: avoid mutating `ast` and return a plain object instead. -/** @type {(ast: T, visitorKeys: unknown) => T} */ -function makeParseReturn(ast, visitorKeys) { - if (ast) { - // @ts-expect-error see TODO - ast.visitorKeys = visitorKeys - // @ts-expect-error see TODO - ast.ast = ast - } - return ast -} - -/** @type {(text: string) => string} */ -function stripUnicodeBOM(text) { - return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text -} - -/** @type {(text: string) => string} */ -function transformHashbang(text) { - return text.replace(/^#!([^\r\n]+)/u, (_, captured) => `//${captured}`) -} - -/** @type {import('./parse').default} */ -exports.default = function parse(path, content, context) { - if (context == null) { - throw new Error('need context to parse properly') - } - - // ESLint in "flat" mode only sets context.languageOptions.parserOptions - let parserOptions = - (context.languageOptions && context.languageOptions.parserOptions) || - context.parserOptions - const parserOrPath = getParser(path, context) - - if (!parserOrPath) { - throw new Error('parserPath or languageOptions.parser is required!') - } - - // hack: espree blows up with frozen options - parserOptions = { ...parserOptions } - parserOptions.ecmaFeatures = { ...parserOptions.ecmaFeatures } - - // always include comments and tokens (for doc parsing) - parserOptions.comment = true - parserOptions.attachComment = true // keeping this for backward-compat with older parsers - parserOptions.tokens = true - - // attach node locations - parserOptions.loc = true - parserOptions.range = true - - // provide the `filePath` like eslint itself does, in `parserOptions` - // https://github.com/eslint/eslint/blob/3ec436ee/lib/linter.js#L637 - parserOptions.filePath = path - - // @typescript-eslint/parser will parse the entire project with typechecking if you provide - // "project" or "projects" in parserOptions. Removing these options means the parser will - // only parse one file in isolate mode, which is much, much faster. - // https://github.com/import-js/eslint-plugin-import/issues/1408#issuecomment-509298962 - delete parserOptions.EXPERIMENTAL_useProjectService - delete parserOptions.project - delete parserOptions.projects - - // require the parser relative to the main module (i.e., ESLint) - const parser = - typeof parserOrPath === 'string' - ? moduleRequire(parserOrPath) - : parserOrPath - - // replicate bom strip and hashbang transform of ESLint - // https://github.com/eslint/eslint/blob/b93af98b3c417225a027cabc964c38e779adb945/lib/linter/linter.js#L779 - content = transformHashbang(stripUnicodeBOM(String(content))) - - if (typeof parser.parseForESLint === 'function') { - let ast - try { - const parserRaw = parser.parseForESLint(content, parserOptions) - ast = parserRaw.ast - // @ts-expect-error TODO: FIXME - return makeParseReturn( - ast, - keysFromParser(parserOrPath, parser, parserRaw), - ) - } catch (e) { - console.warn() - console.warn(`Error while parsing ${parserOptions.filePath}`) - // @ts-expect-error e is almost certainly an Error here - console.warn(`Line ${e.lineNumber}, column ${e.column}: ${e.message}`) - } - if (!ast || typeof ast !== 'object') { - console.warn( - // Can only be invalid for custom parser per imports/parser - `\`parseForESLint\` from parser \`${typeof parserOrPath === 'string' ? parserOrPath : '`context.languageOptions.parser`'}\` is invalid and will just be ignored`, - ) - } else { - // @ts-expect-error TODO: FIXME - return makeParseReturn( - ast, - keysFromParser(parserOrPath, parser, undefined), - ) - } - } - - const ast = parser.parse(content, parserOptions) - // @ts-expect-error TODO: FIXME - return makeParseReturn(ast, keysFromParser(parserOrPath, parser, undefined)) -} - -/** @type {(path: string, context: import('eslint').Rule.RuleContext) => string | null | (import('eslint').Linter.ParserModule)} */ -function getParser(path, context) { - const parserPath = getParserPath(path, context) - if (parserPath) { - return parserPath - } - if ( - !!context.languageOptions && - !!context.languageOptions.parser && - typeof context.languageOptions.parser !== 'string' && - // @ts-expect-error TODO: figure out a better type - (typeof context.languageOptions.parser.parse === 'function' || - // @ts-expect-error TODO: figure out a better type - typeof context.languageOptions.parser.parseForESLint === 'function') - ) { - return context.languageOptions.parser - } - - return null -} - -/** @type {(path: string, context: import('eslint').Rule.RuleContext & { settings?: ESLintSettings }) => import('eslint').Rule.RuleContext['parserPath']} */ -function getParserPath(path, context) { - const parsers = context.settings['import-x/parsers'] - if (parsers != null) { - // eslint-disable-next-line no-extra-parens - const extension = /** @type {Extension} */ (extname(path)) - for (const parserPath in parsers) { - if (parsers[parserPath].indexOf(extension) > -1) { - // use this alternate parser - log('using alt parser:', parserPath) - return parserPath - } - } - } - // default to use ESLint parser - return context.parserPath -} diff --git a/src/utils/parse.ts b/src/utils/parse.ts new file mode 100644 index 000000000..0ba0dff99 --- /dev/null +++ b/src/utils/parse.ts @@ -0,0 +1,172 @@ +import { extname } from 'path' + +import type { TSESLint, TSESTree } from '@typescript-eslint/utils' +import debug from 'debug' + +import { moduleRequire } from './module-require' +import { ChildContext, FileExtension, RuleContext } from '../types' + +const log = debug('eslint-plugin-import-x:parse') + +function keysFromParser( + parserPath: string | TSESLint.Linter.ParserModule, + parserInstance: TSESLint.Linter.ParserModule, + parsedResult?: TSESLint.Linter.ESLintParseResult, +) { + // Exposed by @typescript-eslint/parser and @babel/eslint-parser + if (parsedResult && parsedResult.visitorKeys) { + return parsedResult.visitorKeys + } + if (typeof parserPath === 'string' && /.*espree.*/.test(parserPath)) { + // @ts-expect-error - no type yet + return parserInstance.VisitorKeys as TSESLint.SourceCode.VisitorKeys + } + return null +} + +function makeParseReturn( + ast: TSESTree.Program, + visitorKeys: TSESLint.SourceCode.VisitorKeys | null, +) { + return { + ast, + visitorKeys, + } +} + +function stripUnicodeBOM(text: string) { + return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text +} + +function transformHashbang(text: string) { + return text.replace(/^#!([^\r\n]+)/u, (_, captured) => `//${captured}`) +} + +export function parse( + path: string, + content: string, + context: ChildContext | RuleContext, +) { + if (context == null) { + throw new Error('need context to parse properly') + } + + // ESLint in "flat" mode only sets context.languageOptions.parserOptions + let parserOptions = + ('languageOptions' in context && context.languageOptions?.parserOptions) || + context.parserOptions + + const parserOrPath = getParser(path, context) + + if (!parserOrPath) { + throw new Error('parserPath or languageOptions.parser is required!') + } + + // hack: espree blows up with frozen options + parserOptions = { ...parserOptions } + parserOptions.ecmaFeatures = { ...parserOptions.ecmaFeatures } + + // always include comments and tokens (for doc parsing) + parserOptions.comment = true + parserOptions.attachComment = true // keeping this for backward-compat with older parsers + parserOptions.tokens = true + + // attach node locations + parserOptions.loc = true + parserOptions.range = true + + // provide the `filePath` like eslint itself does, in `parserOptions` + // https://github.com/eslint/eslint/blob/3ec436ee/lib/linter.js#L637 + parserOptions.filePath = path + + // @typescript-eslint/parser will parse the entire project with typechecking if you provide + // "project" or "projects" in parserOptions. Removing these options means the parser will + // only parse one file in isolate mode, which is much, much faster. + // https://github.com/import-js/eslint-plugin-import/issues/1408#issuecomment-509298962 + delete parserOptions.EXPERIMENTAL_useProjectService + delete parserOptions.project + delete parserOptions.projects + + // require the parser relative to the main module (i.e., ESLint) + const parser = + typeof parserOrPath === 'string' + ? moduleRequire(parserOrPath) + : parserOrPath + + // replicate bom strip and hashbang transform of ESLint + // https://github.com/eslint/eslint/blob/b93af98b3c417225a027cabc964c38e779adb945/lib/linter/linter.js#L779 + content = transformHashbang(stripUnicodeBOM(String(content))) + + if ( + 'parseForESLint' in parser && + typeof parser.parseForESLint === 'function' + ) { + let ast: TSESTree.Program | undefined + try { + const parserRaw = parser.parseForESLint(content, parserOptions) + ast = parserRaw.ast + return makeParseReturn( + ast, + keysFromParser(parserOrPath, parser, parserRaw), + ) + } catch (e) { + console.warn() + console.warn(`Error while parsing ${parserOptions.filePath}`) + // @ts-expect-error e is almost certainly an Error here + console.warn(`Line ${e.lineNumber}, column ${e.column}: ${e.message}`) + } + if (!ast || typeof ast !== 'object') { + console.warn( + // Can only be invalid for custom parser per imports/parser + `\`parseForESLint\` from parser \`${typeof parserOrPath === 'string' ? parserOrPath : '`context.languageOptions.parser`'}\` is invalid and will just be ignored`, + ) + } else { + return makeParseReturn(ast, keysFromParser(parserOrPath, parser)) + } + } + + if ('parse' in parser) { + const ast = parser.parse(content, parserOptions) + return makeParseReturn(ast, keysFromParser(parserOrPath, parser, undefined)) + } + + throw new Error('Parser must expose a `parse` or `parseForESLint` method') +} + +function getParser(path: string, context: ChildContext | RuleContext) { + const parserPath = getParserPath(path, context) + + if (parserPath) { + return parserPath + } + + const parser = 'languageOptions' in context && context.languageOptions?.parser + + if ( + parser && + typeof parser !== 'string' && + (('parse' in parser && typeof parse === 'function') || + ('parseForESLint' in parser && + typeof parser.parseForESLint === 'function')) + ) { + return parser + } + + return null +} + +function getParserPath(path: string, context: ChildContext | RuleContext) { + const parsers = context.settings['import-x/parsers'] + if (parsers != null) { + const extension = extname(path) as FileExtension + for (const parserPath in parsers) { + if (parsers[parserPath].includes(extension)) { + // use this alternate parser + log('using alt parser:', parserPath) + return parserPath + } + } + } + // default to use ESLint parser + return context.parserPath +} diff --git a/src/utils/resolve.ts b/src/utils/resolve.ts index 26df24693..1115e898d 100644 --- a/src/utils/resolve.ts +++ b/src/utils/resolve.ts @@ -1,21 +1,25 @@ 'use strict' import fs from 'fs' -import module from 'module' +import Module from 'module' import path from 'path' -import type { TSESLint } from '@typescript-eslint/utils' - -import { ImportSettings, PluginSettings } from '../types' +import { ImportSettings, PluginSettings, RuleContext } from '../types' import { hashObject } from './hash' import { ModuleCache } from './ModuleCache' import { pkgDir } from './pkgDir' -export type ResultNotFound = { found: false; path?: undefined } +export interface ResultNotFound { + found: false + path?: undefined +} -export type ResultFound = { found: true; path: string | null } +export interface ResultFound { + found: true + path: string | null +} export type ResolvedResult = ResultNotFound | ResultFound @@ -54,9 +58,9 @@ function tryRequire( // Check if the target exists if (sourceFile != null) { try { - resolved = module - .createRequire(path.resolve(sourceFile)) - .resolve(target) + resolved = Module.createRequire(path.resolve(sourceFile)).resolve( + target, + ) } catch (e) { resolved = require.resolve(target) } @@ -114,7 +118,7 @@ export function fileExistsWithCaseSync( } let prevSettings: PluginSettings | null = null -let memoizedHash = '' +let memoizedHash: string function fullResolve( modulePath: string, @@ -124,7 +128,10 @@ function fullResolve( // check if this is a bonus core module const coreSet = new Set(settings['import-x/core-modules']) if (coreSet.has(modulePath)) { - return { found: true, path: null } + return { + found: true, + path: null, + } } const sourceDir = path.dirname(sourceFile) @@ -155,11 +162,18 @@ function fullResolve( try { const resolved = resolver.resolveImport(modulePath, sourceFile, config) if (resolved === undefined) { - return { found: false } + return { + found: false, + } + } + return { + found: true, + path: resolved, + } + } catch { + return { + found: false, } - return { found: true, path: resolved } - } catch (err) { - return { found: false } } } @@ -189,7 +203,6 @@ function fullResolve( return { found: false } } -/** @type {import('./resolve').relative} */ export function relative( modulePath: string, sourceFile: string, @@ -264,9 +277,7 @@ function isResolverValid(resolver: object): resolver is Resolver { ) } -const erroredContexts = new Set< - TSESLint.RuleContext ->() +const erroredContexts = new Set() /** * Given @@ -274,10 +285,7 @@ const erroredContexts = new Set< * @param context - ESLint context * @return - the full module filesystem path; null if package is core; undefined if not found */ -export function resolve( - p: string, - context: TSESLint.RuleContext, -) { +export function resolve(p: string, context: RuleContext) { try { return relative( p, @@ -298,7 +306,10 @@ export function resolve( context.report({ // @ts-expect-error - report without messageId message: `Resolve error: ${errMessage}`, - loc: { line: 1, column: 0 }, + loc: { + line: 1, + column: 0, + }, }) erroredContexts.add(context) } diff --git a/src/utils/types.d.ts b/src/utils/types.d.ts deleted file mode 100644 index e1e917286..000000000 --- a/src/utils/types.d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { Rule } from 'eslint' - -export type Extension = `.${string}` - -export type ESLintSettings = NonNullable & { - 'import-x/extensions'?: Extension[] - 'import-x/parsers'?: { [k: string]: Extension[] } - 'import-x/cache'?: { lifetime: number | '∞' | 'Infinity' } -} diff --git a/src/utils/unambiguous.d.ts b/src/utils/unambiguous.d.ts deleted file mode 100644 index 78dd4ddee..000000000 --- a/src/utils/unambiguous.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { AST } from 'eslint' - -declare function isModule(ast: AST.Program): boolean - -declare function test(content: string): boolean - -export { isModule, test } diff --git a/src/utils/unambiguous.js b/src/utils/unambiguous.ts similarity index 74% rename from src/utils/unambiguous.js rename to src/utils/unambiguous.ts index e74d20e6f..fc90cde82 100644 --- a/src/utils/unambiguous.js +++ b/src/utils/unambiguous.ts @@ -1,6 +1,4 @@ -'use strict' - -exports.__esModule = true +import type { TSESTree } from '@typescript-eslint/utils' const pattern = /(^|;)\s*(export|import)((\s+\w)|(\s*[{*=]))|import\(/m /** @@ -11,9 +9,8 @@ const pattern = /(^|;)\s*(export|import)((\s+\w)|(\s*[{*=]))|import\(/m * * Not perfect, just a fast way to disqualify large non-ES6 modules and * avoid a parse. - * @type {import('./unambiguous').test} */ -exports.test = function isMaybeUnambiguousModule(content) { +export function isMaybeUnambiguousModule(content: string) { return pattern.test(content) } @@ -23,8 +20,7 @@ const unambiguousNodeType = /** * Given an AST, return true if the AST unambiguously represents a module. - * @type {import('./unambiguous').isModule} */ -exports.isModule = function isUnambiguousModule(ast) { +export function isUnambiguousModule(ast: TSESTree.Program) { return ast.body && ast.body.some(node => unambiguousNodeType.test(node.type)) } diff --git a/src/utils/visit.d.ts b/src/utils/visit.d.ts deleted file mode 100644 index 012686a58..000000000 --- a/src/utils/visit.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { Node } from 'estree' - -declare function visit( - node: Node, - keys: { [k in Node['type']]?: (keyof Node)[] }, - // eslint-disable-next-line @typescript-eslint/ban-types - visitorSpec: { [k in Node['type'] | `${Node['type']}:Exit`]?: Function }, -): void - -export default visit diff --git a/src/utils/visit.js b/src/utils/visit.js deleted file mode 100644 index 8358b1492..000000000 --- a/src/utils/visit.js +++ /dev/null @@ -1,30 +0,0 @@ -'use strict' - -exports.__esModule = true - -/** @type {import('./visit').default} */ -exports.default = function visit(node, keys, visitorSpec) { - if (!node || !keys) { - return - } - const type = node.type - const visitor = visitorSpec[type] - if (typeof visitor === 'function') { - visitor(node) - } - const childFields = keys[type] - if (!childFields) { - return - } - childFields.forEach(fieldName => { - // @ts-expect-error TS sucks with concat - ;[].concat(node[fieldName]).forEach(item => { - visit(item, keys, visitorSpec) - }) - }) - - const exit = visitorSpec[`${type}:Exit`] - if (typeof exit === 'function') { - exit(node) - } -} diff --git a/src/utils/visit.ts b/src/utils/visit.ts new file mode 100644 index 000000000..f499a4bb0 --- /dev/null +++ b/src/utils/visit.ts @@ -0,0 +1,41 @@ +import { TSESTree } from '@typescript-eslint/utils' + +export function visit( + node: TSESTree.Node, + keys: { [k in TSESTree.Node['type']]?: (keyof TSESTree.Node)[] } | null, + visitorSpec: { + [k in TSESTree.Node['type'] | `${TSESTree.Node['type']}:Exit`]?: ( + node: TSESTree.Node, + ) => void + }, +) { + if (!node || !keys) { + return + } + + const type = node.type + const visitor = visitorSpec[type] + if (typeof visitor === 'function') { + visitor(node) + } + + const childFields = keys[type] + if (!childFields) { + return + } + + childFields.forEach(fieldName => { + ;[node[fieldName]].flat().forEach(item => { + if (!item || typeof item !== 'object' || !('type' in item)) { + return + } + visit(item, keys, visitorSpec) + }) + }) + + const exit = visitorSpec[`${type}:Exit`] + + if (typeof exit === 'function') { + exit(node) + } +} diff --git a/test/cli.spec.js b/test/cli.spec.js index 4cd084dd1..3b8f246d6 100644 --- a/test/cli.spec.js +++ b/test/cli.spec.js @@ -6,7 +6,7 @@ import path from 'path' import { CLIEngine, ESLint } from 'eslint' import eslintPkg from 'eslint/package.json' import semver from 'semver' -import * as importPlugin from '../src/index' +import importPlugin from '../src/index' describe('CLI regression tests', () => { describe('issue #210', () => { @@ -23,7 +23,7 @@ describe('CLI regression tests', () => { named: 2, }, }, - plugins: { 'eslint-plugin-import': importPlugin }, + plugins: { 'eslint-plugin-import-x': importPlugin }, }) } else { cli = new CLIEngine({ @@ -34,9 +34,10 @@ describe('CLI regression tests', () => { named: 2, }, }) - cli.addPlugin('eslint-plugin-import', importPlugin) + cli.addPlugin('eslint-plugin-import-x', importPlugin) } }) + it("doesn't throw an error on gratuitous, erroneous self-reference", () => { if (eslint) { return eslint diff --git a/test/core/getExports.spec.js b/test/core/getExports.spec.js index c4793d877..03ceb286a 100644 --- a/test/core/getExports.spec.js +++ b/test/core/getExports.spec.js @@ -1,14 +1,13 @@ import semver from 'semver' import eslintPkg from 'eslint/package.json' -import typescriptPkg from 'typescript/package.json' import getTsconfig from 'get-tsconfig' -import ExportMap from '../../src/ExportMap' +import { ExportMap } from '../../src/ExportMap' -import * as fs from 'fs' +import fs from 'fs' import { getFilename } from '../utils' -import { test as testUnambiguous } from '../../src/utils/unambiguous' +import { isMaybeUnambiguousModule } from '../../src/utils/unambiguous' describe('ExportMap', () => { const fakeContext = Object.assign( @@ -404,16 +403,6 @@ describe('ExportMap', () => { ]) } - if ( - semver.satisfies(eslintPkg.version, '<6') && - semver.satisfies(typescriptPkg.version, '<4') - ) { - configs.push([ - 'array form', - { 'typescript-eslint-parser': ['.ts', '.tsx'] }, - ]) - } - configs.forEach(([description, parserConfig]) => { describe(description, () => { const context = { @@ -514,7 +503,7 @@ describe('ExportMap', () => { for (const [testFile, expectedRegexResult] of testFiles) { it(`works for ${testFile} (${expectedRegexResult})`, () => { const content = fs.readFileSync(`./test/fixtures/${testFile}`, 'utf8') - expect(testUnambiguous(content)).toBe(expectedRegexResult) + expect(isMaybeUnambiguousModule(content)).toBe(expectedRegexResult) }) } }) diff --git a/test/core/ignore.spec.js b/test/core/ignore.spec.js index f48fb9d1d..9230fd7b2 100644 --- a/test/core/ignore.spec.js +++ b/test/core/ignore.spec.js @@ -1,68 +1,69 @@ -import isIgnored, { +import { + ignore as isIgnored, getFileExtensions, hasValidExtension, } from '../../src/utils/ignore' -import * as utils from '../utils' +import { testContext } from '../utils' describe('ignore', () => { describe('isIgnored', () => { it('ignores paths with extensions other than .js', () => { - const testContext = utils.testContext({}) + const context = testContext({}) - expect(isIgnored('../fixtures/foo.js', testContext)).toBe(false) + expect(isIgnored('../fixtures/foo.js', context)).toBe(false) - expect(isIgnored('../fixtures/bar.jsx', testContext)).toBe(true) + expect(isIgnored('../fixtures/bar.jsx', context)).toBe(true) - expect(isIgnored('../fixtures/typescript.ts', testContext)).toBe(true) + expect(isIgnored('../fixtures/typescript.ts', context)).toBe(true) - expect( - isIgnored('../fixtures/ignore.invalid.extension', testContext), - ).toBe(true) + expect(isIgnored('../fixtures/ignore.invalid.extension', context)).toBe( + true, + ) }) it('ignores paths with invalid extensions when configured with import-x/extensions', () => { - const testContext = utils.testContext({ + const context = testContext({ 'import-x/extensions': ['.js', '.jsx', '.ts'], }) - expect(isIgnored('../fixtures/foo.js', testContext)).toBe(false) + expect(isIgnored('../fixtures/foo.js', context)).toBe(false) - expect(isIgnored('../fixtures/bar.jsx', testContext)).toBe(false) + expect(isIgnored('../fixtures/bar.jsx', context)).toBe(false) - expect(isIgnored('../fixtures/typescript.ts', testContext)).toBe(false) + expect(isIgnored('../fixtures/typescript.ts', context)).toBe(false) - expect( - isIgnored('../fixtures/ignore.invalid.extension', testContext), - ).toBe(true) + expect(isIgnored('../fixtures/ignore.invalid.extension', context)).toBe( + true, + ) }) }) describe('hasValidExtension', () => { it('assumes only .js as valid by default', () => { - const testContext = utils.testContext({}) + const context = testContext({}) - expect(hasValidExtension('../fixtures/foo.js', testContext)).toBe(true) + expect(hasValidExtension('../fixtures/foo.js', context)).toBe(true) - expect(hasValidExtension('../fixtures/foo.jsx', testContext)).toBe(false) + expect(hasValidExtension('../fixtures/foo.jsx', context)).toBe(false) - expect(hasValidExtension('../fixtures/foo.css', testContext)).toBe(false) + expect(hasValidExtension('../fixtures/foo.css', context)).toBe(false) expect( - hasValidExtension('../fixtures/foo.invalid.extension', testContext), + hasValidExtension('../fixtures/foo.invalid.extension', context), ).toBe(false) }) it('can be configured with import-x/extensions', () => { - const testContext = utils.testContext({ + const context = testContext({ 'import-x/extensions': ['.foo', '.bar'], }) - expect(hasValidExtension('../fixtures/foo.foo', testContext)).toBe(true) + expect(hasValidExtension('../fixtures/foo.foo', context)).toBe(true) - expect(hasValidExtension('../fixtures/foo.bar', testContext)).toBe(true) + expect(hasValidExtension('../fixtures/foo.bar', context)).toBe(true) - expect(hasValidExtension('../fixtures/foo.js', testContext)).toBe(false) + expect(hasValidExtension('../fixtures/foo.js', context)).toBe(false) }) }) diff --git a/test/core/parse.spec.js b/test/core/parse.spec.js index 54a3404b3..d38a9a8ae 100644 --- a/test/core/parse.spec.js +++ b/test/core/parse.spec.js @@ -1,5 +1,5 @@ import * as fs from 'fs' -import parse from '../../src/utils/parse' +import { parse } from '../../src/utils/parse' import { getFilename } from '../utils' diff --git a/yarn.lock b/yarn.lock index 6715333a6..b5516ca0e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1835,6 +1835,18 @@ dependencies: "@babel/types" "^7.20.7" +"@types/debug@^4.1.12": + version "4.1.12" + resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.12.tgz#a155f21690871953410df4b6b6f53187f0500917" + integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== + dependencies: + "@types/ms" "*" + +"@types/doctrine@^0.0.9": + version "0.0.9" + resolved "https://registry.yarnpkg.com/@types/doctrine/-/doctrine-0.0.9.tgz#d86a5f452a15e3e3113b99e39616a9baa0f9863f" + integrity sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA== + "@types/eslint@^8.56.5": version "8.56.5" resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.56.5.tgz#94b88cab77588fcecdd0771a6d576fa1c0af9d02" @@ -1892,6 +1904,11 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.5.tgz#ec10755e871497bcd83efe927e43ec46e8c0747e" integrity sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag== +"@types/ms@*": + version "0.7.34" + resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" + integrity sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g== + "@types/node@*", "@types/node@^12.7.1": version "12.20.55" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240"