prefer-export-from.js 10 KB

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