UNPKG

ember-data-codemod

Version:
777 lines (651 loc) 23.8 kB
'use strict'; const { getParser } = require('codemod-cli').jscodeshift; const fs = require('fs'); const MAPPINGS = require('@ember-data/rfc395-data'); const LOG_FILE = 'ember-data-codemod.tmp.' + process.pid; const ERROR_WARNING = 1; const MISSING_GLOBAL_WARNING = 2; const OPTS = { quote: 'single', }; module.exports = transformer; /** * This is the entry point for this jscodeshift transform. * It scans JavaScript files that use the DS global and / or the old imports. * It updates them to use the module syntax from the proposed new RFC. */ function transformer(file, api /*, options*/) { let source = file.source; const j = getParser(api); let root = j(source); // Track any use of `DS.*` that isn't accounted for in the mapping. We'll // use this at the end to generate a report. let warnings = []; let pendingGlobals = {}; try { // Discover existing module imports, if any, in the file. If the user has // already imported one or more exports that we rewrite a global with, we // won't import them again. We also try to be smart about not adding multiple // import statements to import from the same module, condensing default // exports and named exports into one line if necessary. let modules = findExistingModules(root); // Build a data structure that tells us how to map properties on the DS // global into the module syntax. let mappings = buildMappings(modules); let globalDS = getGlobalDSName(root); // Discover global aliases for DS keys that are introduced via destructuring, // e.g. `const { Model } = DS;`. let globalAliases = findGlobalDSAliases(root, globalDS, mappings); // Go through all of the tracked pending DS globals. The ones that have // been marked as missing should be added to the warnings. resolvePendingGlobals(); // Resolve the discovered aliases against the module registry. We intentionally do // this ahead of finding replacements for e.g. `DS.Model` usage in // order to reuse custom names for any fields referenced both ways. resolveAliasImports(globalAliases, mappings, modules); // Scan the source code, looking for any instances of the `DS` identifier // used as the root of a property lookup. If they match one of the provided // mappings, save it off for replacement later. let replacements = findUsageOfDSGlobal(root, globalDS).map( findReplacement(mappings) ); // Now that we've identified all of the replacements that we need to do, we'll // make sure to either add new `import` declarations, or update existing ones // to add new named exports or the default export. updateOrCreateImportDeclarations(root, modules, mappings); // Actually go through and replace each usage of `DS.whatever` with the // imported binding (`whatever`). applyReplacements(replacements); // Finally remove global DS import if no globals left removeGlobalDS(root, globalDS); // jscodeshift is not so great about giving us control over the resulting whitespace. // We'll use a regular expression to try to improve the situation (courtesy of @rwjblue). const lineTerminator = source.indexOf('\r\n') > -1 ? '\r\n' : '\n'; source = root.toSource(Object.assign({}, OPTS, { lineTerminator })); } catch (e) { if (process.env.EMBER_DATA_CODEMOD) { warnings.push([ERROR_WARNING, file.path, source, e.stack]); } throw e; } finally { // If there were modules that we didn't know about, write them to a log file. // We only do this if invoked via the CLI tool, not jscodeshift directly, // because jscodeshift doesn't give us a cleanup hook when everything is done // to parse these files. (This is what the environment variable is checking.) if (warnings.length && process.env.EMBER_DATA_CODEMOD) { warnings.forEach((warning) => { fs.appendFileSync(LOG_FILE, JSON.stringify(warning) + '\n'); }); } } return source; /** * Loops through the raw JSON data in `mapping.json` and converts each entry * into a Mapping instance. The Mapping class lazily reifies its associated * module as they it is consumed. */ function buildMappings(registry) { let mappings = {}; for (let mapping of MAPPINGS) { mappings[mapping.global.substr('DS.'.length)] = new Mapping( mapping, registry ); } return mappings; } function getGlobalDSImport(root) { return root.find(j.ImportDeclaration, { specifiers: [ { type: 'ImportDefaultSpecifier', }, ], source: { value: 'ember-data', }, }); } function getGlobalDSName(root) { const globalDS = getGlobalDSImport(root); let defaultImport = globalDS.find(j.Identifier); let defaultMemberName = defaultImport.size() && defaultImport.get(0).node.name; return defaultMemberName || 'DS'; } /* * Finds all uses of a property looked up on the DS global (i.e., * `DS.something`). Makes sure that it is actually the DS global * and not another variable that happens to be called `DS`. */ function findUsageOfDSGlobal(root, globalDS) { let dsUsages = root.find(j.MemberExpression, { object: { name: globalDS, }, }); return dsUsages.filter(isDSGlobal(globalDS)).paths(); } /* * loops through all modules and replaces literal path if necessary * 'ember-data/model' -> '@ember-data/model' */ function updateExistingLiteralPaths(root, module, mappings) { const { imported, local, source } = module; const mappingName = imported === 'default' ? local : imported; let foundMapping = mappings[mappingName]; let isEmberDataModule = source === 'ember-data' || source.startsWith('ember-data/'); if (!foundMapping || !isEmberDataModule) { return; } const { imported: replacementImported, local: replacementLocal, source: replacementSource, } = foundMapping; // first, update specifier if different than what mapping says it should be if (imported !== replacementImported) { const updateSpecifiers = (path) => { let specifier; if (replacementImported === 'default') { const name = replacementLocal || local; specifier = j.importDefaultSpecifier(j.identifier(name)); } else { specifier = j.importSpecifier(j.identifier(replacementImported)); } j(path.parentPath).replaceWith(specifier); }; let importSpecifiers = root .find(j.ImportDeclaration) .find(j.ImportSpecifier) .find(j.Identifier, { name: local }); let importDefaultSpecifiers = root .find(j.ImportDeclaration) .find(j.ImportDefaultSpecifier) .find(j.Identifier, { name: local }); importSpecifiers.paths().forEach(updateSpecifiers); importDefaultSpecifiers.paths().forEach(updateSpecifiers); } // second, update literal path if (source !== replacementSource) { root .find(j.ImportDeclaration, { source: { type: 'StringLiteral', value: source, }, }) .find(j.Literal) .forEach((importLiteral) => { j(importLiteral).replaceWith(j.literal(replacementSource)); }); } } /* * After modifying existing sources to their new paths, we need * to make sure we clean up duplicate imports */ function cleanupDuplicateLiteralPaths() { const uniqueImports = {}; root.find(j.ImportDeclaration).forEach((nodePath) => { let node = nodePath.node; let value = node.source && node.source.value; if (!(value in uniqueImports)) { // add to found uniqueImports and we wont modify uniqueImports[value] = nodePath; } else { // get all specifiers and add to existing import // then delete this nodePath let specifiers = node.specifiers; let existingNodePath = uniqueImports[value]; specifiers.forEach((spec) => { let { imported, local } = spec; if (spec.type === 'ImportDefaultSpecifier') { let specifier = j.importDefaultSpecifier(j.identifier(local.name)); // default imports go at front existingNodePath.get('specifiers').unshift(specifier); } else if (spec.type === 'ImportSpecifier') { let specifier; if (imported && local) { specifier = j.importSpecifier( j.identifier(imported.name), j.identifier(local.name) ); } else { specifier = j.importSpecifier(j.identifier(imported.name)); } existingNodePath.get('specifiers').push(specifier); } }); nodePath.prune(); } }); } // Find destructured global aliases for fields on the DS global function findGlobalDSAliases(root, globalDS, mappings) { let aliases = {}; let assignments = findUsageOfDestructuredDS(root, globalDS); for (let assignment of assignments) { let dsPath = joinDSPath(assignment.get('init'), globalDS); for (let alias of extractAliases( mappings, assignment.get('id'), dsPath )) { aliases[alias.identifier.node.name] = alias; } } return aliases; } function findUsageOfDestructuredDS(root, globalDS) { // Keep track of the nested properties off of the DS namespace, // const { Model } = DS; let globalDSWithNestedProperties = [globalDS]; let uses = root.find(j.VariableDeclarator, (node) => { if (j.Identifier.check(node.init)) { if (includes(globalDSWithNestedProperties, node.init.name)) { // We've found a DS global, or one of its nested properties. // Add it to the uses, and add its properties to the list of nested properties const identifierProperties = getIdentifierProperties(node); globalDSWithNestedProperties = globalDSWithNestedProperties.concat( identifierProperties ); return true; } } else if (j.MemberExpression.check(node.init)) { return node.init.object.name === globalDS; } }); return uses.paths(); } function resolvePendingGlobals() { Object.keys(pendingGlobals).forEach((key) => { let pendingGlobal = pendingGlobals[key]; const parentPath = pendingGlobal.pattern.parentPath; if (!pendingGlobal.hasMissingGlobal) { parentPath.prune(); } else { warnMissingGlobal(parentPath, pendingGlobal.dsPath); } }); } function getIdentifierProperties(node) { let identifierProperties = []; node.id.properties.forEach((property) => { if (j.Identifier.check(property.value)) { identifierProperties.push(property.key.name); } }); return identifierProperties; } function joinDSPath(nodePath, globalDS) { if (j.Identifier.check(nodePath.node)) { if (nodePath.node.name !== globalDS) { return nodePath.node.name; } } else if (j.MemberExpression.check(nodePath.node)) { let lhs = nodePath.node.object.name; let rhs = joinDSPath(nodePath.get('property')); if (lhs === globalDS) { return rhs; } else { return `${lhs}.${rhs}`; } } } // Determine aliases introduced by the given destructuring pattern, removing // items from the pattern when they're available via a module import instead. // Also tracks and flags pending globals for future patterns, // in case we have multi-statement destructuring, i.e: // const { computed } = Ember; // const { oneWay } = computed; function extractAliases(mappings, pattern, dsPath) { if (j.Identifier.check(pattern.node)) { if (dsPath in mappings) { pattern.parentPath.prune(); const pendingGlobalParent = findPendingGlobal(dsPath); if (pendingGlobalParent) { // A parent has been found. Mark it as no longer being missing. pendingGlobalParent.hasMissingGlobal = false; } return [new GlobalAlias(pattern, dsPath)]; } else { let thisPatternHasMissingGlobal = false; const pendingGlobalParent = findPendingGlobal(dsPath); if (pendingGlobalParent) { // A parent has been found. Mark it as a missing global. pendingGlobalParent.hasMissingGlobal = true; } else { // Otherwise, mark this pattern as a missing global. thisPatternHasMissingGlobal = true; } // Add this pattern to pendingGlobals pendingGlobals[pattern.node.name] = { pattern, dsPath, hasMissingGlobal: thisPatternHasMissingGlobal, }; } } else if (j.ObjectPattern.check(pattern.node)) { let aliases = findObjectPatternAliases(mappings, pattern, dsPath); if (!pattern.node.properties.length) { pattern.parentPath.prune(); } return aliases; } return []; } function findPendingGlobal(dsPath) { if (!dsPath) { return; } const paths = dsPath.split('.'); for (let idx = 0; idx < paths.length; idx++) { const path = paths[idx]; if (pendingGlobals[path]) { return pendingGlobals[path]; } } } function findObjectPatternAliases(mappings, objectPattern, basePath) { let aliases = []; for (let i = objectPattern.node.properties.length - 1; i >= 0; i--) { let property = objectPattern.get('properties', i); let propertyName = property.node.key.name; let fullPath = basePath ? `${basePath}.${propertyName}` : propertyName; aliases = aliases.concat( extractAliases(mappings, property.get('value'), fullPath) ); } return aliases; } function resolveAliasImports(aliases, mappings, registry) { for (let globalName of Object.keys(aliases)) { let alias = aliases[globalName]; let mapping = mappings[alias.dsPath]; registry.get( mapping.source, mapping.imported, alias.identifier.node.name ); } } /** * Returns a function that can be used to map an array of MemberExpression * nodes into Replacement instances. Does the actual work of verifying if the * `DS` identifier used in the MemberExpression is actually replaceable. */ function findReplacement(mappings, namespace) { return function (path) { // Expand the full set of property lookups. For example, we don't want // just "Ember.computed"—we want "Ember.computed.or" as well. let candidates = expandMemberExpressions(path); if (namespace) { candidates = candidates.map((expression) => { let path = expression[0]; let propertyPath = expression[1]; return [path, `${namespace}.${propertyPath}`]; }); } // This will give us an array of tuples ([pathString, node]) that represent // the possible replacements, from most-specific to least-specific. For example: // // [Ember.computed.reads, Ember.computed], or // [Ember.Object.extend, Ember.Object] // // We'll go through these to find the most specific candidate that matches // our global->ES6 map. let found = candidates.find((expression) => { let propertyPath = expression[1]; return propertyPath in mappings; }); // If we got this far but didn't find a viable candidate, that means the user is // using something on the `Ember` global that we don't have a module equivalent for. if (!found) { warnMissingGlobal(path, candidates[candidates.length - 1][1]); return null; } let nodePath = found[0]; let propertyPath = found[1]; let mapping = mappings[propertyPath]; let mod = mapping.getModule(); let local = mod.local; if (!local) { // Ember.computed.or => or local = propertyPath.split('.').slice(-1)[0]; } mod.local = local; return new Replacement(nodePath, mod); }; } function warnMissingGlobal(nodePath, dsPath) { let context = extractSourceContext(nodePath); let lineNumber = nodePath.value.loc.start.line; warnings.push([ MISSING_GLOBAL_WARNING, dsPath, lineNumber, file.path, context, ]); } function extractSourceContext(path) { let start = path.node.loc.start.line; let end = path.node.loc.end.line; let lines = source.split('\n'); start = Math.max(start - 2, 1) - 1; end = Math.min(end + 2, lines.length); return lines.slice(start, end).join('\n'); } function applyReplacements(replacements) { replacements .filter((r) => !!r) .forEach((replacement) => { let local = replacement.mod.local; let nodePath = replacement.nodePath; if (isAliasVariableDeclarator(nodePath, local)) { nodePath.parent.prune(); } else { nodePath.replace(j.identifier(local)); } }); } function removeGlobalDS(root, globalDS) { let remainingGlobals = findUsageOfDSGlobal(root, globalDS); let remainingDestructuring = findUsageOfDestructuredDS(root, globalDS); if (!remainingGlobals.length && !remainingDestructuring.length) { getGlobalDSImport(root).remove(); } } function isAliasVariableDeclarator(nodePath, local) { let parent = nodePath.parent; if (!parent) { return false; } if (!j.VariableDeclarator.check(parent.node)) { return false; } return parent.node.id.name === local; } function updateOrCreateImportDeclarations(root, registry, mappings) { let body = root.get().value.program.body; registry.modules.forEach((mod) => { if (!mod.node) { let source = mod.source; let imported = mod.imported; let local = mod.local; let declaration = root.find(j.ImportDeclaration, { source: { value: mod.source }, }); if (declaration.size() > 0) { let specifier; if (imported === 'default') { specifier = j.importDefaultSpecifier(j.identifier(local)); // default imports go at front declaration.get('specifiers').unshift(specifier); } else { specifier = j.importSpecifier( j.identifier(imported), j.identifier(local) ); declaration.get('specifiers').push(specifier); } mod.node = declaration.at(0); } else { let importStatement = createImportStatement(source, imported, local); body.unshift(importStatement); body[0].comments = body[1].comments; delete body[1].comments; mod.node = importStatement; } } // Update literal paths based on mappings from 'ember-data/model' to '@ember-data/model' // by pushing into existing declaration specifiers updateExistingLiteralPaths(root, mod, mappings); }); // then remove old duplicate specifier if found cleanupDuplicateLiteralPaths(); } function findExistingModules(root) { let registry = new ModuleRegistry(); root.find(j.ImportDeclaration).forEach((mod) => { let node = mod.node; let source = node.source.value; node.specifiers.forEach((spec) => { let isDefault = j.ImportDefaultSpecifier.check(spec); // Some cases like `import * as bar from "foo"` have neither a // default nor a named export, which we don't currently handle. let imported = isDefault ? 'default' : spec.imported ? spec.imported.name : null; if (!imported) { return; } if (!registry.find(source, imported)) { let mod = registry.create(source, imported, spec.local.name); mod.node = node; } }); }); return registry; } function expandMemberExpressions(path) { let propName = path.node.property.name; let expressions = [[path, propName]]; let currentPath = path; while ((currentPath = currentPath.parent)) { if (j.MemberExpression.check(currentPath.node)) { propName = propName + '.' + currentPath.value.property.name; expressions.push([currentPath, propName]); } else { break; } } return expressions.reverse(); } // Flagrantly stolen from https://github.com/5to6/5to6-codemod/blob/master/utils/main.js function createImportStatement(source, imported, local) { let declaration, variable, idIdentifier, nameIdentifier; // console.log('variableName', variableName); // console.log('moduleName', moduleName); // if no variable name, return `import 'jquery'` if (!local) { declaration = j.importDeclaration([], j.literal(source)); return declaration; } // multiple variable names indicates a destructured import if (Array.isArray(local)) { let variableIds = local.map(function (v) { return j.importSpecifier(j.identifier(v), j.identifier(v)); }); declaration = j.importDeclaration(variableIds, j.literal(source)); } else { // else returns `import $ from 'jquery'` nameIdentifier = j.identifier(local); //import var name variable = j.importDefaultSpecifier(nameIdentifier); // if propName, use destructuring `import {pluck} from 'underscore'` if (imported && imported !== 'default') { idIdentifier = j.identifier(imported); variable = j.importSpecifier(idIdentifier, nameIdentifier); // if both are same, one is dropped... } declaration = j.importDeclaration([variable], j.literal(source)); } return declaration; } function isDSGlobal(name) { return function (path) { let localDS = !path.scope.isGlobal && path.scope.declares(name); return !localDS; }; } } function includes(array, value) { return array.indexOf(value) > -1; } class ModuleRegistry { constructor() { this.bySource = {}; this.modules = []; } find(source, imported) { let byImported = this.bySource[source]; if (!byImported) { byImported = this.bySource[source] = {}; } return byImported[imported] || null; } create(source, imported, local) { if (this.find(source, imported)) { throw new Error(`Module { ${source}, ${imported} } already exists.`); } let byImported = this.bySource[source]; if (!byImported) { byImported = this.bySource[source] = {}; } let mod = new Module(source, imported, local); byImported[imported] = mod; this.modules.push(mod); return mod; } get(source, imported, local) { let mod = this.find(source, imported, local); if (!mod) { mod = this.create(source, imported, local); } return mod; } } class Module { constructor(source, imported, local) { this.source = source; this.imported = imported; this.local = local; this.node = null; } } class GlobalAlias { constructor(identifier, dsPath) { this.identifier = identifier; this.dsPath = dsPath; } } class Replacement { constructor(nodePath, mod) { this.nodePath = nodePath; this.mod = mod; } } class Mapping { constructor(options, registry) { this.source = options.replacement.module; this.imported = options.replacement.export; this.local = options.localName; this.registry = registry; } getModule() { return this.registry.get(this.source, this.imported, this.local); } }