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 no-negated-condition rule #1963

Merged
merged 18 commits into from Nov 16, 2022
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 instead.
fisker marked this conversation as resolved.
Show resolved Hide resolved

Improved version of the [`no-negated-condition`](https://eslint.org/docs/latest/rules/no-negated-condition) ESLint rule, which is automatically fixable.
fisker marked this conversation as resolved.
Show resolved Hide resolved

## 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