Skip to content

Commit

Permalink
[Fix]: no-duplicates with type imports
Browse files Browse the repository at this point in the history
  • Loading branch information
snewcomer committed Feb 12, 2023
1 parent 87a6096 commit ab7a2d8
Show file tree
Hide file tree
Showing 3 changed files with 564 additions and 54 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
"watch": "npm run tests-only -- -- --watch",
"pretest": "linklocal",
"posttest": "eslint . && npm run update:eslint-docs -- --check",
"mocha": "cross-env BABEL_ENV=test nyc mocha",
"tests-only": "npm run mocha tests/src",
"mocha": "cross-env BABEL_ENV=test nyc mocha --watch",
"tests-only": "npm run mocha tests/src/rules/no-duplicates",
"test": "npm run tests-only",
"test-compiled": "npm run prepublish && BABEL_ENV=testCompiled mocha --compilers js:babel-register tests/src",
"test-all": "node --require babel-register ./scripts/testAll",
Expand Down
249 changes: 226 additions & 23 deletions src/rules/no-duplicates.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,198 @@ function checkImports(imported, context) {
message,
});
}

}
}
}

function checkTypeImports(imported, context) {
for (const [module, nodes] of imported.entries()) {
const typeImports = nodes.filter((node) => node.importKind === 'type');
if (nodes.length > 1) {
const someInlineTypeImports = nodes.filter((node) => node.specifiers.some((spec) => spec.importKind === 'type'));
if (typeImports.length > 0 && someInlineTypeImports.length > 0) {
const message = `'${module}' imported multiple times.`;
const sourceCode = context.getSourceCode();
const fix = getTypeFix(nodes, sourceCode, context);

const [first, ...rest] = nodes;
context.report({
node: first.source,
message,
fix, // Attach the autofix (if any) to the first import.
});

for (const node of rest) {
context.report({
node: node.source,
message,
});
}
}
}
}
}

function getFix(first, rest, sourceCode, context) {
function checkInlineTypeImports(imported, context) {
for (const [module, nodes] of imported.entries()) {
if (nodes.length > 1) {
const message = `'${module}' imported multiple times.`;
const sourceCode = context.getSourceCode();
const fix = getInlineTypeFix(nodes, sourceCode);

const [first, ...rest] = nodes;
context.report({
node: first.source,
message,
fix, // Attach the autofix (if any) to the first import.
});

for (const node of rest) {
context.report({
node: node.source,
message,
});
}
}
}
}

function isComma(token) {
return token.type === 'Punctuator' && token.value === ',';
}

function getInlineTypeFix(nodes, sourceCode) {
return fixer => {
const fixes = [];

// if (!semver.satisfies(typescriptPkg.version, '>= 4.5')) {
// throw new Error('Your version of TypeScript does not support inline type imports.');
// }

// push to first import
let [firstImport, ...rest] = nodes;
const valueImport = nodes.find((n) => n.specifiers.every((spec) => spec.importKind === 'value')) || nodes.find((n) => n.specifiers.some((spec) => spec.type === 'ImportDefaultSpecifier'));
if (valueImport) {
firstImport = valueImport;
rest = nodes.filter((n) => n !== firstImport);
}

const nodeTokens = sourceCode.getTokens(firstImport);
// we are moving the rest of the Type or Inline Type imports here.
const nodeClosingBrace = nodeTokens.find(token => isPunctuator(token, '}'));
// const preferInline = context.options[0] && context.options[0]['prefer-inline'];
if (nodeClosingBrace) {
for (const node of rest) {
// these will be all Type imports, no Value specifiers
// then add inline type specifiers to importKind === 'type' import
for (const specifier of node.specifiers) {
if (specifier.importKind === 'type') {
fixes.push(fixer.insertTextBefore(nodeClosingBrace, `, type ${specifier.local.name}`));
} else {
fixes.push(fixer.insertTextBefore(nodeClosingBrace, `, ${specifier.local.name}`));
}
}

fixes.push(fixer.remove(node));
}
} else {
// we have a default import only
const defaultSpecifier = firstImport.specifiers.find((spec) => spec.type === 'ImportDefaultSpecifier');
const inlineTypeImports = [];
for (const node of rest) {
// these will be all Type imports, no Value specifiers
// then add inline type specifiers to importKind === 'type' import
for (const specifier of node.specifiers) {
if (specifier.importKind === 'type') {
inlineTypeImports.push(`type ${specifier.local.name}`);
} else {
inlineTypeImports.push(specifier.local.name);
}
}

fixes.push(fixer.remove(node));
}

fixes.push(fixer.insertTextAfter(defaultSpecifier, `, {${inlineTypeImports.join(', ')}}`));
}

return fixes;
};
}

function getTypeFix(nodes, sourceCode, context) {
return fixer => {
const fixes = [];

const preferInline = context.options[0] && context.options[0]['prefer-inline'];

if (preferInline) {
if (!semver.satisfies(typescriptPkg.version, '>= 4.5')) {
throw new Error('Your version of TypeScript does not support inline type imports.');
}

// collapse all type imports to the inline type import
const typeImports = nodes.filter((node) => node.importKind === 'type');
const someInlineTypeImports = nodes.filter((node) => node.specifiers.some((spec) => spec.importKind === 'type'));
// push to first import
const firstImport = someInlineTypeImports[0];

if (firstImport) {
const nodeTokens = sourceCode.getTokens(firstImport);
// we are moving the rest of the Type imports here
const nodeClosingBrace = nodeTokens.find(token => isPunctuator(token, '}'));

for (const node of typeImports) {
for (const specifier of node.specifiers) {
fixes.push(fixer.insertTextBefore(nodeClosingBrace, `, type ${specifier.local.name}`));
}

fixes.push(fixer.remove(node));
}
}
} else {
// move inline types to type imports
const typeImports = nodes.filter((node) => node.importKind === 'type');
const someInlineTypeImports = nodes.filter((node) => node.specifiers.some((spec) => spec.importKind === 'type'));

const firstImport = typeImports[0];

if (firstImport) {
const nodeTokens = sourceCode.getTokens(firstImport);
// we are moving the rest of the Type imports here
const nodeClosingBrace = nodeTokens.find(token => isPunctuator(token, '}'));

for (const node of someInlineTypeImports) {
for (const specifier of node.specifiers) {
if (specifier.importKind === 'type') {
fixes.push(fixer.insertTextBefore(nodeClosingBrace, `, ${specifier.local.name}`));
}
}

if (node.specifiers.every((spec) => spec.importKind === 'type')) {
fixes.push(fixer.remove(node));
} else {
for (const specifier of node.specifiers) {
if (specifier.importKind === 'type') {
const maybeComma = sourceCode.getTokenAfter(specifier);
if (isComma(maybeComma)) {
fixes.push(fixer.remove(maybeComma));
}
// TODO: remove `type`?
fixes.push(fixer.remove(specifier));
}
}
}
}
}
}

return fixes;
};
}

function getFix(first, rest, sourceCode) {
// Sorry ESLint <= 3 users, no autofix for you. Autofixing duplicate imports
// requires multiple `fixer.whatever()` calls in the `fix`: We both need to
// update the first one, and remove the rest. Support for multiple
Expand Down Expand Up @@ -112,26 +299,18 @@ function getFix(first, rest, sourceCode, context) {
isPunctuator(sourceCode.getTokenBefore(closeBrace), ',');
const firstIsEmpty = !hasSpecifiers(first);

const [specifiersText] = specifiers.reduce(
([result, needsComma], specifier) => {
const isTypeSpecifier = specifier.importNode.importKind === 'type';

const preferInline = context.options[0] && context.options[0]['prefer-inline'];
// a user might set prefer-inline but not have a supporting TypeScript version. Flow does not support inline types so this should fail in that case as well.
if (preferInline && (!typescriptPkg || !semver.satisfies(typescriptPkg.version, '>= 4.5'))) {
throw new Error('Your version of TypeScript does not support inline type imports.');
}

const insertText = `${preferInline && isTypeSpecifier ? 'type ' : ''}${specifier.text}`;
return [
needsComma && !specifier.isEmpty
? `${result},${insertText}`
: `${result}${insertText}`,
specifier.isEmpty ? needsComma : true,
];
},
['', !firstHasTrailingComma && !firstIsEmpty],
);
const [specifiersText] = specifiers
.reduce(
([result, needsComma], specifier) => {
return [
needsComma && !specifier.isEmpty
? `${result},${specifier.text}`
: `${result}${specifier.text}`,
specifier.isEmpty ? needsComma : true,
];
},
['', !firstHasTrailingComma && !firstIsEmpty],
);

const fixes = [];

Expand All @@ -158,7 +337,7 @@ function getFix(first, rest, sourceCode, context) {
// `import def from './foo'` → `import def, {...} from './foo'`
fixes.push(fixer.insertTextAfter(first.specifiers[0], `, {${specifiersText}}`));
}
} else if (!shouldAddDefault && openBrace != null && closeBrace != null) {
} else if (!shouldAddDefault && openBrace != null && closeBrace != null && specifiersText) {
// `import {...} './foo'` → `import {..., ...} from './foo'`
fixes.push(fixer.insertTextBefore(closeBrace, specifiersText));
}
Expand Down Expand Up @@ -303,14 +482,18 @@ module.exports = {
nsImported: new Map(),
defaultTypesImported: new Map(),
namedTypesImported: new Map(),
inlineTypesImported: new Map(),
});
}
const map = moduleMaps.get(n.parent);
if (n.importKind === 'type') {
// import type Foo | import type { foo }
return n.specifiers.length > 0 && n.specifiers[0].type === 'ImportDefaultSpecifier' ? map.defaultTypesImported : map.namedTypesImported;
}

if (n.specifiers.some((spec) => spec.importKind === 'type')) {
return map.namedTypesImported;
// import { type foo }
return map.inlineTypesImported;
}

return hasNamespace(n) ? map.nsImported : map.imported;
Expand All @@ -335,6 +518,26 @@ module.exports = {
checkImports(map.nsImported, context);
checkImports(map.defaultTypesImported, context);
checkImports(map.namedTypesImported, context);

const duplicatedImports = new Map([...map.inlineTypesImported]);
map.imported.forEach((value, key) => {
if (duplicatedImports.has(key)) {
duplicatedImports.get(key).push(...value);
} else {
duplicatedImports.set(key, [value]);
}
});
checkInlineTypeImports(duplicatedImports, context);

const duplicatedTypeImports = new Map([...map.inlineTypesImported]);
map.namedTypesImported.forEach((value, key) => {
if (duplicatedTypeImports.has(key)) {
duplicatedTypeImports.get(key).push(...value);
} else {
duplicatedTypeImports.set(key, value);
}
});
checkTypeImports(duplicatedTypeImports, context);
}
},
};
Expand Down
Loading

0 comments on commit ab7a2d8

Please sign in to comment.