Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add template-indent rule #1478

Merged
merged 49 commits into from
Oct 11, 2021
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
be4a8c7
Add template-indent rule
mmkal Aug 11, 2021
c980841
Use selectors, plus a few more tests
mmkal Aug 11, 2021
2155312
Use whitespace character placeholders
mmkal Aug 11, 2021
bb613bf
Placeholder docs
mmkal Aug 11, 2021
9046358
Add docs
mmkal Aug 11, 2021
2cc5798
Link to rule in readme
mmkal Aug 11, 2021
7df897f
Remove incorrect docs
mmkal Aug 11, 2021
1f1459c
xo fixes
mmkal Aug 11, 2021
54f05a1
do not imply this rule will edit your sql queries!
mmkal Aug 11, 2021
6caf8c4
generate-usage-example
mmkal Aug 11, 2021
9a77206
don't slice
mmkal Aug 11, 2021
bae3ea2
generate-usage-example
mmkal Aug 11, 2021
93e9abc
fixup this codebasw!
mmkal Aug 11, 2021
c087efc
comments
mmkal Aug 11, 2021
2cf0018
Case-insensitive comments
mmkal Aug 11, 2021
be9b6c9
Use node.loc.start.line
mmkal Aug 11, 2021
d36e3db
remove unused message id
mmkal Aug 11, 2021
f11e3db
remove out of date comment
mmkal Aug 11, 2021
2090b98
Remove another irrelevant comment
mmkal Aug 11, 2021
fde95bd
don't use Array.from
mmkal Aug 11, 2021
1afe51c
remove unnecessary .reverse()
mmkal Aug 11, 2021
c3e2b60
Use helper
mmkal Aug 11, 2021
aa773d9
Delete errant comment
mmkal Aug 11, 2021
c8f9113
customisable indent
mmkal Aug 12, 2021
a8d373e
no previous token test
mmkal Aug 12, 2021
88f2370
test for multiple matches
mmkal Aug 12, 2021
bad4bde
Use strip-indent; avoid duplicate reporting
mmkal Aug 13, 2021
bbdb741
Test for double-reporting
mmkal Aug 13, 2021
3b4e34d
Fix double-reporting
mmkal Aug 13, 2021
ef7b875
Test that comments can be disabled
mmkal Aug 13, 2021
7c26714
Require unique items
mmkal Aug 13, 2021
5255715
Use .replace
mmkal Aug 13, 2021
970b846
xo --fix
mmkal Aug 13, 2021
47460c5
Document `indent` option
mmkal Aug 13, 2021
a9adb8d
Use esquery.matches
mmkal Sep 8, 2021
ac2c9a7
Explicit carriage return test
mmkal Sep 8, 2021
69e02de
Make \r\n test stricter
mmkal Sep 8, 2021
e445d32
Use sourceCode.getText(...)
mmkal Sep 8, 2021
a2b0678
Merge remote-tracking branch 'origin/main' into template-indent
mmkal Sep 13, 2021
27a8051
Allow non-first-child
mmkal Sep 28, 2021
6e7a465
Use escaped backticks and dollars
mmkal Sep 28, 2021
edb6a42
Use `quasi.tail`
mmkal Sep 28, 2021
2747f8b
Update docs
mmkal Sep 28, 2021
84ada62
Multiline template argument tests
mmkal Sep 28, 2021
7c52002
Add esquery dependency
mmkal Oct 7, 2021
f5c6a66
Merge remote-tracking branch 'origin/main' into template-indent
mmkal Oct 7, 2021
edb2ce6
Return early
mmkal Oct 8, 2021
2868c38
Update template-indent.md
sindresorhus Oct 11, 2021
f4001bf
Update template-indent.md
sindresorhus Oct 11, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions docs/rules/template-indent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Fix whitespace-insensitive template indentation.

Tagged templates often look ugly/jarring because their indentation often 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 time of writing) for a proposal for 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}
`;

// if you "fix" indentation manually, then copy code somewhere else, it can look stupid
mmkal marked this conversation as resolved.
Show resolved Hide resolved
const gqlQuery = gql`
query user(id: 5) {
firstName
lastName
}
`;
}
```

## 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
}
`;
}
```

## Options

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

Default configuration:

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

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'],
}]
}
```
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,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',
},
overrides: [
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
"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 @@ -124,6 +124,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 @@ -243,6 +244,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
135 changes: 135 additions & 0 deletions rules/template-indent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
'use strict';
const stripIndent = require('strip-indent');

const MESSAGE_ID_IMPROPERLY_INDENTED_TEMPLATE = 'template-indent';
const MESSAGE_ID_INVALID_NODE_TYPE = 'invalid-node-type';
const messages = {
[MESSAGE_ID_IMPROPERLY_INDENTED_TEMPLATE]: 'Templates should be properly indented. Selector: {{ selector }}',
[MESSAGE_ID_INVALID_NODE_TYPE]: 'Invalid node type matched by selector {{ selector }}. Expected TemplateLiteral, got {{ type }}',
};

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

const selectors = [
...options.tags.map(tag => `TaggedTemplateExpression[tag.name="${tag}"] > .quasi`),
...options.functions.map(fn => `CallExpression[callee.name="${fn}"] > .arguments:first-child`),
mmkal marked this conversation as resolved.
Show resolved Hide resolved
...options.selectors,
];

/** @param {string} selector */
const getTemplateLiteralHandler = selector =>
/** @param {import('@babel/core').types.TemplateLiteral} node */
node => {
mmkal marked this conversation as resolved.
Show resolved Hide resolved
if (node.type !== 'TemplateLiteral') {
context.report({
node,
messageId: MESSAGE_ID_INVALID_NODE_TYPE,
data: {
selector,
type: node.type,
},
});
return;
}
mmkal marked this conversation as resolved.
Show resolved Hide resolved

const delimiter = '__PLACEHOLDER__' + Math.random();
const joined = node.quasis.map(q => q.value.raw).join(delimiter);
mmkal marked this conversation as resolved.
Show resolved Hide resolved

if (!joined.includes('\n')) {
return;
}

const dedented = stripIndent(joined).trim();

const source = context.getSourceCode().getText();
mmkal marked this conversation as resolved.
Show resolved Hide resolved

const preamble = source.slice(0, node.range[0]).split('\n');
const line = preamble.length;

const marginMatch = preamble[line - 1].match(/^(\s*)\S/);
const parentMargin = marginMatch ? marginMatch[1] : '';
const tabs = parentMargin.startsWith('\t');
const indent = tabs ? '\t' : ' ';
mmkal marked this conversation as resolved.
Show resolved Hide resolved
const templateMargin = parentMargin + indent;

const fixed = '\n' + dedented.split('\n').map(line => templateMargin + line).join('\n').trimEnd() + '\n' + parentMargin;

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

const replacements = fixed.split(delimiter).map((section, i, {length}) => {
const range = [...node.quasis[i].range];

// Add one either for the "`" or "}" prefix character
range[0] += 1;

// Subtract one at the end for the "`" or two in the middle for the "${"
const last = i === length - 1;
range[1] -= last ? 1 : 2;

return {
range,
value: section,
};
});
context.report({
node,
messageId: MESSAGE_ID_IMPROPERLY_INDENTED_TEMPLATE,
data: {
selector,
},
fix: fixer => replacements.reverse().map(r => fixer.replaceTextRange(r.range, r.value)),
});
};

return Object.fromEntries(selectors.map(selector => [selector, getTemplateLiteralHandler(selector)]));
};

/** @type {import('json-schema').JSONSchema7[]} */
const schema = [
{
type: 'object',
properties: {
tags: {
type: 'array',
items: {
type: 'string',
},
},
functions: {
type: 'array',
items: {
type: 'string',
},
},
selectors: {
type: 'array',
items: {
type: 'string',
},
},
},
additionalProperties: false,
},
];

module.exports = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Fix whitespace-insensitive template indentation.',
},
fixable: 'code',
schema,
messages,
},
};
Loading