lib/Importer.js
// @flow
import path from 'path';
import requireRelative from 'require-relative';
import CommandLineEditor from './CommandLineEditor';
import Configuration from './Configuration';
import ImportStatement from './ImportStatement';
import ImportStatements from './ImportStatements';
import JsModule from './JsModule';
import findCurrentImports from './findCurrentImports';
import findJsModulesFor from './findJsModulesFor';
import findUndefinedIdentifiers from './findUndefinedIdentifiers';
import findUsedIdentifiers from './findUsedIdentifiers';
import parse, { configureParserPlugins } from './parse';
function fixImportsMessage(
removedItems: Set<string>,
addedItems: Set<string>,
): ?string {
const messageParts = [];
const firstAdded = addedItems.values().next().value;
const firstRemoved = removedItems.values().next().value;
if (addedItems.size === 1 && firstAdded) {
messageParts.push(`Imported ${firstAdded}`);
} else if (addedItems.size) {
messageParts.push(`Added ${addedItems.size} imports`);
}
if (removedItems.size === 1 && firstRemoved) {
messageParts.push(`Removed \`${firstRemoved}\`.`);
} else if (removedItems.size) {
messageParts.push(`Removed ${removedItems.size} imports.`);
}
if (messageParts.length === 0) {
return undefined;
}
return messageParts.join('. ');
}
function findFilePathFromImports(
imports: ImportStatements,
dirname: string,
variableName: string,
): ?string {
// eslint-disable-next-line no-restricted-syntax
const importStatement = imports.find((is: ImportStatement): boolean =>
is.hasVariable(variableName),
);
if (!importStatement) {
return undefined;
}
try {
return requireRelative.resolve(importStatement.path, dirname);
} catch (e) {
// it's expected that we can't resolve certain paths.
}
return undefined;
}
export default class Importer {
ast: Object;
config: Configuration;
editor: CommandLineEditor;
messages: Array<string>;
pathToCurrentFile: string;
unresolvedImports: Object;
workingDirectory: string;
constructor(
lines: Array<string>,
pathToCurrentFile: ?string,
workingDirectory: string = process.cwd(),
) {
this.pathToCurrentFile = pathToCurrentFile || '';
this.editor = new CommandLineEditor(lines);
this.config = new Configuration(this.pathToCurrentFile, workingDirectory);
this.workingDirectory = workingDirectory;
configureParserPlugins(this.config.get('parserPlugins'));
this.messages = Array.from(this.config.messages);
this.unresolvedImports = {};
try {
this.ast = parse(
this.editor.currentFileContent(),
this.pathToCurrentFile,
);
} catch (e) {
if (e instanceof SyntaxError) {
this.message(`SyntaxError: ${e.message}`);
this.ast = parse('', '');
} else {
throw new Error(e);
}
}
}
results(): Object {
return {
messages: this.messages, // array
fileContent: this.editor.currentFileContent(), // string
unresolvedImports: this.unresolvedImports, // object
};
}
/**
* Imports one variable
*/
import(variableName: string): Promise<Object> {
return new Promise((resolve: Function, reject: Function) => {
this.findOneJsModule(variableName)
.then((jsModule: JsModule) => {
if (!jsModule) {
if (!Object.keys(this.unresolvedImports).length) {
this.message(`No JS module to import for \`${variableName}\``);
}
resolve(this.results());
return;
}
const imported = jsModule.hasNamedExports
? `{ ${variableName} }`
: variableName;
this.message(`Imported ${imported} from '${jsModule.importPath}'`);
const oldImports = this.findCurrentImports();
const importStatement = jsModule.toImportStatement(this.config);
oldImports.imports.push(importStatement);
this.replaceImports(oldImports.range, oldImports.imports);
resolve(this.results());
})
.catch((error: Object) => {
reject(error);
});
});
}
/**
* Searches for an export
*/
search(variableName: string): Promise<Object> {
return findJsModulesFor(this.config, variableName, { search: true }).then(
(modules: Array<JsModule>): Object => ({
modules,
messages: this.messages,
}),
);
}
goto(variableName: string): Promise<Object> {
const { imports } = this.findCurrentImports();
const filePath = findFilePathFromImports(
imports,
path.dirname(this.pathToCurrentFile),
variableName,
);
if (filePath) {
return Promise.resolve({
goto: filePath,
...this.results(),
});
}
return new Promise((resolve: Function, reject: Function) => {
findJsModulesFor(this.config, variableName)
.then((jsModules: Array<JsModule>) => {
if (!jsModules.length) {
// The current word is not mappable to one of the JS modules that we
// found. This can happen if the user does not select one from the list.
// We have nothing to go to, so we return early.
this.message(`No JS module found for \`${variableName}\``);
resolve(this.results());
return;
}
const filePath = jsModules[0].resolvedFilePath(
this.pathToCurrentFile,
this.workingDirectory,
);
const results = this.results();
results.goto = path.isAbsolute(filePath)
? filePath
: path.join(this.workingDirectory, filePath);
resolve(results);
})
.catch((error: Object) => {
reject(error);
});
});
}
// Removes unused imports and adds imports for undefined variables
fixImports(): Promise<Object> {
const undefinedVariables = findUndefinedIdentifiers(
this.ast,
this.config.get('globals'),
);
const usedVariables = findUsedIdentifiers(this.ast);
const oldImports = this.findCurrentImports();
const newImports = oldImports.imports.clone();
const unusedImportVariables = new Set();
oldImports.imports.forEach((importStatement: ImportStatement) => {
importStatement.variables().forEach((variable: string) => {
if (!usedVariables.has(variable)) {
unusedImportVariables.add(variable);
}
});
});
newImports.deleteVariables(unusedImportVariables);
const addedItems = new Set(this.injectSideEffectImports(newImports));
return new Promise((resolve: Function, reject: Function) => {
const allPromises = [];
undefinedVariables.forEach((variable: string) => {
allPromises.push(this.findOneJsModule(variable));
});
Promise.all(allPromises)
.then((results: Array<JsModule>) => {
results.forEach((jsModule: JsModule) => {
if (!jsModule) {
return;
}
const imported = jsModule.hasNamedExports
? `{ ${jsModule.variableName} }`
: jsModule.variableName;
addedItems.add(`${imported} from '${jsModule.importPath}'`);
newImports.push(jsModule.toImportStatement(this.config));
});
this.replaceImports(oldImports.range, newImports);
const message = fixImportsMessage(unusedImportVariables, addedItems);
if (message) {
this.message(message);
}
resolve(this.results());
})
.catch((error: Object) => {
reject(error);
});
});
}
addImports(imports: Object): Promise<Object> {
return new Promise((resolve: Function, reject: Function) => {
const oldImports = this.findCurrentImports();
const newImports = oldImports.imports.clone();
const variables = Object.keys(imports);
const promises = variables.map((variableName: string): Promise<void> =>
findJsModulesFor(this.config, variableName)
.then((jsModules: Array<JsModule>) => {
const importData = imports[variableName];
const dataIsObject = typeof importData === 'object';
const importPath = dataIsObject
? importData.importPath
: importData;
const hasNamedExports = dataIsObject
? importData.isNamedExport
: undefined;
const foundModule = jsModules.find(
(jsModule: JsModule): boolean =>
jsModule.importPath === importPath &&
(hasNamedExports === undefined ||
jsModule.hasNamedExports === hasNamedExports),
);
if (foundModule) {
newImports.push(foundModule.toImportStatement(this.config));
} else {
newImports.push(
new JsModule({
importPath,
variableName,
hasNamedExports,
}).toImportStatement(this.config),
);
}
})
.catch(reject),
);
Promise.all(promises).then(() => {
if (variables.length === 1) {
this.message(`Added import for \`${variables[0]}\``);
} else {
this.message(`Added ${variables.length} imports`);
}
this.replaceImports(oldImports.range, newImports);
resolve(this.results());
});
});
}
rewriteImports(): Object {
const oldImports = this.findCurrentImports();
const newImports = new ImportStatements(this.config);
return new Promise((resolve: Function, reject: Function) => {
const variables = [];
const sideEffectOnlyImports = [];
oldImports.imports.forEach((imp: ImportStatement) => {
if (imp.variables().length) {
variables.push(...imp.variables());
} else if (imp.hasSideEffects) {
// side-effect imports don't have variable names. Tuck them away and just pass
// them through to the end of this operation.
sideEffectOnlyImports.push(imp);
}
});
const promises = variables.map(
(variable: string): Promise<Array<JsModule>> =>
findJsModulesFor(this.config, variable),
);
Promise.all(promises)
.then((results: Array<Array<JsModule>>) => {
results.forEach((jsModules: Array<JsModule>) => {
if (!jsModules.length) {
return;
}
const { variableName } = jsModules[0];
const jsModule =
this.resolveModuleUsingCurrentImports(jsModules, variableName) ||
this.resolveOneJsModule(jsModules, variableName);
if (!jsModule) {
return;
}
newImports.push(jsModule.toImportStatement(this.config));
});
newImports.push(...sideEffectOnlyImports);
this.replaceImports(oldImports.range, newImports);
resolve(this.results());
})
.catch((error: Object) => {
reject(error);
});
});
}
message(str: string) {
this.messages.push(str);
}
findOneJsModule(variableName: string): Promise<JsModule> {
return new Promise((resolve: Function, reject: Function) => {
findJsModulesFor(this.config, variableName)
.then((jsModules: Array<JsModule>) => {
if (!jsModules.length) {
resolve(null);
return;
}
resolve(this.resolveOneJsModule(jsModules, variableName));
})
.catch((error: Object) => {
reject(error);
});
});
}
replaceImports(oldImportsRange: Object, newImports: ImportStatements) {
const importStrings = newImports.toArray();
// Ensure that there is a blank line after the block of all imports
if (importStrings.length && this.editor.get(oldImportsRange.end) !== '') {
this.editor.insertBefore(oldImportsRange.end, '');
}
// Delete old imports, then add the modified list back in.
for (let i = oldImportsRange.end - 1; i >= oldImportsRange.start; i -= 1) {
this.editor.remove(i);
}
if (
importStrings.length === 0 &&
this.editor.get(oldImportsRange.start) === ''
) {
// We have no newlines to write back to the file. Clearing out potential
// whitespace where the imports used to be leaves the file in a better
// state.
this.editor.remove(oldImportsRange.start);
return;
}
importStrings.reverse().forEach((importString: string) => {
// We need to add each line individually because the Vim buffer will
// convert newline characters to `~@`.
if (importString.indexOf('\n') !== -1) {
importString
.split('\n')
.reverse()
.forEach((line: string) => {
this.editor.insertBefore(oldImportsRange.start, line);
});
} else {
this.editor.insertBefore(oldImportsRange.start, importString);
}
});
while (this.editor.get(0) === '') {
this.editor.remove(0);
}
}
findCurrentImports(): Object {
return findCurrentImports(
this.config,
this.editor.currentFileContent(),
this.ast,
);
}
resolveOneJsModule(
jsModules: Array<JsModule>,
variableName: string,
): ?JsModule {
if (jsModules.length === 1) {
const jsModule = jsModules[0];
return jsModule;
}
if (!jsModules.length) {
return undefined;
}
const countSeparators = (importPath: string): number => {
const separators = importPath.match(/\//g);
return separators ? separators.length : 0;
};
this.unresolvedImports[variableName] = jsModules
.map((jsModule: JsModule): Object => ({
displayName: jsModule
.toImportStatement(this.config)
.toImportStrings(Infinity, ' ')[0],
importPath: jsModule.importPath, // backward compatibility
data: {
importPath: jsModule.importPath,
filePath: jsModule.resolvedFilePath(
this.pathToCurrentFile,
this.workingDirectory,
),
isNamedExport: jsModule.hasNamedExports,
},
}))
.sort(
(a: Object, b: Object): number =>
countSeparators(a.data.importPath) -
countSeparators(b.data.importPath),
);
return undefined;
}
resolveModuleUsingCurrentImports(
jsModules: Array<JsModule>,
variableName: string,
): ?JsModule {
if (jsModules.length === 1) {
return jsModules[0];
}
// Look at the current imports and grab what is already imported for the
// variable.
const matchingImportStatement = this.findCurrentImports().imports.find(
(ist: ImportStatement): boolean => ist.hasVariable(variableName),
);
if (!matchingImportStatement) {
return undefined;
}
if (jsModules.length > 0) {
// Look for a module matching what is already imported
const { path: matchingPath } = matchingImportStatement;
return jsModules.find(
(jsModule: JsModule): boolean =>
matchingPath === jsModule.toImportStatement(this.config).path,
);
}
// We couldn't resolve any module for the variable. As a fallback, we
// can use the matching import statement. If that maps to a package
// dependency, we will still open the right file.
const hasNamedExports =
matchingImportStatement.defaultImport !== variableName;
const matchedModule = new JsModule({
importPath: matchingImportStatement.path,
hasNamedExports,
variableName,
});
return matchedModule;
}
injectSideEffectImports(importStatements: ImportStatements): Array<string> {
const addedImports = [];
this.config.get('moduleSideEffectImports').forEach((path: string) => {
const sizeBefore = importStatements.size();
importStatements.push(
new ImportStatement({
namedImports: [],
defaultImport: '',
hasSideEffects: true,
declarationKeyword: this.config.get('declarationKeyword'),
importFunction: this.config.get('importFunction'),
danglingCommas: this.config.get('danglingCommas'),
path,
}),
);
if (importStatements.size() > sizeBefore) {
// The number of imports changed as part of adding the side-effect
// import. This means that the import wasn't previously there.
addedImports.push(`'${path}'`);
}
});
return addedImports;
}
}