template-indent.js 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. 'use strict';
  2. const stripIndent = require('strip-indent');
  3. const indentString = require('indent-string');
  4. const esquery = require('esquery');
  5. const {replaceTemplateElement} = require('./fix/index.js');
  6. const {callExpressionSelector, methodCallSelector} = require('./selectors/index.js');
  7. const MESSAGE_ID_IMPROPERLY_INDENTED_TEMPLATE = 'template-indent';
  8. const messages = {
  9. [MESSAGE_ID_IMPROPERLY_INDENTED_TEMPLATE]: 'Templates should be properly indented.',
  10. };
  11. const jestInlineSnapshotSelector = [
  12. callExpressionSelector({name: 'expect', path: 'callee.object', argumentsLength: 1}),
  13. methodCallSelector({method: 'toMatchInlineSnapshot', argumentsLength: 1}),
  14. ' > TemplateLiteral.arguments:first-child',
  15. ].join('');
  16. /** @param {import('eslint').Rule.RuleContext} context */
  17. const create = context => {
  18. const sourceCode = context.getSourceCode();
  19. const options = {
  20. tags: ['outdent', 'dedent', 'gql', 'sql', 'html', 'styled'],
  21. functions: ['dedent', 'stripIndent'],
  22. selectors: [jestInlineSnapshotSelector],
  23. comments: ['HTML', 'indent'],
  24. ...context.options[0],
  25. };
  26. options.comments = options.comments.map(comment => comment.toLowerCase());
  27. const selectors = [
  28. ...options.tags.map(tagName => `TaggedTemplateExpression[tag.name="${tagName}"] > .quasi`),
  29. ...options.functions.map(functionName => `CallExpression[callee.name="${functionName}"] > .arguments`),
  30. ...options.selectors,
  31. ];
  32. /** @param {import('@babel/core').types.TemplateLiteral} node */
  33. const indentTemplateLiteralNode = node => {
  34. const delimiter = '__PLACEHOLDER__' + Math.random();
  35. const joined = node.quasis
  36. .map(quasi => {
  37. const untrimmedText = sourceCode.getText(quasi);
  38. return untrimmedText.slice(1, quasi.tail ? -1 : -2);
  39. })
  40. .join(delimiter);
  41. const eolMatch = joined.match(/\r?\n/);
  42. if (!eolMatch) {
  43. return;
  44. }
  45. const eol = eolMatch[0];
  46. const startLine = sourceCode.lines[node.loc.start.line - 1];
  47. const marginMatch = startLine.match(/^(\s*)\S/);
  48. const parentMargin = marginMatch ? marginMatch[1] : '';
  49. let indent;
  50. if (typeof options.indent === 'string') {
  51. indent = options.indent;
  52. } else if (typeof options.indent === 'number') {
  53. indent = ' '.repeat(options.indent);
  54. } else {
  55. const tabs = parentMargin.startsWith('\t');
  56. indent = tabs ? '\t' : ' ';
  57. }
  58. const dedented = stripIndent(joined);
  59. const fixed
  60. = eol
  61. + indentString(dedented.trim(), 1, {indent: parentMargin + indent})
  62. + eol
  63. + parentMargin;
  64. if (fixed === joined) {
  65. return;
  66. }
  67. context.report({
  68. node,
  69. messageId: MESSAGE_ID_IMPROPERLY_INDENTED_TEMPLATE,
  70. fix: fixer => fixed
  71. .split(delimiter)
  72. .map((replacement, index) => replaceTemplateElement(fixer, node.quasis[index], replacement)),
  73. });
  74. };
  75. return {
  76. /** @param {import('@babel/core').types.TemplateLiteral} node */
  77. TemplateLiteral(node) {
  78. if (options.comments.length > 0) {
  79. const previousToken = sourceCode.getTokenBefore(node, {includeComments: true});
  80. if (previousToken && previousToken.type === 'Block' && options.comments.includes(previousToken.value.trim().toLowerCase())) {
  81. indentTemplateLiteralNode(node);
  82. return;
  83. }
  84. }
  85. const ancestry = context.getAncestors().reverse();
  86. const shouldIndent = selectors.some(selector => esquery.matches(node, esquery.parse(selector), ancestry));
  87. if (shouldIndent) {
  88. indentTemplateLiteralNode(node);
  89. }
  90. },
  91. };
  92. };
  93. /** @type {import('json-schema').JSONSchema7[]} */
  94. const schema = [
  95. {
  96. type: 'object',
  97. additionalProperties: false,
  98. properties: {
  99. indent: {
  100. oneOf: [
  101. {
  102. type: 'string',
  103. pattern: /^\s+$/.source,
  104. },
  105. {
  106. type: 'integer',
  107. minimum: 1,
  108. },
  109. ],
  110. },
  111. tags: {
  112. type: 'array',
  113. uniqueItems: true,
  114. items: {
  115. type: 'string',
  116. },
  117. },
  118. functions: {
  119. type: 'array',
  120. uniqueItems: true,
  121. items: {
  122. type: 'string',
  123. },
  124. },
  125. selectors: {
  126. type: 'array',
  127. uniqueItems: true,
  128. items: {
  129. type: 'string',
  130. },
  131. },
  132. comments: {
  133. type: 'array',
  134. uniqueItems: true,
  135. items: {
  136. type: 'string',
  137. },
  138. },
  139. },
  140. },
  141. ];
  142. /** @type {import('eslint').Rule.RuleModule} */
  143. module.exports = {
  144. create,
  145. meta: {
  146. type: 'suggestion',
  147. docs: {
  148. description: 'Fix whitespace-insensitive template indentation.',
  149. },
  150. fixable: 'code',
  151. schema,
  152. messages,
  153. },
  154. };