Skip to content

Commit

Permalink
Add template-indent rule (#1478)
Browse files Browse the repository at this point in the history
Co-authored-by: Sindre Sorhus <sindresorhus@gmail.com>
  • Loading branch information
mmkal and sindresorhus committed Oct 11, 2021
1 parent 3e2a4e2 commit 5f4c440
Show file tree
Hide file tree
Showing 9 changed files with 933 additions and 40 deletions.
1 change: 1 addition & 0 deletions configs/recommended.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ module.exports = {
'unicorn/require-number-to-fixed-digits-argument': 'error',
'unicorn/require-post-message-target-origin': 'error',
'unicorn/string-content': 'off',
'unicorn/template-indent': 'warn',
'unicorn/throw-new-error': 'error',
...require('./conflicting-rules.js').rules,
},
Expand Down
134 changes: 134 additions & 0 deletions docs/rules/template-indent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# Fix whitespace-insensitive template indentation

[Tagged templates](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals#tagged_templates) often look ugly/jarring because their indentation doesn't match the code they're found in. In many cases, whitespace is insignificant, or a library like [strip-indent](https://www.npmjs.com/package/strip-indent) is used to remove the margin. See [proposal-string-dedent](https://github.com/tc39/proposal-string-dedent) (stage 1 at the time of writing) for a proposal on fixing this in JavaScript.

This rule will automatically fix the indentation of multiline string templates, to keep them in alignment with the code they are found in. A configurable whitelist is used to ensure no whitespace-sensitive strings are edited.

## Fail

```js
function foo() {
const sqlQuery = sql`
select *
from students
where first_name = ${x}
and last_name = ${y}
`;

const gqlQuery = gql`
query user(id: 5) {
firstName
lastName
}
`;

const html = /* HTML */ `
<div>
<span>hello</span>
</div>
`;
}
```

## Pass

The above will auto-fix to:

```js
function foo() {
const sqlQuery = sql`
select *
from students
where first_name = ${x}
and last_name = ${y}
`;

const gqlQuery = gql`
query user(id: 5) {
firstName
lastName
}
`;

const html = /* HTML */ `
<div>
<span>hello</span>
</div>
`;
}
```

Under the hood, [strip-indent](https://npmjs.com/package/strip-indent) is used to determine how the template "should" look. Then a common indent is added to each line based on the margin of the line the template started at. This rule will *not* alter the relative whitespace between significant lines, it will only shift the content right or left so that it aligns sensibly with the surrounding code.

## Options

The rule accepts lists of `tags`, `functions`, `selectors` and `comments` to match template literals. `tags` are tagged template literal identifiers, functions are names of utility functions like `stripIndent`, selectors can be any [ESLint selector](https://eslint.org/docs/developer-guide/selectors), and comments are `/* block-commented */` strings.

Default configuration:

```js
{
'unicorn/template-indent': [
'warn',
{
tags: [
'outdent',
'dedent',
'gql',
'sql',
'html',
'styled'
],
functions: [
'dedent',
'stripIndent'
],
selectors: [],
comments: [
'HTML',
'indent'
]
}
]
}
```

You can use a selector for custom use-cases, like indenting *all* template literals, even those without template tags or function callers:

```js
{
'unicorn/template-indent': [
'warn',
{
tags: [],
functions: [],
selectors: [
'TemplateLiteral'
]
}
]
}
```

Indentation will be done with tabs or spaces depending on the line of code that the template literal starts at. You can override this by supplying an `indent`, which should be either a number (of spaces) or a string consisting only of whitespace characters:

```js
{
'unicorn/template-indent': [
'warn', {
indent: 8,
}
]
}
```

```js
{
'unicorn/template-indent': [
'warn',
{
indent: '\t\t'
}
]
}
```
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,16 @@
"clean-regexp": "^1.0.0",
"eslint-template-visitor": "^2.3.2",
"eslint-utils": "^3.0.0",
"esquery": "^1.4.0",
"indent-string": "4",
"is-builtin-module": "^3.1.0",
"lodash": "^4.17.21",
"pluralize": "^8.0.0",
"read-pkg-up": "^7.0.1",
"regexp-tree": "^0.1.23",
"safe-regex": "^2.1.1",
"semver": "^7.3.5"
"semver": "^7.3.5",
"strip-indent": "^3.0.0"
},
"devDependencies": {
"@babel/code-frame": "^7.14.5",
Expand Down
2 changes: 2 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ Configure it in `package.json`.
"unicorn/require-number-to-fixed-digits-argument": "error",
"unicorn/require-post-message-target-origin": "error",
"unicorn/string-content": "off",
"unicorn/template-indent": "warn",
"unicorn/throw-new-error": "error"
},
"overrides": [
Expand Down Expand Up @@ -245,6 +246,7 @@ Each rule has emojis denoting:
| [require-number-to-fixed-digits-argument](docs/rules/require-number-to-fixed-digits-argument.md) | Enforce using the digits argument with `Number#toFixed()`. || 🔧 | |
| [require-post-message-target-origin](docs/rules/require-post-message-target-origin.md) | Enforce using the `targetOrigin` argument with `window.postMessage()`. || | 💡 |
| [string-content](docs/rules/string-content.md) | Enforce better string content. | | 🔧 | 💡 |
| [template-indent](docs/rules/template-indent.md) | Fix whitespace-insensitive template indentation. | | 🔧 | |
| [throw-new-error](docs/rules/throw-new-error.md) | Require `new` when throwing an error. || 🔧 | |

<!-- RULES_TABLE_END -->
Expand Down
164 changes: 164 additions & 0 deletions rules/template-indent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
'use strict';
const stripIndent = require('strip-indent');
const indentString = require('indent-string');
const esquery = require('esquery');
const {replaceTemplateElement} = require('./fix/index.js');

const MESSAGE_ID_IMPROPERLY_INDENTED_TEMPLATE = 'template-indent';
const messages = {
[MESSAGE_ID_IMPROPERLY_INDENTED_TEMPLATE]: 'Templates should be properly indented.',
};

/** @param {import('eslint').Rule.RuleContext} context */
const create = context => {
const sourceCode = context.getSourceCode();
const options = {
tags: ['outdent', 'dedent', 'gql', 'sql', 'html', 'styled'],
functions: ['dedent', 'stripIndent'],
selectors: [],
comments: ['HTML', 'indent'],
...context.options[0],
};

options.comments = options.comments.map(comment => comment.toLowerCase());

const selectors = [
...options.tags.map(tag => `TaggedTemplateExpression[tag.name="${tag}"] > .quasi`),
...options.functions.map(fn => `CallExpression[callee.name="${fn}"] > .arguments`),
...options.selectors,
];

/** @param {import('@babel/core').types.TemplateLiteral} node */
const indentTemplateLiteralNode = node => {
const delimiter = '__PLACEHOLDER__' + Math.random();
const joined = node.quasis
.map(quasi => {
const untrimmedText = sourceCode.getText(quasi);
return untrimmedText.slice(1, quasi.tail ? -1 : -2);
})
.join(delimiter);

const eolMatch = joined.match(/\r?\n/);
if (!eolMatch) {
return;
}

const eol = eolMatch[0];

const startLine = sourceCode.lines[node.loc.start.line - 1];
const marginMatch = startLine.match(/^(\s*)\S/);
const parentMargin = marginMatch ? marginMatch[1] : '';

let indent;
if (typeof options.indent === 'string') {
indent = options.indent;
} else if (typeof options.indent === 'number') {
indent = ' '.repeat(options.indent);
} else {
const tabs = parentMargin.startsWith('\t');
indent = tabs ? '\t' : ' ';
}

const dedented = stripIndent(joined);
const fixed
= eol
+ indentString(dedented.trim(), 1, {indent: parentMargin + indent})
+ eol
+ parentMargin;

if (fixed === joined) {
return;
}

context.report({
node,
messageId: MESSAGE_ID_IMPROPERLY_INDENTED_TEMPLATE,
fix: fixer => fixed
.split(delimiter)
.map((replacement, index) => replaceTemplateElement(fixer, node.quasis[index], replacement)),
});
};

return {
/** @param {import('@babel/core').types.TemplateLiteral} node */
TemplateLiteral: node => {
if (options.comments.length > 0) {
const previousToken = sourceCode.getTokenBefore(node, {includeComments: true});
if (previousToken && previousToken.type === 'Block' && options.comments.includes(previousToken.value.trim().toLowerCase())) {
indentTemplateLiteralNode(node);
return;
}
}

const ancestry = context.getAncestors().reverse();
const shouldIndent = selectors.some(selector => esquery.matches(node, esquery.parse(selector), ancestry));

if (shouldIndent) {
indentTemplateLiteralNode(node);
}
},
};
};

/** @type {import('json-schema').JSONSchema7[]} */
const schema = [
{
type: 'object',
properties: {
indent: {
oneOf: [
{
type: 'string',
pattern: /^\s+$/.source,
},
{
type: 'integer',
minimum: 1,
},
],
},
tags: {
type: 'array',
uniqueItems: true,
items: {
type: 'string',
},
},
functions: {
type: 'array',
uniqueItems: true,
items: {
type: 'string',
},
},
selectors: {
type: 'array',
uniqueItems: true,
items: {
type: 'string',
},
},
comments: {
type: 'array',
uniqueItems: true,
items: {
type: 'string',
},
},
},
additionalProperties: false,
},
];

module.exports = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Fix whitespace-insensitive template indentation.',
},
fixable: 'code',
schema,
messages,
},
};
10 changes: 5 additions & 5 deletions test/consistent-function-scoping.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -218,12 +218,12 @@ test({
`,
// Functions that could be extracted are conservatively ignored due to JSX masking references
outdent`
function Foo() {
function Bar () {
return <div />
}
return <div>{ Bar() }</div>
function Foo() {
function Bar () {
return <div />
}
return <div>{ Bar() }</div>
}
`,
outdent`
function foo() {
Expand Down

0 comments on commit 5f4c440

Please sign in to comment.