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

prefer-string-starts-ends-with: add suggestions for safely handling non-strings #1277

Merged
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
14 changes: 14 additions & 0 deletions docs/rules/prefer-string-starts-ends-with.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Prefer [`String#startsWith()`](https://developer.mozilla.org/en/docs/Web/JavaScr

This rule is fixable.

Note: the autofixed code will throw an exception when the value being tested is not a string. Several safer but more verbose automatic suggestions are provided for this situation.
bmish marked this conversation as resolved.
Show resolved Hide resolved

## Fail

```js
Expand All @@ -24,6 +26,18 @@ const foo = baz.startsWith('bar');
const foo = baz.endsWith('bar');
```

```js
const foo = baz?.startsWith('bar');
```

```js
const foo = (baz ?? '').startsWith('bar');
```

```js
const foo = String(baz).startsWith('bar');
```

```js
const foo = /^bar/i.test(baz);
```
102 changes: 75 additions & 27 deletions rules/prefer-string-starts-ends-with.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,28 @@ const getDocumentationUrl = require('./utils/get-documentation-url');
const methodSelector = require('./utils/method-selector');
const quoteString = require('./utils/quote-string');
const shouldAddParenthesesToMemberExpressionObject = require('./utils/should-add-parentheses-to-member-expression-object');
const shouldAddParenthesesToLogicalExpressionChild = require('./utils/should-add-parentheses-to-logical-expression-child');
const {getParenthesizedText, getParenthesizedRange} = require('./utils/parentheses');

const MESSAGE_STARTS_WITH = 'prefer-starts-with';
const MESSAGE_ENDS_WITH = 'prefer-ends-with';
const FIX_STRING_CAST = 'useStringCasting';
const FIX_OPTIONAL_CHAINING = 'useOptionalChaining';
const FIX_NULLISH_COALESCING = 'useNullishCoalescing';
const messages = {
[MESSAGE_STARTS_WITH]: 'Prefer `String#startsWith()` over a regex with `^`.',
[MESSAGE_ENDS_WITH]: 'Prefer `String#endsWith()` over a regex with `$`.'
[MESSAGE_ENDS_WITH]: 'Prefer `String#endsWith()` over a regex with `$`.',
[FIX_STRING_CAST]: 'When testing against a value that may not be a string, use string casting.',
[FIX_OPTIONAL_CHAINING]: 'When testing against a value that may not be a string, use optional chaining.',
[FIX_NULLISH_COALESCING]: 'When testing against a value that may not be a string, use nullish coalescing.'
};

const doesNotContain = (string, characters) => characters.every(character => !string.includes(character));

const isSimpleString = string => doesNotContain(
string,
['^', '$', '+', '[', '{', '(', '\\', '.', '?', '*', '|']
);
const addParentheses = text => `(${text})`;

const regexTestSelector = [
methodSelector({name: 'test', length: 1}),
Expand Down Expand Up @@ -64,33 +72,72 @@ const create = context => {
return;
}

function * fix(fixer, fixType) {
const method = result.messageId === MESSAGE_STARTS_WITH ? 'startsWith' : 'endsWith';
const [target] = node.arguments;
let targetText = getParenthesizedText(target, sourceCode);
const isRegexParenthesized = isParenthesized(regexNode, sourceCode);
const isTargetParenthesized = isParenthesized(target, sourceCode);

switch (fixType) {
// Goal: `(target ?? '').startsWith(pattern)`
case FIX_NULLISH_COALESCING:
if (
!isTargetParenthesized &&
shouldAddParenthesesToLogicalExpressionChild(target, {operator: '??', property: 'left'})
) {
targetText = addParentheses(targetText);
}

targetText += ' ?? \'\'';

// `LogicalExpression` need add parentheses to call `.startsWith()`,
// but if regex is parenthesized, we can reuse it
if (!isRegexParenthesized) {
targetText = addParentheses(targetText);
}

break;

// Goal: `String(target).startsWith(pattern)`
case FIX_STRING_CAST:
// `target` was a call argument, don't need check parentheses
targetText = `String(${targetText})`;
Copy link
Sponsor Contributor Author

@bmish bmish May 18, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds extra parenthesis that aren't needed sometimes. I think I had code to handle this before.

in: (/^b/).test((a))
out: ((a)).startsWith('b')

Not a big deal though.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know that, but better "add missing parens" instead of "remove extra parens".

// `CallExpression` don't need add parentheses to call `.startsWith()`
break;

// Goal: `target.startsWith(pattern)` or `target?.startsWith(pattern)`
case FIX_OPTIONAL_CHAINING:
// Optional chaining: `target.startsWith` => `target?.startsWith`
yield fixer.replaceText(sourceCode.getTokenBefore(node.callee.property), '?.');
// Fallthrough
default:
if (
!isRegexParenthesized &&
!isTargetParenthesized &&
shouldAddParenthesesToMemberExpressionObject(target, sourceCode)
) {
targetText = addParentheses(targetText);
}
}

// The regex literal always starts with `/` or `(`, so we don't need check ASI

// Replace regex with string
yield fixer.replaceText(regexNode, targetText);

// `.test` => `.startsWith` / `.endsWith`
yield fixer.replaceText(node.callee.property, method);

// Replace argument with result.string
yield fixer.replaceTextRange(getParenthesizedRange(target, sourceCode), quoteString(result.string));
}

context.report({
node,
messageId: result.messageId,
fix: fixer => {
const method = result.messageId === MESSAGE_STARTS_WITH ? 'startsWith' : 'endsWith';
const [target] = node.arguments;
let targetString = sourceCode.getText(target);

if (
// If regex is parenthesized, we can use it, so we don't need add again
!isParenthesized(regexNode, sourceCode) &&
(isParenthesized(target, sourceCode) || shouldAddParenthesesToMemberExpressionObject(target, sourceCode))
) {
targetString = `(${targetString})`;
}

// The regex literal always starts with `/` or `(`, so we don't need check ASI

return [
// Replace regex with string
fixer.replaceText(regexNode, targetString),
// `.test` => `.startsWith` / `.endsWith`
fixer.replaceText(node.callee.property, method),
// Replace argument with result.string
fixer.replaceText(target, quoteString(result.string))
];
}
suggest: [FIX_STRING_CAST, FIX_OPTIONAL_CHAINING, FIX_NULLISH_COALESCING].map(type => ({messageId: type, fix: fixer => fix(fixer, type)})),
fix
});
}
};
Expand All @@ -102,7 +149,8 @@ module.exports = {
type: 'suggestion',
docs: {
description: 'Prefer `String#startsWith()` & `String#endsWith()` over `RegExp#test()`.',
url: getDocumentationUrl(__filename)
url: getDocumentationUrl(__filename),
suggest: true
},
fixable: 'code',
schema: [],
Expand Down
38 changes: 38 additions & 0 deletions rules/utils/should-add-parentheses-to-logical-expression-child.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
'use strict';

/**
Check if parentheses should be added to a `node` when it's used as child of `LogicalExpression`.
@param {Node} node - The AST node to check.
@param {{operator: string, property: string}} options - Options
@returns {boolean}
*/
function shouldAddParenthesesToLogicalExpressionChild(node, {operator, property}) {
// Not really needed, but more readable
if (
node.type === 'AwaitExpression' ||
node.type === 'BinaryExpression'
) {
return true;
}

// Lower precedence than `LogicalExpression`
// see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Operator_Precedence#Table
if (
node.type === 'ConditionalExpression' ||
node.type === 'AssignmentExpression' ||
node.type === 'AssignmentExpression' ||
node.type === 'YieldExpression' ||
node.type === 'SequenceExpression'
) {
return true;
}

/* istanbul ignore next: When operator or property is different, need more logic here, not implemented */
if (operator !== '??' || property !== 'left') {
throw new Error('Not supported.');
bmish marked this conversation as resolved.
Show resolved Hide resolved
}

return false;
}

module.exports = shouldAddParenthesesToLogicalExpressionChild;