Repository: seaofvoices/darklua (rust)

Issue:
Add rule to remove floor division operator
Remove usage of `//` in binary expressions and in `//=` assignments.

Example:

```lua
return 10 // divider
```

Converts to

```lua
return math.floor(10 / divider)
```

Pull request description:
Add rule to remove floor division operations
Closes #160 

Add a new rule `remove_floor_division` to remove usage of `//` in expressions or compound assignments (`//=`). The code is replaced with a `math.floor` call on a regular division.

- [x] add entry to the changelog
- [x] add documentation

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 31da1c3..9453c4a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 
 ## Unreleased
 
+* add rule to remove floor divisions (`remove_floor_division`) ([#230](https://github.com/seaofvoices/darklua/pull/230))
 * fix `remove_assertions` rule to make the `assert` calls return their arguments ([#229](https://github.com/seaofvoices/darklua/pull/229))
 * add rule to remove continue statements (`remove_continue`) ([#227](https://github.com/seaofvoices/darklua/pull/227))
 * fix negative zero sign erasure ([#222](https://github.com/seaofvoices/darklua/pull/222))
diff --git a/site/content/rules/remove_floor_division.md b/site/content/rules/remove_floor_division.md
new file mode 100644
index 0000000..cd4b76c
--- /dev/null
+++ b/site/content/rules/remove_floor_division.md
@@ -0,0 +1,10 @@
+---
+description: Removes floor divisions
+added_in: "unreleased"
+parameters: []
+examples:
+  - content: "return variable // divider"
+  - content: "variable //= 5"
+---
+
+This rule removes all usage of the floor division operator (`//`). It replaces those operations with a regular division (`/`) followed by a `math.floor()` call.
diff --git a/src/nodes/expressions/binary.rs b/src/nodes/expressions/binary.rs
index dca8d7b..06adcb9 100644
--- a/src/nodes/expressions/binary.rs
+++ b/src/nodes/expressions/binary.rs
@@ -265,6 +265,17 @@ impl BinaryExpression {
         self.operator
     }
 
+    #[inline]
+    pub fn set_operator(&mut self, operator: BinaryOperator) {
+        if self.operator == operator {
+            return;
+        }
+        self.operator = operator;
+        if let Some(token) = self.token.as_mut() {
+            token.replace_with_content(operator.to_str());
+        }
+    }
+
     super::impl_token_fns!(iter = [token]);
 }
 
diff --git a/src/rules/mod.rs b/src/rules/mod.rs
index fc80cb6..5f38013 100644
--- a/src/rules/mod.rs
+++ b/src/rules/mod.rs
@@ -19,6 +19,7 @@ mod remove_comments;
 mod remove_compound_assign;
 mod remove_continue;
 mod remove_debug_profiling;
+mod remove_floor_division;
 mod remove_if_expression;
 mod remove_interpolated_string;
 mod remove_nil_declarations;
@@ -50,6 +51,7 @@ pub use remove_comments::*;
 pub use remove_compound_assign::*;
 pub use remove_continue::*;
 pub use remove_debug_profiling::*;
+pub use remove_floor_division::*;
 pub use remove_if_expression::*;
 pub use remove_interpolated_string::*;
 pub use remove_nil_declarations::*;
@@ -271,6 +273,7 @@ impl FromStr for Box<dyn Rule> {
             REMOVE_COMPOUND_ASSIGNMENT_RULE_NAME => Box::<RemoveCompoundAssignment>::default(),
             REMOVE_DEBUG_PROFILING_RULE_NAME => Box::<RemoveDebugProfiling>::default(),
             REMOVE_EMPTY_DO_RULE_NAME => Box::<RemoveEmptyDo>::default(),
+            REMOVE_FLOOR_DIVISION_RULE_NAME => Box::<RemoveFloorDivision>::default(),
             REMOVE_FUNCTION_CALL_PARENS_RULE_NAME => Box::<RemoveFunctionCallParens>::default(),
             REMOVE_INTERPOLATED_STRING_RULE_NAME => Box::<RemoveInterpolatedString>::default(),
             REMOVE_METHOD_DEFINITION_RULE_NAME => Box::<RemoveMethodDefinition>::default(),
diff --git a/src/rules/remove_compound_assign.rs b/src/rules/remove_compound_assign.rs
index 4061aa0..c36e3e3 100644
--- a/src/rules/remove_compound_assign.rs
+++ b/src/rules/remove_compound_assign.rs
@@ -297,6 +297,13 @@ pub const REMOVE_COMPOUND_ASSIGNMENT_RULE_NAME: &str = "remove_compound_assignme
 #[derive(Debug, Default, PartialEq, Eq)]
 pub struct RemoveCompoundAssignment {}
 
+impl RemoveCompoundAssignment {
+    pub(crate) fn replace_compound_assignment(&self, statement: &mut Statement) {
+        let mut processor = Processor::default();
+        ScopeVisitor::visit_statement(statement, &mut processor);
+    }
+}
+
 impl FlawlessRule for RemoveCompoundAssignment {
     fn flawless_process(&self, block: &mut Block, _: &Context) {
         let mut processor = Processor::default();
diff --git a/src/rules/remove_floor_division.rs b/src/rules/remove_floor_division.rs
new file mode 100644
index 0000000..61a7afa
--- /dev/null
+++ b/src/rules/remove_floor_division.rs
@@ -0,0 +1,157 @@
+use std::{mem, ops};
+
+use crate::nodes::{
+    BinaryOperator, Block, CompoundOperator, Expression, FieldExpression, FunctionCall,
+    LocalAssignStatement, Prefix, Statement,
+};
+use crate::process::{IdentifierTracker, NodeProcessor, NodeVisitor, ScopeVisitor};
+use crate::rules::{
+    verify_no_rule_properties, Context, FlawlessRule, RemoveCompoundAssignment, RuleConfiguration,
+    RuleConfigurationError, RuleProperties,
+};
+
+struct RemoveFloorDivisionProcessor {
+    math_floor_identifier: String,
+    define_math_floor: bool,
+    identifier_tracker: IdentifierTracker,
+}
+
+impl ops::Deref for RemoveFloorDivisionProcessor {
+    type Target = IdentifierTracker;
+
+    fn deref(&self) -> &Self::Target {
+        &self.identifier_tracker
+    }
+}
+
+impl ops::DerefMut for RemoveFloorDivisionProcessor {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.identifier_tracker
+    }
+}
+
+const DEFAULT_MATH_LIBRARY: &str = "math";
+const DEFAULT_MATH_FLOOR_NAME: &str = "floor";
+
+impl RemoveFloorDivisionProcessor {
+    fn new(math_floor_identifier: impl Into<String>) -> Self {
+        Self {
+            math_floor_identifier: math_floor_identifier.into(),
+            define_math_floor: false,
+            identifier_tracker: Default::default(),
+        }
+    }
+
+    fn build_math_floor_call(&mut self, value: Expression) -> Expression {
+        FunctionCall::from_prefix(if self.is_identifier_used(DEFAULT_MATH_LIBRARY) {
+            self.define_math_floor = true;
+            Prefix::from_name(&self.math_floor_identifier)
+        } else {
+            FieldExpression::new(
+                Prefix::from_name(DEFAULT_MATH_LIBRARY),
+                DEFAULT_MATH_FLOOR_NAME,
+            )
+            .into()
+        })
+        .with_argument(value)
+        .into()
+    }
+}
+
+impl NodeProcessor for RemoveFloorDivisionProcessor {
+    fn process_statement(&mut self, statement: &mut Statement) {
+        match statement {
+            Statement::CompoundAssign(assign_statement)
+                if assign_statement.get_operator() == CompoundOperator::DoubleSlash =>
+            {
+                RemoveCompoundAssignment::default().replace_compound_assignment(statement);
+            }
+            _ => {}
+        }
+    }
+
+    fn process_expression(&mut self, expression: &mut Expression) {
+        if let Expression::Binary(binary) = expression {
+            if binary.operator() == BinaryOperator::DoubleSlash {
+                binary.set_operator(BinaryOperator::Slash);
+
+                let value = mem::replace(expression, Expression::nil());
+
+                *expression = self.build_math_floor_call(value);
+            }
+        }
+    }
+}
+
+pub const REMOVE_FLOOR_DIVISION_RULE_NAME: &str = "remove_floor_division";
+
+/// A rule that removes interpolated strings.
+#[derive(Debug, Default, PartialEq, Eq)]
+pub struct RemoveFloorDivision {}
+
+impl FlawlessRule for RemoveFloorDivision {
+    fn flawless_process(&self, block: &mut Block, _: &Context) {
+        const MATH_FLOOR_IDENTIFIER: &str = "__DARKLUA_MATH_FLOOR";
+
+        let mut processor = RemoveFloorDivisionProcessor::new(MATH_FLOOR_IDENTIFIER);
+        ScopeVisitor::visit_block(block, &mut processor);
+
+        if processor.define_math_floor {
+            block.insert_statement(
+                0,
+                LocalAssignStatement::from_variable(MATH_FLOOR_IDENTIFIER).with_value(
+                    FieldExpression::new(
+                        Prefix::from_name(DEFAULT_MATH_LIBRARY),
+                        DEFAULT_MATH_FLOOR_NAME,
+                    ),
+                ),
+            );
+        }
+    }
+}
+
+impl RuleConfiguration for RemoveFloorDivision {
+    fn configure(&mut self, properties: RuleProperties) -> Result<(), RuleConfigurationError> {
+        verify_no_rule_properties(&properties)?;
+
+        Ok(())
+    }
+
+    fn get_name(&self) -> &'static str {
+        REMOVE_FLOOR_DIVISION_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() -> RemoveFloorDivision {
+        RemoveFloorDivision::default()
+    }
+
+    #[test]
+    fn serialize_default_rule() {
+        let rule: Box<dyn Rule> = Box::new(new_rule());
+
+        assert_json_snapshot!("default_remove_floor_division", rule);
+    }
+
+    #[test]
+    fn configure_with_extra_field_error() {
+        let result = json5::from_str::<Box<dyn Rule>>(
+            r#"{
+            rule: 'remove_floor_division',
+            prop: "something",
+        }"#,
+        );
+        pretty_assertions::assert_eq!(result.unwrap_err().to_string(), "unexpected field 'prop'");
+    }
+}


Test patch:
diff --git a/src/rules/snapshots/darklua_core__rules__remove_floor_division__test__default_remove_floor_division.snap b/src/rules/snapshots/darklua_core__rules__remove_floor_division__test__default_remove_floor_division.snap
new file mode 100644
index 0000000..0f242a7
--- /dev/null
+++ b/src/rules/snapshots/darklua_core__rules__remove_floor_division__test__default_remove_floor_division.snap
@@ -0,0 +1,5 @@
+---
+source: src/rules/remove_floor_division.rs
+expression: rule
+---
+"remove_floor_division"
diff --git a/tests/rule_tests/mod.rs b/tests/rule_tests/mod.rs
index 592ed19..f73e558 100644
--- a/tests/rule_tests/mod.rs
+++ b/tests/rule_tests/mod.rs
@@ -430,6 +430,7 @@ mod remove_compound_assignment;
 mod remove_continue;
 mod remove_debug_profiling;
 mod remove_empty_do;
+mod remove_floor_division;
 mod remove_if_expression;
 mod remove_interpolated_string;
 mod remove_method_definition;
diff --git a/tests/rule_tests/remove_floor_division.rs b/tests/rule_tests/remove_floor_division.rs
new file mode 100644
index 0000000..1dbc5b2
--- /dev/null
+++ b/tests/rule_tests/remove_floor_division.rs
@@ -0,0 +1,44 @@
+use darklua_core::rules::{RemoveFloorDivision, Rule};
+
+test_rule!(
+    remove_floor_division,
+    RemoveFloorDivision::default(),
+    compound_floor_division("variable //= num") => "variable = math.floor(variable / num)",
+    return_floor_division("return 1 // 3") => "return math.floor(1 / 3)",
+    floor_division_in_binary_expression("return offset + variable // divider") => "return offset + math.floor(variable / divider)",
+
+    floor_division_with_index_without_side_effect("a[prop] //= 1") => "a[prop] = math.floor(a[prop] / 1)",
+    floor_division_with_index_with_true("a[true] //= 1") => "a[true] = math.floor(a[true] / 1)",
+    floor_division_with_index_with_false("a[false] //= 1") => "a[false] = math.floor(a[false] / 1)",
+    floor_division_with_index_with_parenthese_expression("a[(key)] //= 1") => "a[key] = math.floor(a[key] / 1)",
+    floor_division_with_field("a.counter //= 1") => "a.counter = math.floor(a.counter / 1)",
+    floor_division_with_field_on_function_call("getObject().counter //= 1")
+        => "do local __DARKLUA_VAR = getObject() __DARKLUA_VAR.counter = math.floor(__DARKLUA_VAR.counter / 1) end",
+    floor_division_with_field_on_parentheses("(if condition then a else b).counter //= 1")
+        => "do local __DARKLUA_VAR = if condition then a else b __DARKLUA_VAR.counter = math.floor(__DARKLUA_VAR.counter / 1) end",
+    floor_division_with_identifier_in_parenthese_for_field("(a).counter //= 1") => "a.counter = math.floor(a.counter / 1)",
+    floor_division_with_false_in_parenthese_for_field("(false).counter //= 1") => "(false).counter = math.floor((false).counter / 1)",
+    floor_division_with_identifier_in_parenthese_for_index("(a)['counter'] //= 1") => "a['counter'] = math.floor(a['counter'] / 1)",
+    floor_division_with_true_in_parenthese_for_index("(true)['counter'] //= 1") => "(true)['counter'] = math.floor((true)['counter'] / 1)",
+    floor_division_with_index_with_side_effects_in_index("a[call()] //= 1")
+        => "do local __DARKLUA_VAR = call() a[__DARKLUA_VAR] = math.floor(a[__DARKLUA_VAR] / 1) end",
+    floor_division_with_index_with_side_effects_in_prefix("object[call()][key] //= 1")
+        => "do local __DARKLUA_VAR = object[call()] __DARKLUA_VAR[key] = math.floor(__DARKLUA_VAR[key] / 1) end",
+    floor_division_with_index_with_side_effects_in_prefix_and_index("object[call()][getKey()] //= 1")
+        => "do local __DARKLUA_VAR, __DARKLUA_VAR0 = object[call()], getKey() __DARKLUA_VAR[__DARKLUA_VAR0] = math.floor(__DARKLUA_VAR[__DARKLUA_VAR0] / 1) end",
+);
+
+#[test]
+fn deserialize_from_object_notation() {
+    json5::from_str::<Box<dyn Rule>>(
+        r#"{
+        rule: 'remove_floor_division',
+    }"#,
+    )
+    .unwrap();
+}
+
+#[test]
+fn deserialize_from_string() {
+    json5::from_str::<Box<dyn Rule>>("'remove_floor_division'").unwrap();
+}