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

feat: add noYodaExpression rule #2853

Merged
merged 33 commits into from
May 22, 2024

Conversation

michellocana
Copy link
Contributor

Summary

Implementation of the yoda ESLint rule in Biome.
Closes #2743

Invalid case Fix suggestion
if ("red" == value) {} if (value == "red") {}
if (true === value) {} if (value === true) {}
if (5 != value) {} if (value != 5) {}
if (5n != value) {} if (value != 5n) {}
if (null !== value) {} if (value !== null) {}
if ("red" <= value) {} if (value >= "red") {}
if (`red` <= value) {} if (value >= `red`) {}
if (`red` <= `${foo}`) {} if (`${foo}` >= `red`) {}
if (`red` <= `${"red"}`) {} if (`${"red"}` >= `red`) {}
if (true >= value) {} if (value <= true) {}

I'm not entirely sure the error messages are the best it could be, so I'm open to suggestions :)

Test Plan

Added snapshots, for valid and invalid cases according to the yoda ESLint tests.

@github-actions github-actions bot added A-CLI Area: CLI A-Project Area: project A-Linter Area: linter L-JavaScript Language: JavaScript and super languages A-Diagnostic Area: diagnostocis A-Changelog Area: changelog labels May 14, 2024
Copy link

codspeed-hq bot commented May 14, 2024

CodSpeed Performance Report

Merging #2853 will not alter performance

Comparing michellocana:feat/no-yoda-expression (8e462f2) with michellocana:feat/no-yoda-expression (e6f9787)

Summary

✅ 92 untouched benchmarks

Copy link
Member

@Conaclos Conaclos left a comment

Choose a reason for hiding this comment

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

Thanks for your contribution! This is greatly appreciated ❤️ I didn't review the implementation yet. I just left two preliminary comments.

@michellocana michellocana force-pushed the feat/no-yoda-expression branch 2 times, most recently from a065cb6 to ef3f5e5 Compare May 17, 2024 21:56
Copy link
Member

@ematipico ematipico left a comment

Choose a reason for hiding this comment

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

Here are a few things we should do:

  • use the try ? operator to bubble up all those Option types
  • all functions that need to deal with the Option type should return Option
    , e.g. -> bool to -> Option<bool>
  • I think the type AnyJsLiteralExpression has a method called omit_parenthesis, let's use it. I can't remember exactly the names though. However, we already solved the problem internally
  • is_comparison_operator should be already solved, I think there's already a function for that. If not, we should move it instead biome_js_syntax

Comment on lines 77 to 79
let left = node.left().ok();
let right = node.right().ok();
let operator = node.operator().ok();
Copy link
Member

Choose a reason for hiding this comment

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

Inside rules, if you return an Option inside the run function, always use .ok()? when extracting/querying nodes. There's no need to keep the Option around unless you're doing an existing check

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 wasn't familiar with the ? operator until now and... damn, it really makes things a lot easier 👀, I tried to use it on all the possible places

let left = node.left().ok();
let right = node.right().ok();
let operator = node.operator().ok();
let parent_logical_expression = node.parent::<JsLogicalExpression>();
Copy link
Member

Choose a reason for hiding this comment

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

Same here. Use the try operator ? to bubble up the Option type

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, moved this one to the is_range_assertion helper

}

/// Compare literals as number if possible, compare as string otherwise
fn compare_literals(left_string_value: &str, right_string_value: &str) -> bool {
Copy link
Member

Choose a reason for hiding this comment

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

Nit: compare_string_literals

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!

}
}

fn is_wrapped_in_parenthesis(logical_expression: &JsLogicalExpression) -> bool {
Copy link
Member

Choose a reason for hiding this comment

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

The name of the function isn't aligned with the implementation. The implementation checks more than parenthesis. I suggest

  • rename the function
  • or document 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.

I wasn't aware of .prev_sibling_or_token() and .next_sibling_or_token(), so I ended up with this hacky solution, but I just found out about it and now the implementation is more aligned with the name: it is actually looking for ( and ) tokens

}
}

fn is_comparison_operator(operator: &Option<JsBinaryOperator>) -> bool {
Copy link
Member

Choose a reason for hiding this comment

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

This function exists on JsBinaryOperator.

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!

) -> bool {
match (left_expression, right_expression) {
(Some(left), Some(right)) => {
// We can't consider two call expressions equal here because the result of the expression might be different if we call it twice
Copy link
Member

Choose a reason for hiding this comment

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

Could you add a quick example in the comment?

Do we really need this check?

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've updated the documentation of this method to explain it better why it's needed, but I had to do this check to ensure that this method will return false when there's a call expression inside the expression.

Consider this expression: if (0 <= a[b()] && a[b()] < 1) {}

We can't assume the expressions are equal because the result of the JsCallExpression might be different in multiple calls:
image

Copy link
Member

Choose a reason for hiding this comment

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

I see. I think it is a small thing. It is most likely to cause false positives. I assume you ported it from the ESLint rule? Instead, I could remove this check and just recognize this as a range. What do you think @ematipico ?

Copy link
Member

Choose a reason for hiding this comment

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

Expressions can't be statically evaluated (unless they are literal), so we should skip them.

We do the same in the rule noSelfAssignment: even though left and right call the same expressions, we can't know their result.

Copy link
Member

Choose a reason for hiding this comment

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

What do you mean by skip them?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

In this case if (0 <= a[b()] && a[b()] < 1) {} the rule won't recognize the expression as a range, but 0 <= a[b()] alone still is a yoda expression, so it will trigger the rule anyways, are you ok with that @ematipico?

Copy link
Member

@Conaclos Conaclos May 20, 2024

Choose a reason for hiding this comment

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

I think that @ematipico means that every expression should be considered as different. For example 0 < o.p && o.p < 10 would not be recognized as a range because o.p can give different results (potentially a getter).

Conversely, I think we could recognize all cases, including (0 <= a[b()] && a[b()] < 1) as ranges.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Conversely, I think we could recognize all cases, including (0 <= a[b()] && a[b()] < 1) as ranges.

I'm cool with that too actually, I'm just gonna wait for @ematipico response and I can make the change

Copy link
Member

Choose a reason for hiding this comment

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

@michellocana let's accept (0 <= a[b()] && a[b()] < 1) and I think we can merge.

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! @Conaclos

CHANGELOG.md Outdated Show resolved Hide resolved
///
/// ```js
/// if (0 < value && value < 1) {}
/// ```
Copy link
Member

Choose a reason for hiding this comment

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

Let's put the Wikipidia link at the bottom, like this:

/// ## Resources
///
/// - [WebAIM: Keyboard Accessibility - Accesskey](https://webaim.org/techniques/keyboard/accesskey#spec)
/// - [MDN `accesskey` documentation](https://developer.mozilla.org/docs/Web/HTML/Global_attributes/accesskey)

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!

@michellocana michellocana force-pushed the feat/no-yoda-expression branch 2 times, most recently from 372e51e to 49a63d3 Compare May 21, 2024 11:32
Copy link
Member

@ematipico ematipico left a comment

Choose a reason for hiding this comment

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

There's some unsafe code to change, mainly the use of unwrap

let new_left = if has_missing_left_trivia {
clone_with_trivia(right, &left_leading_trivia, &left_trailing_trivia)
.with_leading_trivia_pieces(whitespace.clone())
.unwrap()
Copy link
Member

Choose a reason for hiding this comment

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

Don't unwrap

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, removed all .unwrap() calls, but .unwrap_or_default() is ok, right?

let new_right = if has_missing_right_trivia {
clone_with_trivia(left, &right_leading_trivia, &right_trailing_trivia)
.append_trivia_pieces(whitespace.clone())
.unwrap()
Copy link
Member

Choose a reason for hiding this comment

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

Don't unwrap

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

@michellocana michellocana force-pushed the feat/no-yoda-expression branch 2 times, most recently from e3dc624 to f076688 Compare May 21, 2024 12:22
Copy link
Member

@ematipico ematipico left a comment

Choose a reason for hiding this comment

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

I thought I already suggested using the try operator ? to bubble up Option. We shouldn't use unwrap_or_default because it forces the to code still run.

The reason why we turn SyntaxResult to Option is because the CST might contain bogus nodes or invalid syntax. If a node has invalid syntax, we just stop the analysis.

Comment on lines 85 to 87
&& is_literal_expression(&left).unwrap_or_default()
&& !is_literal_expression(&right).unwrap_or_default()
&& !is_range_assertion(node).unwrap_or_default();
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
&& is_literal_expression(&left).unwrap_or_default()
&& !is_literal_expression(&right).unwrap_or_default()
&& !is_range_assertion(node).unwrap_or_default();
&& is_literal_expression(&left)?
&& !is_literal_expression(&right)?
&& !is_range_assertion(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.

I was able to remove all but this call: !is_range_assertion(node).unwrap_or_default(), this helper might return None but the code should keep running in that case, is there another approach I could take here? I can convert it to return bool instead of Option<bool> if you think it's better

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, in this case, it makes sense to return bool. Some documentation could help explain why

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The method is now returning bool, and also, I did my best to explain in details what this method does, let me know what you think :)

Comment on lines 268 to 269
&& (is_inside_range_assertion(operator, &left, &right).unwrap_or_default()
|| is_outside_range_assertion(operator, &left, &right).unwrap_or_default())
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
&& (is_inside_range_assertion(operator, &left, &right).unwrap_or_default()
|| is_outside_range_assertion(operator, &left, &right).unwrap_or_default())
&& (is_inside_range_assertion(operator, &left, &right)?
|| is_outside_range_assertion(operator, &left, &right)?)

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!

AnyJsExpression::JsUnaryExpression(unary) => match unary.operator() {
Ok(JsUnaryOperator::Minus) => {
let argument = unary.argument().ok()?.text();
let is_numeric_literal = unary.is_signed_numeric_literal().unwrap_or_default();
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
let is_numeric_literal = unary.is_signed_numeric_literal().unwrap_or_default();
let is_numeric_literal = unary.is_signed_numeric_literal()?;

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!

michellocana and others added 25 commits May 21, 2024 12:04
Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
Co-authored-by: Emanuele Stoppa <my.burning@gmail.com>
… and make it return bool instead of Option<bool>
@ematipico ematipico merged commit a51fa60 into biomejs:main May 22, 2024
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-Changelog Area: changelog A-CLI Area: CLI A-Diagnostic Area: diagnostocis A-Linter Area: linter A-Project Area: project L-JavaScript Language: JavaScript and super languages
Projects
None yet
Development

Successfully merging this pull request may close these issues.

📎 Implement noYodaExpression - yoda
3 participants