Repository: qBraid/pyqasm

Issue:
[FEATURE] Migrate `reverse_qubit_order` from qBraid SDK
### Feature Description

Part of #12 

Migrate the `reverse_qubit_order` function from `qBraid/qbraid/programs/gate_model/qasm3.py::OpenQasm3Program` to `QasmModule`. This is a nice to have feature for enabling interconversions between little and big endian qubit notations. 

### Implementation (Optional)

- Similar to #55, use `openqasm3.ast` objects to build the method in the `QasmModule` containing an `in_place` attribute.

Hints from issue comments:

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d9594d7..5199742 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -64,6 +64,49 @@ Out[7]:
  'z q3[2];']
 ```
 
+- Implemented the `reverse_qubit_order` method to the `QasmModule` class which can be used to reverse the order of qubits in a quantum program ([#60](https://github.com/qBraid/pyqasm/pull/60)). Usage is as follows - 
+
+```python
+In [3]: import pyqasm
+
+In [4]: qasm3_str = """
+   ...:     OPENQASM 3.0;
+   ...:     include "stdgates.inc";
+   ...:     qubit[2] q;
+   ...:     qubit[4] q2;
+   ...:     qubit q3;
+   ...:     bit[1] c;
+   ...:
+   ...:     cnot q[0], q[1];
+   ...:     cnot q2[0], q2[1];
+   ...:     x q2[3];
+   ...:     cnot q2[0], q2[2];
+   ...:     x q3;
+   ...:     c[0] = measure q2[0];
+   ...:     """
+
+In [5]: module = pyqasm.load(qasm3_str)
+
+In [6]: module.reverse_qubit_order()
+Out[6]: <pyqasm.modules.Qasm3Module at 0x105bc9ac0>
+
+In [7]: module.unrolled_qasm.splitlines()
+Out[7]:
+['OPENQASM 3.0;',
+ 'include "stdgates.inc";',
+ 'qubit[2] q;',
+ 'qubit[4] q2;',
+ 'qubit[1] q3;',
+ 'bit[1] c;',
+ 'cx q[1], q[0];',
+ 'cx q2[3], q2[2];',
+ 'x q2[0];',
+ 'cx q2[3], q2[1];',
+ 'x q3[0];',
+ 'c[0] = measure q2[3];']
+ ```
+
+
 ### Improved / Modified
 - Improved qubit declaration semantics by adding check for quantum registers being declared as predefined constants ([#44](https://github.com/qBraid/pyqasm/pull/44))
 - Updated pre-release scripts + workflow ([#47](https://github.com/qBraid/pyqasm/pull/47))
diff --git a/docs/index.rst b/docs/index.rst
index 6a2f4ef..4d1ce7f 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -118,9 +118,12 @@ Example
    result = measure q;
    """
 
-   unrolled = pyqasm.unroll(qasm)
+   module = pyqasm.load(qasm)
+   module.unroll()
 
-   print(unrolled)
+   unrolled_qasm = module.unrolled_qasm
+
+   print(unrolled_qasm)
 
 .. code-block:: bash
 
diff --git a/examples/unroll_example.py b/examples/unroll_example.py
index 04464bd..044879e 100755
--- a/examples/unroll_example.py
+++ b/examples/unroll_example.py
@@ -11,7 +11,7 @@
 # pylint: disable=invalid-name
 
 """
-Script demonstrating how to unroll a QASM 3 program using the `pyqasm.unrolll` function.
+Script demonstrating how to unroll a QASM 3 program using pyqasm.
 
 """
 
diff --git a/examples/validate_example.py b/examples/validate_example.py
index 943cbda..0d0d13a 100755
--- a/examples/validate_example.py
+++ b/examples/validate_example.py
@@ -11,7 +11,7 @@
 # pylint: disable=invalid-name
 
 """
-Script demonstrating how to validate a QASM 3 program using the `pyqasm.validate` function.
+Script demonstrating how to validate a QASM 3 program using pyqasm.
 
 """
 
diff --git a/pyqasm/analyzer.py b/pyqasm/analyzer.py
index de0582d..f6ce817 100644
--- a/pyqasm/analyzer.py
+++ b/pyqasm/analyzer.py
@@ -25,6 +25,7 @@
     IndexExpression,
     IntegerLiteral,
     IntType,
+    QuantumMeasurementStatement,
     RangeDefinition,
 )
 from openqasm3.parser import QASM3ParsingError
@@ -185,6 +186,29 @@ def find_array_element(multi_dim_arr: np.ndarray, indices: list[tuple[int, int,
         )
         return multi_dim_arr[slicing]  # type: ignore[index]
 
+    @staticmethod
+    def get_op_bit_list(operation):
+        """
+        Get the list of qubits associated with an operation.
+
+        Args:
+            operation (QuantumOperation): The quantum operation.
+
+        Returns:
+            list: The list of qubits associated with the operation.
+        """
+        bit_list = []
+        if isinstance(operation, QuantumMeasurementStatement):
+            assert operation.target is not None
+            bit_list = [operation.measure.qubit]
+        else:
+            bit_list = (
+                operation.qubits
+                if isinstance(operation.qubits, list)
+                else [operation.qubits]  # type: ignore[assignment]
+            )
+        return bit_list
+
     @staticmethod
     def extract_qasm_version(qasm: str) -> int:  # type: ignore # pylint: disable=R1710
         """
diff --git a/pyqasm/modules.py b/pyqasm/modules.py
index 1abd09a..3cb062c 100644
--- a/pyqasm/modules.py
+++ b/pyqasm/modules.py
@@ -21,6 +21,7 @@
 from openqasm3.ast import Include, Program, Statement
 from openqasm3.printer import dumps
 
+from .analyzer import Qasm3Analyzer
 from .elements import ClbitDepthNode, QubitDepthNode
 from .exceptions import UnrollError, ValidationError
 from .maps import QUANTUM_STATEMENTS
@@ -296,16 +297,7 @@ def _remap_qubits(self, reg_name: str, size: int, idle_indices: list[int]):
         # update the operations that use the qubits
         for operation in self._unrolled_ast.statements:
             if isinstance(operation, QUANTUM_STATEMENTS):
-                bit_list = []
-                if isinstance(operation, qasm3_ast.QuantumMeasurementStatement):
-                    assert operation.target is not None
-                    bit_list = [operation.measure.qubit]
-                else:
-                    bit_list = (
-                        operation.qubits
-                        if isinstance(operation.qubits, list)
-                        else [operation.qubits]  # type: ignore[assignment]
-                    )
+                bit_list = Qasm3Analyzer.get_op_bit_list(operation)
                 for bit in bit_list:
                     assert isinstance(bit, qasm3_ast.IndexedIdentifier)
                     if bit.name.name == reg_name:
@@ -369,8 +361,60 @@ def remove_idle_qubits(self, in_place: bool = True):
 
         return qasm_module
 
-    def reverse_qubit_order(self):
-        """Reverse the order of qubits in the module"""
+    def reverse_qubit_order(self, in_place=True):
+        """Reverse the order of qubits in the module
+
+        Args:
+            in_place (bool): Flag to indicate if the reversal should be done in place.
+
+        Returns:
+            QasmModule: The module the qubit order reversed. If in_place is False, a new module
+                        with the reversed qubit order is returned.
+        """
+
+        qasm_module = self if in_place else self.copy()
+        qasm_module.unroll()
+
+        new_qubit_mappings = {}
+        for register, size in self._qubit_registers.items():
+            new_qubit_mappings[register] = {0: 0}
+            if size > 1:
+                new_qubit_mappings[register] = {old_id: size - old_id - 1 for old_id in range(size)}
+
+        # Example -
+        # q[0], q[1], q[2], q[3] -> q[3], q[2], q[1], q[0]
+        # new_qubit_mappings = {"q": {0: 3, 1: 2, 2: 1, 3: 0}}
+
+        # 1. Qubit depths will be recalculated whenever we calculate the depth so we do not update
+        #    the depth maps here
+
+        # 2. replace each qubit index in the Quantum Operations with the new index
+        for operation in qasm_module.unrolled_ast.statements:
+            if isinstance(operation, QUANTUM_STATEMENTS):
+                bit_list = Qasm3Analyzer.get_op_bit_list(operation)
+                for bit in bit_list:
+                    curr_reg_name = bit.name.name
+                    curr_reg_idx = bit.indices[0][0].value
+                    new_reg_idx = new_qubit_mappings[curr_reg_name][curr_reg_idx]
+
+                    # mark it -ve so that this is not touched
+                    # while updating the same index later
+                    bit.indices[0][0].value = -1 * new_reg_idx - 1
+
+        # remove the -ve marker
+        for operation in qasm_module.unrolled_ast.statements:
+            if isinstance(operation, QUANTUM_STATEMENTS):
+                bit_list = Qasm3Analyzer.get_op_bit_list(operation)
+                for bit in bit_list:
+                    if bit.indices[0][0].value < 0:
+                        bit.indices[0][0].value += 1
+                        bit.indices[0][0].value *= -1
+
+        # 3. update the original AST with the unrolled AST
+        qasm_module._statements = qasm_module._unrolled_ast.statements
+
+        # 4. return the module
+        return qasm_module
 
     def validate(self):
         """Validate the module"""


Test patch:
diff --git a/tests/qasm3/test_transformations.py b/tests/qasm3/test_transformations.py
index 9898d1c..9c9352f 100644
--- a/tests/qasm3/test_transformations.py
+++ b/tests/qasm3/test_transformations.py
@@ -89,3 +89,43 @@ def test_remove_idle_qubits_qasm3():
     assert module.num_qubits == 7
 
     check_unrolled_qasm(module.unrolled_qasm, expected_qasm3_str)
+
+
+def test_reverse_qubit_order_qasm3():
+    """Test the reverse qubit ordering function for qasm3 string"""
+    qasm3_str = """
+    OPENQASM 3.0;
+    include "stdgates.inc";
+    qubit[2] q;
+    qubit[4] q2;
+    qubit q3;
+    bit[1] c; 
+
+    cnot q[0], q[1];
+    cnot q2[0], q2[1];
+    x q2[3];
+    cnot q2[0], q2[2];
+    x q3;
+    c[0] = measure q2[0];
+    """
+
+    expected_qasm3_str = """
+    OPENQASM 3.0;
+    include "stdgates.inc";
+    qubit[2] q;
+    qubit[4] q2;
+    qubit[1] q3;
+    bit[1] c;
+
+    cx q[1], q[0];
+    cx q2[3], q2[2];
+    x q2[0];
+    cx q2[3], q2[1];
+    x q3[0];
+
+    c[0] = measure q2[3];
+    """
+
+    module = load(qasm3_str)
+    module.reverse_qubit_order()
+    check_unrolled_qasm(module.unrolled_qasm, expected_qasm3_str)