lion/packages-node/providence-analytics/src/program/analyzers/helpers/track-down-identifier.js

266 lines
8.9 KiB
JavaScript

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,
};