diff --git a/CHANGELOG.md b/CHANGELOG.md index c05ea32c0..a07647c82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange - [Tests] appveyor -> GHA (run tests on Windows in both pwsh and WSL + Ubuntu) ([#2987], thanks [@joeyguerra]) - [actions] migrate OSX tests to GHA ([ljharb#37], thanks [@aks-]) - [Refactor] `exportMapBuilder`: avoid hoisting ([#2989], thanks [@soryy708]) +- [Refactor] `ExportMap`: extract "builder" logic to separate files ([#2991], thanks [@soryy708]) ## [2.29.1] - 2023-12-14 @@ -1114,6 +1115,7 @@ for info on changes for earlier releases. [`memo-parser`]: ./memo-parser/README.md +[#2991]: https://github.com/import-js/eslint-plugin-import/pull/2991 [#2989]: https://github.com/import-js/eslint-plugin-import/pull/2989 [#2987]: https://github.com/import-js/eslint-plugin-import/pull/2987 [#2985]: https://github.com/import-js/eslint-plugin-import/pull/2985 diff --git a/src/exportMap/builder.js b/src/exportMap/builder.js new file mode 100644 index 000000000..5348dba37 --- /dev/null +++ b/src/exportMap/builder.js @@ -0,0 +1,206 @@ +import fs from 'fs'; + +import doctrine from 'doctrine'; + +import debug from 'debug'; + +import parse from 'eslint-module-utils/parse'; +import visit from 'eslint-module-utils/visit'; +import resolve from 'eslint-module-utils/resolve'; +import isIgnored, { hasValidExtension } from 'eslint-module-utils/ignore'; + +import { hashObject } from 'eslint-module-utils/hash'; +import * as unambiguous from 'eslint-module-utils/unambiguous'; + +import ExportMap from '.'; +import childContext from './childContext'; +import { isEsModuleInterop } from './typescript'; +import { RemotePath } from './remotePath'; +import ImportExportVisitorBuilder from './visitor'; + +const log = debug('eslint-plugin-import:ExportMap'); + +const exportCache = new Map(); + +/** + * 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) { + // eslint-disable-next-line no-use-before-define + return () => ExportMapBuilder.for(childContext(p, context)); +} + +export default class ExportMapBuilder { + static get(source, context) { + const path = resolve(source, context); + if (path == null) { return null; } + + return ExportMapBuilder.for(childContext(path, context)); + } + + static for(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 = ExportMapBuilder.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 parse(path, content, context) { + const exportMap = new ExportMap(path); + const isEsModuleInteropTrue = isEsModuleInterop(context); + + let ast; + let visitorKeys; + try { + const result = parse(path, content, context); + ast = result.ast; + visitorKeys = result.visitorKeys; + } catch (err) { + exportMap.errors.push(err); + return exportMap; // can't continue + } + + exportMap.visitorKeys = visitorKeys; + + let hasDynamicImports = false; + + const remotePathResolver = new RemotePath(path, context); + + function processDynamicImport(source) { + hasDynamicImports = true; + if (source.type !== 'Literal') { + return null; + } + const p = remotePathResolver.resolve(source.value); + if (p == null) { + return null; + } + const importedSpecifiers = new Set(); + importedSpecifiers.add('ImportNamespaceSpecifier'); + const getter = thunkFor(p, context); + exportMap.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; } + + // 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')) { + exportMap.doc = doc; + return true; + } + } catch (err) { /* ignore */ } + return false; + }); + } + + const visitorBuilder = new ImportExportVisitorBuilder( + path, + context, + exportMap, + ExportMapBuilder, + content, + ast, + isEsModuleInteropTrue, + thunkFor, + ); + ast.body.forEach(function (astNode) { + const visitor = visitorBuilder.build(astNode); + + if (visitor[astNode.type]) { + visitor[astNode.type].call(visitorBuilder); + } + }); + + if ( + isEsModuleInteropTrue // esModuleInterop is on in tsconfig + && exportMap.namespace.size > 0 // anything is exported + && !exportMap.namespace.has('default') // and default isn't added already + ) { + exportMap.namespace.set('default', {}); // add default export + } + + if (unambiguouslyESM) { + exportMap.parseGoal = 'Module'; + } + return exportMap; + } +} diff --git a/src/exportMap/captureDependency.js b/src/exportMap/captureDependency.js new file mode 100644 index 000000000..9ad37d0e2 --- /dev/null +++ b/src/exportMap/captureDependency.js @@ -0,0 +1,60 @@ +export function captureDependency( + { source }, + isOnlyImportingTypes, + remotePathResolver, + exportMap, + context, + thunkFor, + importedSpecifiers = new Set(), +) { + if (source == null) { return null; } + + const p = remotePathResolver.resolve(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 = exportMap.imports.get(p); + if (existing != null) { + existing.declarations.add(declarationMetadata); + return existing.getter; + } + + const getter = thunkFor(p, context); + exportMap.imports.set(p, { getter, declarations: new Set([declarationMetadata]) }); + return getter; +} + +const supportedImportTypes = new Set(['ImportDefaultSpecifier', 'ImportNamespaceSpecifier']); + +export function captureDependencyWithSpecifiers( + n, + remotePathResolver, + exportMap, + context, + thunkFor, +) { + // 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, remotePathResolver, exportMap, context, thunkFor, importedSpecifiers); +} diff --git a/src/exportMap/childContext.js b/src/exportMap/childContext.js new file mode 100644 index 000000000..5f82b8e57 --- /dev/null +++ b/src/exportMap/childContext.js @@ -0,0 +1,32 @@ +import { hashObject } from 'eslint-module-utils/hash'; + +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 + */ +export default 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, + }; +} diff --git a/src/exportMap/doc.js b/src/exportMap/doc.js new file mode 100644 index 000000000..c721ae25f --- /dev/null +++ b/src/exportMap/doc.js @@ -0,0 +1,90 @@ +import doctrine from 'doctrine'; + +/** + * parse docs from the first node that has leading comments + */ +export 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; +} + +/** + * 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], + }], + }; + } +} + +export const availableDocStyleParsers = { + jsdoc: captureJsDoc, + tomdoc: captureTomDoc, +}; diff --git a/src/exportMap.js b/src/exportMap/index.js similarity index 100% rename from src/exportMap.js rename to src/exportMap/index.js diff --git a/src/exportMap/namespace.js b/src/exportMap/namespace.js new file mode 100644 index 000000000..370f47579 --- /dev/null +++ b/src/exportMap/namespace.js @@ -0,0 +1,39 @@ +import childContext from './childContext'; +import { RemotePath } from './remotePath'; + +export default class Namespace { + constructor( + path, + context, + ExportMapBuilder, + ) { + this.remotePathResolver = new RemotePath(path, context); + this.context = context; + this.ExportMapBuilder = ExportMapBuilder; + this.namespaces = new Map(); + } + + resolveImport(value) { + const rp = this.remotePathResolver.resolve(value); + if (rp == null) { return null; } + return this.ExportMapBuilder.for(childContext(rp, this.context)); + } + + getNamespace(identifier) { + if (!this.namespaces.has(identifier.name)) { return; } + return () => this.resolveImport(this.namespaces.get(identifier.name)); + } + + add(object, identifier) { + const nsfn = this.getNamespace(identifier); + if (nsfn) { + Object.defineProperty(object, 'namespace', { get: nsfn }); + } + + return object; + } + + rawSet(name, value) { + this.namespaces.set(name, value); + } +} diff --git a/src/exportMap/patternCapture.js b/src/exportMap/patternCapture.js new file mode 100644 index 000000000..5bc980641 --- /dev/null +++ b/src/exportMap/patternCapture.js @@ -0,0 +1,40 @@ +/** + * Traverse a pattern/identifier node, calling 'callback' + * for each leaf identifier. + * @param {node} pattern + * @param {Function} callback + * @return {void} + */ +export default 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: + } +} diff --git a/src/exportMap/remotePath.js b/src/exportMap/remotePath.js new file mode 100644 index 000000000..0dc5fc095 --- /dev/null +++ b/src/exportMap/remotePath.js @@ -0,0 +1,12 @@ +import resolve from 'eslint-module-utils/resolve'; + +export class RemotePath { + constructor(path, context) { + this.path = path; + this.context = context; + } + + resolve(value) { + return resolve.relative(value, this.path, this.context.settings); + } +} diff --git a/src/exportMap/specifier.js b/src/exportMap/specifier.js new file mode 100644 index 000000000..dfaaf618e --- /dev/null +++ b/src/exportMap/specifier.js @@ -0,0 +1,32 @@ +export default function processSpecifier(specifier, astNode, exportMap, namespace) { + const nsource = astNode.source && astNode.source.value; + const exportMeta = {}; + let local; + + switch (specifier.type) { + case 'ExportDefaultSpecifier': + if (!nsource) { return; } + local = 'default'; + break; + case 'ExportNamespaceSpecifier': + exportMap.namespace.set(specifier.exported.name, Object.defineProperty(exportMeta, 'namespace', { + get() { return namespace.resolveImport(nsource); }, + })); + return; + case 'ExportAllDeclaration': + exportMap.namespace.set(specifier.exported.name || specifier.exported.value, namespace.add(exportMeta, specifier.source.value)); + return; + case 'ExportSpecifier': + if (!astNode.source) { + exportMap.namespace.set(specifier.exported.name || specifier.exported.value, namespace.add(exportMeta, specifier.local)); + return; + } + // else falls through + default: + local = specifier.local.name; + break; + } + + // todo: JSDoc + exportMap.reexports.set(specifier.exported.name, { local, getImport: () => namespace.resolveImport(nsource) }); +} diff --git a/src/exportMap/typescript.js b/src/exportMap/typescript.js new file mode 100644 index 000000000..7db4356da --- /dev/null +++ b/src/exportMap/typescript.js @@ -0,0 +1,43 @@ +import { dirname } from 'path'; +import { tsConfigLoader } from 'tsconfig-paths/lib/tsconfig-loader'; +import { hashObject } from 'eslint-module-utils/hash'; + +let ts; +const tsconfigCache = new Map(); + +function readTsConfig(context) { + const tsconfigInfo = tsConfigLoader({ + cwd: context.parserOptions && context.parserOptions.tsconfigRootDir || process.cwd(), + getEnv: (key) => process.env[key], + }); + try { + if (tsconfigInfo.tsConfigPath !== undefined) { + // Projects not using TypeScript won't have `typescript` installed. + if (!ts) { ts = require('typescript'); } // eslint-disable-line import/no-extraneous-dependencies + + const configFile = ts.readConfigFile(tsconfigInfo.tsConfigPath, ts.sys.readFile); + return ts.parseJsonConfigFileContent( + configFile.config, + ts.sys, + dirname(tsconfigInfo.tsConfigPath), + ); + } + } catch (e) { + // Catch any errors + } + + return null; +} + +export function isEsModuleInterop(context) { + const cacheKey = hashObject({ + tsconfigRootDir: context.parserOptions && context.parserOptions.tsconfigRootDir, + }).digest('hex'); + let tsConfig = tsconfigCache.get(cacheKey); + if (typeof tsConfig === 'undefined') { + tsConfig = readTsConfig(context); + tsconfigCache.set(cacheKey, tsConfig); + } + + return tsConfig && tsConfig.options ? tsConfig.options.esModuleInterop : false; +} diff --git a/src/exportMap/visitor.js b/src/exportMap/visitor.js new file mode 100644 index 000000000..21c1a7c64 --- /dev/null +++ b/src/exportMap/visitor.js @@ -0,0 +1,171 @@ +import includes from 'array-includes'; +import { SourceCode } from 'eslint'; +import { availableDocStyleParsers, captureDoc } from './doc'; +import Namespace from './namespace'; +import processSpecifier from './specifier'; +import { captureDependency, captureDependencyWithSpecifiers } from './captureDependency'; +import recursivePatternCapture from './patternCapture'; +import { RemotePath } from './remotePath'; + +/** + * 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 }); + } +} + +export default class ImportExportVisitorBuilder { + constructor( + path, + context, + exportMap, + ExportMapBuilder, + content, + ast, + isEsModuleInteropTrue, + thunkFor, + ) { + this.context = context; + this.namespace = new Namespace(path, context, ExportMapBuilder); + this.remotePathResolver = new RemotePath(path, context); + this.source = makeSourceCode(content, ast); + this.exportMap = exportMap; + this.ast = ast; + this.isEsModuleInteropTrue = isEsModuleInteropTrue; + this.thunkFor = thunkFor; + const docstyle = this.context.settings && this.context.settings['import/docstyle'] || ['jsdoc']; + this.docStyleParsers = {}; + docstyle.forEach((style) => { + this.docStyleParsers[style] = availableDocStyleParsers[style]; + }); + } + + build(astNode) { + return { + ExportDefaultDeclaration() { + const exportMeta = captureDoc(this.source, this.docStyleParsers, astNode); + if (astNode.declaration.type === 'Identifier') { + this.namespace.add(exportMeta, astNode.declaration); + } + this.exportMap.namespace.set('default', exportMeta); + }, + ExportAllDeclaration() { + const getter = captureDependency(astNode, astNode.exportKind === 'type', this.remotePathResolver, this.exportMap, this.context, this.thunkFor); + if (getter) { this.exportMap.dependencies.add(getter); } + if (astNode.exported) { + processSpecifier(astNode, astNode.exported, this.exportMap, this.namespace); + } + }, + /** capture namespaces in case of later export */ + ImportDeclaration() { + captureDependencyWithSpecifiers(astNode, this.remotePathResolver, this.exportMap, this.context, this.thunkFor); + const ns = astNode.specifiers.find((s) => s.type === 'ImportNamespaceSpecifier'); + if (ns) { + this.namespace.rawSet(ns.local.name, astNode.source.value); + } + }, + ExportNamedDeclaration() { + captureDependencyWithSpecifiers(astNode, this.remotePathResolver, this.exportMap, this.context, this.thunkFor); + // capture declaration + if (astNode.declaration != null) { + switch (astNode.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': + this.exportMap.namespace.set(astNode.declaration.id.name, captureDoc(this.source, this.docStyleParsers, astNode)); + break; + case 'VariableDeclaration': + astNode.declaration.declarations.forEach((d) => { + recursivePatternCapture( + d.id, + (id) => this.exportMap.namespace.set(id.name, captureDoc(this.source, this.docStyleParsers, d, astNode)), + ); + }); + break; + default: + } + } + astNode.specifiers.forEach((s) => processSpecifier(s, astNode, this.exportMap, this.namespace)); + }, + TSExportAssignment: () => this.typeScriptExport(astNode), + ...this.isEsModuleInteropTrue && { TSNamespaceExportDeclaration: () => this.typeScriptExport(astNode) }, + }; + } + + // This doesn't declare anything, but changes what's being exported. + typeScriptExport(astNode) { + const exportedName = astNode.type === 'TSNamespaceExportDeclaration' + ? (astNode.id || astNode.name).name + : astNode.expression && astNode.expression.name || astNode.expression.id && astNode.expression.id.name || null; + const declTypes = [ + 'VariableDeclaration', + 'ClassDeclaration', + 'TSDeclareFunction', + 'TSEnumDeclaration', + 'TSTypeAliasDeclaration', + 'TSInterfaceDeclaration', + 'TSAbstractClassDeclaration', + 'TSModuleDeclaration', + ]; + const exportedDecls = this.ast.body.filter(({ type, id, declarations }) => includes(declTypes, 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 + this.exportMap.namespace.set('default', captureDoc(this.source, this.docStyleParsers, astNode)); + return; + } + if ( + this.isEsModuleInteropTrue // esModuleInterop is on in tsconfig + && !this.exportMap.namespace.has('default') // and default isn't added already + ) { + this.exportMap.namespace.set('default', {}); // add default export + } + exportedDecls.forEach((decl) => { + if (decl.type === 'TSModuleDeclaration') { + if (decl.body && decl.body.type === 'TSModuleDeclaration') { + this.exportMap.namespace.set(decl.body.id.name, captureDoc(this.source, this.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) => this.exportMap.namespace.set( + id.name, + captureDoc(this.source, this.docStyleParsers, decl, namespaceDecl, moduleBlockNode), + )), + ); + } else { + this.exportMap.namespace.set( + namespaceDecl.id.name, + captureDoc(this.source, this.docStyleParsers, moduleBlockNode)); + } + }); + } + } else { + // Export as default + this.exportMap.namespace.set('default', captureDoc(this.source, this.docStyleParsers, decl)); + } + }); + } +} diff --git a/src/exportMapBuilder.js b/src/exportMapBuilder.js deleted file mode 100644 index 5aeb306d0..000000000 --- a/src/exportMapBuilder.js +++ /dev/null @@ -1,651 +0,0 @@ -import fs from 'fs'; -import { dirname } from 'path'; - -import doctrine from 'doctrine'; - -import debug from 'debug'; - -import { SourceCode } from 'eslint'; - -import parse from 'eslint-module-utils/parse'; -import visit from 'eslint-module-utils/visit'; -import resolve from 'eslint-module-utils/resolve'; -import isIgnored, { hasValidExtension } from 'eslint-module-utils/ignore'; - -import { hashObject } from 'eslint-module-utils/hash'; -import * as unambiguous from 'eslint-module-utils/unambiguous'; - -import { tsConfigLoader } from 'tsconfig-paths/lib/tsconfig-loader'; - -import includes from 'array-includes'; -import ExportMap from './exportMap'; - -let ts; - -const log = debug('eslint-plugin-import:ExportMap'); - -const exportCache = new Map(); -const tsconfigCache = new Map(); - -/** - * 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; -} - -/** - * 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 availableDocStyleParsers = { - jsdoc: captureJsDoc, - tomdoc: captureTomDoc, -}; - -const supportedImportTypes = new Set(['ImportDefaultSpecifier', 'ImportNamespaceSpecifier']); - -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, - }; -} - -/** - * 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 }); - } -} - -/** - * 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: - } -} - -/** - * 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) { - // eslint-disable-next-line no-use-before-define - return () => ExportMapBuilder.for(childContext(p, context)); -} - -export default class ExportMapBuilder { - static get(source, context) { - const path = resolve(source, context); - if (path == null) { return null; } - - return ExportMapBuilder.for(childContext(path, context)); - } - - static for(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 = ExportMapBuilder.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 parse(path, content, context) { - function readTsConfig(context) { - const tsconfigInfo = tsConfigLoader({ - cwd: context.parserOptions && context.parserOptions.tsconfigRootDir || process.cwd(), - getEnv: (key) => process.env[key], - }); - try { - if (tsconfigInfo.tsConfigPath !== undefined) { - // Projects not using TypeScript won't have `typescript` installed. - if (!ts) { ts = require('typescript'); } // eslint-disable-line import/no-extraneous-dependencies - - const configFile = ts.readConfigFile(tsconfigInfo.tsConfigPath, ts.sys.readFile); - return ts.parseJsonConfigFileContent( - configFile.config, - ts.sys, - dirname(tsconfigInfo.tsConfigPath), - ); - } - } catch (e) { - // Catch any errors - } - - return null; - } - - function isEsModuleInterop() { - const cacheKey = hashObject({ - tsconfigRootDir: context.parserOptions && context.parserOptions.tsconfigRootDir, - }).digest('hex'); - let tsConfig = tsconfigCache.get(cacheKey); - if (typeof tsConfig === 'undefined') { - tsConfig = readTsConfig(context); - tsconfigCache.set(cacheKey, tsConfig); - } - - return tsConfig && tsConfig.options ? tsConfig.options.esModuleInterop : false; - } - - 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 remotePath(value) { - return resolve.relative(value, path, context.settings); - } - - 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/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 resolveImport(value) { - const rp = remotePath(value); - if (rp == null) { return null; } - return ExportMapBuilder.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 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; - } - - 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); - } - - const source = makeSourceCode(content, ast); - - 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 (includes(exports, 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 }) => includes(declTypes, 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; - } -} diff --git a/src/rules/default.js b/src/rules/default.js index cbaa49f1f..0de787c33 100644 --- a/src/rules/default.js +++ b/src/rules/default.js @@ -1,4 +1,4 @@ -import ExportMapBuilder from '../exportMapBuilder'; +import ExportMapBuilder from '../exportMap/builder'; import docsUrl from '../docsUrl'; module.exports = { diff --git a/src/rules/export.js b/src/rules/export.js index b1dc5ca9e..197a0eb51 100644 --- a/src/rules/export.js +++ b/src/rules/export.js @@ -1,4 +1,5 @@ -import ExportMapBuilder, { recursivePatternCapture } from '../exportMapBuilder'; +import ExportMapBuilder from '../exportMap/builder'; +import recursivePatternCapture from '../exportMap/patternCapture'; import docsUrl from '../docsUrl'; import includes from 'array-includes'; import flatMap from 'array.prototype.flatmap'; diff --git a/src/rules/named.js b/src/rules/named.js index 043d72eab..ed7e5e018 100644 --- a/src/rules/named.js +++ b/src/rules/named.js @@ -1,5 +1,5 @@ import * as path from 'path'; -import ExportMapBuilder from '../exportMapBuilder'; +import ExportMapBuilder from '../exportMap/builder'; import docsUrl from '../docsUrl'; module.exports = { diff --git a/src/rules/namespace.js b/src/rules/namespace.js index e1ca2870b..60a4220de 100644 --- a/src/rules/namespace.js +++ b/src/rules/namespace.js @@ -1,5 +1,5 @@ import declaredScope from 'eslint-module-utils/declaredScope'; -import ExportMapBuilder from '../exportMapBuilder'; +import ExportMapBuilder from '../exportMap/builder'; import ExportMap from '../exportMap'; import importDeclaration from '../importDeclaration'; import docsUrl from '../docsUrl'; diff --git a/src/rules/no-cycle.js b/src/rules/no-cycle.js index b7b907b06..e65ff11a4 100644 --- a/src/rules/no-cycle.js +++ b/src/rules/no-cycle.js @@ -4,7 +4,7 @@ */ import resolve from 'eslint-module-utils/resolve'; -import ExportMapBuilder from '../exportMapBuilder'; +import ExportMapBuilder from '../exportMap/builder'; import { isExternalModule } from '../core/importType'; import moduleVisitor, { makeOptionsSchema } from 'eslint-module-utils/moduleVisitor'; import docsUrl from '../docsUrl'; diff --git a/src/rules/no-deprecated.js b/src/rules/no-deprecated.js index 50072f3f8..b4299a51d 100644 --- a/src/rules/no-deprecated.js +++ b/src/rules/no-deprecated.js @@ -1,5 +1,5 @@ import declaredScope from 'eslint-module-utils/declaredScope'; -import ExportMapBuilder from '../exportMapBuilder'; +import ExportMapBuilder from '../exportMap/builder'; import ExportMap from '../exportMap'; import docsUrl from '../docsUrl'; diff --git a/src/rules/no-named-as-default-member.js b/src/rules/no-named-as-default-member.js index d594c5843..54bec64a2 100644 --- a/src/rules/no-named-as-default-member.js +++ b/src/rules/no-named-as-default-member.js @@ -4,7 +4,7 @@ * @copyright 2016 Desmond Brand. All rights reserved. * See LICENSE in root directory for full license. */ -import ExportMapBuilder from '../exportMapBuilder'; +import ExportMapBuilder from '../exportMap/builder'; import importDeclaration from '../importDeclaration'; import docsUrl from '../docsUrl'; diff --git a/src/rules/no-named-as-default.js b/src/rules/no-named-as-default.js index 3e73ff2f4..5b24f8e88 100644 --- a/src/rules/no-named-as-default.js +++ b/src/rules/no-named-as-default.js @@ -1,4 +1,4 @@ -import ExportMapBuilder from '../exportMapBuilder'; +import ExportMapBuilder from '../exportMap/builder'; import importDeclaration from '../importDeclaration'; import docsUrl from '../docsUrl'; diff --git a/src/rules/no-unused-modules.js b/src/rules/no-unused-modules.js index 812efffbc..0ad330b48 100644 --- a/src/rules/no-unused-modules.js +++ b/src/rules/no-unused-modules.js @@ -13,7 +13,8 @@ import values from 'object.values'; import includes from 'array-includes'; import flatMap from 'array.prototype.flatmap'; -import ExportMapBuilder, { recursivePatternCapture } from '../exportMapBuilder'; +import ExportMapBuilder from '../exportMap/builder'; +import recursivePatternCapture from '../exportMap/patternCapture'; import docsUrl from '../docsUrl'; let FileEnumerator; diff --git a/tests/src/core/getExports.js b/tests/src/core/getExports.js index 611a13055..76003410d 100644 --- a/tests/src/core/getExports.js +++ b/tests/src/core/getExports.js @@ -4,7 +4,7 @@ import sinon from 'sinon'; import eslintPkg from 'eslint/package.json'; import typescriptPkg from 'typescript/package.json'; import * as tsConfigLoader from 'tsconfig-paths/lib/tsconfig-loader'; -import ExportMapBuilder from '../../../src/exportMapBuilder'; +import ExportMapBuilder from '../../../src/exportMap/builder'; import * as fs from 'fs';