template-indent.js 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  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 trimmed = dedented.replace(new RegExp(`^${eol}|${eol}[ \t]*$`, 'g'), '');
  60. const fixed
  61. = eol
  62. + indentString(trimmed, 1, {indent: parentMargin + indent})
  63. + eol
  64. + parentMargin;
  65. if (fixed === joined) {
  66. return;
  67. }
  68. context.report({
  69. node,
  70. messageId: MESSAGE_ID_IMPROPERLY_INDENTED_TEMPLATE,
  71. fix: fixer => fixed
  72. .split(delimiter)
  73. .map((replacement, index) => replaceTemplateElement(fixer, node.quasis[index], replacement)),
  74. });
  75. };
  76. return {
  77. /** @param {import('@babel/core').types.TemplateLiteral} node */
  78. TemplateLiteral(node) {
  79. if (options.comments.length > 0) {
  80. const previousToken = sourceCode.getTokenBefore(node, {includeComments: true});
  81. if (previousToken?.type === 'Block' && options.comments.includes(previousToken.value.trim().toLowerCase())) {
  82. indentTemplateLiteralNode(node);
  83. return;
  84. }
  85. }
  86. const ancestry = context.getAncestors().reverse();
  87. const shouldIndent = selectors.some(selector => esquery.matches(node, esquery.parse(selector), ancestry));
  88. if (shouldIndent) {
  89. indentTemplateLiteralNode(node);
  90. }
  91. },
  92. };
  93. };
  94. /** @type {import('json-schema').JSONSchema7[]} */
  95. const schema = [
  96. {
  97. type: 'object',
  98. additionalProperties: false,
  99. properties: {
  100. indent: {
  101. oneOf: [
  102. {
  103. type: 'string',
  104. pattern: /^\s+$/.source,
  105. },
  106. {
  107. type: 'integer',
  108. minimum: 1,
  109. },
  110. ],
  111. },
  112. tags: {
  113. type: 'array',
  114. uniqueItems: true,
  115. items: {
  116. type: 'string',
  117. },
  118. },
  119. functions: {
  120. type: 'array',
  121. uniqueItems: true,
  122. items: {
  123. type: 'string',
  124. },
  125. },
  126. selectors: {
  127. type: 'array',
  128. uniqueItems: true,
  129. items: {
  130. type: 'string',
  131. },
  132. },
  133. comments: {
  134. type: 'array',
  135. uniqueItems: true,
  136. items: {
  137. type: 'string',
  138. },
  139. },
  140. },
  141. },
  142. ];
  143. /** @type {import('eslint').Rule.RuleModule} */
  144. module.exports = {
  145. create,
  146. meta: {
  147. type: 'suggestion',
  148. docs: {
  149. description: 'Fix whitespace-insensitive template indentation.',
  150. },
  151. fixable: 'code',
  152. schema,
  153. messages,
  154. },
  155. };