Repository: seaofvoices/darklua (rust)

Issue:
A way of converting number literals to decimals
so uh basically, i want to have 0b000010 to be a decimal (result of that is 2) because lua compatibility

this rule will be called `remove_number_literals` since it literally removes (preferably converts) number literals to its decimal counterpart

Pull request:
Convert luau number
Closes #256 #257 

Add the new rule `convert_luau_number` to convert number literals like `100_000` into `100000` and binary numbers into hexadecimal numbers.

- [x] add entry to the changelog


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index baf0023..98713a1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,6 @@
 # Changelog
 
+* add rule to convert Luau numbers (`convert_luau_number`) ([#274](https://github.com/seaofvoices/darklua/pull/274))
 * export the `PathRequireMode` struct when using the darklua library and refactor AST node types to reduce size difference between variants ([#273](https://github.com/seaofvoices/darklua/pull/273))
 
 ## 0.16.0
diff --git a/site/content/rules/convert_luau_number.md b/site/content/rules/convert_luau_number.md
new file mode 100644
index 0000000..8648c22
--- /dev/null
+++ b/site/content/rules/convert_luau_number.md
@@ -0,0 +1,10 @@
+---
+description: Convert Luau numbers into Lua compatible numbers
+added_in: "unreleased"
+parameters: []
+examples:
+  - content: "print(0b1000_0001)"
+  - content: "return 0xA000_FFFF"
+---
+
+This rule transforms Luau-specific number literals into Lua compatible number literals. It converts binary literals (prefixed with `0b` or `0B`) to their hexadecimal equivalents. It also removes underscores used as digit separators in numbers.
diff --git a/src/nodes/expressions/number.rs b/src/nodes/expressions/number.rs
index 423bc8e..b6fceca 100644
--- a/src/nodes/expressions/number.rs
+++ b/src/nodes/expressions/number.rs
@@ -35,11 +35,18 @@ impl DecimalNumber {
         self.token = Some(token);
     }
 
+    /// Returns a reference to the token attached to this decimal number, if any.
     #[inline]
-    fn get_token(&self) -> Option<&Token> {
+    pub fn get_token(&self) -> Option<&Token> {
         self.token.as_ref()
     }
 
+    /// Returns a mutable reference to the token attached to this decimal number, if any.
+    #[inline]
+    pub fn mutate_token(&mut self) -> Option<&mut Token> {
+        self.token.as_mut()
+    }
+
     /// Sets an exponent for this decimal number and returns the updated number.
     ///
     /// The `is_uppercase` parameter determines whether the exponent uses uppercase 'E'
@@ -121,10 +128,16 @@ impl HexNumber {
 
     /// Returns a reference to the token attached to this hexadecimal number, if any.
     #[inline]
-    fn get_token(&self) -> Option<&Token> {
+    pub fn get_token(&self) -> Option<&Token> {
         self.token.as_ref()
     }
 
+    /// Returns a mutable reference to the token attached to this hexadecimal number, if any.
+    #[inline]
+    pub fn mutate_token(&mut self) -> Option<&mut Token> {
+        self.token.as_mut()
+    }
+
     /// Sets a binary exponent for this hexadecimal number and returns the updated number.
     ///
     /// The `is_uppercase` parameter determines whether the exponent uses uppercase 'P'
@@ -213,10 +226,16 @@ impl BinaryNumber {
 
     /// Returns a reference to the token attached to this binary number, if any.
     #[inline]
-    fn get_token(&self) -> Option<&Token> {
+    pub fn get_token(&self) -> Option<&Token> {
         self.token.as_ref()
     }
 
+    /// Returns a mutable reference to the token attached to this binary number, if any.
+    #[inline]
+    pub fn mutate_token(&mut self) -> Option<&mut Token> {
+        self.token.as_mut()
+    }
+
     /// Sets whether the binary prefix should use uppercase 'B' (0B) or lowercase 'b' (0b).
     pub fn set_uppercase(&mut self, is_uppercase: bool) {
         self.is_b_uppercase = is_uppercase;
@@ -302,6 +321,16 @@ impl NumberExpression {
         }
     }
 
+    /// Returns a mutable reference to the token attached to this number expression, if any.
+    #[inline]
+    pub fn mutate_token(&mut self) -> Option<&mut Token> {
+        match self {
+            NumberExpression::Decimal(number) => number.mutate_token(),
+            NumberExpression::Hex(number) => number.mutate_token(),
+            NumberExpression::Binary(number) => number.mutate_token(),
+        }
+    }
+
     /// Clears all comments from the tokens in this node.
     pub fn clear_comments(&mut self) {
         match self {
diff --git a/src/rules/convert_luau_number.rs b/src/rules/convert_luau_number.rs
new file mode 100644
index 0000000..e6892f2
--- /dev/null
+++ b/src/rules/convert_luau_number.rs
@@ -0,0 +1,108 @@
+use crate::{
+    nodes::{Block, HexNumber, NumberExpression, Token},
+    process::{DefaultVisitor, NodeProcessor, NodeVisitor},
+    rules::{Context, FlawlessRule, RuleConfiguration, RuleConfigurationError, RuleProperties},
+};
+
+use super::verify_no_rule_properties;
+
+#[derive(Default)]
+struct Processor<'a> {
+    code: &'a str,
+}
+
+impl Processor<'_> {
+    fn trim_underscores(&self, token: &mut Token) {
+        let content = token.read(self.code);
+
+        if content.contains('_') {
+            token.replace_with_content(content.chars().filter(|c| *c != '_').collect::<String>());
+        }
+    }
+}
+
+impl NodeProcessor for Processor<'_> {
+    fn process_number_expression(&mut self, number: &mut NumberExpression) {
+        match number {
+            NumberExpression::Binary(binary) => {
+                let value = binary.get_raw_value();
+                *number = HexNumber::new(value, false).into();
+            }
+            NumberExpression::Hex(hex_number) => {
+                if let Some(token) = hex_number.mutate_token() {
+                    self.trim_underscores(token);
+                }
+            }
+            NumberExpression::Decimal(decimal_number) => {
+                if let Some(token) = decimal_number.mutate_token() {
+                    self.trim_underscores(token);
+                }
+            }
+        }
+    }
+}
+
+impl<'a> Processor<'a> {
+    fn new(code: &'a str) -> Self {
+        Self { code }
+    }
+}
+
+pub const CONVERT_LUAU_NUMBER_RULE_NAME: &str = "convert_luau_number";
+
+/// A rule that converts Luau number literals to regular Lua numbers.
+#[derive(Default, Debug, PartialEq, Eq)]
+pub struct ConvertLuauNumber {}
+
+impl FlawlessRule for ConvertLuauNumber {
+    fn flawless_process(&self, block: &mut Block, context: &Context) {
+        let mut processor = Processor::new(context.original_code());
+        DefaultVisitor::visit_block(block, &mut processor);
+    }
+}
+
+impl RuleConfiguration for ConvertLuauNumber {
+    fn configure(&mut self, properties: RuleProperties) -> Result<(), RuleConfigurationError> {
+        verify_no_rule_properties(&properties)?;
+
+        Ok(())
+    }
+
+    fn get_name(&self) -> &'static str {
+        CONVERT_LUAU_NUMBER_RULE_NAME
+    }
+
+    fn serialize_to_properties(&self) -> RuleProperties {
+        RuleProperties::new()
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+    use crate::rules::Rule;
+
+    use insta::assert_json_snapshot;
+
+    fn new_rule() -> ConvertLuauNumber {
+        ConvertLuauNumber::default()
+    }
+
+    #[test]
+    fn serialize_default_rule() {
+        let rule: Box<dyn Rule> = Box::new(new_rule());
+
+        assert_json_snapshot!("default_convert_luau_number", rule);
+    }
+
+    #[test]
+    fn configure_with_extra_field_error() {
+        let result = json5::from_str::<Box<dyn Rule>>(
+            r#"{
+            rule: 'convert_luau_number',
+            prop: "something",
+        }"#,
+        );
+        pretty_assertions::assert_eq!(result.unwrap_err().to_string(), "unexpected field 'prop'");
+    }
+}
diff --git a/src/rules/mod.rs b/src/rules/mod.rs
index c8a2f4f..a5f2f9c 100644
--- a/src/rules/mod.rs
+++ b/src/rules/mod.rs
@@ -10,6 +10,7 @@ mod call_parens;
 mod compute_expression;
 mod configuration_error;
 mod convert_index_to_field;
+mod convert_luau_number;
 mod convert_require;
 mod empty_do;
 mod filter_early_return;
@@ -43,6 +44,7 @@ pub use call_parens::*;
 pub use compute_expression::*;
 pub use configuration_error::RuleConfigurationError;
 pub use convert_index_to_field::*;
+pub use convert_luau_number::*;
 pub use convert_require::*;
 pub use empty_do::*;
 pub use filter_early_return::*;
@@ -290,6 +292,7 @@ pub fn get_all_rule_names() -> Vec<&'static str> {
         COMPUTE_EXPRESSIONS_RULE_NAME,
         CONVERT_INDEX_TO_FIELD_RULE_NAME,
         CONVERT_LOCAL_FUNCTION_TO_ASSIGN_RULE_NAME,
+        CONVERT_LUAU_NUMBER_RULE_NAME,
         CONVERT_REQUIRE_RULE_NAME,
         FILTER_AFTER_EARLY_RETURN_RULE_NAME,
         GROUP_LOCAL_ASSIGNMENT_RULE_NAME,
@@ -325,6 +328,7 @@ impl FromStr for Box<dyn Rule> {
             CONVERT_LOCAL_FUNCTION_TO_ASSIGN_RULE_NAME => {
                 Box::<ConvertLocalFunctionToAssign>::default()
             }
+            CONVERT_LUAU_NUMBER_RULE_NAME => Box::<ConvertLuauNumber>::default(),
             CONVERT_REQUIRE_RULE_NAME => Box::<ConvertRequire>::default(),
             FILTER_AFTER_EARLY_RETURN_RULE_NAME => Box::<FilterAfterEarlyReturn>::default(),
             GROUP_LOCAL_ASSIGNMENT_RULE_NAME => Box::<GroupLocalAssignment>::default(),


Test patch:
diff --git a/src/rules/snapshots/darklua_core__rules__convert_luau_number__test__default_convert_luau_number.snap b/src/rules/snapshots/darklua_core__rules__convert_luau_number__test__default_convert_luau_number.snap
new file mode 100644
index 0000000..ebaafa8
--- /dev/null
+++ b/src/rules/snapshots/darklua_core__rules__convert_luau_number__test__default_convert_luau_number.snap
@@ -0,0 +1,5 @@
+---
+source: src/rules/convert_luau_number.rs
+expression: rule
+---
+"convert_luau_number"
diff --git a/src/rules/snapshots/darklua_core__rules__test__all_rule_names.snap b/src/rules/snapshots/darklua_core__rules__test__all_rule_names.snap
index b7cf252..66e01b2 100644
--- a/src/rules/snapshots/darklua_core__rules__test__all_rule_names.snap
+++ b/src/rules/snapshots/darklua_core__rules__test__all_rule_names.snap
@@ -7,6 +7,7 @@ expression: rule_names
   "compute_expression",
   "convert_index_to_field",
   "convert_local_function_to_assign",
+  "convert_luau_number",
   "convert_require",
   "filter_after_early_return",
   "group_local_assignment",
diff --git a/tests/rule_tests/convert_luau_number.rs b/tests/rule_tests/convert_luau_number.rs
new file mode 100644
index 0000000..0e73e1f
--- /dev/null
+++ b/tests/rule_tests/convert_luau_number.rs
@@ -0,0 +1,71 @@
+use darklua_core::rules::{ConvertLuauNumber, Rule};
+
+test_rule!(
+    convert_luau_number,
+    ConvertLuauNumber::default(),
+    binary_literal_lowercase("local a = 0b10101010")
+        => "local a = 0xAA",
+    binary_literal_uppercase("local b = 0B11001100")
+        => "local b = 0xCC",
+    binary_literal_lowercase_with_underscores("local c = 0b_1111_0000")
+        => "local c = 0xF0",
+    binary_literal_uppercase_with_underscores("local d = 0B_0000_1111")
+        => "local d = 0x0F",
+    binary_literal_with_multiple_underscores("local e = 0b__________1010_1010")
+        => "local e = 0xAA",
+    decimal_with_underscores("local a = 1_048_576")
+        => "local a = 1048576",
+    decimal_with_multiple_underscores("local a1 = 1___048__576__")
+        => "local a1 = 1048576",
+    hexadecimal_with_underscores("local b = 0xFFFF_FFFF")
+        => "local b = 0xFFFFFFFF",
+    hexadecimal_lowercase("local c = 0xabcd_ef12")
+        => "local c = 0xabcdef12",
+    hexadecimal_mixed_case("local d = 0xA1b2_C3d4")
+        => "local d = 0xA1b2C3d4",
+    hexadecimal_single_underscore("local e = 0x1234_5678")
+        => "local e = 0x12345678",
+    hexadecimal_multiple_underscores("local f = 0x1___234__567")
+        => "local f = 0x1234567",
+    hexadecimal_leading_underscore("local g = 0x_DEAD_BEEF")
+        => "local g = 0xDEADBEEF",
+    hexadecimal_trailing_underscore("local h = 0xCAFE_BABE_")
+        => "local h = 0xCAFEBABE",
+    hexadecimal_small_number("local i = 0x1_2_3_4")
+        => "local i = 0x1234",
+    hexadecimal_zero("local j = 0x0_0_0_0")
+        => "local j = 0x0000",
+    hexadecimal_single_digit("local k = 0xF")
+        => "local k = 0xF",
+    hexadecimal_single_digit_with_underscore("local l = 0xF_")
+        => "local l = 0xF",
+    hexadecimal_two_digits("local m = 0xAB")
+        => "local m = 0xAB",
+    hexadecimal_three_digits("local n = 0x123")
+        => "local n = 0x123",
+    hexadecimal_four_digits("local o = 0xABCD")
+        => "local o = 0xABCD",
+    hexadecimal_six_digits("local p = 0x123___456")
+        => "local p = 0x123456",
+    hexadecimal_eight_digits("local q = 0x1234_5678")
+        => "local q = 0x12345678",
+    hexadecimal_ten_digits("local r = 0x12_34_56_78_9A")
+        => "local r = 0x123456789A",
+    hexadecimal_twelve_digits("local s = 0x123_456_789_ABC")
+        => "local s = 0x123456789ABC",
+);
+
+#[test]
+fn deserialize_from_object_notation() {
+    json5::from_str::<Box<dyn Rule>>(
+        r#"{
+        rule: 'convert_luau_number',
+    }"#,
+    )
+    .unwrap();
+}
+
+#[test]
+fn deserialize_from_string() {
+    json5::from_str::<Box<dyn Rule>>("'convert_luau_number'").unwrap();
+}
diff --git a/tests/rule_tests/mod.rs b/tests/rule_tests/mod.rs
index f73e558..664fddd 100644
--- a/tests/rule_tests/mod.rs
+++ b/tests/rule_tests/mod.rs
@@ -418,6 +418,7 @@ macro_rules! test_rule_snapshot {
 mod append_text_comment;
 mod compute_expression;
 mod convert_index_to_field;
+mod convert_luau_number;
 mod convert_require;
 mod filter_early_return;
 mod group_local_assignment;