prefer-export-from.js 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. 'use strict';
  2. const {
  3. isCommaToken,
  4. isOpeningBraceToken,
  5. isClosingBraceToken,
  6. } = require('eslint-utils');
  7. const MESSAGE_ID_ERROR = 'error';
  8. const MESSAGE_ID_SUGGESTION = 'suggestion';
  9. const messages = {
  10. [MESSAGE_ID_ERROR]: 'Use `export…from` to re-export `{{exported}}`.',
  11. [MESSAGE_ID_SUGGESTION]: 'Switch to `export…from`.',
  12. };
  13. // Default import/export can be `Identifier`, have to use `Symbol.for`
  14. const DEFAULT_SPECIFIER_NAME = Symbol.for('default');
  15. const NAMESPACE_SPECIFIER_NAME = Symbol('NAMESPACE_SPECIFIER_NAME');
  16. const getSpecifierName = node => {
  17. switch (node.type) {
  18. case 'Identifier':
  19. return Symbol.for(node.name);
  20. case 'Literal':
  21. return node.value;
  22. // No default
  23. }
  24. };
  25. const isTypeExport = specifier => specifier.exportKind === 'type' || specifier.parent.exportKind === 'type';
  26. const isTypeImport = specifier => specifier.importKind === 'type' || specifier.parent.importKind === 'type';
  27. function * removeSpecifier(node, fixer, sourceCode) {
  28. const {parent} = node;
  29. const {specifiers} = parent;
  30. if (specifiers.length === 1) {
  31. yield * removeImportOrExport(parent, fixer, sourceCode);
  32. return;
  33. }
  34. switch (node.type) {
  35. case 'ImportSpecifier': {
  36. const hasOtherSpecifiers = specifiers.some(specifier => specifier !== node && specifier.type === node.type);
  37. if (!hasOtherSpecifiers) {
  38. const closingBraceToken = sourceCode.getTokenAfter(node, isClosingBraceToken);
  39. // If there are other specifiers, they have to be the default import specifier
  40. // And the default import has to write before the named import specifiers
  41. // So there must be a comma before
  42. const commaToken = sourceCode.getTokenBefore(node, isCommaToken);
  43. yield fixer.replaceTextRange([commaToken.range[0], closingBraceToken.range[1]], '');
  44. return;
  45. }
  46. // Fallthrough
  47. }
  48. case 'ExportSpecifier':
  49. case 'ImportNamespaceSpecifier':
  50. case 'ImportDefaultSpecifier': {
  51. yield fixer.remove(node);
  52. const tokenAfter = sourceCode.getTokenAfter(node);
  53. if (isCommaToken(tokenAfter)) {
  54. yield fixer.remove(tokenAfter);
  55. }
  56. break;
  57. }
  58. // No default
  59. }
  60. }
  61. function * removeImportOrExport(node, fixer, sourceCode) {
  62. switch (node.type) {
  63. case 'ImportSpecifier':
  64. case 'ExportSpecifier':
  65. case 'ImportDefaultSpecifier':
  66. case 'ImportNamespaceSpecifier': {
  67. yield * removeSpecifier(node, fixer, sourceCode);
  68. return;
  69. }
  70. case 'ImportDeclaration':
  71. case 'ExportDefaultDeclaration':
  72. case 'ExportNamedDeclaration': {
  73. yield fixer.remove(node);
  74. }
  75. // No default
  76. }
  77. }
  78. function getSourceAndAssertionsText(declaration, sourceCode) {
  79. const keywordFromToken = sourceCode.getTokenBefore(
  80. declaration.source,
  81. token => token.type === 'Identifier' && token.value === 'from',
  82. );
  83. const [start] = keywordFromToken.range;
  84. const [, end] = declaration.range;
  85. return sourceCode.text.slice(start, end);
  86. }
  87. function getFixFunction({
  88. sourceCode,
  89. imported,
  90. exported,
  91. exportDeclarations,
  92. program,
  93. }) {
  94. const importDeclaration = imported.declaration;
  95. const sourceNode = importDeclaration.source;
  96. const sourceValue = sourceNode.value;
  97. const shouldExportAsType = imported.isTypeImport || exported.isTypeExport;
  98. let exportDeclaration;
  99. if (shouldExportAsType) {
  100. // If a type export declaration already exists, reuse it, else use a value export declaration with an inline type specifier.
  101. exportDeclaration = exportDeclarations.find(({source, exportKind}) => source.value === sourceValue && exportKind === 'type');
  102. }
  103. if (!exportDeclaration) {
  104. exportDeclaration = exportDeclarations.find(({source, exportKind}) => source.value === sourceValue && exportKind !== 'type');
  105. }
  106. /** @param {import('eslint').Rule.RuleFixer} fixer */
  107. return function * (fixer) {
  108. if (imported.name === NAMESPACE_SPECIFIER_NAME) {
  109. yield fixer.insertTextAfter(
  110. program,
  111. `\nexport * as ${exported.text} ${getSourceAndAssertionsText(importDeclaration, sourceCode)}`,
  112. );
  113. } else {
  114. let specifierText = exported.name === imported.name
  115. ? exported.text
  116. : `${imported.text} as ${exported.text}`;
  117. // Add an inline type specifier if the value is a type and the export deceleration is a value deceleration
  118. if (shouldExportAsType && (!exportDeclaration || exportDeclaration.exportKind !== 'type')) {
  119. specifierText = `type ${specifierText}`;
  120. }
  121. if (exportDeclaration) {
  122. const lastSpecifier = exportDeclaration.specifiers[exportDeclaration.specifiers.length - 1];
  123. // `export {} from 'foo';`
  124. if (lastSpecifier) {
  125. yield fixer.insertTextAfter(lastSpecifier, `, ${specifierText}`);
  126. } else {
  127. const openingBraceToken = sourceCode.getFirstToken(exportDeclaration, isOpeningBraceToken);
  128. yield fixer.insertTextAfter(openingBraceToken, specifierText);
  129. }
  130. } else {
  131. yield fixer.insertTextAfter(
  132. program,
  133. `\nexport {${specifierText}} ${getSourceAndAssertionsText(importDeclaration, sourceCode)}`,
  134. );
  135. }
  136. }
  137. if (imported.variable.references.length === 1) {
  138. yield * removeImportOrExport(imported.node, fixer, sourceCode);
  139. }
  140. yield * removeImportOrExport(exported.node, fixer, sourceCode);
  141. };
  142. }
  143. function getExported(identifier, context, sourceCode) {
  144. const {parent} = identifier;
  145. switch (parent.type) {
  146. case 'ExportDefaultDeclaration':
  147. return {
  148. node: parent,
  149. name: DEFAULT_SPECIFIER_NAME,
  150. text: 'default',
  151. isTypeExport: isTypeExport(parent),
  152. };
  153. case 'ExportSpecifier':
  154. return {
  155. node: parent,
  156. name: getSpecifierName(parent.exported),
  157. text: sourceCode.getText(parent.exported),
  158. isTypeExport: isTypeExport(parent),
  159. };
  160. case 'VariableDeclarator': {
  161. if (
  162. parent.init === identifier
  163. && parent.id.type === 'Identifier'
  164. && !parent.id.typeAnnotation
  165. && parent.parent.type === 'VariableDeclaration'
  166. && parent.parent.kind === 'const'
  167. && parent.parent.declarations.length === 1
  168. && parent.parent.declarations[0] === parent
  169. && parent.parent.parent.type === 'ExportNamedDeclaration'
  170. && isVariableUnused(parent, context)
  171. ) {
  172. return {
  173. node: parent.parent.parent,
  174. name: Symbol.for(parent.id.name),
  175. text: sourceCode.getText(parent.id),
  176. };
  177. }
  178. break;
  179. }
  180. // No default
  181. }
  182. }
  183. function isVariableUnused(node, context) {
  184. const variables = context.getDeclaredVariables(node);
  185. /* c8 ignore next 3 */
  186. if (variables.length !== 1) {
  187. return false;
  188. }
  189. const [{identifiers, references}] = variables;
  190. return identifiers.length === 1
  191. && identifiers[0] === node.id
  192. && references.length === 1
  193. && references[0].identifier === node.id;
  194. }
  195. function getImported(variable, sourceCode) {
  196. const specifier = variable.defs[0].node;
  197. const result = {
  198. node: specifier,
  199. declaration: specifier.parent,
  200. variable,
  201. isTypeImport: isTypeImport(specifier),
  202. };
  203. switch (specifier.type) {
  204. case 'ImportDefaultSpecifier':
  205. return {
  206. name: DEFAULT_SPECIFIER_NAME,
  207. text: 'default',
  208. ...result,
  209. };
  210. case 'ImportSpecifier':
  211. return {
  212. name: getSpecifierName(specifier.imported),
  213. text: sourceCode.getText(specifier.imported),
  214. ...result,
  215. };
  216. case 'ImportNamespaceSpecifier':
  217. return {
  218. name: NAMESPACE_SPECIFIER_NAME,
  219. text: '*',
  220. ...result,
  221. };
  222. // No default
  223. }
  224. }
  225. function getExports(imported, context, sourceCode) {
  226. const exports = [];
  227. for (const {identifier} of imported.variable.references) {
  228. const exported = getExported(identifier, context, sourceCode);
  229. if (!exported) {
  230. continue;
  231. }
  232. /*
  233. There is no substitution for:
  234. ```js
  235. import * as foo from 'foo';
  236. export default foo;
  237. ```
  238. */
  239. if (imported.name === NAMESPACE_SPECIFIER_NAME && exported.name === DEFAULT_SPECIFIER_NAME) {
  240. continue;
  241. }
  242. exports.push(exported);
  243. }
  244. return exports;
  245. }
  246. const schema = [
  247. {
  248. type: 'object',
  249. additionalProperties: false,
  250. properties: {
  251. ignoreUsedVariables: {
  252. type: 'boolean',
  253. default: false,
  254. },
  255. },
  256. },
  257. ];
  258. /** @param {import('eslint').Rule.RuleContext} context */
  259. function create(context) {
  260. const sourceCode = context.getSourceCode();
  261. const {ignoreUsedVariables} = {ignoreUsedVariables: false, ...context.options[0]};
  262. const importDeclarations = new Set();
  263. const exportDeclarations = [];
  264. return {
  265. 'ImportDeclaration[specifiers.length>0]'(node) {
  266. importDeclarations.add(node);
  267. },
  268. // `ExportAllDeclaration` and `ExportDefaultDeclaration` can't be reused
  269. 'ExportNamedDeclaration[source.type="Literal"]'(node) {
  270. exportDeclarations.push(node);
  271. },
  272. * 'Program:exit'(program) {
  273. for (const importDeclaration of importDeclarations) {
  274. let variables = context.getDeclaredVariables(importDeclaration);
  275. if (variables.some(variable => variable.defs.length !== 1 || variable.defs[0].parent !== importDeclaration)) {
  276. continue;
  277. }
  278. variables = variables.map(variable => {
  279. const imported = getImported(variable, sourceCode);
  280. const exports = getExports(imported, context, sourceCode);
  281. return {
  282. variable,
  283. imported,
  284. exports,
  285. };
  286. });
  287. if (
  288. ignoreUsedVariables
  289. && variables.some(({variable, exports}) => variable.references.length !== exports.length)
  290. ) {
  291. continue;
  292. }
  293. const shouldUseSuggestion = ignoreUsedVariables
  294. && variables.some(({variable}) => variable.references.length === 0);
  295. for (const {imported, exports} of variables) {
  296. for (const exported of exports) {
  297. const problem = {
  298. node: exported.node,
  299. messageId: MESSAGE_ID_ERROR,
  300. data: {
  301. exported: exported.text,
  302. },
  303. };
  304. const fix = getFixFunction({
  305. sourceCode,
  306. imported,
  307. exported,
  308. exportDeclarations,
  309. program,
  310. });
  311. if (shouldUseSuggestion) {
  312. problem.suggest = [
  313. {
  314. messageId: MESSAGE_ID_SUGGESTION,
  315. fix,
  316. },
  317. ];
  318. } else {
  319. problem.fix = fix;
  320. }
  321. yield problem;
  322. }
  323. }
  324. }
  325. },
  326. };
  327. }
  328. /** @type {import('eslint').Rule.RuleModule} */
  329. module.exports = {
  330. create,
  331. meta: {
  332. type: 'suggestion',
  333. docs: {
  334. description: 'Prefer `export…from` when re-exporting.',
  335. },
  336. fixable: 'code',
  337. hasSuggestions: true,
  338. schema,
  339. messages,
  340. },
  341. };