Skip to content

Commit

Permalink
Add no-negated-condition rule (#1963)
Browse files Browse the repository at this point in the history
  • Loading branch information
fisker committed Nov 16, 2022
1 parent 5f23c98 commit e4aaa42
Show file tree
Hide file tree
Showing 10 changed files with 936 additions and 2 deletions.
2 changes: 2 additions & 0 deletions configs/recommended.js
Expand Up @@ -40,6 +40,8 @@ module.exports = {
'unicorn/no-invalid-remove-event-listener': 'error',
'unicorn/no-keyword-prefix': 'off',
'unicorn/no-lonely-if': 'error',
'no-negated-condition': 'off',
'unicorn/no-negated-condition': 'error',
'no-nested-ternary': 'off',
'unicorn/no-nested-ternary': 'error',
'unicorn/no-new-array': 'error',
Expand Down
2 changes: 1 addition & 1 deletion docs/rules/expiring-todo-comments.md
Expand Up @@ -7,7 +7,7 @@

This rule makes it possible to pass arguments to TODO, FIXME and XXX comments to trigger ESLint to report.

From [ESLint's documentation](https://eslint.org/docs/rules/no-warning-comments):
From [ESLint's documentation](https://eslint.org/docs/latest/rules/no-warning-comments):

> Developers often add comments to code which is not complete or needs review.
Expand Down
92 changes: 92 additions & 0 deletions docs/rules/no-negated-condition.md
@@ -0,0 +1,92 @@
# Disallow negated conditions

✅ This rule is enabled in the `recommended` [config](https://github.com/sindresorhus/eslint-plugin-unicorn#preset-configs).

🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).

<!-- end auto-generated rule header -->
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->

Negated conditions are more difficult to understand. Code can be made more readable by inverting the condition.

This is an improved version of the [`no-negated-condition`](https://eslint.org/docs/latest/rules/no-negated-condition) ESLint rule that makes it automatically fixable. [ESLint did not want to make it fixable.](https://github.com/eslint/eslint/issues/14792)

## Fail

```js
if (!a) {
doSomethingC();
} else {
doSomethingB();
}
```

```js
if (a !== b) {
doSomethingC();
} else {
doSomethingB();
}
```

```js
!a ? c : b
```

```js
if (a != b) {
doSomethingC();
} else {
doSomethingB();
}
```

## Pass

```js
if (a) {
doSomethingB();
} else {
doSomethingC();
}
```

```js
if (a === b) {
doSomethingB();
} else {
doSomethingC();
}
```

```js
a ? b : c
```

```js
if (a == b) {
doSomethingB();
} else {
doSomethingC();
}
```

```js
if (!a) {
doSomething();
}
```

```js
if (!a) {
doSomething();
} else if (b) {
doSomethingElse();
}
```

```js
if (a != b) {
doSomething();
}
```
2 changes: 1 addition & 1 deletion docs/rules/no-nested-ternary.md
Expand Up @@ -7,7 +7,7 @@
<!-- end auto-generated rule header -->
<!-- Do not manually modify this header. Run: `npm run fix:eslint-docs` -->

Improved version of the [`no-nested-ternary`](https://eslint.org/docs/rules/no-nested-ternary) ESLint rule, which allows cases where the nested ternary is only one level and wrapped in parens.
Improved version of the [`no-nested-ternary`](https://eslint.org/docs/latest/rules/no-nested-ternary) ESLint rule, which allows cases where the nested ternary is only one level and wrapped in parens.

## Fail

Expand Down
1 change: 1 addition & 0 deletions readme.md
Expand Up @@ -81,6 +81,7 @@ Use a [preset config](#preset-configs) or configure each rules in `package.json`
| [no-invalid-remove-event-listener](docs/rules/no-invalid-remove-event-listener.md) | Prevent calling `EventTarget#removeEventListener()` with the result of an expression. || | |
| [no-keyword-prefix](docs/rules/no-keyword-prefix.md) | Disallow identifiers starting with `new` or `class`. | | | |
| [no-lonely-if](docs/rules/no-lonely-if.md) | Disallow `if` statements as the only statement in `if` blocks without `else`. || 🔧 | |
| [no-negated-condition](docs/rules/no-negated-condition.md) | Disallow negated conditions. || 🔧 | |
| [no-nested-ternary](docs/rules/no-nested-ternary.md) | Disallow nested ternary expressions. || 🔧 | |
| [no-new-array](docs/rules/no-new-array.md) | Disallow `new Array()`. || 🔧 | 💡 |
| [no-new-buffer](docs/rules/no-new-buffer.md) | Enforce the use of `Buffer.from()` and `Buffer.alloc()` instead of the deprecated `new Buffer()`. || 🔧 | 💡 |
Expand Down
137 changes: 137 additions & 0 deletions rules/no-negated-condition.js
@@ -0,0 +1,137 @@
/*
Based on ESLint builtin `no-negated-condition` rule
https://github.com/eslint/eslint/blob/5c39425fc55ecc0b97bbd07ac22654c0eb4f789c/lib/rules/no-negated-condition.js
*/
'use strict';
const {matches} = require('./selectors/index.js');
const {
removeParentheses,
fixSpaceAroundKeyword,
addParenthesizesToReturnOrThrowExpression,
} = require('./fix/index.js');
const {
getParenthesizedRange,
isParenthesized,
} = require('./utils/parentheses.js');
const isOnSameLine = require('./utils/is-on-same-line.js');
const needsSemicolon = require('./utils/needs-semicolon.js');

const MESSAGE_ID = 'no-negated-condition';
const messages = {
[MESSAGE_ID]: 'Unexpected negated condition.',
};

const selector = [
matches([
'IfStatement[alternate][alternate.type!="IfStatement"]',
'ConditionalExpression',
]),
matches([
'[test.type="UnaryExpression"][test.operator="!"]',
'[test.type="BinaryExpression"][test.operator="!="]',
'[test.type="BinaryExpression"][test.operator="!=="]',
]),
].join('');

function * convertNegatedCondition(fixer, node, sourceCode) {
const {test} = node;
if (test.type === 'UnaryExpression') {
const token = sourceCode.getFirstToken(test);

if (node.type === 'IfStatement') {
yield * removeParentheses(test.argument, fixer, sourceCode);
}

yield fixer.remove(token);
return;
}

const token = sourceCode.getTokenAfter(
test.left,
token => token.type === 'Punctuator' && token.value === test.operator,
);

yield fixer.replaceText(token, '=' + token.value.slice(1));
}

function * swapConsequentAndAlternate(fixer, node, sourceCode) {
const isIfStatement = node.type === 'IfStatement';
const [consequent, alternate] = [
node.consequent,
node.alternate,
].map(node => {
const range = getParenthesizedRange(node, sourceCode);
let text = sourceCode.text.slice(...range);
// `if (!a) b(); else c()` can't fix to `if (!a) c() else b();`
if (isIfStatement && node.type !== 'BlockStatement') {
text = `{${text}}`;
}

return {
range,
text,
};
});

if (consequent.text === alternate.text) {
return;
}

yield fixer.replaceTextRange(consequent.range, alternate.text);
yield fixer.replaceTextRange(alternate.range, consequent.text);
}

/** @param {import('eslint').Rule.RuleContext} context */
const create = context => ({
[selector](node) {
return {
node: node.test,
messageId: MESSAGE_ID,
/** @param {import('eslint').Rule.RuleFixer} fixer */
* fix(fixer) {
const sourceCode = context.getSourceCode();
yield * convertNegatedCondition(fixer, node, sourceCode);
yield * swapConsequentAndAlternate(fixer, node, sourceCode);

if (
node.type !== 'ConditionalExpression'
|| node.test.type !== 'UnaryExpression'
) {
return;
}

yield * fixSpaceAroundKeyword(fixer, node, sourceCode);

const {test, parent} = node;
const [firstToken, secondToken] = sourceCode.getFirstTokens(test, 2);
if (
(parent.type === 'ReturnStatement' || parent.type === 'ThrowStatement')
&& parent.argument === node
&& !(isParenthesized(node, sourceCode) && !isParenthesized(test, sourceCode))
&& !isOnSameLine(firstToken, secondToken)
) {
yield * addParenthesizesToReturnOrThrowExpression(fixer, parent, sourceCode);
return;
}

const tokenBefore = sourceCode.getTokenBefore(node);
if (needsSemicolon(tokenBefore, sourceCode, secondToken.value)) {
yield fixer.insertTextBefore(node, ';');
}
},
};
},
});

/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
create,
meta: {
type: 'suggestion',
docs: {
description: 'Disallow negated conditions.',
},
fixable: 'code',
messages,
},
};
109 changes: 109 additions & 0 deletions test/no-negated-condition.mjs
@@ -0,0 +1,109 @@
import outdent from 'outdent';
import {getTester} from './utils/test.mjs';

const {test} = getTester(import.meta);

test.snapshot({
valid: [
'if (a) {}',
'if (a) {} else {}',
'if (!a) {}',
'if (!a) {} else if (b) {}',
'if (!a) {} else if (b) {} else {}',
'if (a == b) {}',
'if (a == b) {} else {}',
'if (a != b) {}',
'if (a != b) {} else if (b) {}',
'if (a != b) {} else if (b) {} else {}',
'if (a !== b) {}',
'if (a === b) {} else {}',
'a ? b : c',
],
invalid: [
'if (!a) {;} else {;}',
'if (a != b) {;} else {;}',
'if (a !== b) {;} else {;}',
'!a ? b : c',
'a != b ? c : d',
'a !== b ? c : d',
'(( !a )) ? b : c',
'!(( a )) ? b : c',
'if(!(( a ))) b(); else c();',
'if((( !a ))) b(); else c();',
'function a() {return!a ? b : c}',
'function a() {return!(( a )) ? b : c}',
outdent`
function a() {
return ! // comment
a ? b : c;
}
`,
outdent`
function a() {
return (! // ReturnStatement argument is parenthesized
a ? b : c);
}
`,
outdent`
function a() {
return (
! // UnaryExpression argument is parenthesized
a) ? b : c;
}
`,
outdent`
function a() {
throw ! // comment
a ? b : c;
}
`,
'!a ? b : c ? d : e',
'!a ? b : (( c ? d : e ))',
outdent`
a
![] ? b : c
`,
outdent`
a
!+b ? c : d
`,
outdent`
a
!(b) ? c : d
`,
outdent`
a
!b ? c : d
`,
outdent`
if (!a)
b()
else
c()
`,
'if(!a) b(); else c()',
outdent`
function fn() {
if(!a) b(); else return
}
`,
'if(!a) {b()} else {c()}',
'if(!!a) b(); else c();',
'(!!a) ? b() : c();',
outdent`
function fn() {
return!a !== b ? c : d
return((!((a)) != b)) ? c : d
}
`,
outdent`
if (!a) {
b();
} else if (!c) {
d();
} else {
e();
}
`,
],
});
1 change: 1 addition & 0 deletions test/package.mjs
Expand Up @@ -14,6 +14,7 @@ test.before(async () => {

const ignoredRules = [
'no-nested-ternary',
'no-negated-condition',
];

const deprecatedRules = Object.entries(eslintPluginUnicorn.rules)
Expand Down

0 comments on commit e4aaa42

Please sign in to comment.