Skip to content
This repository has been archived by the owner on Mar 25, 2021. It is now read-only.

Add new ban-parens rule #3238

Closed
wants to merge 23 commits into from
Closed

Add new ban-parens rule #3238

wants to merge 23 commits into from

Conversation

calebegg
Copy link
Contributor

Since the definition of “unnecessary” is by its nature very opinionated, I made this rule very open-ended, with a nice set of default “unnecessary” parens that I hope should be relatively uncontroversial.

Let me know if there are defaults you think I should either add or remove.

return {
message: `the ${whichChild} child of an expression of type ${parentKind}`,
test(node: ts.ParenthesizedExpression | ts.ParenthesizedTypeNode) {
if (node.parent == undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

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

That condition will never be true

Copy link
Contributor Author

Choose a reason for hiding this comment

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

True, but I thought the check would be preferable to the !. Switched to the !.

"VariableDeclaration.initializer",
"ParenthesizedExpression.expression",
"CallExpression.arguments",
"ExpressionStatement.expression",
Copy link
Contributor

Choose a reason for hiding this comment

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

All of these options require knowledge of typescript's AST. I don't think that's practicle for most TSLint users.

Maybe the rule should just find all unnecessary parens by default and provide options to disable some of them. Something like "allow-typeof" to allow (typeof foo)==="string" for example. These can be added on demand.

Also with these options, there's currently no way to ban parens on "*.type", e.g. let foo: (string) and foo(param: (1)) {}

Copy link
Contributor

Choose a reason for hiding this comment

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

There's currently no way to ban (1 + 2) + 3 without banning (1 + 2) * 3, too.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maybe the rule should just find all unnecessary parens

So, I didn't want to get into litigating what's "necessary". I don't want to tell people to not do (true && false) || true or something, when I think that's probably preferable to leaving out the parentheses since it's hard to remember boolean prescience.

Instead, I want to ban them where they're clearly unnecessary, and leave the edge cases for human reviewers (or the judgement of the author).

I feel like having a list of allowances would get out of hand.

If you think this approach is fundamentally unsuitable, that's fine, just let me know. I'll just use it on my project.

Also with these options, there's currently no way to ban parens on "*.type", e.g. let foo: (string) and foo(param: (1)) {}

Why not? I added some examples and tests.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Maybe the documentation could point to http://astexplorer.net. That's how I find these AST names.

Copy link
Contributor

Choose a reason for hiding this comment

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

Adding a link to astexplorer.net sounds like a good idea.

Copy link
Contributor

@ajafff ajafff left a comment

Choose a reason for hiding this comment

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

Code looks good to me. Need to think a bit more about the defaults since we cannot simply change them later.


function isNodeOfKind(node: ts.Node, kindName: string) {
switch (kindName) {
case "LiteralExpression":
Copy link
Contributor

Choose a reason for hiding this comment

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

consider adding case "*": return true; to allow for *.type

// ex: (foo.bar());
"ExpressionStatement.expression",
// ex: function foo((a: string), b: number) {}
"SignatureDeclaration.parameters",
Copy link
Contributor

Choose a reason for hiding this comment

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

SignatureDeclaration is not a SyntaxKind. It's the base type for FunctionDeclaration, FunctionExpression, ConstructorDeclaration, IndexSignature, ...
This option will not work as intended.
Also note that a parameter cannot be wrapped in parens

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ah, good call on both counts. I was being overenthusiastic. :-)

// ex: let x: (string|number) = 3;
"VariableDeclaration.type",
// ex: function(foo: (number|string)) {}
"Parameter.type",
Copy link
Contributor

Choose a reason for hiding this comment

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

// foo[(bar + "baz")]
"ElementAccessExpression.argumentExpression",
// `${(foo)}`
"TemplateSpan.expression"

// ex: ((1 + 1)) + 2
"ParenthesizedExpression.expression",
// ex: foo((a), b)
"CallExpression.arguments",
Copy link
Contributor

Choose a reason for hiding this comment

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

also "NewExpression.arguments"

// ex: foo((a), b)
"CallExpression.arguments",
// ex: Foo<(string|number), string>
"TypeReference.typeArguments",
Copy link
Contributor

Choose a reason for hiding this comment

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

"*.typeArguments"?

"VariableDeclaration.initializer",
"ParenthesizedExpression.expression",
"CallExpression.arguments",
"ExpressionStatement.expression",
Copy link
Contributor

Choose a reason for hiding this comment

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

Adding a link to astexplorer.net sounds like a good idea.

@ajafff
Copy link
Contributor

ajafff commented Sep 26, 2017

@calebegg please merge master to fix the build

* Add link to astexplorer.net
* Support *.foo in asChildOf (and add a test)
* Add new default rules
* Remove erroneous default rule
Copy link
Contributor

@ajafff ajafff left a comment

Choose a reason for hiding this comment

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

CI is failing at

(castType.getProperties().some((symbol) => !isNaN(Number(symbol.name))))) {

@@ -0,0 +1,33 @@
let x = (3);
Copy link
Contributor

Choose a reason for hiding this comment

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

There's one edge case that would result in invalid code:

let y, z;
let x = (y = 1, z = 2);

I hope nobody writes such code.
Nontheless there should probably be an exception for ts.SyntaxKind.BinaryExpression where operatorToken.kind === ts.SyntaxKind.CommaToken

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ugh, the comma operator is the worst. On my shortlist of new tslint rules is one to ban it entirely.

I made it so that it doesn't suggest a fix if the child uses a comma operator, but it still flags it as an issue. How does that sound?

// ex: let x: (string|number) = 3;
"VariableDeclaration.type",
// ex: function(foo: (number|string)) {}
"Parameter.type",
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm still in favor of using "*.type". I can't come up with a case where parens are desired or even required.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

* Don’t suggest a fix when the child is a comma operator expresssion
* Use *.type instead of Parameter.type
@calebegg
Copy link
Contributor Author

Hey, that's my own commit! :-) Fixed.

Copy link
Contributor

@ajafff ajafff left a comment

Choose a reason for hiding this comment

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

LGTM
What do you think about renaming the rule to ban-parens? I think that expresses the intention of the rule better: the user needs to specify where to ban parens, the rule does not automagically find all unnecessary parens.

// ex: let x = (1 + foo());
"VariableDeclaration.initializer",
// ex: type x = (string|number);
"TypeAliasDeclaration.type",
Copy link
Contributor

Choose a reason for hiding this comment

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

this is redundant now, but I'm fine with leaving it in for documentation

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, that's true. I guess it does give a better error message, kinda, to have the more specific ones.

I'd be fine either way. Up to you.

// ex: (foo.bar());
"ExpressionStatement.expression",
// ex: let x: (string|number) = 3;
"VariableDeclaration.type",
Copy link
Contributor

Choose a reason for hiding this comment

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

also redundant

@calebegg
Copy link
Contributor Author

Makes sense. Renamed.

@calebegg calebegg changed the title Add new no-unnecessary-parens rule Add new ban-parens rule Sep 26, 2017
@calebegg
Copy link
Contributor Author

FYI, updated to fix a minor issue that came up when running over our codebase -- typeof(x) and throw(y) become typeofx and throwy.

@calebegg
Copy link
Contributor Author

Fixed more issues that cropped up in our codebase -- (0).toString() became 0.toString(), which doesn't compile.

return (
   foo);

becomes

return 
  foo;

which also doesn't compile.

// Don't flag `(0).foo()`, because `0.foo()` doesn't work.
(isParenthesizedExpression(node) &&
isNumericLiteral(node.expression) &&
node.parent != undefined &&
Copy link
Contributor

Choose a reason for hiding this comment

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

node.parent cannot be undefined

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Changed to !

For another case, this broke the type narrowing, so I had to add a cast.

isPropertyAccessExpression(node.parent)) ||
// Don't flag `return (\nfoo)`, since the parens are necessary.
(isParenthesizedExpression(node) &&
node.parent != undefined &&
Copy link
Contributor

Choose a reason for hiding this comment

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

same here

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

(isParenthesizedExpression(node) &&
node.parent != undefined &&
isReturnStatement(node.parent) &&
ctx.sourceFile.text[node.getStart() + 1] === "\n")
Copy link
Contributor

Choose a reason for hiding this comment

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

isSameLine(ctx.sourceFile, node.expression.pos, node.expression.getStart(ctx.sourceFile))

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done (had to tweak it a bit; used node.expression.getStart() and node.getStart())

replacement = [];
}

if (!(
Copy link
Contributor

Choose a reason for hiding this comment

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

Consider extracting this condition to a function and invoke it in the condition in line 199

if (isParenthesizedExpression(node) && !parensAreNecessary(node, ctx.sourceFile) || isParenthesizedType(node))

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

}
}

function isParenthesizedType(node: ts.Node): node is ts.ParenthesizedTypeNode {
Copy link
Contributor

Choose a reason for hiding this comment

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

note to myself: remove this after the next release of tsutils

isPropertyAccessExpression(node.parent!)) ||
// Don't flag `return (\nfoo)`, since the parens are necessary.
(isReturnStatement(node.parent!) &&
!isSameLine(sourceFile, node.getStart(), node.expression.getStart())) ||
Copy link
Contributor

Choose a reason for hiding this comment

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

please pass the sourceFile parameter to .getStart() to avoid unnecesary lookups.
I'm also very confident that you can replace node.getStart() with node.expression.pos. The former gives you the start of the open paren, the latter gives the end of the open paren. But that difference doesn't matter for isSameLine

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. Sorry, I wrestled with this a bit until I got something working and then didn't pay more attention. I didn't really know the difference between .getStart and .pos (though I think I get it now). Is there documentation for this stuff somewhere?

What's the benefit of passing sourceFile in?

Copy link
Contributor

Choose a reason for hiding this comment

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

.pos is the position where the parser started to parse the current node (or token). node.pos is the same value as the preceding token's .end.
node.getStart() is the position where the first token of a node starts. The difference between .pos and .getStart() is caused by trivia (whitespace and comments).
Also note that node.pos === node.getFullStart() and node.end === node.getEnd()

Re: passing sourceFile parameter:
I wrote this down in https://palantir.github.io/tslint/develop/custom-rules/performance-tips.html

There are serveral methods that have an optional parameter sourceFile. Don’t omit this parameter if you care for performance. If ommitted, typescript needs to walk up the node’s parent chain until it reaches the SourceFile. This can be quite costly when done frequently on deeply nested nodes.

You also find there why I prefer not to call .getStart():

node.getStart() scans the source to skip all the leading trivia. Although barely noticeable, this operation is not for free. If you need the start position of a node more than once per function, consider caching it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Got it, makes sense. Thanks for the tips!

isExpressionStatement(node.parent!)) ||
// Don't flag parentheses in an arrow function's body
(isArrowFunction(node.parent!) &&
(node.parent as ts.ArrowFunction).body === node));
Copy link
Contributor

Choose a reason for hiding this comment

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

you can remove the second part of the condition. A ParenthesizedExpression can only occur in the body of an ArrowFunction

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

// Don't flag parentheses in an arrow function's body
(isArrowFunction(node.parent!) &&
(node.parent as ts.ArrowFunction).body === node));
}
Copy link
Contributor

Choose a reason for hiding this comment

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

We should stop adding more exceptions or it will get out of hand. Users should not ban parens around certain Expressions, for example BinaryExpression and ConditionalExpression. (maybe add this to the docs?)
Otherwise you would need to allow BinaryExpression and ConditionalExpression when it is (New|Call|PropertyAccess|ElementAccess|Void|Await|Delete)Expression.expression, e.g. (foo.bar || foo.baz)(), (foo ? bar : baz)(), ...

... and probably many more

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added documentation.

I agree it's getting complex. If you think there are any default syntax kinds that aren't worth the exceptions, let me know. It's running cleanly over our codebase now with defaults, so I am at least somewhat confident that we've found most of the strange cases.

@calebegg
Copy link
Contributor Author

Anything else you need from me for this?

@ajafff
Copy link
Contributor

ajafff commented Oct 10, 2017

@calebegg nope, I just want @adidahiya to sign this off before landing, but it seems like he's busy lately.

// No issues with
let z = (x) => x + 1;
let x = (1 + 1) * 2;
(f()) + 1;
Copy link
Contributor

Choose a reason for hiding this comment

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

can you also add some tests for common JSX syntax?

const foo = (
    <div>text</div>
);

<button ref={(el => this.buttonEl = el)} />
             ~                        ~

<button ref={el => (this.buttonEl = el)} />
                   ~                  ~

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added two tests. I don't really know what I'm doing with JSX :-) so let me know if they could be improved. (The 2nd and 3rd you gave aren't errors with my current test settings)

Copy link
Contributor

@adidahiya adidahiya Oct 20, 2017

Choose a reason for hiding this comment

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

Ok, the 3rd example I gave should not be an error, but I think the 2nd one should so that we can align more closely with prettier. Would you be able to update your rule implementation so that my 2nd snippet does cause a lint failure?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

Copy link
Contributor

@adidahiya adidahiya left a comment

Choose a reason for hiding this comment

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

sorry for the delay. I have some minor comments, see above

~
<div>text</div>
~~~~~~~~~~~~~~~~~~~
);
Copy link
Contributor

Choose a reason for hiding this comment

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

these should not be banned by default. parens around multiline jsx statements are enforced in the default tslint-react config.

this is why I wanted you to add these tests -- the default config should not conflict with tslint-react.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I see -- I wish you'd just said that.

I don't know if it'll be easy to do this, if jsx expressions can occur anywhere. That eliminates most of the rules. I can just exclude jsx files entirely from this check, maybe?

Copy link
Contributor

Choose a reason for hiding this comment

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

Sorry if I wasn't clear upfront. Excluding .tsx files completely in this rule is not a good option because it will feel pretty inconsistent for TSX users -- there's lots of regular TS code in .tsx files. I haven't really dug into the internals of this rule implementation here (deferring to @ajafff) but I am concerned about the overall UX of this rule for tslint-react users, so I'd like you to update it before merging.

@ajafff
Copy link
Contributor

ajafff commented Oct 22, 2017

@calebegg I would avoid any hard coded special handling for JsxElements or .jsx files.
Instead you could add a config option for exceptions that override the blacklisted locations.

Consider this code snippet from the tests:

const foo = (
    <div>text</div>
);

The blacklist disallows parens in VariableDeclaration.initializer while the whitelist requires parens around JsxElement. Therefore no error.

@adidahiya I'd rather not distinguish between single-line and multi-line JsxElements.
Do we also need to whitelist JsxSelfClosingElement? It will mostly be on a single line but can also span multiple lines when there are many attributes.

In the near future TypeScript will add support for JsxFragment. We should whitelist it now to be prepared for that feature.

@calebegg
Copy link
Contributor Author

calebegg commented Oct 25, 2017

Makes sense. Done. (I can easily add JsxSelfClosingElement if we need it).

Copy link
Contributor

@ajafff ajafff left a comment

Choose a reason for hiding this comment

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

Sorry for the delay. This should be good to go once my 2 comments are addressed

return ts.forEachChild(ctx.sourceFile, function cb(node: ts.Node): void {
if ((isParenthesizedExpression(node) && !parensAreNecessary(node, ctx.sourceFile)) || isParenthesizedType(node)) {
const restriction = restrictions.find((r) => r.test(node));
if (restriction != undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

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

consider moving the check for exceptions to this condition. that avoid checking exceptions for every blacklist entry.

also: prefer !== over !=

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Oh, that makes much more sense!

I actually moved it to above restrictions.find so that it can just skip the testing entirely for exception nodes.

...ctx.options.asChildOf,
] : ctx.options.asChildOf;
const exceptions = ctx.options.default ? [
"JsxElement",
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it's best to also whitelist JsxSelfClosingElement

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@ajafff
Copy link
Contributor

ajafff commented Nov 2, 2017

You need to merge latest master to fix the CI failures

* Add JsxSelfClosingElement to default exceptions
* Refactor exception logic to one place.
@ajafff
Copy link
Contributor

ajafff commented Nov 3, 2017

@adidahiya PTAL

@calebegg merging master caused some new lint failures:

/home/ubuntu/tslint/src/rules/completed-docs/tagExclusion.ts
ERROR: 78:43   ban-parens  Don't include parentheses around an expression of type LiteralExpression

/home/ubuntu/tslint/src/rules/maxLineLengthRule.ts
ERROR: 99:28   ban-parens  Don't include parentheses around an expression of type Identifier

/home/ubuntu/tslint/src/rules/noUnsafeAnyRule.ts
ERROR: 102:45  ban-parens  Don't include parentheses around the initializer child of an expression of type VariableDeclaration

@adidahiya
Copy link
Contributor

Ok, I have some more feedback but I apologize if this is poorly timed. I haven't been able to keep up with all the external PRs submitted to this repo recently.

I don't think this rule in its current form belongs in the core rule set. The configuration options are esoteric and unapproachable for most users. This is mainly because they expose internals of the AST semantics to the end user -- I'm drawing a line there when it comes to developer experience. I think we need to limit the scope of possible configuration because otherwise users will get confused by the error messages it produces and will have a hard time figuring out how to configure the rule's options to make their linter pass. Alternatively, it would be fine to keep this in a separate ruleset package for power users of TSLint.

@calebegg
Copy link
Contributor Author

Closing. It would have been great to get this feedback earlier on in the process.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants