Skip to content

Instantly share code, notes, and snippets.

@miyaokamarina
Last active May 28, 2021 05:01
Show Gist options
  • Save miyaokamarina/6596410110cc49e403826a1071f70132 to your computer and use it in GitHub Desktop.
Save miyaokamarina/6596410110cc49e403826a1071f70132 to your computer and use it in GitHub Desktop.
Scan JS/TS files for exported symbols
// WTFPL or MIT
//
// Extracts all the exported symbols from files.
// For example, if you have a prelude and want to feed all its symbols
// to the `webpack.ProvidePlugin` or your fav Babel auto-import plugin.
//
// Written to be used with webpack (depends on its resolver),
// but it’s pretty easy to get rid of this dependency.
//
// Only supports ES modules. Doesn’t support Flow.
//
// When scanning TS files even with `types=false`, it’s still not guaranteed,
// the result will not include any type-only symbols. The scanner will do its
// best, but in some cases it currently can’t distinct value symbols from type
// symbols.
//
// Example usage with a Babel plugin:
//
// const scan = require('./scan-exports');
//
// const symbols = scan({ config: require('./webpack.config'), entry: '~/prelude' });
// const prelude = Object.fromEntries([...symbols].map(symbol => [symbol, { from: '~/prelude' }]));
//
// module.exports = {
// plugins: [
// ['@xymopen/babel-plugin-auto-import', prelude],
// ],
// };
//
// Depends on the `fs-extra-sync` (`./fses`):
// https://gist.github.com/miyaokamarina/f7c7cd03cf41b396386966ada5a6efa6
const up = require('upath');
const wp = require('webpack');
const bp = require('@babel/parser');
const fs = require('./fses');
/**
* @param {string} name
*/
const inferBabelOptions = name => {
/** @type {(string | [string, object])[]} */ let plugins = ['decorators-legacy', 'exportDefaultFrom', 'topLevelAwait'];
if (/\.(?:jsx?|mjs)$/.test(name)) plugins.push('throwExpressions');
if (/\.[jt]sx$/.test(name)) plugins.push('jsx');
if (/\.tsx?$/.test(name)) {
if (/\.d\.ts$/.test(name)) plugins.push(['typescript', { dts: true }]);
else plugins.push('typescript');
}
return {
sourceFilename: name,
sourceType: 'module',
errorRecovery: true,
allowUndeclaredExports: true,
plugins,
};
};
/**
* @returns {boolean}
*/
const selectExport = node => {
switch (node.type) {
case 'ExportNamedDeclaration':
case 'ExportDefaultDeclaration':
case 'ExportAllDeclaration':
return true;
default:
return false;
}
};
/**
* @param {object} options.config webpack configuration object
* @param {string} options.entry A file to start scan from.
* @param {boolean} [options.recursive=true] Include re-exported symbols.
* @param {boolean} [options.types=true] Include type-only exports.
* @return {Set<string>}
*/
const scanExports = options => {
let { config, entry, recursive = true, types = true } = options;
let webpack = wp(config);
let root = webpack.context; // Project root.
webpack.inputFileSystem = fs; // Use the sync filesystem to allow the `resolver.resolveSync` method.
let resolver = webpack.resolverFactory.get('normal', webpack.options.resolve);
/**
* Returns a project-relative path.
*
* @param {string} name
* @returns {string}
*/
const relative = name => up.relative(root, name);
/**
* Resolves a file by module identifier.
*
* Tweak this function if you don’t need webpack.
*
* @param {string} base Directory to resolve a module identifier in.
* @param {string} name The module identifier to resolve.
* @returns {string | undefined}
*/
const resolve = (base, name) => {
try {
return relative(resolver.resolveSync({}, base, name) || '') || undefined;
} catch (err) {
return undefined;
}
};
/**
* @param {string} [name]
*/
const scanFile = name => {
const visitNode = node => {
switch (node.type) {
case 'ExportDefaultDeclaration':
return;
case 'ExportNamedDeclaration':
if (node.exportKind === 'type' && !types) return; // export type {} from, export type, export interface
if (node.declaration) return visitNode(node.declaration); // export const/let/var/class/function
if (node.specifiers) return node.specifiers.forEach(visitNode); // export {}
return;
case 'Identifier':
symbols.add(node.name);
return;
case 'ObjectPattern':
return node.properties.forEach(visitNode);
case 'ArrayPattern':
return node.elements.forEach(visitNode);
case 'VariableDeclaration':
return node.declarations.forEach(visitNode);
case 'ObjectProperty':
return visitNode(node.value);
case 'RestElement':
return visitNode(node.argument);
case 'ExportSpecifier':
case 'ExportDefaultSpecifier':
case 'ExportNamespaceSpecifier':
return visitNode(node.exported);
case 'FunctionDeclaration':
case 'ClassDeclaration':
case 'VariableDeclarator':
case 'TSModuleDeclaration':
case 'TSInterfaceDeclaration':
case 'TSTypeAliasDeclaration':
return visitNode(node.id);
case 'TSEnumDeclaration':
if (node.const && !types) return;
return visitNode(node.id);
case 'ExportAllDeclaration':
if (recursive) queue.push(resolve(base, node.source.value));
return;
default:
console.log(node);
return;
}
};
if (!name || visited.has(name)) return;
else visited.add(name);
let full = up.join(root, name);
let base = up.dirname(name);
let text = fs.utf8Sync(full);
let file = bp.parse(text, inferBabelOptions(name));
file.program.body.filter(selectExport).forEach(visitNode);
};
/** @type {Set<string>} */ let visited = new Set();
/** @type {Set<string>} */ let symbols = new Set();
/** @type {string[]} */ let queue = [resolve(root, entry)];
while (queue.length) {
scanFile(queue.shift());
}
return symbols;
};
module.exports = scanExports;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment