lion/packages-node/providence-analytics/src/program/analyzers/find-exports.js

272 lines
8.6 KiB
JavaScript

/* eslint-disable no-shadow, no-param-reassign */
import pathLib from 'path';
import babelTraverse from '@babel/traverse';
import { Analyzer } from '../core/Analyzer.js';
import { trackDownIdentifier } from './helpers/track-down-identifier.js';
import { normalizeSourcePaths } from './helpers/normalize-source-paths.js';
import { getReferencedDeclaration } from '../utils/get-source-code-fragment-of-declaration.js';
import { LogService } from '../core/LogService.js';
/**
* @typedef {import('@babel/types').File} File
* @typedef {import('@babel/types').Node} Node
* @typedef {import('../../../types/index.js').AnalyzerName} AnalyzerName
* @typedef {import('../../../types/index.js').FindExportsAnalyzerResult} FindExportsAnalyzerResult
* @typedef {import('../../../types/index.js').FindExportsAnalyzerEntry} FindExportsAnalyzerEntry
* @typedef {import('../../../types/index.js').PathRelativeFromProjectRoot} PathRelativeFromProjectRoot
* @typedef {import('./helpers/track-down-identifier.js').RootFile} RootFile
* @typedef {object} RootFileMapEntry
* @typedef {string} currentFileSpecifier this is the local name in the file we track from
* @typedef {RootFile} rootFile contains file(filePath) and specifier
* @typedef {RootFileMapEntry[]} RootFileMap
* @typedef {{ exportSpecifiers:string[]; localMap: object; source:string, __tmp: { path:string } }} FindExportsSpecifierObj
*/
/**
* @param {FindExportsSpecifierObj[]} transformedFile
*/
async function trackdownRoot(transformedFile, relativePath, projectPath) {
const fullCurrentFilePath = pathLib.resolve(projectPath, relativePath);
for (const specObj of transformedFile) {
/** @type {RootFileMap} */
const rootFileMap = [];
if (specObj.exportSpecifiers[0] === '[file]') {
rootFileMap.push(undefined);
} else {
/**
* './src/origin.js': `export class MyComp {}`
* './index.js:' `export { MyComp as RenamedMyComp } from './src/origin'`
*
* Goes from specifier like 'RenamedMyComp' to object for rootFileMap like:
* {
* currentFileSpecifier: 'RenamedMyComp',
* rootFile: {
* file: './src/origin.js',
* specifier: 'MyCompDefinition',
* }
* }
*/
for (const specifier of specObj.exportSpecifiers) {
let rootFile;
let localMapMatch;
if (specObj.localMap) {
localMapMatch = specObj.localMap.find(m => m.exported === specifier);
}
// TODO: find out if possible to use trackDownIdentifierFromScope
if (specObj.source) {
// TODO: see if still needed: && (localMapMatch || specifier === '[default]')
const importedIdentifier = localMapMatch?.local || specifier;
rootFile = await trackDownIdentifier(
specObj.source,
importedIdentifier,
fullCurrentFilePath,
projectPath,
);
/** @type {RootFileMapEntry} */
const entry = {
currentFileSpecifier: specifier,
rootFile,
};
rootFileMap.push(entry);
} else {
/** @type {RootFileMapEntry} */
const entry = {
currentFileSpecifier: specifier,
rootFile: { file: '[current]', specifier },
};
rootFileMap.push(entry);
}
}
}
specObj.rootFileMap = rootFileMap;
}
return transformedFile;
}
function cleanup(transformedFile) {
transformedFile.forEach(specObj => {
if (specObj.__tmp) {
delete specObj.__tmp;
}
});
return transformedFile;
}
/**
* @returns {string[]}
*/
function getExportSpecifiers(node) {
// handles default [export const g = 4];
if (node.declaration) {
if (node.declaration.declarations) {
return [node.declaration.declarations[0].id.name];
}
if (node.declaration.id) {
return [node.declaration.id.name];
}
}
// handles (re)named specifiers [export { x (as y)} from 'y'];
return node.specifiers.map(s => {
let specifier;
if (s.exported) {
// { x as y }
specifier = s.exported.name === 'default' ? '[default]' : s.exported.name;
} else {
// { x }
specifier = s.local.name;
}
return specifier;
});
}
/**
* @returns {object[]}
*/
function getLocalNameSpecifiers(node) {
return node.specifiers
.map(s => {
if (s.exported && s.local && s.exported.name !== s.local.name) {
return {
// if reserved keyword 'default' is used, translate it into 'providence keyword'
local: s.local.name === 'default' ? '[default]' : s.local.name,
exported: s.exported.name,
};
}
return undefined;
})
.filter(s => s);
}
const isImportingSpecifier = pathOrNode =>
pathOrNode.type === 'ImportDefaultSpecifier' || pathOrNode.type === 'ImportSpecifier';
/**
* Finds import specifiers and sources for a given ast result
* @param {File} babelAst
* @param {FindExportsConfig} config
*/
function findExportsPerAstFile(babelAst, { skipFileImports }) {
LogService.debug(`Analyzer "find-exports": started findExportsPerAstFile method`);
// Visit AST...
/** @type {FindExportsSpecifierObj[]} */
const transformedFile = [];
// Unfortunately, we cannot have async functions in babel traverse.
// Therefore, we store a temp reference to path that we use later for
// async post processing (tracking down original export Identifier)
let globalScopeBindings;
babelTraverse.default(babelAst, {
Program: {
enter(babelPath) {
const body = babelPath.get('body');
if (body.length) {
globalScopeBindings = body[0].scope.bindings;
}
},
},
ExportNamedDeclaration(astPath) {
const exportSpecifiers = getExportSpecifiers(astPath.node);
const localMap = getLocalNameSpecifiers(astPath.node);
const source = astPath.node.source?.value;
const entry = { exportSpecifiers, localMap, source, __tmp: { astPath } };
if (astPath.node.assertions?.length) {
entry.assertionType = astPath.node.assertions[0].value?.value;
}
transformedFile.push(entry);
},
ExportDefaultDeclaration(defaultExportPath) {
const exportSpecifiers = ['[default]'];
let source;
if (defaultExportPath.node.declaration?.type !== 'Identifier') {
source = defaultExportPath.node.declaration.name;
} else {
const importOrDeclPath = getReferencedDeclaration({
referencedIdentifierName: defaultExportPath.node.declaration.name,
globalScopeBindings,
});
if (isImportingSpecifier(importOrDeclPath)) {
source = importOrDeclPath.parentPath.node.source.value;
}
}
transformedFile.push({ exportSpecifiers, source, __tmp: { astPath: defaultExportPath } });
},
});
if (!skipFileImports) {
// Always add an entry for just the file 'relativePath'
// (since this also can be imported directly from a search target project)
transformedFile.push({
exportSpecifiers: ['[file]'],
// source: relativePath,
});
}
return transformedFile;
}
export default class FindExportsAnalyzer extends Analyzer {
/** @type {AnalyzerName} */
static analyzerName = 'find-exports';
/** @type {'babel'|'swc-to-babel'} */
requiredAst = 'swc-to-babel';
/**
* Finds export specifiers and sources
* @param {FindExportsConfig} customConfig
*/
async execute(customConfig = {}) {
/**
* @typedef FindExportsConfig
* @property {boolean} [onlyInternalSources=false]
* @property {boolean} [skipFileImports=false] Instead of both focusing on specifiers like
* [import {specifier} 'lion-based-ui/foo.js'], and [import 'lion-based-ui/foo.js'] as a result,
* not list file exports
*/
const cfg = {
targetProjectPath: null,
skipFileImports: false,
...customConfig,
};
/**
* Prepare
*/
const cachedAnalyzerResult = this._prepare(cfg);
if (cachedAnalyzerResult) {
return cachedAnalyzerResult;
}
/**
* Traverse
*/
const projectPath = cfg.targetProjectPath;
const traverseEntryFn = async (ast, { relativePath }) => {
let transformedFile = findExportsPerAstFile(ast, cfg);
transformedFile = await normalizeSourcePaths(transformedFile, relativePath, projectPath);
transformedFile = await trackdownRoot(transformedFile, relativePath, projectPath);
transformedFile = cleanup(transformedFile);
return { result: transformedFile };
};
const queryOutput = await this._traverse({
traverseEntryFn,
filePaths: cfg.targetFilePaths,
projectPath: cfg.targetProjectPath,
});
/**
* Finalize
*/
return this._finalize(queryOutput, cfg);
}
}