const fs = require('fs'); const { default: traverse } = require('@babel/traverse'); const { isRelativeSourcePath, toRelativeSourcePath, } = require('../../utils/relative-source-path.js'); const { resolveImportPath } = require('../../utils/resolve-import-path.js'); const { AstService } = require('../../services/AstService.js'); const { LogService } = require('../../services/LogService.js'); const { memoizeAsync } = require('../../utils/memoize.js'); /** @typedef {import('./types').RootFile} RootFile */ /** * Other than with import, no binding is created for MyClass by Babel(?) * This means 'path.scope.getBinding('MyClass')' returns undefined * and we have to find a different way to retrieve this value. * @param {object} astPath Babel ast traversal path * @param {string} identifierName the name that should be tracked (and that exists inside scope of astPath) */ function getBindingAndSourceReexports(astPath, identifierName) { // Get to root node of file and look for exports like `export { identifierName } from 'src';` let source; let bindingType; let bindingPath; let curPath = astPath; while (curPath.parentPath) { curPath = curPath.parentPath; } const rootPath = curPath; rootPath.traverse({ ExportSpecifier(path) { // eslint-disable-next-line arrow-body-style const found = path.node.exported.name === identifierName || path.node.local.name === identifierName; if (found) { bindingPath = path; bindingType = 'ExportSpecifier'; source = path.parentPath.node.source.value; path.stop(); } }, }); return [source, bindingType, bindingPath]; } /** * @desc returns source and importedIdentifierName: We might be an import that was locally renamed. * Since we are traversing, we are interested in the imported name. Or in case of a re-export, * the local name. * @param {object} astPath Babel ast traversal path * @param {string} identifierName the name that should be tracked (and that exists inside scope of astPath) */ function getImportSourceFromAst(astPath, identifierName) { let source; let importedIdentifierName; const binding = astPath.scope.getBinding(identifierName); let bindingType = binding && binding.path.type; let bindingPath = binding && binding.path; const matchingTypes = ['ImportSpecifier', 'ImportDefaultSpecifier', 'ExportSpecifier']; if (binding && matchingTypes.includes(bindingType)) { source = binding.path.parentPath.node.source.value; } else { // no binding [source, bindingType, bindingPath] = getBindingAndSourceReexports(astPath, identifierName); } const shouldLookForDefaultExport = bindingType === 'ImportDefaultSpecifier'; if (shouldLookForDefaultExport) { importedIdentifierName = '[default]'; } else if (source) { const { node } = bindingPath; importedIdentifierName = (node.imported && node.imported.name) || node.local.name; } return { source, importedIdentifierName }; } let trackDownIdentifier; /** * @example *```js * // 1. Starting point * // target-proj/my-comp-import.js * import { MyComp as TargetComp } from 'ref-proj'; * * // 2. Intermediate stop: a re-export * // ref-proj/exportsIndex.js (package.json has main: './exportsIndex.js') * export { RefComp as MyComp } from './src/RefComp.js'; * * // 3. End point: our declaration * // ref-proj/src/RefComp.js * export class RefComp extends LitElement {...} *``` * * @param {string} source an importSpecifier source, like 'ref-proj' or '../file' * @param {string} identifierName imported reference/Identifier name, like 'MyComp' * @param {string} currentFilePath file path, like '/path/to/target-proj/my-comp-import.js' * @param {string} rootPath dir path, like '/path/to/target-proj' * @returns {object} file: path of file containing the binding (exported declaration), * like '/path/to/ref-proj/src/RefComp.js' */ async function trackDownIdentifierFn(source, identifierName, currentFilePath, rootPath, depth = 0) { let rootFilePath; // our result path let rootSpecifier; // the name under which it was imported if (!isRelativeSourcePath(source)) { // So, it is an external ref like '@lion/core' or '@open-wc/scoped-elements/index.js' // At this moment in time, we don't know if we have file system access to this particular // project. Therefore, we limit ourselves to tracking down local references. // In case this helper is used inside an analyzer like 'match-subclasses', the external // (search-target) project can be accessed and paths can be resolved to local ones, // just like in 'match-imports' analyzer. /** @type {RootFile} */ const result = { file: source, specifier: identifierName }; return result; } /** * @prop resolvedSourcePath * @type {string} * @example resolveImportPath('../file') // => '/path/to/target-proj/file.js' */ const resolvedSourcePath = await resolveImportPath(source, currentFilePath); LogService.debug(`[trackDownIdentifier] ${resolvedSourcePath}`); const code = fs.readFileSync(resolvedSourcePath, 'utf8'); const ast = AstService.getAst(code, 'babel', { filePath: resolvedSourcePath }); const shouldLookForDefaultExport = identifierName === '[default]'; let reexportMatch = null; // named specifier declaration let pendingTrackDownPromise; traverse(ast, { ExportDefaultDeclaration(path) { if (!shouldLookForDefaultExport) { return; } let newSource; if (path.node.declaration.type === 'Identifier') { newSource = getImportSourceFromAst(path, path.node.declaration.name).source; } if (newSource) { pendingTrackDownPromise = trackDownIdentifier( newSource, '[default]', resolvedSourcePath, rootPath, depth + 1, ); } else { // We found our file! rootSpecifier = identifierName; rootFilePath = toRelativeSourcePath(resolvedSourcePath, rootPath); } path.stop(); }, ExportNamedDeclaration: { enter(path) { if (reexportMatch || shouldLookForDefaultExport) { return; } // Are we dealing with a re-export ? if (path.node.specifiers && path.node.specifiers.length) { const exportMatch = path.node.specifiers.find(s => s.exported.name === identifierName); if (exportMatch) { const localName = exportMatch.local.name; let newSource; if (path.node.source) { /** * @example * export { x } from 'y' */ newSource = path.node.source.value; } else { /** * @example * import { x } from 'y' * export { x } */ newSource = getImportSourceFromAst(path, identifierName).source; if (!newSource) { /** * @example * const x = 12; * export { x } */ return; } } reexportMatch = true; pendingTrackDownPromise = trackDownIdentifier( newSource, localName, resolvedSourcePath, rootPath, depth + 1, ); path.stop(); } } }, exit(path) { if (!reexportMatch) { // We didn't find a re-exported Identifier, that means the reference is declared // in current file... rootSpecifier = identifierName; rootFilePath = toRelativeSourcePath(resolvedSourcePath, rootPath); path.stop(); } }, }, }); if (pendingTrackDownPromise) { // We can't handle promises inside Babel traverse, so we do it here... const resObj = await pendingTrackDownPromise; rootFilePath = resObj.file; rootSpecifier = resObj.specifier; } return /** @type {RootFile } */ { file: rootFilePath, specifier: rootSpecifier }; } trackDownIdentifier = memoizeAsync(trackDownIdentifierFn); /** * @param {BabelPath} astPath * @param {string} identifierNameInScope * @param {string} fullCurrentFilePath * @param {string} projectPath */ async function trackDownIdentifierFromScopeFn( astPath, identifierNameInScope, fullCurrentFilePath, projectPath, ) { const sourceObj = getImportSourceFromAst(astPath, identifierNameInScope); /** @type {RootFile} */ let rootFile; if (sourceObj.source) { rootFile = await trackDownIdentifier( sourceObj.source, sourceObj.importedIdentifierName, fullCurrentFilePath, projectPath, ); } else { const specifier = sourceObj.importedIdentifierName || identifierNameInScope; rootFile = { file: '[current]', specifier }; } return rootFile; } const trackDownIdentifierFromScope = memoizeAsync(trackDownIdentifierFromScopeFn); module.exports = { trackDownIdentifier, getImportSourceFromAst, trackDownIdentifierFromScope, };