From ba941cd6b7d82e5a11bee159fe9d3800a7e0a4aa Mon Sep 17 00:00:00 2001 From: Chris Garrett Date: Wed, 24 Feb 2021 10:36:34 -0800 Subject: [PATCH] Exposes a manual API for JS processing Exposes two methods, `setupState` and `processImportDeclaration`. These methods can be used by other babel plugins in order to manually run the API polyfill. This can be useful when other Babel plugins add imports which may not be handled by this plugin due to ordering/race conditions. --- src/index.js | 512 ++++++++++++++++++++++++++------------------------- 1 file changed, 261 insertions(+), 251 deletions(-) diff --git a/src/index.js b/src/index.js index 3160327..4cb46bf 100644 --- a/src/index.js +++ b/src/index.js @@ -31,288 +31,294 @@ function isDecorator(moduleName, importName) { } } -module.exports = function (babel) { - const t = babel.types; +const TSTypesRequiringModification = [ + 'TSAsExpression', + 'TSTypeAssertion', + 'TSNonNullExpression', +]; + +const isTypescriptNode = (node) => + node.type.startsWith('TS') && + !TSTypesRequiringModification.includes(node.type); + +// Flips the ember-rfc176-data mapping into an 'import' indexed object, that exposes the +// default import as well as named imports, e.g. import {foo} from 'bar' +const reverseMapping = {}; +mapping.forEach((exportDefinition) => { + const imported = exportDefinition.global; + const importRoot = exportDefinition.module; + let importName = exportDefinition.export; + + if (!reverseMapping[importRoot]) { + reverseMapping[importRoot] = {}; + } - const TSTypesRequiringModification = [ - 'TSAsExpression', - 'TSTypeAssertion', - 'TSNonNullExpression', - ]; - const isTypescriptNode = (node) => - node.type.startsWith('TS') && - !TSTypesRequiringModification.includes(node.type); - - // Flips the ember-rfc176-data mapping into an 'import' indexed object, that exposes the - // default import as well as named imports, e.g. import {foo} from 'bar' - const reverseMapping = {}; - mapping.forEach((exportDefinition) => { - const imported = exportDefinition.global; - const importRoot = exportDefinition.module; - let importName = exportDefinition.export; - - if (!reverseMapping[importRoot]) { - reverseMapping[importRoot] = {}; - } + reverseMapping[importRoot][importName] = imported; +}); - reverseMapping[importRoot][importName] = imported; - }); +function getMemberExpressionFor(t, global, emberIdentifier) { + let parts = global.split('.'); - function getMemberExpressionFor(global, emberIdentifier) { - let parts = global.split('.'); + let object = parts.shift(); + let property = parts.shift(); - let object = parts.shift(); - let property = parts.shift(); + let objectIdentifier = + object === 'Ember' ? emberIdentifier : t.identifier(object); + + let memberExpression = t.MemberExpression( + objectIdentifier, + t.identifier(property) + ); - let objectIdentifier = - object === 'Ember' ? emberIdentifier : t.identifier(object); + while (parts.length > 0) { + let property = parts.shift(); - let memberExpression = t.MemberExpression( - objectIdentifier, + memberExpression = t.MemberExpression( + memberExpression, t.identifier(property) ); - - while (parts.length > 0) { - let property = parts.shift(); - - memberExpression = t.MemberExpression( - memberExpression, - t.identifier(property) - ); - } - - return memberExpression; } - return { - name: 'ember-modules-api-polyfill', - visitor: { - Program(path, state) { - let options = state.opts || {}; - let useEmberModule = Boolean(options.useEmberModule); - let allAddedImports = {}; - - state.ensureImport = (exportName, moduleName) => { - let addedImports = (allAddedImports[moduleName] = - allAddedImports[moduleName] || {}); - - if (addedImports[exportName]) return addedImports[exportName]; - - if ( - exportName === 'default' && - moduleName === 'ember' && - !useEmberModule - ) { - addedImports[exportName] = t.identifier('Ember'); - return addedImports[exportName]; - } - - let importDeclarations = path - .get('body') - .filter((n) => n.type === 'ImportDeclaration'); - - let preexistingImportDeclaration = importDeclarations.find( - (n) => n.get('source').get('value').node === moduleName - ); - - if (preexistingImportDeclaration) { - let importSpecifier = preexistingImportDeclaration - .get('specifiers') - .find(({ node }) => { - return exportName === 'default' - ? t.isImportDefaultSpecifier(node) - : node.imported.name === exportName; - }); - - if (importSpecifier) { - addedImports[exportName] = importSpecifier.node.local; - } - } + return memberExpression; +} - if (!addedImports[exportName]) { - let uid = path.scope.generateUidIdentifier( - exportName === 'default' ? moduleName : exportName - ); - addedImports[exportName] = uid; +function setupState(t, path, state) { + let options = state.opts || {}; + let useEmberModule = Boolean(options.useEmberModule); + let allAddedImports = {}; - let newImportSpecifier = - exportName === 'default' - ? t.importDefaultSpecifier(uid) - : t.importSpecifier(uid, t.identifier(exportName)); + state.ensureImport = (exportName, moduleName) => { + let addedImports = (allAddedImports[moduleName] = + allAddedImports[moduleName] || {}); - let newImport = t.importDeclaration( - [newImportSpecifier], - t.stringLiteral(moduleName) - ); - path.unshiftContainer('body', newImport); - } + if (addedImports[exportName]) return addedImports[exportName]; - return addedImports[exportName]; - }; - }, + if (exportName === 'default' && moduleName === 'ember' && !useEmberModule) { + addedImports[exportName] = t.identifier('Ember'); + return addedImports[exportName]; + } - ImportDeclaration(path, state) { - let options = state.opts || {}; - let ignore = options.ignore || []; - let useEmberModule = Boolean(options.useEmberModule); - let node = path.node; - let declarations = []; - let removals = []; - let specifiers = path.get('specifiers'); - let importPath = node.source.value; + let importDeclarations = path + .get('body') + .filter((n) => n.type === 'ImportDeclaration'); - if (importPath === 'ember') { - // For `import Ember from 'ember'`, we can just remove the import - // and change `Ember` usage to to global Ember object. - let specifierPath = specifiers.find((specifierPath) => { - if (specifierPath.isImportDefaultSpecifier()) { - return true; - } - // TODO: Use the nice Babel way to throw - throw new Error(`Unexpected non-default import from 'ember'`); - }); + let preexistingImportDeclaration = importDeclarations.find( + (n) => n.get('source').get('value').node === moduleName + ); - if (specifierPath) { - let local = specifierPath.node.local; + if (preexistingImportDeclaration) { + let importSpecifier = preexistingImportDeclaration + .get('specifiers') + .find(({ node }) => { + return exportName === 'default' + ? t.isImportDefaultSpecifier(node) + : node.imported.name === exportName; + }); + + if (importSpecifier) { + addedImports[exportName] = importSpecifier.node.local; + } + } - // when `useEmberModule` is set, we don't need to do anything here - if (!useEmberModule) { - if (local.name !== 'Ember') { - path.scope.rename(local.name, 'Ember'); - } - removals.push(specifierPath); - } - } else { - // import 'ember'; - path.remove(); - } - } + if (!addedImports[exportName]) { + let uid = path.scope.generateUidIdentifier( + exportName === 'default' ? moduleName : exportName + ); + addedImports[exportName] = uid; - // This is the mapping to use for the import statement - const mapping = reverseMapping[importPath]; + let newImportSpecifier = + exportName === 'default' + ? t.importDefaultSpecifier(uid) + : t.importSpecifier(uid, t.identifier(exportName)); - // Only walk specifiers if this is a module we have a mapping for - if (mapping) { - // Iterate all the specifiers and attempt to locate their mapping - specifiers.forEach((specifierPath) => { - let specifier = specifierPath.node; - let importName; + let newImport = t.importDeclaration( + [newImportSpecifier], + t.stringLiteral(moduleName) + ); + path.unshiftContainer('body', newImport); + } - // imported is the name of the module being imported, e.g. import foo from bar - const imported = specifier.imported; + return addedImports[exportName]; + }; +} - // local is the name of the module in the current scope, this is usually the same - // as the imported value, unless the module is aliased - const local = specifier.local; +function processImportDeclaration(t, path, state) { + let options = state.opts || {}; + let ignore = options.ignore || []; + let useEmberModule = Boolean(options.useEmberModule); + let node = path.node; + let declarations = []; + let removals = []; + let specifiers = path.get('specifiers'); + let importPath = node.source.value; + + if (importPath === 'ember') { + // For `import Ember from 'ember'`, we can just remove the import + // and change `Ember` usage to to global Ember object. + let specifierPath = specifiers.find((specifierPath) => { + if (specifierPath.isImportDefaultSpecifier()) { + return true; + } + // TODO: Use the nice Babel way to throw + throw new Error(`Unexpected non-default import from 'ember'`); + }); + + if (specifierPath) { + let local = specifierPath.node.local; + + // when `useEmberModule` is set, we don't need to do anything here + if (!useEmberModule) { + if (local.name !== 'Ember') { + path.scope.rename(local.name, 'Ember'); + } + removals.push(specifierPath); + } + } else { + // import 'ember'; + path.remove(); + } + } - // We only care about these 2 specifiers - if ( - specifier.type !== 'ImportDefaultSpecifier' && - specifier.type !== 'ImportSpecifier' - ) { - if (specifier.type === 'ImportNamespaceSpecifier') { - throw new Error( - `Using \`import * as ${specifier.local.name} from '${importPath}'\` is not supported.` - ); + // This is the mapping to use for the import statement + const mapping = reverseMapping[importPath]; + + // Only walk specifiers if this is a module we have a mapping for + if (mapping) { + // Iterate all the specifiers and attempt to locate their mapping + specifiers.forEach((specifierPath) => { + let specifier = specifierPath.node; + let importName; + + // imported is the name of the module being imported, e.g. import foo from bar + const imported = specifier.imported; + + // local is the name of the module in the current scope, this is usually the same + // as the imported value, unless the module is aliased + const local = specifier.local; + + // We only care about these 2 specifiers + if ( + specifier.type !== 'ImportDefaultSpecifier' && + specifier.type !== 'ImportSpecifier' + ) { + if (specifier.type === 'ImportNamespaceSpecifier') { + throw new Error( + `Using \`import * as ${specifier.local.name} from '${importPath}'\` is not supported.` + ); + } + return; + } + + // Determine the import name, either default or named + if (specifier.type === 'ImportDefaultSpecifier') { + importName = 'default'; + } else { + importName = imported.name; + } + + if (isIgnored(ignore, importPath, importName)) { + return; + } + + // Extract the global mapping + const global = mapping[importName]; + + // Ensure the module being imported exists + if (!global) { + throw path.buildCodeFrameError( + `${importPath} does not have a ${importName} export` + ); + } + + removals.push(specifierPath); + + if ( + path.scope.bindings[local.name].referencePaths.find( + (rp) => rp.parent.type === 'ExportSpecifier' + ) + ) { + // not safe to use path.scope.rename directly when this identifier is being directly re-exported + declarations.push( + t.variableDeclaration('var', [ + t.variableDeclarator( + t.identifier(local.name), + t.identifier(global) + ), + ]) + ); + } else { + // Replace the occurences of the imported name with the global name. + let binding = path.scope.getBinding(local.name); + let referencePaths = binding.referencePaths; + + if (isDecorator(importPath, importName)) { + // tldr; decorator paths are not always included in `path.scope.getBinding(local.name)` + // + // In some circumstances, decorators are not included in the + // reference paths for a local binding when the decorator + // identifier name is also defined _within_ the method being + // decorated. This is likely a bug in Babel, that should be + // reported and fixed. + // + // in order to fix that, we have to manually traverse to gather + // the decorator references **before** the + // @babel/plugin-proposal-decorators runs (because it removes + // them) + path.parentPath.traverse({ + Decorator(decoratorPath) { + if ( + decoratorPath.node.expression.type === 'Identifier' && + decoratorPath.node.expression.name === local.name + ) { + referencePaths.push(decoratorPath.get('expression')); } - return; - } - - // Determine the import name, either default or named - if (specifier.type === 'ImportDefaultSpecifier') { - importName = 'default'; - } else { - importName = imported.name; - } - - if (isIgnored(ignore, importPath, importName)) { - return; - } + }, + }); + } - // Extract the global mapping - const global = mapping[importName]; + // Replace the occurrences of the imported name with the global name. + referencePaths.forEach((referencePath) => { + if (!isTypescriptNode(referencePath.parentPath)) { + const memberExpression = getMemberExpressionFor( + t, + global, + state.ensureImport('default', 'ember') + ); - // Ensure the module being imported exists - if (!global) { - throw path.buildCodeFrameError( - `${importPath} does not have a ${importName} export` - ); + try { + referencePath.replaceWith(memberExpression); + } catch (e) { + referencePath.scope.crawl(); + referencePath.replaceWith(memberExpression); } + } + }); + } + }); + } - removals.push(specifierPath); + if (removals.length > 0 || mapping) { + if (removals.length === node.specifiers.length) { + path.replaceWithMultiple(declarations); + } else { + removals.forEach((specifierPath) => specifierPath.remove()); + path.insertAfter(declarations); + } + } +} - if ( - path.scope.bindings[local.name].referencePaths.find( - (rp) => rp.parent.type === 'ExportSpecifier' - ) - ) { - // not safe to use path.scope.rename directly when this identifier is being directly re-exported - declarations.push( - t.variableDeclaration('var', [ - t.variableDeclarator( - t.identifier(local.name), - t.identifier(global) - ), - ]) - ); - } else { - // Replace the occurences of the imported name with the global name. - let binding = path.scope.getBinding(local.name); - let referencePaths = binding.referencePaths; - - if (isDecorator(importPath, importName)) { - // tldr; decorator paths are not always included in `path.scope.getBinding(local.name)` - // - // In some circumstances, decorators are not included in the - // reference paths for a local binding when the decorator - // identifier name is also defined _within_ the method being - // decorated. This is likely a bug in Babel, that should be - // reported and fixed. - // - // in order to fix that, we have to manually traverse to gather - // the decorator references **before** the - // @babel/plugin-proposal-decorators runs (because it removes - // them) - path.parentPath.traverse({ - Decorator(decoratorPath) { - if ( - decoratorPath.node.expression.type === 'Identifier' && - decoratorPath.node.expression.name === local.name - ) { - referencePaths.push(decoratorPath.get('expression')); - } - }, - }); - } +module.exports = function (babel) { + const t = babel.types; - // Replace the occurrences of the imported name with the global name. - referencePaths.forEach((referencePath) => { - if (!isTypescriptNode(referencePath.parentPath)) { - const memberExpression = getMemberExpressionFor( - global, - state.ensureImport('default', 'ember') - ); - - try { - referencePath.replaceWith(memberExpression); - } catch (e) { - referencePath.scope.crawl(); - referencePath.replaceWith(memberExpression); - } - } - }); - } - }); - } + return { + name: 'ember-modules-api-polyfill', + visitor: { + Program(path, state) { + setupState(t, path, state); + }, - if (removals.length > 0 || mapping) { - if (removals.length === node.specifiers.length) { - path.replaceWithMultiple(declarations); - } else { - removals.forEach((specifierPath) => specifierPath.remove()); - path.insertAfter(declarations); - } - } + ImportDeclaration(path, state) { + processImportDeclaration(t, path, state); }, ExportNamedDeclaration(path, state) { @@ -415,3 +421,7 @@ module.exports = function (babel) { // Provide the path to the package's base directory for caching with broccoli // Ref: https://github.com/babel/broccoli-babel-transpiler#caching module.exports.baseDir = () => path.resolve(__dirname, '..'); + +// Provide public APIs for manually traversing/transforming via other Babel plugins +module.exports.setupState = setupState; +module.exports.processImportDeclaration = processImportDeclaration;