Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
29 changes: 29 additions & 0 deletions .changeset/use-numeric-separators-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"@biomejs/biome": minor
---

Added configurable options to the [`useNumericSeparators`](https://biomejs.dev/linter/rules/use-numeric-separators/) rule. Users can now customize the minimum number of digits required before adding separators and the group length for each type of numeric literal (`binary`, `octal`, `decimal`, `hexadecimal`).

```json
{
"linter": {
"rules": {
"style": {
"useNumericSeparators": {
"level": "error",
"options": {
"decimal": {
"minimumDigits": 7,
"groupLength": 3
},
"hexadecimal": {
"minimumDigits": 4,
"groupLength": 2
}
}
}
}
}
}
}
```
9 changes: 9 additions & 0 deletions crates/biome_cli/src/execute/migrate/eslint_eslint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,11 @@ impl Deserializable for Rules {
result.insert(Rule::UnicornFilenameCase(conf));
}
}
"unicorn/numeric-separators-style" => {
if let Some(conf) = RuleConf::deserialize(ctx, &value, name) {
result.insert(Rule::UnicornNumericSeparatorsStyle(conf));
}
}
// Other rules
rule_name => {
if let Some(conf) = RuleConf::<()>::deserialize(ctx, &value, name) {
Expand Down Expand Up @@ -663,6 +668,7 @@ pub(crate) enum Rule {
),
TypeScriptNamingConvention(RuleConf<Box<eslint_typescript::NamingConventionSelection>>),
UnicornFilenameCase(RuleConf<eslint_unicorn::FilenameCaseOptions>),
UnicornNumericSeparatorsStyle(RuleConf<eslint_unicorn::NumericSeparatorsStyleOptions>),
// If you add new variants, don't forget to update [Rules::deserialize].
}
impl Rule {
Expand All @@ -683,6 +689,9 @@ impl Rule {
Cow::Borrowed("@typescript-eslint/naming-convention")
}
Self::UnicornFilenameCase(_) => Cow::Borrowed("unicorn/filename-case"),
Self::UnicornNumericSeparatorsStyle(_) => {
Cow::Borrowed("unicorn/numeric-separators-style")
}
}
}
}
Expand Down
15 changes: 15 additions & 0 deletions crates/biome_cli/src/execute/migrate/eslint_to_biome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,21 @@ fn migrate_eslint_rule(
}
}
}
eslint_eslint::Rule::UnicornNumericSeparatorsStyle(conf) => {
if migrate_eslint_any_rule(rules, &name, conf.severity(), opts, results) {
let group = rules.style.get_or_insert_with(Default::default);
if let SeverityOrGroup::Group(group) = group {
group.use_numeric_separators =
Some(biome_config::RuleFixConfiguration::WithOptions(
biome_config::RuleWithFixOptions {
level: conf.severity().into(),
fix: None,
options: conf.option_or_default().into(),
},
));
}
}
}
eslint_eslint::Rule::UnicornFilenameCase(conf) => {
if migrate_eslint_any_rule(rules, &name, conf.severity(), opts, results) {
let group = rules.style.get_or_insert_with(Default::default);
Expand Down
51 changes: 51 additions & 0 deletions crates/biome_cli/src/execute/migrate/eslint_unicorn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
/// ALso, the module includes implementation to convert rule options to Biome's rule options.
use biome_deserialize_macros::Deserializable;
use biome_rule_options::use_filenaming_convention;
use biome_rule_options::use_numeric_separators;
use smallvec::SmallVec;

#[derive(Clone, Debug, Default, Deserializable)]
Expand Down Expand Up @@ -77,3 +78,53 @@ impl From<FilenameCases> for Option<use_filenaming_convention::FilenameCases> {
}
}
}

/// ESLint options for `unicorn/numeric-separators-style`.
///
/// Note: ESLint uses `number` for decimal literals, while Biome uses `decimal`.
/// The `onlyIfContainsSeparator` option is not supported and is ignored.
#[derive(Clone, Debug, Default, Deserializable)]
pub(crate) struct NumericSeparatorsStyleOptions {
number: EslintNumericSeparatorTypeOptions,
binary: EslintNumericSeparatorTypeOptions,
octal: EslintNumericSeparatorTypeOptions,
hexadecimal: EslintNumericSeparatorTypeOptions,
}

#[derive(Clone, Debug, Default, Deserializable)]
pub(crate) struct EslintNumericSeparatorTypeOptions {
minimum_digits: Option<u8>,
group_length: Option<u8>,
}
Comment on lines +84 to +98
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In eslint-plugin-unicorn's numeric-separators-stylerule, isonlyIfContainsSeparator supported both at top-level and per numeric type?

💡 Result:

Yes. The rule supports onlyIfContainsSeparator both:

  • Top-level ({ onlyIfContainsSeparator: true }), applying as the default for all numeric types, and
  • Per numeric type (binary / octal / hexadecimal / number), overriding the top-level value for that type. The docs state this explicitly, and the implementation merges top-level into each type while allowing the per-type option to override it. (raw.githubusercontent.com)

Citations:


🏁 Script executed:

cat -n crates/biome_cli/src/execute/migrate/eslint_unicorn.rs | sed -n '111,130p'

Repository: biomejs/biome

Length of output: 837


🏁 Script executed:

rg -t rs "struct.*UseNumericSeparatorsOptions" -A 15

Repository: biomejs/biome

Length of output: 83


🏁 Script executed:

rg "struct.*UseNumericSeparatorsOptions" -A 15

Repository: biomejs/biome

Length of output: 1731


🏁 Script executed:

rg "struct.*NumericLiteralSeparatorOptions" -A 10

Repository: biomejs/biome

Length of output: 1137


Capture onlyIfContainsSeparator for migration notice—Biome doesn't support it yet, but silently dropping it can surprise users.

ESLint's rule supports this option at both top-level and per-type, but Biome's NumericLiteralSeparatorOptions doesn't include it. Rather than silently dropping it (lines 84–98 and 111–130), capture it in the structs and emit a migration notice when present, so the behaviour delta is explicit.

Suggested capture
 pub(crate) struct NumericSeparatorsStyleOptions {
+    only_if_contains_separator: Option<bool>,
     number: EslintNumericSeparatorTypeOptions,
     binary: EslintNumericSeparatorTypeOptions,
     octal: EslintNumericSeparatorTypeOptions,
     hexadecimal: EslintNumericSeparatorTypeOptions,
 }

 pub(crate) struct EslintNumericSeparatorTypeOptions {
+    only_if_contains_separator: Option<bool>,
     minimum_digits: Option<u8>,
     group_length: Option<u8>,
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Note: ESLint uses `number` for decimal literals, while Biome uses `decimal`.
/// The `onlyIfContainsSeparator` option is not supported and is ignored.
#[derive(Clone, Debug, Default, Deserializable)]
pub(crate) struct NumericSeparatorsStyleOptions {
number: EslintNumericSeparatorTypeOptions,
binary: EslintNumericSeparatorTypeOptions,
octal: EslintNumericSeparatorTypeOptions,
hexadecimal: EslintNumericSeparatorTypeOptions,
}
#[derive(Clone, Debug, Default, Deserializable)]
pub(crate) struct EslintNumericSeparatorTypeOptions {
minimum_digits: Option<u8>,
group_length: Option<u8>,
}
/// Note: ESLint uses `number` for decimal literals, while Biome uses `decimal`.
/// The `onlyIfContainsSeparator` option is not supported and is ignored.
#[derive(Clone, Debug, Default, Deserializable)]
pub(crate) struct NumericSeparatorsStyleOptions {
only_if_contains_separator: Option<bool>,
number: EslintNumericSeparatorTypeOptions,
binary: EslintNumericSeparatorTypeOptions,
octal: EslintNumericSeparatorTypeOptions,
hexadecimal: EslintNumericSeparatorTypeOptions,
}
#[derive(Clone, Debug, Default, Deserializable)]
pub(crate) struct EslintNumericSeparatorTypeOptions {
only_if_contains_separator: Option<bool>,
minimum_digits: Option<u8>,
group_length: Option<u8>,
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_cli/src/execute/migrate/eslint_unicorn.rs` around lines 84 - 98,
The migration currently drops the ESLint onlyIfContainsSeparator option
silently; update the NumericSeparatorsStyleOptions and
EslintNumericSeparatorTypeOptions structs to include an Option<bool> field named
only_if_contains_separator (or similar) to capture that flag, then modify the
migration path to detect when only_if_contains_separator is Some(true/false) and
emit a migration notice explaining Biome doesn't support it and the behavior
will be different; reference the structs NumericSeparatorsStyleOptions and
EslintNumericSeparatorTypeOptions and ensure the migration notice is produced
wherever these structs are parsed/converted so users see the delta.


impl From<EslintNumericSeparatorTypeOptions>
for use_numeric_separators::NumericLiteralSeparatorOptions
{
fn from(val: EslintNumericSeparatorTypeOptions) -> Self {
Self {
minimum_digits: val.minimum_digits,
group_length: val.group_length,
}
}
}

impl From<NumericSeparatorsStyleOptions> for use_numeric_separators::UseNumericSeparatorsOptions {
fn from(val: NumericSeparatorsStyleOptions) -> Self {
Self {
binary: some_if_set(val.binary),
octal: some_if_set(val.octal),
decimal: some_if_set(val.number),
hexadecimal: some_if_set(val.hexadecimal),
}
}
}

fn some_if_set(
opts: EslintNumericSeparatorTypeOptions,
) -> Option<use_numeric_separators::NumericLiteralSeparatorOptions> {
if opts.minimum_digits.is_some() || opts.group_length.is_some() {
Some(opts.into())
} else {
None
}
}
67 changes: 54 additions & 13 deletions crates/biome_js_analyze/src/lint/style/use_numeric_separators.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,53 @@ declare_lint_rule! {
/// var a = 0b1100_1100;
/// ```
///
/// ## Options
///
/// Each numeric literal type can be configured with its own options object.
///
/// ```json,options
/// {
/// "options": {
/// "decimal": {
/// "minimumDigits": 7,
/// "groupLength": 3
/// },
/// "hexadecimal": {
/// "minimumDigits": 4,
/// "groupLength": 2
/// }
/// }
/// }
/// ```
Comment on lines +59 to +72
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

To remove, as it doesn't follow the writing style for options

///
/// ### `binary`
///
/// Options for binary literals (e.g., `0b1010_0001`).
///
/// - `minimumDigits` — Minimum number of digits required before adding separators. **Default: `0`**
/// - `groupLength` — Number of digits between separators. **Default: `4`**
///
/// ### `octal`
///
/// Options for octal literals (e.g., `0o1234_5670`).
///
/// - `minimumDigits` — Minimum number of digits required before adding separators. **Default: `0`**
/// - `groupLength` — Number of digits between separators. **Default: `4`**
///
/// ### `decimal`
///
/// Options for decimal literals (e.g., `1_234_567`).
///
/// - `minimumDigits` — Minimum number of digits required before adding separators. **Default: `5`**
/// - `groupLength` — Number of digits between separators. **Default: `3`**
///
Comment on lines +74 to +94
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

None of these options follows our pattern. Each option should have an option code block and a code block

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

We don't want to overload users with repeated information, which was my original intention.

I have 2 ideas, but I'm kinda struggling to decide what would be better.

  1. list the top level options and then provide examples that use both sub-options,
### `decimal`

[description]
[default values for both sub options]
[example options block, both sub options]
[valid/invalid examples]
  1. document the sub-options object, and explain they go under the numeric literal type. so we would have entries like:
### `<numeric literal type>.minimumDigits`

Minimum number of digits required before adding separators. For example, with `minimumDigits` set to `5`, the number `1234` would not require separators, but `12345` would be expected to be formatted as `12_345`.

Default values for `minimumDigits` vary by numeric literal type:
 - `binary`: `0`
 - `octal`: `0`
- `decimal`: `5`
 - `hexadecimal`: `0`

[valid/invalid examples, only one number type]

Which do you think is better? I'm personally leaning towards 2.

/// ### `hexadecimal`
///
/// Options for hexadecimal literals (e.g., `0xAB_CD`).
///
/// - `minimumDigits` — Minimum number of digits required before adding separators. **Default: `0`**
/// - `groupLength` — Number of digits between separators. **Default: `2`**
///
pub UseNumericSeparators {
version: "2.0.0",
name: "useNumericSeparators",
Expand All @@ -77,7 +124,7 @@ impl Rule for UseNumericSeparators {
return None;
}

let expected = format_numeric_literal(raw);
let expected = format_numeric_literal(raw, ctx.options());

if raw == expected {
None
Expand Down Expand Up @@ -122,7 +169,7 @@ impl Rule for UseNumericSeparators {

fn action(ctx: &RuleContext<Self>, state: &Self::State) -> Option<JsRuleAction> {
let token = ctx.query().value_token().ok()?;
let num = format_numeric_literal(token.text_trimmed());
let num = format_numeric_literal(token.text_trimmed(), ctx.options());

let new_token = JsSyntaxToken::new_detached(token.kind(), &num, [], []);
let mut mutation = ctx.root().begin();
Expand All @@ -148,14 +195,8 @@ pub enum State {
UnnecessaryGrouping,
}

// Options for the minimum length of a number required before adding separators and the length of digit groups between separators, respectively.
const BINARY_OPTS: (usize, usize) = (0, 4);
const OCTAL_OPTS: (usize, usize) = (0, 4);
const DECIMAL_OPTS: (usize, usize) = (5, 3);
const HEXADECIMAL_OPTS: (usize, usize) = (0, 2);

/// Formats all parts of a numeric literal by adding separators between groups of digits when appropriate.
fn format_numeric_literal(raw: &str) -> String {
fn format_numeric_literal(raw: &str, options: &UseNumericSeparatorsOptions) -> String {
let mut bytes = raw.bytes().peekable();
let mut result = Vec::new();
let mut current_num = Vec::new();
Expand All @@ -164,17 +205,17 @@ fn format_numeric_literal(raw: &str) -> String {
let mut in_fraction = false;
let mut prefix_parsed = false;

let (mut min_digits, mut group_len) = DECIMAL_OPTS;
let (mut min_digits, mut group_len) = options.decimal();

while let Some(b) = bytes.next() {
match b {
b'_' => {}
b'0' if !prefix_parsed && !in_fraction => {
if let Some(&next) = bytes.peek() {
let opts = match next {
b'b' | b'B' => Some(BINARY_OPTS),
b'o' | b'O' | b'0'..=b'7' => Some(OCTAL_OPTS),
b'x' | b'X' => Some(HEXADECIMAL_OPTS),
b'b' | b'B' => Some(options.binary()),
b'o' | b'O' | b'0'..=b'7' => Some(options.octal()),
b'x' | b'X' => Some(options.hexadecimal()),
_ => None,
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/* should generate diagnostics */

let foo;

// Decimal: minimumDigits: 7, so 7+ digits without separators are invalid
foo = 1234567; // 1_234_567
foo = 1234567890; // 1_234_567_890

// Hexadecimal: minimumDigits: 4, so 4+ digits without separators are invalid
foo = 0xABCD; // 0xAB_CD
foo = 0xABCDEF; // 0xAB_CD_EF

// Binary: groupLength: 2, wrong grouping
foo = 0b11001100; // 0b11_00_11_00
foo = 0b1010_0001; // 0b10_10_00_01

// Octal: groupLength: 2, wrong grouping
foo = 0o12345670; // 0o12_34_56_70
foo = 0o1234_5670; // 0o12_34_56_70
Loading