Skip to content
Open
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
53 changes: 53 additions & 0 deletions crates/ruff_linter/resources/test/fixtures/ruff/RUF062.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Tests for the RUF062 rule (large numeric literals without underscore separators)."""

# These should trigger the rule (large numbers without underscore separators)
i = 1000000
f = 123456789.123456789
x = 0x1234ABCD
b = 0b10101010101010101010101
o = 0o12345671234

# Scientific notation
sci = 1000000e10
sci_uppercase = 1000000E10

# These should not trigger the rule (small numbers or already have separators)
dec_small_int = 1234
dec_small_float = 123.45
dec_with_separators = 1_000_000
hex_with_separators = 0x1234_ABCD
bin_with_separators = 0b10101_01010101_01010101
oct_with_separators = 0o123_4567_1234
sci_with_separators = 1_000_000e10

# These should trigger the rule because their separators are misplaced
dec_misplaced_separators = 123_4567_89
oct_misplaced_separators = 0o12_34_56
hex_misplaced_separators = 0xABCD_EF
flt_misplaced_separators = 123.12_3456_789

# uppercase base prefix
hex_uppercase = 0XABCDEF
oct_uppercase = 0O123456
bin_uppercase = 0B01010101010101

# Negative numbers should also be checked
neg_large = -1000000
neg_with_separators = -1_000_000 # should not trigger
neg_with_spaces = - 100000
neg_oct = -0o1234567
neg_hex = -0xABCDEF
neg_bin -0b0101010100101
neg_hex_with_spaces = - 0xABCDEF

# Testing for minimun size thresholds
dec_4_digits = 1234 # Should not trigger, just below the threshold of 5 digits
dec_5_digits = 12345 # Should trigger, 5 digits
oct_4_digits = 0o1234 # Should not trigger, just below the threshold of 4 digits
oct_5_digits = 0o12345 # Should trigger, 5 digits
bin_8_digits = 0b01010101 # Should not trigger, just below the threshold of 9 digits
bin_9_digits = 0b101010101 # Should trigger, 9 digits
hex_4_digits = 0xABCD # Should not trigger, just below the threshold of 5 digits
hex_5_digits = 0xABCDE # Should trigger, 5 digits
flt_4_digits = .1234 # Should not trigger, just below the threshold of 5 digits
flt_5_digits = .12345 # Should trigger, 5 digits
3 changes: 3 additions & 0 deletions crates/ruff_linter/src/checkers/ast/analyze/expression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1573,6 +1573,9 @@ pub(crate) fn expression(expr: &Expr, checker: &Checker) {
if checker.is_rule_enabled(Rule::MathConstant) {
refurb::rules::math_constant(checker, number_literal);
}
if checker.is_rule_enabled(Rule::LargeNumberWithoutUnderscoreSeparators) {
ruff::rules::large_number_without_underscore_separators(checker, expr);
}
}
Expr::StringLiteral(
string_like @ ast::ExprStringLiteral {
Expand Down
1 change: 1 addition & 0 deletions crates/ruff_linter/src/codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1034,6 +1034,7 @@ pub fn code_to_rule(linter: Linter, code: &str) -> Option<(RuleGroup, Rule)> {
(Ruff, "059") => (RuleGroup::Preview, rules::ruff::rules::UnusedUnpackedVariable),
(Ruff, "060") => (RuleGroup::Preview, rules::ruff::rules::InEmptyCollection),
(Ruff, "061") => (RuleGroup::Preview, rules::ruff::rules::LegacyFormPytestRaises),
(Ruff, "062") => (RuleGroup::Preview, rules::ruff::rules::LargeNumberWithoutUnderscoreSeparators),
(Ruff, "063") => (RuleGroup::Preview, rules::ruff::rules::AccessAnnotationsFromClassDict),
(Ruff, "064") => (RuleGroup::Preview, rules::ruff::rules::NonOctalPermissions),
(Ruff, "100") => (RuleGroup::Stable, rules::ruff::rules::UnusedNOQA),
Expand Down
3 changes: 3 additions & 0 deletions crates/ruff_linter/src/rules/ruff/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ mod tests {
#[test_case(Rule::LegacyFormPytestRaises, Path::new("RUF061_raises.py"))]
#[test_case(Rule::LegacyFormPytestRaises, Path::new("RUF061_warns.py"))]
#[test_case(Rule::LegacyFormPytestRaises, Path::new("RUF061_deprecated_call.py"))]
#[test_case(Rule::LargeNumberWithoutUnderscoreSeparators, Path::new("RUF062.py"))]
#[test_case(Rule::NonOctalPermissions, Path::new("RUF064.py"))]
#[test_case(Rule::RedirectedNOQA, Path::new("RUF101_0.py"))]
#[test_case(Rule::RedirectedNOQA, Path::new("RUF101_1.py"))]
Expand All @@ -131,6 +132,7 @@ mod tests {
&LinterSettings {
ruff: super::settings::Settings {
parenthesize_tuple_in_subscript: true,
..Default::default()
},
..LinterSettings::for_rule(Rule::IncorrectlyParenthesizedTupleInSubscript)
},
Expand All @@ -146,6 +148,7 @@ mod tests {
&LinterSettings {
ruff: super::settings::Settings {
parenthesize_tuple_in_subscript: false,
..Default::default()
},
unresolved_target_version: PythonVersion::PY310.into(),
..LinterSettings::for_rule(Rule::IncorrectlyParenthesizedTupleInSubscript)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
use crate::AlwaysFixableViolation;
use crate::checkers::ast::Checker;
use crate::rules::ruff::settings::Settings;
use ruff_diagnostics::{Edit, Fix};
use ruff_macros::{ViolationMetadata, derive_message_formats};
use ruff_python_ast as ast;
use ruff_text_size::Ranged;

/// ## What it does
/// Checks for numeric literals that could be more readable with underscore separators
/// between groups of digits.
///
/// ## Why is this bad?
/// Large numeric literals can be difficult to read. Using underscore separators
/// improves readability by visually separating groups of digits.
///
/// ## Example
///
/// ```python
/// # Before
/// x = 1000000
/// y = 1234567.89
/// ```
///
/// Use instead:
/// ```python
/// # After
/// x = 1_000_000
/// y = 1_234_567.89
/// ```
///
/// ## References
/// - [PEP 515 - Underscores in Numeric Literals](https://peps.python.org/pep-0515/)
/// - [Number Localization Formatting Guide](https://randombits.dev/articles/number-localization/formatting)
#[derive(ViolationMetadata)]
pub(crate) struct LargeNumberWithoutUnderscoreSeparators;

impl AlwaysFixableViolation for LargeNumberWithoutUnderscoreSeparators {
#[derive_message_formats]
fn message(&self) -> String {
"Large numeric literal without underscore separators".to_string()
}

fn fix_title(&self) -> String {
"Add underscore separators to numeric literal".to_string()
}
}

/// RUF062: Large numeric literal without underscore separators
pub(crate) fn large_number_without_underscore_separators(checker: &Checker, expr: &ast::Expr) {
let value_text = checker.locator().slice(expr.range());

// format number to compare with the source
let formatted_value: String =
format_number_with_underscores(value_text, &checker.settings().ruff);

if formatted_value != value_text {
checker
.report_diagnostic(LargeNumberWithoutUnderscoreSeparators, expr.range())
.set_fix(Fix::safe_edit(Edit::range_replacement(
formatted_value,
expr.range(),
)));
}
}

/// Format a numeric literal with properly placed underscore separators
fn format_number_with_underscores(value: &str, settings: &Settings) -> String {
// Remove existing underscores
let value = value.replace("_", "");
if value.starts_with("0x") || value.starts_with("0X") {
// Hexadecimal
let prefix = &value[..2];
let hex_part = &value[2..];

let formatted = format_digits(
hex_part,
settings.hex_digit_group_size,
settings.hex_digit_group_size,
settings.hex_digit_grouping_threshold,
);
format!("{}{}", prefix, formatted)
} else if value.starts_with("0b") || value.starts_with("0B") {
// Binary
let prefix = &value[..2];
let bin_part = &value[2..];

let formatted = format_digits(
bin_part,
settings.bin_digit_group_size,
settings.bin_digit_group_size,
settings.bin_digit_grouping_threshold,
);
format!("{}{}", prefix, formatted)
} else if value.starts_with("0o") || value.starts_with("0O") {
// Octal
let prefix = &value[..2];
let oct_part = &value[2..];

let formatted = format_digits(
oct_part,
settings.oct_digit_group_size,
settings.oct_digit_group_size,
settings.oct_digit_grouping_threshold,
);
format!("{}{}", prefix, formatted)
} else {
if value.contains(['e', 'E']) {
// Handle scientific notation
let parts: Vec<&str> = value.split(['e', 'E']).collect();
let base = format_number_with_underscores(parts[0], settings);
let exponent = parts[1];

// Determine which separator was used (e or E)
let separator = if value.contains('e') { 'e' } else { 'E' };

return format!("{}{}{}", base, separator, exponent);
}

// Decimal (integer or float)
let parts: Vec<&str> = value.split('.').collect();
let group_size = if settings.use_indian_decimal_format {
2
} else {
3
};
let integer_part = format_digits(
&parts[0],
group_size,
3,
settings.dec_digit_grouping_threshold,
);

if parts.len() > 1 {
// It's a float, handle the fractional part
let float_part = format_float(
parts[1],
group_size,
3,
settings.dec_digit_grouping_threshold,
);
format!("{}.{}", integer_part, float_part)
} else {
// It's an integer
format!("{}", integer_part)
}
}
}

/// Helper function to format digits with underscores at specified intervals
fn format_digits(
digits: &str,
group_size: usize,
first_group_size: usize,
threshold: usize,
) -> String {
if digits.len() < threshold || group_size == 0 || first_group_size == 0 {
return digits.to_string();
}

let mut result = String::with_capacity(digits.len() * 2);
let mut count = 0;

// Process digits from right to left
for c in digits.chars().rev() {
if count == first_group_size
|| (count > first_group_size + 1 && (count - first_group_size) % group_size == 0)
{
result.push('_');
}
result.push(c);
count += 1;
}

// Reverse the result to get the correct order
result.chars().rev().collect()
}

// Helper function to format float parts with underscores at specified intervals
fn format_float(
digits: &str,
group_size: usize,
first_group_size: usize,
threshold: usize,
) -> String {
if digits.len() < threshold || group_size == 0 || first_group_size == 0 {
return digits.to_string();
}

let mut result = String::with_capacity(digits.len() * 2);
let mut count = 0;

// Process digits from right to left
for c in digits.chars() {
if count == first_group_size
|| (count > first_group_size && (count - first_group_size) % group_size == 0)
{
result.push('_');
}
result.push(c);
count += 1;
}

result
}
2 changes: 2 additions & 0 deletions crates/ruff_linter/src/rules/ruff/rules/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub(crate) use needless_else::*;
pub(crate) use never_union::*;
pub(crate) use non_octal_permissions::*;
pub(crate) use none_not_at_end_of_union::*;
pub(crate) use large_number_without_underscore_separators::*;
pub(crate) use parenthesize_chained_operators::*;
pub(crate) use post_init_default::*;
pub(crate) use pytest_raises_ambiguous_pattern::*;
Expand Down Expand Up @@ -62,6 +63,7 @@ pub(crate) use zip_instead_of_pairwise::*;

mod access_annotations_from_class_dict;
mod ambiguous_unicode_character;
mod large_number_without_underscore_separators;
mod assert_with_print_message;
mod assignment_in_assert;
mod asyncio_dangling_task;
Expand Down
34 changes: 33 additions & 1 deletion crates/ruff_linter/src/rules/ruff/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,33 @@ use crate::display_settings;
use ruff_macros::CacheKey;
use std::fmt;

#[derive(Debug, Clone, CacheKey, Default)]
#[derive(Debug, Clone, CacheKey)]
pub struct Settings {
pub parenthesize_tuple_in_subscript: bool,
pub use_indian_decimal_format: bool,
pub hex_digit_group_size: usize,
pub oct_digit_group_size: usize,
pub bin_digit_group_size: usize,
pub hex_digit_grouping_threshold: usize,
pub dec_digit_grouping_threshold: usize,
pub oct_digit_grouping_threshold: usize,
pub bin_digit_grouping_threshold: usize,
}

impl Default for Settings {
fn default() -> Self {
Settings {
parenthesize_tuple_in_subscript: false,
use_indian_decimal_format: false,
hex_digit_group_size: 4,
oct_digit_group_size: 4,
bin_digit_group_size: 8,
hex_digit_grouping_threshold: 5,
dec_digit_grouping_threshold: 5,
oct_digit_grouping_threshold: 5,
bin_digit_grouping_threshold: 8,
}
}
}

impl fmt::Display for Settings {
Expand All @@ -16,6 +40,14 @@ impl fmt::Display for Settings {
namespace = "linter.ruff",
fields = [
self.parenthesize_tuple_in_subscript,
self.use_indian_decimal_format,
self.bin_digit_grouping_threshold,
self.oct_digit_grouping_threshold,
self.hex_digit_grouping_threshold,
self.dec_digit_grouping_threshold,
self.bin_digit_group_size,
self.oct_digit_group_size,
self.hex_digit_group_size,
]
}
Ok(())
Expand Down
Loading