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: noMagicNumbers #4971

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 108 additions & 89 deletions crates/biome_configuration/src/analyzer/linter/rules.rs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions crates/biome_diagnostics_categories/src/categories.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ define_categories! {
"lint/nursery/noInvalidGridAreas": "https://biomejs.dev/linter/rules/use-consistent-grid-areas",
"lint/nursery/noInvalidPositionAtImportRule": "https://biomejs.dev/linter/rules/no-invalid-position-at-import-rule",
"lint/nursery/noIrregularWhitespace": "https://biomejs.dev/linter/rules/no-irregular-whitespace",
"lint/nursery/noMagicNumbers": "https://biomejs.dev/linter/rules/no-magic-numbers",
"lint/nursery/noMissingGenericFamilyKeyword": "https://biomejs.dev/linter/rules/no-missing-generic-family-keyword",
"lint/nursery/noMissingVarFunction": "https://biomejs.dev/linter/rules/no-missing-var-function",
"lint/nursery/noNestedTernary": "https://biomejs.dev/linter/rules/no-nested-ternary",
Expand Down
2 changes: 2 additions & 0 deletions crates/biome_js_analyze/src/lint/nursery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub mod no_head_element;
pub mod no_head_import_in_document;
pub mod no_img_element;
pub mod no_irregular_whitespace;
pub mod no_magic_numbers;
pub mod no_nested_ternary;
pub mod no_noninteractive_element_interactions;
pub mod no_octal_escape;
Expand Down Expand Up @@ -63,6 +64,7 @@ declare_lint_group! {
self :: no_head_import_in_document :: NoHeadImportInDocument ,
self :: no_img_element :: NoImgElement ,
self :: no_irregular_whitespace :: NoIrregularWhitespace ,
self :: no_magic_numbers :: NoMagicNumbers ,
self :: no_nested_ternary :: NoNestedTernary ,
self :: no_noninteractive_element_interactions :: NoNoninteractiveElementInteractions ,
self :: no_octal_escape :: NoOctalEscape ,
Expand Down
240 changes: 240 additions & 0 deletions crates/biome_js_analyze/src/lint/nursery/no_magic_numbers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
use biome_analyze::{
context::RuleContext, declare_lint_rule, Ast, FixKind, Rule, RuleDiagnostic, RuleSource,
};
use biome_console::markup;
use biome_deserialize_macros::Deserializable;
use biome_js_syntax::{
AnyJsExpression, JsAssignmentExpression, JsCallExpression, JsSyntaxKind, JsUnaryOperator,
JsVariableDeclarator,
};
use biome_rowan::{AstNode, SyntaxNode, TextRange};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

const MAX_ARRAY_LENGTH: i64 = (2_i64.pow(32)) - 1;

#[derive(
Debug, Default, Clone, Serialize, Deserialize, Deserializable, PartialEq, Eq, JsonSchema,
)]
pub struct NoMagicNumbersConfig {
detect_objects: bool,
enforce_const: bool,
ignore: Vec<i64>,
ignore_array_indexes: bool,
ignore_default_values: bool,
ignore_class_field_initial_values: bool,
Comment on lines +20 to +25
Copy link
Member

Choose a reason for hiding this comment

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

Each field must be documented. These options will appear in the configuration schema, so we should provide a description for our users.

}

declare_lint_rule! {
/// Disallow magic numbers
///
/// This rule aims to make code more maintainable by ensuring that special numbers
/// are declared as constants with meaningful names.
///
/// ## Examples
///
/// ### Invalid
///
/// ```js,expect_diagnostic
/// let total = price * 1.23; // Magic number for tax rate
/// ```
///
/// ### Valid
///
/// ```js
/// const TAX_RATE = 1.23;
/// let total = price * TAX_RATE;
/// ```
pub NoMagicNumbers {
version: "1.0.0",
kerolloz marked this conversation as resolved.
Show resolved Hide resolved
name: "noMagicNumbers",
language: "js",
sources: &[RuleSource::Eslint("no-magic-numbers")],
recommended: false,
fix_kind: FixKind::None,
kerolloz marked this conversation as resolved.
Show resolved Hide resolved
}
}

#[derive(Debug)]
pub struct NumberContext {
range: TextRange,
raw: String,
is_const_violation: bool,
}

impl Rule for NoMagicNumbers {
type Query = Ast<AnyJsExpression>;
type State = NumberContext;
type Signals = Option<Self::State>;
type Options = NoMagicNumbersConfig;

fn run(ctx: &RuleContext<Self>) -> Self::Signals {
let node = ctx.query();
let config = ctx.options();
Copy link
Member

Choose a reason for hiding this comment

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

Nit: let's call it options


match node {
AnyJsExpression::AnyJsLiteralExpression(literal_expr) => {
if let Some(literal) = literal_expr.as_js_number_literal_expression() {
let value_token = literal.value_token().ok()?;
let value_text = value_token.text();
let value = value_text.parse::<i64>().ok()?;
if is_allowed_number(literal.syntax(), value, config) {
return None;
}

let is_const_violation = check_const_violation(literal.syntax(), config);

Some(NumberContext {
range: literal.range(),
raw: value_token.text().to_string(),
Comment on lines +87 to +88
Copy link
Member

Choose a reason for hiding this comment

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

Pass the literal node instead, and then extract the range and the name inside the diagnostic function. It will avoid allocating a String

is_const_violation,
})
} else {
return None;
}
}
AnyJsExpression::JsUnaryExpression(unary) => {
if let Ok(operator) = unary.operator() {
if operator == JsUnaryOperator::Minus {
if let Ok(argument) = unary.argument() {
if let Some(literal_expr) = argument.as_any_js_literal_expression() {
if let Ok(value_token) = literal_expr.value_token() {
let value_text = value_token.text();
let value = value_text.parse::<i64>().ok()?;
let neg_value = -value;
if is_allowed_number(unary.syntax(), neg_value, config) {
return None;
}
let is_const_violation =
check_const_violation(unary.syntax(), config);
return Some(NumberContext {
range: unary.range(),
raw: format!("-{}", value_token.text()),
is_const_violation,
});
}
}
}
}
}
None
}
_ => None,
}
}

fn diagnostic(ctx: &RuleContext<Self>, state: &Self::State) -> Option<RuleDiagnostic> {
let diagnostic = if state.is_const_violation {
RuleDiagnostic::new(
rule_category!(),
state.range,
markup! { "Number constants declarations must use 'const'" },
)
} else {
RuleDiagnostic::new(
rule_category!(),
state.range,
markup! { "No magic number: "{state.raw} },
)
.note(markup! {
"Consider extracting this magic number into a named constant."
})
};
Some(diagnostic)
Comment on lines +126 to +142
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 diagnostic = if state.is_const_violation {
RuleDiagnostic::new(
rule_category!(),
state.range,
markup! { "Number constants declarations must use 'const'" },
)
} else {
RuleDiagnostic::new(
rule_category!(),
state.range,
markup! { "No magic number: "{state.raw} },
)
.note(markup! {
"Consider extracting this magic number into a named constant."
})
};
Some(diagnostic)
if state.is_const_violation {
RuleDiagnostic::new(
rule_category!(),
state.range,
markup! { "Number constants declarations must use 'const'" },
)
} else {
RuleDiagnostic::new(
rule_category!(),
state.range,
markup! { "No magic number: "{state.raw} },
)
.note(markup! {
"Consider extracting this magic number into a named constant."
})
}

}
}

fn is_allowed_number(
Copy link
Member

Choose a reason for hiding this comment

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

Remove the commented code and add some documentation to the function. We want to explain the business logic of "when a number is allowed"

node: &SyntaxNode<biome_js_syntax::JsLanguage>,
value: i64,
config: &NoMagicNumbersConfig,
) -> bool {
// Ignore specific values from config
if config.ignore.contains(&value) {
return true;
}

// Check parent nodes for various allowed contexts
if let Some(parent) = node.parent() {
match parent.kind() {
// Array index check
// k if k == JsSyntaxKind::JS_STATIC_MEMBER_EXPRESSION
// || k == JsSyntaxKind::JS_COMPUTED_MEMBER_EXPRESSION =>
// {
// if config.ignore_array_indexes
// && JsMemberExpression::cast(parent)
// .map_or(false, |m| m.object().is_ok() && is_valid_array_index(value))
// {
// return true;
// }
// }
// parseInt radix check
k if k == JsSyntaxKind::JS_CALL_EXPRESSION => {
if let Some(call) = JsCallExpression::cast(parent) {
if let Ok(callee) = call.callee() {
if callee.syntax().text() == "parseInt" {
return true;
}
}
}
}
// Default value check
k if k == JsSyntaxKind::JS_OBJECT_ASSIGNMENT_PATTERN
|| k == JsSyntaxKind::JS_ARRAY_ASSIGNMENT_PATTERN =>
{
if config.ignore_default_values {
return true;
}
}
// Class field check
k if k == JsSyntaxKind::JS_PROPERTY_CLASS_MEMBER => {
if config.ignore_class_field_initial_values {
return true;
}
}
_ => {}
}
}

if !config.detect_objects {
if let Some(parent) = node.parent() {
match parent.kind() {
JsSyntaxKind::JS_OBJECT_EXPRESSION | JsSyntaxKind::JS_PROPERTY_OBJECT_MEMBER => {
return true;
}
JsSyntaxKind::JS_ASSIGNMENT_EXPRESSION => {
if let Some(expr) = JsAssignmentExpression::cast(parent) {
if let Ok(left_pattern) = expr.left() {
if left_pattern.as_js_object_assignment_pattern().is_none() {
return true;
}
}
}
}
_ => {}
}
}
}

false
}

fn check_const_violation(
Copy link
Member

Choose a reason for hiding this comment

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

There's no documentation about this "const violation" concept. You might want to add it somewhere, even in the official documentation of the rule

node: &SyntaxNode<biome_js_syntax::JsLanguage>,
config: &NoMagicNumbersConfig,
) -> bool {
if !config.enforce_const {
return false;
}

node.ancestors()
.find_map(JsVariableDeclarator::cast)
.and_then(|decl| decl.syntax().parent())
.and_then(JsVariableDeclarator::cast)
.and_then(|parent| parent.id().ok())
.map_or(false, |kind| kind.to_string() != "const")
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 convert to String when you just need a &str. Look for text_trimmed method

}

fn is_valid_array_index(value: f64) -> bool {
value.floor() == value && value >= 0.0 && value < MAX_ARRAY_LENGTH as f64
}
2 changes: 2 additions & 0 deletions crates/biome_js_analyze/src/options.rs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
var koko = 1;
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
expression: invalid.js
---
# Input
```jsx
var koko = 1;

```

# Diagnostics
```
invalid.js:1:12 lint/nursery/noMagicNumbers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

! No magic number: 1

> 1 │ var koko = 1;
│ ^
2 │

i Consider extracting this magic number into a named constant.


```
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/* should not generate diagnostics */
var a = 1;
Copy link
Member

Choose a reason for hiding this comment

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

The scaffolding should already provide a comment saying that code here shouldn't generate diagnostics, but the snapshot test did. Can you fix it?

Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
source: crates/biome_js_analyze/tests/spec_tests.rs
expression: valid.js
---
# Input
```jsx
/* should not generate diagnostics */
var a = 1;

```

# Diagnostics
```
valid.js:2:9 lint/nursery/noMagicNumbers ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

! No magic number: 1

1 │ /* should not generate diagnostics */
> 2 │ var a = 1;
│ ^
3 │

i Consider extracting this magic number into a named constant.


```
5 changes: 5 additions & 0 deletions packages/@biomejs/backend-jsonrpc/src/workspace.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions packages/@biomejs/biome/configuration_schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading