Repository: qBraid/pyqasm

Issue:
qBraid-SDK migrations
Migrate the following functions and classes (+ corresponding tests) from `qbraid` to `pyqasm`:

- [`depth()`](https://github.com/qBraid/qBraid/blob/0ef7714d7a75ffe7aaefda0e0f1ae55611250ce0/qbraid/passes/qasm/analyze.py#L67)
- [`rebase()`](https://github.com/qBraid/qBraid/blob/0ef7714d7a75ffe7aaefda0e0f1ae55611250ce0/qbraid/passes/qasm/decompose.py#L344) (and all associated helpers)
- [`extract_qasm_version()`](https://github.com/qBraid/qBraid/blob/da28f77c71d18ce526afd77a9eebf8bc43c00132/qbraid/programs/typer.py#L106) (don't mask "opaque")
- [`QasmStringType`](https://github.com/qBraid/qBraid/blob/da28f77c71d18ce526afd77a9eebf8bc43c00132/qbraid/programs/typer.py#L207), [`Qasm2StringType`](https://github.com/qBraid/qBraid/blob/da28f77c71d18ce526afd77a9eebf8bc43c00132/qbraid/programs/typer.py#L220), [`Qasm3StringType`](https://github.com/qBraid/qBraid/blob/da28f77c71d18ce526afd77a9eebf8bc43c00132/qbraid/programs/typer.py#L226)

- [`qasm3_drawer()`](https://github.com/qBraid/qBraid/blob/da28f77c71d18ce526afd77a9eebf8bc43c00132/qbraid/visualization/draw_qasm3.py#L459) (This function needs *a lot* of work done. We will need to refactor it to leverage the `openqasm3.ast` explicitly, and from there perhaps, limit its scope to just OpenQASM 2 strings until it is stable enough to safely begin expanding to OpenQASM 3. This could be a great issue to turn over to the broader open-source community for help.)

- [`qas2_to_qasm3()`](https://github.com/qBraid/qBraid/blob/0ef7714d7a75ffe7aaefda0e0f1ae55611250ce0/qbraid/transpiler/conversions/qasm2/qasm2_to_qasm3.py#L103) (maybe)

Possibly more to come, but this should be a good start.

Create a dev branch for qBraid-SDK, add pyqasm as a dependency, and for each of the above functions/classes that are migrated, import/replace with the appropriate pyqasm function/class in the qBraid-SDK. Open draft PRs across the qBraid-SDK and pyqasm repos so that we can review the updates in parallel.

*Note*: If things begin to get cluttered, we may want to discuss creating `pyqasm` sub-modules to distinguish different "families" of functions / operations, e.g. `core`, `typing`, `passes`, etc. But we also don't want to do so pre-maturely, before the project has had a chance to gain it's footing. Otherwise we may set ourselves up for re-factors early on, which we don't want.

In `qbraid-qir[qasm3]` we plan to maintain the dependency open `openqasm3[parser]` even after we integrate `pyqasm`. By contrast, the ultimate goal that this issue begins to work towards is to move `openqasm3` from a core to an optional dependency within the qBraid-SDK, and to replace any critical functionality implemented using `openqasm3` with equivalent out-of-the-box functionality provided via `pyqasm`.

For example, we hope to eventually refactor bloated logic surrounding calculations of number of qubits / classical bits in [`OpenQasm2Program`](https://github.com/qBraid/qBraid/blob/0ef7714d7a75ffe7aaefda0e0f1ae55611250ce0/qbraid/programs/gate_model/qasm2.py#L33) and [`OpenQasm3Program`](https://github.com/qBraid/qBraid/blob/0ef7714d7a75ffe7aaefda0e0f1ae55611250ce0/qbraid/programs/gate_model/qasm3.py#L44) and move streamlined versions of these functions into `pyqasm`. We hope to eventually remove functions like [`unfold_qasm2()`](https://github.com/qBraid/qBraid/blob/0ef7714d7a75ffe7aaefda0e0f1ae55611250ce0/qbraid/passes/qasm/unfold.py#L83) and replace them with simple calls to `pyqasm.unroll()`. Similarly, we hope to replace functions like [`decompose_qasm2()`](https://github.com/qBraid/qBraid/blob/0ef7714d7a75ffe7aaefda0e0f1ae55611250ce0/qbraid/passes/qasm/decompose.py#L124) with `pyqasm.rebase()` from the function linked above (pending migration). Eventually, we would like to dissipate all of [`qbraid.passes`](https://github.com/qBraid/qBraid/tree/0ef7714d7a75ffe7aaefda0e0f1ae55611250ce0/qbraid/passes), and sort these functions into appropriate modules, handlers, and/or helpers in `pyqasm` or within the qBraid-SDK.

Pending candidate functions up for migration may also include:

- [`has_measurements()`](https://github.com/qBraid/qBraid/blob/0ef7714d7a75ffe7aaefda0e0f1ae55611250ce0/qbraid/passes/qasm/analyze.py#L41)
- [`remove_measurements()`](https://github.com/qBraid/qBraid/blob/0ef7714d7a75ffe7aaefda0e0f1ae55611250ce0/qbraid/passes/qasm/compat.py#L299)
- others? (TBD)

> [!IMPORTANT] 
> Amidst all of these migrations, we want to be careful to *only* migrate things into `pyqasm` which are **generic** enough to also be useful to a "3rd party". Any helpers that provide functionality that is noticeably **specific** to `qbraid`, we should instead look to re-locate / reorganize somewhere else within the qBraid-SDK, wherever it is best suited.

The "ethos" of this project, in my mind, is to offer a concise, focused, and well-documented set of highly practical functions and methods to help users analyze and interface with OpenQASM programs. Overall, the package should be lightweight, fast, straightforward, user-friendly, and clear in both its purpose and utility. Anything that detracts from that, we leave out.

Hints from issue comments:
@TheGupta2012: Breaking this off into several sub-issues as the PR #49 is getting pretty big. @TheGupta2012: - I'm a little skeptical about the development of an in-house `qasm3_drawer` as, once unrolled, the program can be easily visualized by the qiskit drawer which has become a lot more robust. 
- Maybe we can think of visualizing some higher level representation instead of the qubit level that gives a better sense of how the program is evolving. I'm coming from a place where looking at the circuit level drawing I can not understand what the program "is doing". 
- A functional-level or a basic block level drawer would be much more useful which highlights "what a particular block of quantum instructions are doing" rather than just being present as a circuit, which is hard to interpret.
- It could start off as a simple "highlighter" where in we take the quantum circuit representation and demarcate what is the "use" of a block of circuit operations. 
- Eg. - 
![Screenshot 2024-10-30 at 1 19 30 PM](https://github.com/user-attachments/assets/b642772b-8cf1-45b2-8ba4-7983586c7239)
- @ryanhill1 let me know if this is something which you think would be worth exploring. If yes, I'll open up an issue describing more details about what I have in mind. As you mentioned, it'll be nice to see the open-source community pitch in on this.@TheGupta2012: @ryanhill1 turns out qiskit's drawer already supports something like this - 

```python
import qiskit 

print(qiskit.__version__) 
# 1.2.0 
 
program = """
    OPENQASM 3.0;
    include "stdgates.inc";
 
    input float[64] a;
    qubit[3] q;
    bit[2] mid;
    bit[3] out;
 
    let aliased = q[0:1];
 
    gate my_gate(a) c, t {
      gphase(a / 2);
      ry(a) c;
      cx c, t;
    }
    gate my_phase(a) c {
      ctrl @ inv @ gphase(a) c;
    }
 
    my_gate(a * 2) aliased[0], q[{1, 2}][0];
    measure q[0] -> mid[0];
    measure q[1] -> mid[1];
 
    while (mid == "00") {
      reset q[0];
      reset q[1];
      my_gate(a) q[0], q[1];
      my_phase(a - pi/2) q[1];
      mid[0] = measure q[0];
      mid[1] = measure q[1];
    }
 
    if (mid[0]) {
      let inner_alias = q[{0, 1}];
      reset inner_alias;
    }
 
    out = measure q;
"""
circuit = qiskit.qasm3.loads(program)
circuit.draw("mpl")
```

Output - 

![Screenshot 2024-11-19 at 10 59 56 AM](https://github.com/user-attachments/assets/ba714efe-c999-45dd-9e99-e913481ab06b)


Solution patch:
diff --git a/.pylintrc b/.pylintrc
index 11f7447..26998d8 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -430,7 +430,8 @@ disable=protected-access,
         fixme,
         missing-function-docstring,
         import-error,
-        duplicate-code
+        duplicate-code,
+        too-many-positional-arguments
 
 # Enable the message, report, category or checker with the given id(s). You can
 # either give multiple identifier separated by comma (,) or put this option
diff --git a/examples/README.md b/examples/README.md
index b811a08..cbc9dd9 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -30,9 +30,9 @@ bit[4] result;
 result = measure q;
 """
 
-unrolled = pyqasm.unroll(program)
-
-print(unrolled)
+qasm_module = pyqasm.load(program)
+qasm_module.unroll()
+print(qasm_module.unrolled_qasm)
 ```
 
 ```text
@@ -61,7 +61,7 @@ result[2] = measure q[2];
 result[3] = measure q[3];
 ```
 
-`pyqasm.unroll()` simplifies a quantum program by expanding custom gate definitions and flattening complex constructs like subroutines, loops, and conditionals into basic operations. This process, also called **program flattening** or **inlining**, transforms the program into a linear sequence of qubit and classical bit declarations, gate operations, and measurements, making it easier to transpile or compile for execution on a quantum device. See the extended [Deutsch Josza program unrolling example](unroll_example.py) for more details about the OpenQASM 3 language features being "unrolled" in the above program.
+`pyqasm.QasmModule.unroll()` simplifies a quantum program by expanding custom gate definitions and flattening complex constructs like subroutines, loops, and conditionals into basic operations. This process, also called **program flattening** or **inlining**, transforms the program into a linear sequence of qubit and classical bit declarations, gate operations, and measurements, making it easier to transpile or compile for execution on a quantum device. See the extended [Deutsch Josza program unrolling example](unroll_example.py) for more details about the OpenQASM 3 language features being "unrolled" in the above program.
 
 ### Program Validation
 
@@ -81,11 +81,11 @@ h q[2];
 c = measure q;
 """
 
-pyqasm.validate(program)
+pyqasm.load(program).validate()
 ```
 
 ```text
 pyqasm.exceptions.ValidationError: Index 2 out of range for register of size 1 in qubit
 ```
 
-`pyqasm.validate()` returns `None` if the program is semantically valid, otherwise raises an Exception. Check out a more detailed [validation example](validate_example.py) for a deeper look into the capabilities of our semantic analyzer.
\ No newline at end of file
+`pyqasm.QasmModule.validate()` returns `None` if the program is semantically valid, otherwise raises an Exception. Check out a more detailed [validation example](validate_example.py) for a deeper look into the capabilities of our semantic analyzer.
\ No newline at end of file
diff --git a/examples/unroll_example.py b/examples/unroll_example.py
index 1e02fa7..04464bd 100755
--- a/examples/unroll_example.py
+++ b/examples/unroll_example.py
@@ -70,6 +70,7 @@ def deutsch_jozsa(qubit[N] q_func, qubit[1] ancilla_q) {
 result = measure q;
 """
 
-qasm_unrolled = pyqasm.unroll(qasm_program)
+qasm_module = pyqasm.load(qasm_program)
+qasm_module.unroll()
 
-print(qasm_unrolled)
+print(qasm_module.unrolled_qasm)
diff --git a/examples/validate_example.py b/examples/validate_example.py
index 3edb112..943cbda 100755
--- a/examples/validate_example.py
+++ b/examples/validate_example.py
@@ -84,4 +84,4 @@ def generic_operation(qubit[N] q) {
 c = measure q;
 """
 
-pyqasm.validate(qasm_program)
+pyqasm.load(qasm_program).validate()
diff --git a/pyqasm/__init__.py b/pyqasm/__init__.py
index 82be47d..71ceca9 100644
--- a/pyqasm/__init__.py
+++ b/pyqasm/__init__.py
@@ -19,8 +19,6 @@
 .. autosummary::
    :toctree: ../stubs/
 
-   validate
-   unroll
    load
 
 Classes
@@ -30,6 +28,8 @@
    :toctree: ../stubs/
 
    Qasm3Module
+   Qasm2Module
+   QasmModule
 
 Exceptions
 -----------
@@ -39,6 +39,7 @@
 
    PyQasmError
    ValidationError
+   QasmParsingError
 
 """
 import warnings
@@ -50,16 +51,17 @@
     warnings.warn("Importing 'pyqasm' outside a proper installation.")
     __version__ = "dev"
 
-from .elements import Qasm3Module
-from .entrypoint import load, unroll, validate
-from .exceptions import PyQasmError, ValidationError
+from .entrypoint import load
+from .exceptions import PyQasmError, QasmParsingError, ValidationError
+from .modules import Qasm2Module, Qasm3Module, QasmModule
 
 __all__ = [
     "PyQasmError",
     "ValidationError",
-    "validate",
-    "unroll",
+    "QasmParsingError",
     "load",
+    "QasmModule",
+    "Qasm2Module",
     "Qasm3Module",
     "__version__",
 ]
diff --git a/pyqasm/analyzer.py b/pyqasm/analyzer.py
index ba6d03e..de0582d 100644
--- a/pyqasm/analyzer.py
+++ b/pyqasm/analyzer.py
@@ -9,7 +9,7 @@
 # THERE IS NO WARRANTY for pyqasm, as per Section 15 of the GPL v3.
 
 """
-Module with analysis functions for QASM3 visitor
+Module with analysis functions for QASM visitor
 
 """
 from __future__ import annotations
@@ -17,6 +17,7 @@
 from typing import TYPE_CHECKING, Any, Optional, Union
 
 import numpy as np
+from openqasm3 import parse
 from openqasm3.ast import (
     DiscreteSet,
     Expression,
@@ -26,8 +27,9 @@
     IntType,
     RangeDefinition,
 )
+from openqasm3.parser import QASM3ParsingError
 
-from .exceptions import ValidationError, raise_qasm3_error
+from .exceptions import QasmParsingError, ValidationError, raise_qasm3_error
 
 if TYPE_CHECKING:
     from .elements import Variable
@@ -182,3 +184,28 @@ def find_array_element(multi_dim_arr: np.ndarray, indices: list[tuple[int, int,
             slice(start, end + 1, step) if start != end else start for start, end, step in indices
         )
         return multi_dim_arr[slicing]  # type: ignore[index]
+
+    @staticmethod
+    def extract_qasm_version(qasm: str) -> int:  # type: ignore # pylint: disable=R1710
+        """
+        Parses an OpenQASM program string to determine its major version, either 2 or 3.
+
+        Args:
+            qasm (str): The OpenQASM program string.
+
+        Returns:
+            int: The OpenQASM version as an integer.
+
+        Raises:
+            QasmError: If the string does not represent a valid OpenQASM program.
+        """
+        try:
+            # TODO: optimize this to just check the start of the program for version
+            parsed_program = parse(qasm)
+            assert parsed_program.version is not None
+            version = int(float(parsed_program.version))
+            return version
+        except (QASM3ParsingError, ValueError, TypeError):
+            raise_qasm3_error(
+                "Could not determine the OpenQASM version.", err_type=QasmParsingError
+            )
diff --git a/pyqasm/elements.py b/pyqasm/elements.py
index 96366b5..42a86b5 100644
--- a/pyqasm/elements.py
+++ b/pyqasm/elements.py
@@ -9,20 +9,13 @@
 # THERE IS NO WARRANTY for pyqasm, as per Section 15 of the GPL v3.
 
 """
-Module defining Qasm3 Converter elements.
+Module defining Qasm Converter elements.
 
 """
-import re
-from abc import ABC, abstractmethod
 from enum import Enum
 from typing import Any, Optional, Union
 
 import numpy as np
-import openqasm3.ast as qasm3_ast
-from openqasm3.ast import Include, Program, Statement
-from openqasm3.printer import dumps
-
-from .exceptions import ValidationError
 
 
 class InversionOp(Enum):
@@ -45,6 +38,76 @@ class Context(Enum):
     GATE = "gate"
 
 
+class DepthNode:
+    """Base class for depth nodes."""
+
+    def __init__(self, reg_name: str, reg_index: int):
+        self._depth = 0
+        self._reg_name = reg_name
+        self._reg_index = reg_index
+
+    @property
+    def depth(self) -> int:
+        """Return the depth of the node."""
+        return self._depth
+
+    @depth.setter
+    def depth(self, value: int):
+        """Set the depth of the node."""
+        self._depth = value
+
+    @property
+    def reg_name(self) -> str:
+        """Return the register name."""
+        return self._reg_name
+
+    @reg_name.setter
+    def reg_name(self, value: str):
+        """Set the register name."""
+        self._reg_name = value
+
+    @property
+    def reg_index(self) -> int:
+        """Return the register index."""
+        return self._reg_index
+
+    @reg_index.setter
+    def reg_index(self, value: int):
+        """Set the register index."""
+        self._reg_index = value
+
+
+class QubitDepthNode(DepthNode):
+    """Qubit depth node."""
+
+    def __init__(self, reg_name: str, reg_index: int):
+        super().__init__(reg_name, reg_index)
+        self.num_resets = 0
+        self.num_measurements = 0
+        self.num_gates = 0
+        self.num_barriers = 0
+
+    def __repr__(self) -> str:
+        return (
+            f"QubitDepthNode(reg_name = {self.reg_name}, reg_index = {self.reg_index}, "
+            f"depth = {self.depth})"
+        )
+
+
+class ClbitDepthNode(DepthNode):
+    """Classical bit depth node."""
+
+    def __init__(self, reg_name: str, reg_index: int):
+        super().__init__(reg_name, reg_index)
+        self.num_measurements = 0
+
+    def __repr__(self) -> str:
+        return (
+            f"ClbitDepthNode(reg_name = {self.reg_name}, reg_index = {self.reg_index},"
+            f" depth = {self.depth})"
+        )
+
+
 # pylint: disable-next=too-many-instance-attributes
 class Variable:
     """
@@ -62,7 +125,7 @@ class Variable:
 
     """
 
-    # pylint: disable-next=too-many-arguments,too-many-positional-arguments
+    # pylint: disable-next=too-many-arguments,
     def __init__(
         self,
         name: str,
@@ -90,224 +153,3 @@ def __repr__(self) -> str:
             f"value = {self.value}, is_constant = {self.is_constant}, "
             f"readonly = {self.readonly}, is_register = {self.is_register})"
         )
-
-
-class QasmModule(ABC):
-    """Abstract class for a Qasm module
-
-    Args:
-        name (str): Name of the module.
-        program (Program): The original openqasm3 program.
-        statements (list[Statement]): list of openqasm3 Statements.
-    """
-
-    def __init__(self, name: str, program: Program, statements: list):
-        self._name = name
-        self._original_program = program
-        self._statements = statements
-        self._num_qubits = 0
-        self._num_clbits = 0
-        self._unrolled_qasm = ""
-        self._unrolled_ast = Program(statements=[])
-
-    @property
-    def name(self) -> str:
-        """Returns the name of the module."""
-        return self._name
-
-    @property
-    def num_qubits(self) -> int:
-        """Returns the number of qubits in the circuit."""
-        return self._num_qubits
-
-    def add_qubits(self, num_qubits: int):
-        """Add qubits to the module
-
-        Args:
-            num_qubits (int): The number of qubits to add to the module
-
-        Returns:
-            None
-        """
-        self._num_qubits += num_qubits
-
-    @property
-    def num_clbits(self) -> int:
-        """Returns the number of classical bits in the circuit."""
-        return self._num_clbits
-
-    @property
-    def original_program(self) -> Program:
-        """Returns the program AST for the original qasm supplied by the user"""
-        return self._original_program
-
-    @property
-    def unrolled_ast(self) -> Program:
-        """Returns the unrolled AST for the module"""
-        return self._unrolled_ast
-
-    @unrolled_ast.setter
-    def unrolled_ast(self, value: Program):
-        """Setter for the unrolled AST"""
-        self._unrolled_ast = value
-
-    @property
-    @abstractmethod
-    def unrolled_qasm(self) -> str:
-        """Abstract property for unrolled_qasm"""
-
-    @unrolled_qasm.setter
-    @abstractmethod
-    def unrolled_qasm(self, value: str):
-        """Abstract setter for unrolled_qasm"""
-
-    def add_classical_bits(self, num_clbits: int):
-        """Add classical bits to the module
-
-        Args:
-            num_clbits (int): The number of classical bits to add to the module
-
-        Returns:
-            None
-        """
-        self._num_clbits += num_clbits
-
-    @classmethod
-    def from_program(cls, program: Program):
-        """
-        Construct a Qasm3Module from a given openqasm3.ast.Program object
-        """
-        statements: list[Statement] = []
-
-        for statement in program.statements:
-            statements.append(statement)
-
-        return cls(
-            name="main",
-            program=program,
-            statements=statements,
-        )
-
-    @abstractmethod
-    def accept(self, visitor):
-        """Accept a visitor for the module
-
-        Args:
-            visitor (BasicQasmVisitor): The visitor to accept
-        """
-
-
-class Qasm2Module(QasmModule):
-    """
-    A module representing an unrolled openqasm2 quantum program.
-
-    Args:
-        name (str): Name of the module.
-        program (Program): The original openqasm2 program.
-        statements (list[Statement]): list of openqasm2 Statements.
-    """
-
-    def __init__(
-        self,
-        name: str,
-        program: Program,
-        statements: list,
-    ):
-        super().__init__(name, program, statements)
-        self._unrolled_ast = Program(statements=[Include("stdgates.inc")], version="2.0")
-        self._whitelist_statements = {
-            qasm3_ast.BranchingStatement,
-            qasm3_ast.QubitDeclaration,
-            qasm3_ast.ClassicalDeclaration,
-            qasm3_ast.Include,
-            qasm3_ast.QuantumGateDefinition,
-            qasm3_ast.QuantumGate,
-            qasm3_ast.QuantumMeasurement,
-            qasm3_ast.QuantumMeasurementStatement,
-            qasm3_ast.QuantumReset,
-            qasm3_ast.QuantumBarrier,
-        }
-
-    def _filter_statements(self):
-        """Filter statements according to the whitelist"""
-        for stmt in self._statements:
-            stmt_type = type(stmt)
-            if stmt_type not in self._whitelist_statements:
-                raise ValidationError(f"Statement of type {stmt_type} not supported in QASM 2.0")
-            # TODO: add more filtering here if needed
-
-    def _format_declarations(self):
-        """Format the unrolled qasm for declarations in openqasm 2.0 format"""
-        qasm = self._unrolled_qasm
-        for declaration_type, replacement_type in [("qubit", "qreg"), ("bit", "creg")]:
-            pattern = rf"{declaration_type}\[(\d+)\]\s+(\w+);"
-            replacement = rf"{replacement_type} \2[\1];"
-            qasm = re.sub(pattern, replacement, qasm)
-
-        self._unrolled_qasm = qasm
-
-    @property
-    def unrolled_qasm(self) -> str:
-        """Returns the unrolled qasm for the given module"""
-        if self._unrolled_qasm == "":
-            self._unrolled_qasm = dumps(self._unrolled_ast, old_measurement=True)
-            self._format_declarations()
-        return self._unrolled_qasm
-
-    @unrolled_qasm.setter
-    def unrolled_qasm(self, value: str):
-        """Setter for the unrolled qasm"""
-        self._unrolled_qasm = value
-
-    def accept(self, visitor):
-        """Accept a visitor for the module
-
-        Args:
-            visitor (BasicQasmVisitor): The visitor to accept
-        """
-        self._filter_statements()
-        unrolled_stmt_list = visitor.visit_basic_block(self._statements)
-        self.unrolled_ast.statements.extend(unrolled_stmt_list)
-        # TODO: some finalizing method here probably
-
-
-class Qasm3Module(QasmModule):
-    """
-    A module representing an unrolled openqasm3 quantum program.
-
-    Args:
-        name (str): Name of the module.
-        program (Program): The original openqasm3 program.
-        statements (list[Statement]): list of openqasm3 Statements.
-    """
-
-    def __init__(
-        self,
-        name: str,
-        program: Program,
-        statements: list,
-    ):
-        super().__init__(name, program, statements)
-        self._unrolled_ast = Program(statements=[Include("stdgates.inc")], version="3.0")
-
-    @property
-    def unrolled_qasm(self) -> str:
-        """Returns the unrolled qasm for the given module"""
-        if self._unrolled_qasm == "":
-            self._unrolled_qasm = dumps(self._unrolled_ast)
-        return self._unrolled_qasm
-
-    @unrolled_qasm.setter
-    def unrolled_qasm(self, value: str):
-        """Setter for the unrolled qasm"""
-        self._unrolled_qasm = value
-
-    def accept(self, visitor):
-        """Accept a visitor for the module
-
-        Args:
-            visitor (BasicQasmVisitor): The visitor to accept
-        """
-        unrolled_stmt_list = visitor.visit_basic_block(self._statements)
-        self.unrolled_ast.statements.extend(unrolled_stmt_list)
-        # TODO: some finalizing method here probably
diff --git a/pyqasm/entrypoint.py b/pyqasm/entrypoint.py
index 3585c97..37a04c4 100644
--- a/pyqasm/entrypoint.py
+++ b/pyqasm/entrypoint.py
@@ -18,15 +18,15 @@
 
 import openqasm3
 
-from .elements import Qasm2Module, Qasm3Module
 from .exceptions import ValidationError
-from .visitor import BasicQasmVisitor
+from .maps import SUPPORTED_QASM_VERSIONS
+from .modules import Qasm2Module, Qasm3Module, QasmModule
 
 if TYPE_CHECKING:
     import openqasm3.ast
 
 
-def load(program: openqasm3.ast.Program | str) -> Qasm3Module:
+def load(program: openqasm3.ast.Program | str) -> QasmModule:
     """Loads an OpenQASM 3 program into a `Qasm3Module` object.
 
     Args:
@@ -47,60 +47,13 @@ def load(program: openqasm3.ast.Program | str) -> Qasm3Module:
             raise ValidationError(f"Failed to parse OpenQASM string: {err}") from err
     elif not isinstance(program, openqasm3.ast.Program):
         raise TypeError("Input quantum program must be of type 'str' or 'openqasm3.ast.Program'.")
-    if program.version not in {"2.0", "3.0", "2", "3"}:
+    if program.version not in SUPPORTED_QASM_VERSIONS:
         raise ValidationError(f"Unsupported OpenQASM version: {program.version}")
 
+    # change version string to x.0 format
+    program.version = str(float(program.version))
+
     qasm_module = Qasm3Module if program.version.startswith("3") else Qasm2Module
     module = qasm_module.from_program(program)
 
     return module
-
-
-def validate(program: openqasm3.ast.Program | str) -> None:
-    """Validates a given OpenQASM 3 program for semantic correctness.
-
-    Args:
-        program (openqasm3.ast.Program or str): The OpenQASM 3 program to validate.
-
-    Raises:
-        ValidationError: If the program fails parsing or semantic validation.
-    """
-    module = load(program)
-
-    try:
-        visitor = BasicQasmVisitor(module, check_only=True)
-        module.accept(visitor)
-    except (TypeError, ValueError) as err:
-        raise ValidationError(err) from err
-
-
-def unroll(
-    program: openqasm3.ast.Program | str,
-    as_module: bool = False,
-    **kwargs,
-) -> Qasm3Module | str:
-    """Transforms the input program into a linear sequence of qubit and
-    classical bit declarations, gate operations, and measurements
-
-    Args:
-        program (openqasm3.ast.Program or str): The OpenQASM 3 program to unroll.
-
-    Returns:
-        Qasm3Module or str: Returns the flattened program as a string. Or, if
-        ``as_module`` is Truem, returns a ``Qasm3Module`` containing the flattened
-        program and along with other program metadata as attributes.
-
-    Raises:
-        TypeError: If the input is not a supported OpenQASM 3 program type.
-        ValueError: If the input program fails to be parsed.
-        ValidationError: If the input program fails semantic validation.
-    """
-    module = load(program)
-
-    visitor = BasicQasmVisitor(module=module, **kwargs)
-    module.accept(visitor)
-
-    if as_module:
-        return module
-
-    return module.unrolled_qasm
diff --git a/pyqasm/exceptions.py b/pyqasm/exceptions.py
index db6ccd2..db355a6 100644
--- a/pyqasm/exceptions.py
+++ b/pyqasm/exceptions.py
@@ -17,6 +17,7 @@
 from typing import Optional, Type
 
 from openqasm3.ast import Span
+from openqasm3.parser import QASM3ParsingError
 
 
 class PyQasmError(Exception):
@@ -31,6 +32,11 @@ class UnrollError(PyQasmError):
     """Exception raised when a OpenQASM program fails unrolling."""
 
 
+class QasmParsingError(QASM3ParsingError):
+    """An error raised by the AST visitor during the AST-generation phase.  This is raised in cases
+    where the given program could not be correctly parsed."""
+
+
 def raise_qasm3_error(
     message: Optional[str] = None,
     err_type: Type[Exception] = ValidationError,
diff --git a/pyqasm/expressions.py b/pyqasm/expressions.py
index 7acceee..f39b283 100644
--- a/pyqasm/expressions.py
+++ b/pyqasm/expressions.py
@@ -9,7 +9,7 @@
 # THERE IS NO WARRANTY for pyqasm, as per Section 15 of the GPL v3.
 
 """
-Module containing the class for evaluating QASM3 expressions.
+Module containing the class for evaluating QASM expressions.
 
 """
 
diff --git a/pyqasm/maps.py b/pyqasm/maps.py
index 4cf507c..79db33b 100644
--- a/pyqasm/maps.py
+++ b/pyqasm/maps.py
@@ -321,6 +321,84 @@ def pswap_gate(
     return result
 
 
+def crx_gate(
+    theta: Union[int, float], qubit0: IndexedIdentifier, qubit1: IndexedIdentifier
+) -> list[QuantumGate]:
+    """
+    Implements the CRX gate as a decomposition of other gates.
+
+    Used the following qiskit decomposition:
+
+        In [24]: q = QuantumCircuit(2)
+
+        In [25]: q.crx(Parameter('theta'), 0, 1)
+        Out[25]: <qiskit.circuit.instructionset.InstructionSet at 0x12e533be0>
+
+        In [26]: q.draw()
+        Out[26]:
+
+        q_0: ──────■──────
+             ┌─────┴─────┐
+        q_1: ┤ Rx(theta) ├
+             └───────────┘
+
+        In [27]: q.decompose().decompose().decompose().draw()
+        Out[27]:
+
+        q_0: ────────────────■───────────────────────■───────────────────────
+             ┌────────────┐┌─┴─┐┌─────────────────┐┌─┴─┐┌───────────────────┐
+        q_1: ┤ U(0,0,π/2) ├┤ X ├┤ U(-theta/2,0,0) ├┤ X ├┤ U(theta/2,-π/2,0) ├
+             └────────────┘└───┘└─────────────────┘└───┘└───────────────────┘
+    """
+    result: list[QuantumGate] = []
+    result.extend(u3_gate(0, 0, CONSTANTS_MAP["pi"] / 2, qubit1))
+    result.extend(two_qubit_gate_op("cx", qubit0, qubit1))
+    result.extend(u3_gate(-1 * theta / 2, 0, 0, qubit1))
+    result.extend(two_qubit_gate_op("cx", qubit0, qubit1))
+    result.extend(u3_gate(theta / 2, -1 * CONSTANTS_MAP["pi"] / 2, 0, qubit1))
+    return result
+
+
+def crz_gate(
+    theta: Union[int, float], qubit0: IndexedIdentifier, qubit1: IndexedIdentifier
+) -> list[QuantumGate]:
+    """
+        Implements the CRZ gate as a decomposition of other gates.
+
+        Used the following qiskit decomposition -
+
+        In [1]: from qiskit.circuit import Parameter, QuantumCircuit
+
+        In [2]: q = QuantumCircuit(2)
+
+        In [3]: q.crz(Parameter('theta'), 0, 1)
+        Out[3]: <qiskit.circuit.instructionset.InstructionSet at 0x119fbfbe0>
+
+        In [4]: q.draw()
+        Out[4]:
+
+    q_0: ──────■──────
+         ┌─────┴─────┐
+    q_1: ┤ Rz(theta) ├
+         └───────────┘
+
+        In [5]: q.decompose().decompose().decompose().draw()
+        Out[5]:
+        global phase: 0
+
+    q_0: ─────────────────────■────────────────────────■──
+         ┌─────────────────┐┌─┴─┐┌──────────────────┐┌─┴─┐
+    q_1: ┤ U3(0,0,theta/2) ├┤ X ├┤ U3(0,0,-theta/2) ├┤ X ├
+         └─────────────────┘└───┘└──────────────────┘└───┘
+    """
+    result: list[QuantumGate] = []
+    result.extend(u3_gate(0, 0, theta / 2, qubit1))
+    result.extend(two_qubit_gate_op("cx", qubit0, qubit1))
+    result.extend(u3_gate(0, 0, -1 * theta / 2, qubit1))
+    result.extend(two_qubit_gate_op("cx", qubit0, qubit1))
+    return result
+
+
 def cphaseshift_gate(
     theta: Union[int, float], qubit0: IndexedIdentifier, qubit1: IndexedIdentifier
 ) -> list[QuantumGate]:
@@ -591,6 +669,8 @@ def two_qubit_gate_op(
     "zz": zz_gate,
     "pswap": pswap_gate,
     "cp": cphaseshift_gate,
+    "crx": crx_gate,
+    "crz": crz_gate,
     "cphaseshift": cphaseshift_gate,
     "cp00": cphaseshift00_gate,
     "cphaseshift00": cphaseshift00_gate,
@@ -781,3 +861,5 @@ def qasm_variable_type_cast(openqasm_type, var_name, base_size, rhs_value):
     SubroutineDefinition,
     QuantumGateDefinition,
 }
+
+SUPPORTED_QASM_VERSIONS = {"3.0", "3", "2", "2.0"}
diff --git a/pyqasm/modules.py b/pyqasm/modules.py
new file mode 100644
index 0000000..d385017
--- /dev/null
+++ b/pyqasm/modules.py
@@ -0,0 +1,416 @@
+# Copyright (C) 2024 qBraid
+#
+# This file is part of pyqasm
+#
+# Pyqasm is free software released under the GNU General Public License v3
+# or later. You can redistribute and/or modify it under the terms of the GPL v3.
+# See the LICENSE file in the project root or <https://www.gnu.org/licenses/gpl-3.0.html>.
+#
+# THERE IS NO WARRANTY for pyqasm, as per Section 15 of the GPL v3.
+
+"""
+Module defining Qasm modules
+"""
+
+import re
+from abc import ABC, abstractmethod
+from copy import deepcopy
+from typing import Optional
+
+import openqasm3.ast as qasm3_ast
+from openqasm3.ast import Include, Program, Statement
+from openqasm3.printer import dumps
+
+from .elements import ClbitDepthNode, QubitDepthNode
+from .exceptions import UnrollError, ValidationError
+from .visitor import QasmVisitor
+
+
+class QasmModule(ABC):  # pylint: disable=too-many-instance-attributes
+    """Abstract class for a Qasm module
+
+    Args:
+        name (str): Name of the module.
+        program (Program): The original openqasm3 program.
+        statements (list[Statement]): list of openqasm3 Statements.
+    """
+
+    def __init__(self, name: str, program: Program, statements: list):
+        self._name = name
+        self._original_program = program
+        self._statements = statements
+        self._num_qubits = -1
+        self._qubit_depths: dict[tuple[str, int], QubitDepthNode] = {}
+        self._num_clbits = -1
+        self._clbit_depths: dict[tuple[str, int], ClbitDepthNode] = {}
+        self._has_measurements: Optional[bool] = None
+        self._validated_program = False
+        self._unrolled_qasm = ""
+        self._unrolled_ast = Program(statements=[Include("stdgates.inc")])
+
+    @property
+    def name(self) -> str:
+        """Returns the name of the module."""
+        return self._name
+
+    @property
+    def num_qubits(self) -> int:
+        """Returns the number of qubits in the circuit."""
+        if self._num_qubits == -1:
+            self._num_qubits = 0
+            self.validate()
+        return self._num_qubits
+
+    @num_qubits.setter
+    def num_qubits(self, value: int):
+        """Setter for the number of qubits"""
+        self._num_qubits = value
+
+    def _add_qubits(self, num_qubits: int):
+        """Add qubits to the module
+
+        Args:
+            num_qubits (int): The number of qubits to add to the module
+
+        Returns:
+            None
+        """
+        self._num_qubits += num_qubits
+
+    @property
+    def num_clbits(self) -> int:
+        """Returns the number of classical bits in the circuit."""
+        if self._num_clbits == -1:
+            self._num_clbits = 0
+            self.validate()
+        return self._num_clbits
+
+    @num_clbits.setter
+    def num_clbits(self, value: int):
+        """Setter for the number of classical bits"""
+        self._num_clbits = value
+
+    def _add_classical_bits(self, num_clbits: int):
+        """Add classical bits to the module
+
+        Args:
+            num_clbits (int): The number of classical bits to add to the module
+
+        Returns:
+            None
+        """
+        self._num_clbits += num_clbits
+
+    @property
+    def original_program(self) -> Program:
+        """Returns the program AST for the original qasm supplied by the user"""
+        return self._original_program
+
+    @property
+    def unrolled_ast(self) -> Program:
+        """Returns the unrolled AST for the module"""
+        return self._unrolled_ast
+
+    @unrolled_ast.setter
+    def unrolled_ast(self, value: Program):
+        """Setter for the unrolled AST"""
+        self._unrolled_ast = value
+
+    @property
+    def unrolled_qasm(self) -> str:
+        """Returns the unrolled qasm for the given module"""
+        return self._qasm_ast_to_str(self._unrolled_ast)
+
+    @unrolled_qasm.setter
+    def unrolled_qasm(self, value: str):
+        """Setter for the unrolled qasm"""
+        self._unrolled_qasm = value
+
+    @classmethod
+    def from_program(cls, program: Program):
+        """
+        Construct a Qasm3Module from a given openqasm3.ast.Program object
+        """
+        statements: list[Statement] = []
+
+        for statement in program.statements:
+            statements.append(statement)
+
+        return cls(
+            name="main",
+            program=program,
+            statements=statements,
+        )
+
+    def has_measurements(self):
+        """Check if the module has any measurement operations."""
+        if self._has_measurements is None:
+            self._has_measurements = False
+            # try to check in the unrolled version as that will a better indicator of
+            # the presence of measurements
+            stmts_to_check = (
+                self._unrolled_ast.statements
+                if len(self._unrolled_ast.statements) > 1
+                else self._statements
+            )
+            for stmt in stmts_to_check:
+                if isinstance(stmt, qasm3_ast.QuantumMeasurementStatement):
+                    self._has_measurements = True
+                    break
+        return self._has_measurements
+
+    def remove_measurements(self, in_place: bool = True):
+        """Remove the measurement operations
+
+        Args:
+            in_place (bool): Flag to indicate if the removal should be done in place.
+
+        Returns:
+            QasmModule: The module with the measurements removed if in_place is False
+        """
+        stmt_list = (
+            self._statements
+            if len(self._unrolled_ast.statements) == 1
+            else self._unrolled_ast.statements
+        )
+        stmts_without_meas = [
+            stmt
+            for stmt in stmt_list
+            if not isinstance(stmt, qasm3_ast.QuantumMeasurementStatement)
+        ]
+        curr_module = self
+
+        if not in_place:
+            curr_module = self.copy()
+
+        for qubit in curr_module._qubit_depths.values():
+            qubit.num_measurements = 0
+        for clbit in curr_module._clbit_depths.values():
+            clbit.num_measurements = 0
+
+        curr_module._has_measurements = False
+        curr_module._statements = stmts_without_meas
+        curr_module._unrolled_ast.statements = stmts_without_meas
+        curr_module._unrolled_qasm = self._qasm_ast_to_str(curr_module._unrolled_ast)
+
+        return curr_module
+
+    def remove_barriers(self, in_place: bool = True):
+        """Remove the barrier operations
+
+        Args:
+            in_place (bool): Flag to indicate if the removal should be done in place.
+
+        Returns:
+            QasmModule: The module with the barriers removed if in_place is False
+        """
+        stmt_list = (
+            self._statements
+            if len(self._unrolled_ast.statements) == 1
+            else self._unrolled_ast.statements
+        )
+        stmts_without_barriers = [
+            stmt for stmt in stmt_list if not isinstance(stmt, qasm3_ast.QuantumBarrier)
+        ]
+        curr_module = self
+        if not in_place:
+            curr_module = self.copy()
+
+        for qubit in curr_module._qubit_depths.values():
+            qubit.num_barriers = 0
+
+        curr_module._statements = stmts_without_barriers
+        curr_module._unrolled_ast.statements = stmts_without_barriers
+        curr_module._unrolled_qasm = self._qasm_ast_to_str(curr_module._unrolled_ast)
+
+        return curr_module
+
+    def depth(self):
+        """Calculate the depth of the unrolled openqasm program.
+
+        Args:
+            None
+
+        Returns:
+            int: The depth of the current "unrolled" openqasm program
+        """
+        # 1. Since the program will be unrolled before its execution on a QC, it makes sense to
+        # calculate the depth of the unrolled program.
+
+        # We are performing operations in place, thus we need to calculate depth
+        # at "each instance of the function call".
+        # TODO: optimize by tracking whether the program changed since we
+        # last calculated the depth
+
+        qasm_module = self.copy()
+        qasm_module._qubit_depths = {}
+        qasm_module._clbit_depths = {}
+        qasm_module.unroll()
+
+        max_depth = 0
+        max_qubit_depth, max_clbit_depth = 0, 0
+
+        # calculate the depth using the qubit and clbit depths
+        if len(qasm_module._qubit_depths) != 0:
+            max_qubit_depth = max(qubit.depth for qubit in qasm_module._qubit_depths.values())
+        if len(qasm_module._clbit_depths) != 0:
+            max_clbit_depth = max(clbit.depth for clbit in qasm_module._clbit_depths.values())
+        max_depth = max(max_qubit_depth, max_clbit_depth)
+        return max_depth
+
+    def remove_idle_qubits(self):
+        """Remove idle qubits from the module. Either collapse the size of a partially used
+        quantum register OR remove the unused quantum register entirely."""
+
+    def reverse_qubit_order(self):
+        """Reverse the order of qubits in the module"""
+
+    def validate(self):
+        """Validate the module"""
+        if self._validated_program is True:
+            return
+        try:
+            self.num_qubits, self.num_clbits = 0, 0
+            visitor = QasmVisitor(self, check_only=True)
+            self.accept(visitor)
+        except (ValidationError, NotImplementedError) as err:
+            self.num_qubits, self.num_clbits = -1, -1
+            raise err
+        self._validated_program = True
+
+    def unroll(self, **kwargs):
+        """Unroll the module into basic qasm operations"""
+        if not kwargs:
+            kwargs = {}
+        try:
+            self.num_qubits, self.num_clbits = 0, 0
+            visitor = QasmVisitor(module=self, **kwargs)
+            self.accept(visitor)
+        except (ValidationError, UnrollError) as err:
+            # reset the unrolled ast and qasm
+            self.num_qubits, self.num_clbits = -1, -1
+            self._unrolled_qasm = ""
+            self._unrolled_ast = Program(
+                statements=[Include("stdgates.inc")], version=self.original_program.version
+            )
+            raise err
+
+    def copy(self):
+        """Return a deep copy of the module"""
+        return deepcopy(self)
+
+    @abstractmethod
+    def _qasm_ast_to_str(self, qasm_ast):
+        """Convert the qasm AST to a string"""
+
+    @abstractmethod
+    def accept(self, visitor):
+        """Accept a visitor for the mßodule
+
+        Args:
+            visitor (QasmVisitor): The visitor to accept
+        """
+
+
+class Qasm2Module(QasmModule):
+    """
+    A module representing an unrolled openqasm2 quantum program.
+
+    Args:
+        name (str): Name of the module.
+        program (Program): The original openqasm2 program.
+        statements (list[Statement]): list of openqasm2 Statements.
+    """
+
+    def __init__(
+        self,
+        name: str,
+        program: Program,
+        statements: list,
+    ):
+        super().__init__(name, program, statements)
+        self._unrolled_ast = Program(statements=[Include("qelib1.inc")], version="2.0")
+        self._whitelist_statements = {
+            qasm3_ast.BranchingStatement,
+            qasm3_ast.QubitDeclaration,
+            qasm3_ast.ClassicalDeclaration,
+            qasm3_ast.Include,
+            qasm3_ast.QuantumGateDefinition,
+            qasm3_ast.QuantumGate,
+            qasm3_ast.QuantumMeasurement,
+            qasm3_ast.QuantumMeasurementStatement,
+            qasm3_ast.QuantumReset,
+            qasm3_ast.QuantumBarrier,
+        }
+
+    def _filter_statements(self):
+        """Filter statements according to the whitelist"""
+        for stmt in self._statements:
+            stmt_type = type(stmt)
+            if stmt_type not in self._whitelist_statements:
+                raise ValidationError(f"Statement of type {stmt_type} not supported in QASM 2.0")
+            # TODO: add more filtering here if needed
+
+    def _format_declarations(self, qasm_str):
+        """Format the unrolled qasm for declarations in openqasm 2.0 format"""
+        for declaration_type, replacement_type in [("qubit", "qreg"), ("bit", "creg")]:
+            pattern = rf"{declaration_type}\[(\d+)\]\s+(\w+);"
+            replacement = rf"{replacement_type} \2[\1];"
+            qasm_str = re.sub(pattern, replacement, qasm_str)
+        return qasm_str
+
+    def _qasm_ast_to_str(self, qasm_ast):
+        """Convert the qasm AST to a string"""
+        # set the version to 2.0
+        qasm_ast.version = "2.0"
+        raw_qasm = dumps(qasm_ast, old_measurement=True)
+        return self._format_declarations(raw_qasm)
+
+    def accept(self, visitor):
+        """Accept a visitor for the module
+
+        Args:
+            visitor (QasmVisitor): The visitor to accept
+        """
+        self._filter_statements()
+        unrolled_stmt_list = visitor.visit_basic_block(self._statements)
+        self.unrolled_ast.statements = [Include("qelib1.inc")]  # pylint: disable=W0201
+        self.unrolled_ast.statements.extend(unrolled_stmt_list)
+        # TODO: some finalizing method here probably
+
+
+class Qasm3Module(QasmModule):
+    """
+    A module representing an unrolled openqasm3 quantum program.
+
+    Args:
+        name (str): Name of the module.
+        program (Program): The original openqasm3 program.
+        statements (list[Statement]): list of openqasm3 Statements.
+    """
+
+    def __init__(
+        self,
+        name: str,
+        program: Program,
+        statements: list,
+    ):
+        super().__init__(name, program, statements)
+        self._unrolled_ast = Program(statements=[Include("stdgates.inc")], version="3.0")
+
+    def _qasm_ast_to_str(self, qasm_ast):
+        """Convert the qasm AST to a string"""
+        # set the version to 3.0
+        qasm_ast.version = "3.0"
+        return dumps(qasm_ast)
+
+    def accept(self, visitor):
+        """Accept a visitor for the module
+
+        Args:
+            visitor (QasmVisitor): The visitor to accept
+        """
+        unrolled_stmt_list = visitor.visit_basic_block(self._statements)
+        self.unrolled_ast.statements = [Include("stdgates.inc")]  # pylint: disable=W0201
+        self.unrolled_ast.statements.extend(unrolled_stmt_list)
+        # TODO: some finalizing method here probably
diff --git a/pyqasm/subroutines.py b/pyqasm/subroutines.py
index 0c1b750..3515746 100644
--- a/pyqasm/subroutines.py
+++ b/pyqasm/subroutines.py
@@ -41,7 +41,7 @@ def set_visitor_obj(cls, visitor_obj) -> None:
         """Set the visitor object for the class.
 
         Args:
-            visitor_obj (BasicQasmVisitor): The visitor object to set.
+            visitor_obj (QasmVisitor): The visitor object to set.
         """
         cls.visitor_obj = visitor_obj
 
@@ -91,7 +91,7 @@ def process_classical_arg(cls, formal_arg, actual_arg, fn_name, span):
             formal_arg, actual_arg, actual_arg_name, fn_name, span
         )
 
-    @classmethod  # pylint: disable-next=too-many-arguments,too-many-positional-arguments
+    @classmethod  # pylint: disable-next=too-many-arguments
     def _process_classical_arg_by_value(
         cls, formal_arg, actual_arg, actual_arg_name, fn_name, span
     ):
@@ -141,7 +141,7 @@ def _process_classical_arg_by_value(
             is_constant=False,
         )
 
-    @classmethod  # pylint: disable-next=too-many-arguments,too-many-positional-arguments,too-many-locals,too-many-branches
+    @classmethod  # pylint: disable-next=too-many-arguments,too-many-locals,too-many-branches
     def _process_classical_arg_by_reference(
         cls, formal_arg, actual_arg, actual_arg_name, fn_name, span
     ):
@@ -295,7 +295,7 @@ def _process_classical_arg_by_reference(
             readonly=readonly_arr,
         )
 
-    @classmethod  # pylint: disable-next=too-many-arguments,too-many-positional-arguments
+    @classmethod  # pylint: disable-next=too-many-arguments
     def process_quantum_arg(
         cls,
         formal_arg,
diff --git a/pyqasm/transformer.py b/pyqasm/transformer.py
index 2409597..3a5233d 100644
--- a/pyqasm/transformer.py
+++ b/pyqasm/transformer.py
@@ -47,7 +47,7 @@
 
 
 class Qasm3Transformer:
-    """Class with utility functions for transforming QASM3 elements"""
+    """Class with utility functions for transforming QASM elements"""
 
     visitor_obj = None
 
diff --git a/pyqasm/validator.py b/pyqasm/validator.py
index 5d2faed..0e63c26 100644
--- a/pyqasm/validator.py
+++ b/pyqasm/validator.py
@@ -9,7 +9,7 @@
 # THERE IS NO WARRANTY for pyqasm, as per Section 15 of the GPL v3.
 
 """
-Module with utility functions for QASM3 visitor
+Module with utility functions for QASM visitor
 
 """
 from typing import Any, Optional, Union
@@ -25,7 +25,7 @@
 
 
 class Qasm3Validator:
-    """Class with validation functions for QASM3 visitor"""
+    """Class with validation functions for QASM visitor"""
 
     @staticmethod
     def validate_register_index(index: Optional[int], size: int, qubit: bool = False) -> None:
diff --git a/pyqasm/visitor.py b/pyqasm/visitor.py
index ce64508..3af8733 100644
--- a/pyqasm/visitor.py
+++ b/pyqasm/visitor.py
@@ -11,7 +11,7 @@
 # pylint: disable=too-many-lines
 
 """
-Module defining Qasm3 Visitor.
+Module defining Qasm Visitor.
 
 """
 import copy
@@ -23,7 +23,7 @@
 import openqasm3.ast as qasm3_ast
 
 from .analyzer import Qasm3Analyzer
-from .elements import Context, InversionOp, Qasm3Module, Variable
+from .elements import ClbitDepthNode, Context, InversionOp, QubitDepthNode, Variable
 from .exceptions import ValidationError, raise_qasm3_error
 from .expressions import Qasm3ExprEvaluator
 from .maps import (
@@ -42,7 +42,7 @@
 
 
 # pylint: disable-next=too-many-instance-attributes
-class BasicQasmVisitor:
+class QasmVisitor:
     """A visitor for basic OpenQASM program elements.
 
     This class is designed to traverse and interact with elements in an OpenQASM program.
@@ -52,8 +52,8 @@ class BasicQasmVisitor:
         record_output (bool): If True, output of the circuit will be recorded. Defaults to True.
     """
 
-    def __init__(self, module: Qasm3Module, check_only: bool = False):
-        self._module: Qasm3Module = module
+    def __init__(self, module, check_only: bool = False):
+        self._module = module
         self._scope: deque = deque([{}])
         self._context: deque = deque([Context.GLOBAL])
         self._qubit_labels: dict[str, int] = {}
@@ -293,12 +293,15 @@ def _visit_quantum_register(
         for i in range(register_size):
             # required if indices are not used while applying a gate or measurement
             label_map[f"{register_name}_{i}"] = current_size + i
+            self._module._qubit_depths[(register_name, i)] = QubitDepthNode(register_name, i)
 
         self._label_scope_level[self._curr_scope].add(register_name)
 
-        self._module.add_qubits(register_size)
+        self._module._add_qubits(register_size)
         logger.debug("Added labels for register '%s'", str(register))
 
+        if self._check_only:
+            return []
         return [register]
 
     def _check_if_name_in_scope(self, name: str, operation: Any) -> None:
@@ -409,7 +412,7 @@ def _get_op_bits(
 
         return openqasm_bits
 
-    def _visit_measurement(
+    def _visit_measurement(  # pylint: disable=too-many-locals
         self, statement: qasm3_ast.QuantumMeasurementStatement
     ) -> list[qasm3_ast.QuantumMeasurementStatement]:
         """Visit a measurement statement element.
@@ -466,6 +469,22 @@ def _visit_measurement(
                 unrolled_measure = qasm3_ast.QuantumMeasurementStatement(
                     measure=qasm3_ast.QuantumMeasurement(qubit=src_id), target=tgt_id
                 )
+                src_name, src_id = src_id.name.name, src_id.indices[0][0].value  # type: ignore
+                tgt_name, tgt_id = tgt_id.name.name, tgt_id.indices[0][0].value  # type: ignore
+
+                qubit_node, clbit_node = (
+                    self._module._qubit_depths[(src_name, src_id)],
+                    self._module._clbit_depths[(tgt_name, tgt_id)],
+                )
+                qubit_node.depth += 1
+                qubit_node.num_measurements += 1
+
+                clbit_node.depth += 1
+                clbit_node.num_measurements += 1
+
+                qubit_node.depth = max(qubit_node.depth, clbit_node.depth)
+                clbit_node.depth = max(qubit_node.depth, clbit_node.depth)
+
                 unrolled_measurements.append(unrolled_measure)
         return unrolled_measurements
 
@@ -494,6 +513,13 @@ def _visit_reset(self, statement: qasm3_ast.QuantumReset) -> list[qasm3_ast.Quan
         if not self._check_only:
             for qid in qubit_ids:
                 unrolled_reset = qasm3_ast.QuantumReset(qubits=qid)
+
+                qubit_name, qubit_id = qid.name.name, qid.indices[0][0].value  # type: ignore
+                qubit_node = self._module._qubit_depths[(qubit_name, qubit_id)]
+
+                qubit_node.depth += 1
+                qubit_node.num_resets += 1
+
                 unrolled_resets.append(unrolled_reset)
         return unrolled_resets
 
@@ -522,11 +548,25 @@ def _visit_barrier(self, barrier: qasm3_ast.QuantumBarrier) -> list[qasm3_ast.Qu
         barrier_qubits = self._get_op_bits(barrier, self._global_qreg_size_map)
         unrolled_barriers = []
         if not self._check_only:
+            max_involved_depth = 0
             for qubit in barrier_qubits:
                 unrolled_barrier = qasm3_ast.QuantumBarrier(
                     qubits=[qubit]  # type: ignore[list-item]
                 )
+                qubit_name, qubit_id = qubit.name.name, qubit.indices[0][0].value  # type: ignore
+                qubit_node = self._module._qubit_depths[(qubit_name, qubit_id)]
+
+                qubit_node.depth += 1
+                qubit_node.num_barriers += 1
+
+                max_involved_depth = max(max_involved_depth, qubit_node.depth)
                 unrolled_barriers.append(unrolled_barrier)
+
+            for qubit in barrier_qubits:
+                qubit_name, qubit_id = qubit.name.name, qubit.indices[0][0].value  # type: ignore
+                qubit_node = self._module._qubit_depths[(qubit_name, qubit_id)]
+                qubit_node.depth = max_involved_depth
+
         return unrolled_barriers
 
     def _get_op_parameters(self, operation: qasm3_ast.QuantumGate) -> list[float]:
@@ -561,7 +601,7 @@ def _visit_gate_definition(self, definition: qasm3_ast.QuantumGateDefinition) ->
 
         return []
 
-    def _visit_basic_gate_operation(
+    def _visit_basic_gate_operation(  # pylint: disable=too-many-locals
         self, operation: qasm3_ast.QuantumGate, inverse: bool = False
     ) -> list[qasm3_ast.QuantumGate]:
         """Visit a gate operation element.
@@ -588,14 +628,15 @@ def _visit_basic_gate_operation(
         """
 
         logger.debug("Visiting basic gate operation '%s'", str(operation))
-        op_name: str = operation.name.name
         op_qubits = self._get_op_bits(operation, self._global_qreg_size_map)
         inverse_action = None
         if not inverse:
-            qasm_func, op_qubit_count = map_qasm_op_to_callable(op_name)
+            qasm_func, op_qubit_count = map_qasm_op_to_callable(operation.name.name)
         else:
             # in basic gates, inverse action only affects the rotation gates
-            qasm_func, op_qubit_count, inverse_action = map_qasm_inv_op_to_callable(op_name)
+            qasm_func, op_qubit_count, inverse_action = map_qasm_inv_op_to_callable(
+                operation.name.name
+            )
 
         op_parameters = None
 
@@ -615,14 +656,25 @@ def _visit_basic_gate_operation(
         for i in range(0, len(op_qubits), op_qubit_count):
             # we apply the gate on the qubit subset linearly
             qubit_subset = op_qubits[i : i + op_qubit_count]
+            unrolled_gate = []
             if op_parameters is not None:
                 unrolled_gate = qasm_func(*op_parameters, *qubit_subset)
             else:
                 unrolled_gate = qasm_func(*qubit_subset)
-            unrolled_gates = []
-            for gate in unrolled_gate:
-                unrolled_gates.append(gate)
-            result.extend(unrolled_gates)
+            result.extend(unrolled_gate)
+
+            # update qubit depths
+            max_involved_depth = 0
+            for qubit in qubit_subset:
+                qubit_name, qubit_id = qubit.name.name, qubit.indices[0][0].value  # type: ignore
+                qubit_node = self._module._qubit_depths[(qubit_name, qubit_id)]
+                qubit_node.num_gates += 1
+                max_involved_depth = max(max_involved_depth, qubit_node.depth + 1)
+
+            for qubit in qubit_subset:
+                qubit_name, qubit_id = qubit.name.name, qubit.indices[0][0].value  # type: ignore
+                qubit_node = self._module._qubit_depths[(qubit_name, qubit_id)]
+                qubit_node.depth = max_involved_depth
 
         if self._check_only:
             return []
@@ -943,6 +995,8 @@ def _visit_classical_declaration(
             current_classical_size = len(self._clbit_labels)
             for i in range(base_size):
                 self._clbit_labels[f"{var_name}_{i}"] = current_classical_size + i
+                self._module._clbit_depths[(var_name, i)] = ClbitDepthNode(var_name, i)
+
             self._label_scope_level[self._curr_scope].add(var_name)
 
             if hasattr(statement.type, "size"):
@@ -952,7 +1006,7 @@ def _visit_classical_declaration(
                     else qasm3_ast.IntegerLiteral(base_size)
                 )
             statements.append(statement)
-            self._module.add_classical_bits(base_size)
+            self._module._add_classical_bits(base_size)
 
         return statements
 
@@ -1358,6 +1412,7 @@ def _visit_alias_statement(self, statement: qasm3_ast.AliasStatement) -> list[No
         # this will only build a global alias map
 
         # whenever we are referring to qubits , we will first check in the global map of registers
+
         # if the register is present, we will use the global map to get the qubit labels
         # if not, we will check the alias map for the labels
 
@@ -1544,8 +1599,8 @@ def visit_statement(self, statement: qasm3_ast.Statement) -> list[qasm3_ast.Stat
         visitor_function = visit_map.get(type(statement))
 
         if visitor_function:
-            # these are special, they return a tuple of return value and list of statements
             if isinstance(statement, qasm3_ast.ExpressionStatement):
+                # these return a tuple of return value and list of statements
                 _, ret_stmts = visitor_function(statement)  # type: ignore[operator]
                 result.extend(ret_stmts)
             else:


Test patch:
diff --git a/tests/qasm2/test_declarations.py b/tests/qasm2/test_declarations.py
index 5372b6e..dacf14b 100644
--- a/tests/qasm2/test_declarations.py
+++ b/tests/qasm2/test_declarations.py
@@ -13,7 +13,7 @@
 declarations.
 
 """
-from pyqasm.entrypoint import unroll
+from pyqasm.entrypoint import load
 from tests.utils import check_unrolled_qasm
 
 
@@ -28,12 +28,15 @@ def test_qubit_declarations():
     """
 
     expected_qasm = """OPENQASM 2.0;
-    include "stdgates.inc";
+    include 'qelib1.inc';
     qreg q3[3];
     qreg q[1];
     """
 
-    unrolled_qasm = unroll(qasm2_string)
+    result = load(qasm2_string)
+    result.unroll()
+    unrolled_qasm = result.unrolled_qasm
+
     check_unrolled_qasm(unrolled_qasm, expected_qasm)
 
 
@@ -42,19 +45,22 @@ def test_clbit_declarations():
     """Test clbit declarations in different ways"""
     qasm2_string = """
     OPENQASM 2.0;
-    include "stdgates.inc";
+    include 'qelib1.inc';
     
     creg c3[3];
     creg c;
     """
 
     expected_qasm = """OPENQASM 2.0;
-    include "stdgates.inc";
+    include 'qelib1.inc';
     creg c3[3];
     creg c[1];
     """
 
-    unrolled_qasm = unroll(qasm2_string)
+    result = load(qasm2_string)
+    result.unroll()
+    unrolled_qasm = result.unrolled_qasm
+
     check_unrolled_qasm(unrolled_qasm, expected_qasm)
 
 
@@ -63,7 +69,7 @@ def test_qubit_clbit_declarations():
     """Test qubit and clbit declarations in different ways"""
     qasm2_string = """
     OPENQASM 2.0;
-    include "stdgates.inc";
+    include 'qelib1.inc';
 
     // qubit declarations
     qreg q1[1];
@@ -75,12 +81,15 @@ def test_qubit_clbit_declarations():
     """
 
     expected_qasm = """OPENQASM 2.0;
-    include "stdgates.inc";
+    include 'qelib1.inc';
     qreg q1[1];
     qreg q2[2];
     creg c1[1];
     creg c2[2];
     """
 
-    unrolled_qasm = unroll(qasm2_string)
+    result = load(qasm2_string)
+    result.unroll()
+    unrolled_qasm = result.unrolled_qasm
+
     check_unrolled_qasm(unrolled_qasm, expected_qasm)
diff --git a/tests/qasm2/test_depth.py b/tests/qasm2/test_depth.py
new file mode 100644
index 0000000..b2dea00
--- /dev/null
+++ b/tests/qasm2/test_depth.py
@@ -0,0 +1,77 @@
+# Copyright (C) 2024 qBraid
+#
+# This file is part of pyqasm
+#
+# Pyqasm is free software released under the GNU General Public License v3
+# or later. You can redistribute and/or modify it under the terms of the GPL v3.
+# See the LICENSE file in the project root or <https://www.gnu.org/licenses/gpl-3.0.html>.
+#
+# THERE IS NO WARRANTY for pyqasm, as per Section 15 of the GPL v3.
+
+"""
+Module containing unit tests for calculating program depth.
+
+"""
+
+from pyqasm.entrypoint import load
+
+
+def test_gate_depth():
+    qasm3_string = """
+    OPENQASM 2.0;
+    include "stdgates.inc";
+
+    gate my_gate_2(p) q {
+        ry(p * 2) q;
+    }
+
+    gate my_gate(a, b, c) q {
+        rx(5 * a) q;
+        rz(2 * b / a) q;
+        my_gate_2(a) q;
+        rx(a) q; 
+        rx(c) q;
+    }
+
+    qreg q[1];
+    my_gate(1, 2, 3) q;
+    """
+    result = load(qasm3_string)
+    result.unroll()
+    assert result.num_qubits == 1
+    assert result.num_clbits == 0
+    assert result.depth() == 5
+
+
+def test_qubit_depth_with_unrelated_measure_op():
+    qasm3_string = """
+    OPENQASM 2.0;
+    include "stdgates.inc";
+    qreg q1;
+    qreg q2[3];
+    h q2;
+    creg c; 
+    measure q2[0] -> c;
+    measure q2[1] -> c;
+    measure q2[2] -> c;
+    // This should affect the depth as measurement will have to wait 
+    measure q1 -> c; 
+    """
+    result = load(qasm3_string)
+    result.unroll()
+    assert result.num_qubits == 4
+    assert result.num_clbits == 1
+    assert result.depth() == 5
+
+
+def test_depth_with_no_ops():
+    qasm3_string = """
+    OPENQASM 2.0;
+    include "stdgates.inc";
+    qreg q;
+    """
+    result = load(qasm3_string)
+    result.unroll()
+    assert result.num_qubits == 1
+    assert result.num_clbits == 0
+    assert result.depth() == 0
diff --git a/tests/qasm2/test_operations.py b/tests/qasm2/test_operations.py
index 3c9a7c5..6098972 100644
--- a/tests/qasm2/test_operations.py
+++ b/tests/qasm2/test_operations.py
@@ -15,7 +15,7 @@
 """
 import pytest
 
-from pyqasm.entrypoint import unroll, validate
+from pyqasm.entrypoint import load
 from pyqasm.exceptions import ValidationError
 from tests.utils import check_unrolled_qasm
 
@@ -25,7 +25,7 @@ def test_whitelisted_ops():
     """Test qubit declarations in different ways"""
     qasm2_string = """
     OPENQASM 2.0;
-    include "stdgates.inc";
+    include 'qelib1.inc';
     gate custom_gate a, b {
         cx a, b;
     }
@@ -43,7 +43,7 @@ def test_whitelisted_ops():
 
     expected_qasm = """
     OPENQASM 2.0;
-    include "stdgates.inc";
+    include 'qelib1.inc';
     qreg q[2];
     creg c[2];
     barrier q[0];
@@ -58,18 +58,19 @@ def test_whitelisted_ops():
     cx q[0], q[1];
     """
 
-    unrolled_qasm = unroll(qasm2_string)
-    check_unrolled_qasm(unrolled_qasm, expected_qasm)
+    result = load(qasm2_string)
+    result.unroll()
+    check_unrolled_qasm(result.unrolled_qasm, expected_qasm)
 
 
 def test_subroutine_blacklist():
 
     # subroutines
     with pytest.raises(ValidationError):
-        validate(
+        load(
             """
             OPENQASM 2.0;
-            include "stdgates.inc";
+            include 'qelib1.inc';
             qreg q[2];
             creg c[2];
 
@@ -77,16 +78,16 @@ def my_func(int[32] a) -> int[32] {
                 return a;
             }
             """
-        )
+        ).validate()
 
 
 def test_switch_blacklist():
     # switch statements
     with pytest.raises(ValidationError):
-        validate(
+        load(
             """
             OPENQASM 2.0;
-            include "stdgates.inc";
+            include 'qelib1.inc';
             qreg q[2];
             creg c[2];
 
@@ -97,16 +98,16 @@ def test_switch_blacklist():
                     h q[0];
             }
             """
-        )
+        ).validate()
 
 
 def test_for_blacklist():
     # for loops
     with pytest.raises(ValidationError):
-        validate(
+        load(
             """
             OPENQASM 2.0;
-            include "stdgates.inc";
+            include 'qelib1.inc';
             qreg q[2];
             creg c[2];
 
@@ -114,16 +115,16 @@ def test_for_blacklist():
                 h q[i];
             }
             """
-        )
+        ).validate()
 
 
 def test_while_blacklist():
     # while loops
     with pytest.raises(ValidationError):
-        validate(
+        load(
             """
             OPENQASM 2.0;
-            include "stdgates.inc";
+            include 'qelib1.inc';
             qreg q[2];
             creg c[2];
 
@@ -131,7 +132,7 @@ def test_while_blacklist():
                 h q[0];
             }
             """
-        )
+        ).validate()
 
 
 # TODO : extend to more constructs
diff --git a/tests/qasm2/test_rotation_gates.py b/tests/qasm2/test_rotation_gates.py
new file mode 100644
index 0000000..e02a9c9
--- /dev/null
+++ b/tests/qasm2/test_rotation_gates.py
@@ -0,0 +1,153 @@
+# Copyright (C) 2024 qBraid
+#
+# This file is part of pyqasm
+#
+# Pyqasm is free software released under the GNU General Public License v3
+# or later. You can redistribute and/or modify it under the terms of the GPL v3.
+# See the LICENSE file in the project root or <https://www.gnu.org/licenses/gpl-3.0.html>.
+#
+# THERE IS NO WARRANTY for pyqasm, as per Section 15 of the GPL v3.
+
+"""
+Module containing unit tests for parsing and unrolling programs that contain quantum 
+rotations in qasm2 format.
+
+"""
+from pyqasm.entrypoint import load
+from tests.utils import check_unrolled_qasm
+
+
+def test_convert_qasm_one_param():
+    """Test converting qasm string from one-parameter gate"""
+
+    qasm_in = """
+OPENQASM 2.0;
+include 'qelib1.inc';
+gate ryy(param0) q0,q1 { rx(pi/2) q0; rx(pi/2) q1; cx q0,q1; rz(param0) q1; cx q0,q1; rx(-pi/2) q0; rx(-pi/2) q1; }
+qreg q[2];
+ryy(2.0425171585294746) q[1],q[0];
+"""
+    expected_out = """
+OPENQASM 2.0;
+include 'qelib1.inc';
+qreg q[2];
+rx(1.5707963267948966) q[1];
+rx(1.5707963267948966) q[0];
+cx q[1], q[0];
+rz(2.0425171585294746) q[0];
+cx q[1], q[0];
+rx(-1.5707963267948966) q[1];
+rx(-1.5707963267948966) q[0];
+"""
+    result = load(qasm_in)
+    result.unroll()
+    unrolled_qasm = result.unrolled_qasm
+    check_unrolled_qasm(unrolled_qasm, expected_out)
+
+
+def test_convert_qasm_two_param():
+    """Test converting qasm string from two-parameter gate"""
+
+    qasm_in = """
+OPENQASM 2.0;
+include 'qelib1.inc';
+gate xx_minus_yy(param0,param1) q0,q1 { rz(-1.0*param1) q1; ry(param0/2) q0; }
+qreg q[2];
+xx_minus_yy(2.00367210595874,5.07865952845335) q[0],q[1];
+"""
+    expected_out = """
+OPENQASM 2.0;
+include 'qelib1.inc';
+qreg q[2];
+rz(-5.07865952845335) q[1];
+ry(1.00183605297937) q[0];
+"""
+    result = load(qasm_in)
+    result.unroll()
+    unrolled_qasm = result.unrolled_qasm
+    check_unrolled_qasm(unrolled_qasm, expected_out)
+
+
+def test_convert_qasm_three_qubit_gate():
+    """Test converting qasm string that uses three qubit gate"""
+
+    qasm_in = """
+OPENQASM 2.0;
+include 'qelib1.inc';
+gate ryy(param0) q0,q1,q2 { rx(pi/2) q0; rx(pi/2) q1; cx q0,q2; rz(param0) q1; cx q0,q1; rx(-pi/2) q2; rx(-pi/2) q1; }
+qreg q[3];
+ryy(2.0425171585294746) q[1],q[0],q[2];
+"""
+    expected_out = """
+OPENQASM 2.0;
+include 'qelib1.inc';
+qreg q[3];
+rx(1.5707963267948966) q[1];
+rx(1.5707963267948966) q[0];
+cx q[1], q[2];
+rz(2.0425171585294746) q[0];
+cx q[1], q[0];
+rx(-1.5707963267948966) q[2];
+rx(-1.5707963267948966) q[0];
+"""
+    result = load(qasm_in)
+    result.unroll()
+    unrolled_qasm = result.unrolled_qasm
+    check_unrolled_qasm(unrolled_qasm, expected_out)
+
+
+def test_convert_qasm_non_param_gate_def():
+    """Test converting qasm string from non-parameterized gate def"""
+
+    qasm_in = """
+OPENQASM 2.0;
+include 'qelib1.inc';
+qreg q[2];
+ecr q[0],q[1];
+"""
+    expected_out = """
+OPENQASM 2.0;
+include 'qelib1.inc';
+qreg q[2];
+s q[0];
+rx(1.5707963267948966) q[1];
+cx q[0], q[1];
+x q[0];
+"""
+    result = load(qasm_in)
+    result.unroll()
+    unrolled_qasm = result.unrolled_qasm
+    check_unrolled_qasm(unrolled_qasm, expected_out)
+
+
+def test_convert_qasm_recursive_gate_def():
+    """Test converting qasm string from gate defined in terms of another gate"""
+
+    qasm_in = """
+OPENQASM 2.0;
+include 'qelib1.inc';
+gate rzx(param0) q0,q1 { h q1; cx q0,q1; rz(param0) q1; cx q0,q1; h q1; }
+gate ecr q0,q1 { rzx(pi/4) q0,q1; x q0; rzx(-pi/4) q0,q1; }
+qreg q[2];
+ecr q[0], q[1];
+"""
+    expected_out = """
+OPENQASM 2.0;
+include 'qelib1.inc';
+qreg q[2];
+h q[1];
+cx q[0], q[1];
+rz(0.7853981633974483) q[1];
+cx q[0], q[1];
+h q[1];
+x q[0];
+h q[1];
+cx q[0], q[1];
+rz(-0.7853981633974483) q[1];
+cx q[0], q[1];
+h q[1];
+"""
+    result = load(qasm_in)
+    result.unroll()
+    unrolled_qasm = result.unrolled_qasm
+    check_unrolled_qasm(unrolled_qasm, expected_out)
diff --git a/tests/qasm3/declarations/test_classical.py b/tests/qasm3/declarations/test_classical.py
index 0ddae5f..2c14934 100644
--- a/tests/qasm3/declarations/test_classical.py
+++ b/tests/qasm3/declarations/test_classical.py
@@ -14,7 +14,7 @@
 """
 import pytest
 
-from pyqasm.entrypoint import unroll, validate
+from pyqasm.entrypoint import load
 from pyqasm.exceptions import ValidationError
 from tests.qasm3.resources.variables import ASSIGNMENT_TESTS, DECLARATION_TESTS
 from tests.utils import check_single_qubit_rotation_op
@@ -36,7 +36,7 @@ def test_scalar_declarations():
     bool i;
     """
 
-    validate(qasm3_string)
+    load(qasm3_string).validate()
 
 
 # 2. Test const declarations in different ways
@@ -61,7 +61,7 @@ def test_const_declarations():
     const float[32] f1 = 0.00000023 + f;
     """
 
-    validate(qasm3_string)
+    load(qasm3_string).validate()
 
 
 # 3. Test non-constant scalar assignments
@@ -81,7 +81,7 @@ def test_scalar_assignments():
     r = 12.2;
     """
 
-    validate(qasm3_string)
+    load(qasm3_string).validate()
 
 
 # 4. Scalar value assignment
@@ -103,7 +103,9 @@ def test_scalar_value_assignment():
     b = 5.0
     r = 0.23
     f = 0.5
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
+
     assert result.num_clbits == 0
     assert result.num_qubits == 1
 
@@ -136,7 +138,9 @@ def test_scalar_type_casts():
     f = 0
     g = 1
 
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
+
     assert result.num_clbits == 1
     assert result.num_qubits == 1
 
@@ -165,7 +169,9 @@ def test_array_type_casts():
     arr_bool_val = 1
     arr_float32_val = 6.0
 
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
+
     assert result.num_clbits == 0
     assert result.num_qubits == 1
 
@@ -190,7 +196,7 @@ def test_array_declarations():
     array[float[64], 3, 2] arr_float64;
     array[bool, 3, 2] arr_bool;
     """
-    validate(qasm3_string)
+    load(qasm3_string).validate()
 
 
 # 6. Array assignments
@@ -241,7 +247,9 @@ def test_array_assignments():
     c = 4.5
     d = 6.7
     f = True
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
+
     assert result.num_clbits == 0
     assert result.num_qubits == 1
     check_single_qubit_rotation_op(
@@ -284,7 +292,9 @@ def test_array_expressions():
     b = 3
     c = 4.5
     d = 6.7
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
+
     assert result.num_clbits == 0
     assert result.num_qubits == 1
     check_single_qubit_rotation_op(
@@ -313,7 +323,9 @@ def test_array_initializations():
     rx(arr_bool[0][1]) q;
     """
 
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
+
     assert result.num_clbits == 0
     assert result.num_qubits == 1
 
@@ -344,7 +356,9 @@ def test_array_range_assignment():
 
     """
 
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
+
     assert result.num_clbits == 0
     assert result.num_qubits == 1
 
@@ -355,11 +369,11 @@ def test_array_range_assignment():
 def test_incorrect_declarations(test_name):
     qasm_input, error_message = DECLARATION_TESTS[test_name]
     with pytest.raises(ValidationError, match=error_message):
-        validate(qasm_input)
+        load(qasm_input).validate()
 
 
 @pytest.mark.parametrize("test_name", ASSIGNMENT_TESTS.keys())
 def test_incorrect_assignments(test_name):
     qasm_input, error_message = ASSIGNMENT_TESTS[test_name]
     with pytest.raises(ValidationError, match=error_message):
-        validate(qasm_input)
+        load(qasm_input).validate()
diff --git a/tests/qasm3/declarations/test_quantum.py b/tests/qasm3/declarations/test_quantum.py
index 2748d34..46e7813 100644
--- a/tests/qasm3/declarations/test_quantum.py
+++ b/tests/qasm3/declarations/test_quantum.py
@@ -15,7 +15,7 @@
 """
 import pytest
 
-from pyqasm.entrypoint import unroll, validate
+from pyqasm.entrypoint import load
 from pyqasm.exceptions import ValidationError
 from tests.utils import check_unrolled_qasm
 
@@ -44,7 +44,10 @@ def test_qubit_declarations():
     qubit[10] q5;
     """
 
-    unrolled_qasm = unroll(qasm3_string)
+    result = load(qasm3_string)
+    result.unroll()
+    unrolled_qasm = result.unrolled_qasm
+
     check_unrolled_qasm(unrolled_qasm, expected_qasm)
 
 
@@ -72,7 +75,10 @@ def test_clbit_declarations():
     bit[10] c5;
     """
 
-    unrolled_qasm = unroll(qasm3_string)
+    result = load(qasm3_string)
+    result.unroll()
+    unrolled_qasm = result.unrolled_qasm
+
     check_unrolled_qasm(unrolled_qasm, expected_qasm)
 
 
@@ -108,7 +114,10 @@ def test_qubit_clbit_declarations():
     bit[1] c4;
     """
 
-    unrolled_qasm = unroll(qasm3_string)
+    result = load(qasm3_string)
+    result.unroll()
+    unrolled_qasm = result.unrolled_qasm
+
     check_unrolled_qasm(unrolled_qasm, expected_qasm)
 
 
@@ -121,7 +130,7 @@ def test_qubit_redeclaration_error():
         qubit q1;
         qubit q1;
         """
-        validate(qasm3_string)
+        load(qasm3_string).validate()
 
 
 def test_invalid_qubit_name():
@@ -134,7 +143,7 @@ def test_invalid_qubit_name():
         include "stdgates.inc";
         qubit pi;
         """
-        validate(qasm3_string)
+        load(qasm3_string).validate()
 
 
 def test_clbit_redeclaration_error():
@@ -146,7 +155,7 @@ def test_clbit_redeclaration_error():
         bit c1;
         bit[4] c1;
         """
-        validate(qasm3_string)
+        load(qasm3_string).validate()
 
 
 def test_non_constant_size():
@@ -160,7 +169,7 @@ def test_non_constant_size():
         int[32] N = 10;
         qubit[N] q;
         """
-        validate(qasm3_string)
+        load(qasm3_string).validate()
 
     with pytest.raises(
         ValidationError, match=r"Variable 'size' is not a constant in given expression"
@@ -171,4 +180,4 @@ def test_non_constant_size():
         int[32] size = 10;
         bit[size] c;
         """
-        validate(qasm3_string)
+        load(qasm3_string).validate()
diff --git a/tests/qasm3/resources/gates.py b/tests/qasm3/resources/gates.py
index 204c0e7..412a855 100644
--- a/tests/qasm3/resources/gates.py
+++ b/tests/qasm3/resources/gates.py
@@ -24,6 +24,7 @@
     THREE_QUBIT_OP_MAP,
     TWO_QUBIT_OP_MAP,
 )
+from tests.utils import CONTROLLED_ROTATION_ANGLE_1
 
 CUSTOM_OPS = ["simple", "nested", "complex"]
 
@@ -104,13 +105,18 @@ def _generate_two_qubit_fixture(gate_name: str):
     def test_fixture():
         if not _validate_gate_name(gate_name):
             raise ValueError(f"Unknown qasm3 gate {gate_name}")
+
+        theta = ""
+        if gate_name in ["crz", "crx"]:
+            theta = f"({CONTROLLED_ROTATION_ANGLE_1})"
+
         qasm3_string = f"""
         OPENQASM 3;
         include "stdgates.inc";
 
         qubit[2] q;
-        {gate_name} q[0], q[1];
-        {gate_name} q;
+        {gate_name}{theta} q[0], q[1];
+        {gate_name}{theta} q;
         """
         return qasm3_string
 
diff --git a/tests/qasm3/subroutines/test_subroutines.py b/tests/qasm3/subroutines/test_subroutines.py
index cd24370..126727d 100644
--- a/tests/qasm3/subroutines/test_subroutines.py
+++ b/tests/qasm3/subroutines/test_subroutines.py
@@ -15,7 +15,7 @@
 
 import pytest
 
-from pyqasm.entrypoint import unroll, validate
+from pyqasm.entrypoint import load
 from pyqasm.exceptions import ValidationError
 from tests.qasm3.resources.subroutines import SUBROUTINE_INCORRECT_TESTS
 from tests.utils import check_single_qubit_gate_op, check_single_qubit_rotation_op
@@ -33,7 +33,8 @@ def my_function(qubit q) {
     my_function(q);
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
     assert result.num_clbits == 0
     assert result.num_qubits == 1
 
@@ -56,7 +57,8 @@ def my_function(qubit a, float[32] b) {
     my_function(q, r);
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
     assert result.num_clbits == 0
     assert result.num_qubits == 1
 
@@ -77,7 +79,8 @@ def my_function(qubit q) {
     my_function(q);
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
     assert result.num_clbits == 0
     assert result.num_qubits == 1
 
@@ -99,7 +102,8 @@ def my_function(qubit q) {
     my_function(q);
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
     assert result.num_clbits == 0
     assert result.num_qubits == 1
 
@@ -121,7 +125,8 @@ def my_function(qubit[4] q) -> bool{
     bool b = my_function(q);
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
     assert result.num_clbits == 0
     assert result.num_qubits == 4
 
@@ -152,7 +157,8 @@ def my_function(qubit q, int[32] iter) -> int[32]{
     if(new_var>2)
         h q[0];
     """
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
     assert result.num_clbits == 0
     assert result.num_qubits == 4
 
@@ -176,7 +182,8 @@ def my_function(int[32] a, qubit q_arg) {
     my_function(0, q[0]);
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
     assert result.num_clbits == 0
     assert result.num_qubits == 3
 
@@ -197,7 +204,8 @@ def my_function(qubit q) -> float[32] {
     float[32] r = my_function(q);
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
     assert result.num_clbits == 0
     assert result.num_qubits == 1
 
@@ -226,7 +234,8 @@ def my_function_2(qubit q, float[32] r) {
 
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
     assert result.num_clbits == 0
     assert result.num_qubits == 2
 
@@ -254,7 +263,8 @@ def my_function(qubit a, float[32] b) {
     my_function(q, r);
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
     assert result.num_clbits == 0
     assert result.num_qubits == 1
 
@@ -280,7 +290,8 @@ def my_function_2(qubit[2] q2) {
     my_function_2(q);
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
     assert result.num_clbits == 0
     assert result.num_qubits == 2
 
@@ -309,7 +320,7 @@ def my_function(qubit q) {
     with pytest.raises(
         ValidationError, match=r"Return type mismatch for subroutine 'my_function'.*"
     ):
-        validate(qasm_str)
+        load(qasm_str).validate()
 
 
 @pytest.mark.parametrize("keyword", ["pi", "euler", "tau"])
@@ -327,7 +338,7 @@ def {keyword}(qubit q) {{
     """
 
     with pytest.raises(ValidationError, match=f"Subroutine name '{keyword}' is a reserved keyword"):
-        validate(qasm_str)
+        load(qasm_str).validate()
 
 
 @pytest.mark.parametrize("qubit_params", ["q", "q[:2]", "q[{0,1}]"])
@@ -353,11 +364,11 @@ def my_function(qubit[3] q) {
         match="Qubit register size mismatch for function 'my_function'. "
         "Expected 3 in variable 'q' but got 2",
     ):
-        validate(qasm_str)
+        load(qasm_str).validate()
 
 
 @pytest.mark.parametrize("test_name", SUBROUTINE_INCORRECT_TESTS.keys())
 def test_incorrect_custom_ops(test_name):
-    qasm_input, error_message = SUBROUTINE_INCORRECT_TESTS[test_name]
+    qasm_str, error_message = SUBROUTINE_INCORRECT_TESTS[test_name]
     with pytest.raises(ValidationError, match=error_message):
-        validate(qasm_input)
+        load(qasm_str).validate()
diff --git a/tests/qasm3/subroutines/test_subroutines_arrays.py b/tests/qasm3/subroutines/test_subroutines_arrays.py
index 17581ae..025d07a 100644
--- a/tests/qasm3/subroutines/test_subroutines_arrays.py
+++ b/tests/qasm3/subroutines/test_subroutines_arrays.py
@@ -16,7 +16,7 @@
 
 import pytest
 
-from pyqasm.entrypoint import unroll, validate
+from pyqasm.entrypoint import load
 from pyqasm.exceptions import ValidationError
 from tests.qasm3.resources.subroutines import SUBROUTINE_INCORRECT_TESTS_WITH_ARRAYS
 from tests.utils import check_single_qubit_rotation_op
@@ -36,7 +36,8 @@ def my_function(qubit a, readonly array[int[8], 2, 2] my_arr) {
 
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
     assert result.num_clbits == 0
     assert result.num_qubits == 1
 
@@ -59,7 +60,8 @@ def my_function(readonly array[int[8], 2, 2] my_arr, qubit q) {
     my_function(arr, my_q);
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
     assert result.num_clbits == 0
     assert result.num_qubits == 1
 
@@ -86,7 +88,8 @@ def my_function(readonly array[int[8], 2, 2] my_arr, qubit q) {
 
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
     assert result.num_clbits == 0
     assert result.num_qubits == 1
 
@@ -110,7 +113,8 @@ def my_function(readonly array[int[8], #dim = 2] my_arr, qubit q) {
     my_function(arr[0, :, :], my_q);
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
     assert result.num_clbits == 0
     assert result.num_qubits == 1
 
@@ -140,7 +144,8 @@ def my_function(readonly array[int[8], 2, 2] my_arr1,
     my_function(arr_1, arr_2, my_q);
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
     assert result.num_clbits == 0
     assert result.num_qubits == 1
 
@@ -153,4 +158,4 @@ def my_function(readonly array[int[8], 2, 2] my_arr1,
 def test_incorrect_custom_ops_with_arrays(test_name):
     qasm_input, error_message = SUBROUTINE_INCORRECT_TESTS_WITH_ARRAYS[test_name]
     with pytest.raises(ValidationError, match=error_message):
-        validate(qasm_input)
+        load(qasm_input).validate()
diff --git a/tests/qasm3/test_alias.py b/tests/qasm3/test_alias.py
index 49f0fb9..b86e0aa 100644
--- a/tests/qasm3/test_alias.py
+++ b/tests/qasm3/test_alias.py
@@ -18,7 +18,7 @@
 
 import pytest
 
-from pyqasm.entrypoint import unroll, validate
+from pyqasm.entrypoint import load
 from pyqasm.exceptions import ValidationError
 from tests.utils import (
     check_single_qubit_gate_op,
@@ -54,7 +54,9 @@ def test_alias():
     swap myqreg5[0], myqreg5[1];
     cz myqreg6;
     """
-    result = unroll(qasm3_alias_program, as_module=True)
+    result = load(qasm3_alias_program)
+    result.unroll()
+
     assert result.num_qubits == 5
     assert result.num_clbits == 0
     check_single_qubit_gate_op(result.unrolled_ast, 1, [0], "x")
@@ -79,7 +81,8 @@ def test_alias_update():
 
     x alias[1];
     """
-    result = unroll(qasm3_alias_program, as_module=True)
+    result = load(qasm3_alias_program)
+    result.unroll()
     assert result.num_qubits == 4
     assert result.num_clbits == 0
     check_single_qubit_gate_op(result.unrolled_ast, 1, [3], "x")
@@ -104,7 +107,8 @@ def test_valid_alias_redefinition():
     let alias = q[2];
     x alias;
     """
-    result = unroll(qasm3_alias_program, as_module=True)
+    result = load(qasm3_alias_program)
+    result.unroll()
     assert result.num_qubits == 5
     assert result.num_clbits == 5
 
@@ -130,7 +134,7 @@ def test_alias_wrong_indexing():
 
         x myqreg[0];
         """
-        validate(qasm3_alias_program)
+        load(qasm3_alias_program).validate()
 
 
 def test_alias_invalid_discrete_indexing():
@@ -149,7 +153,7 @@ def test_alias_invalid_discrete_indexing():
 
         x myqreg[0];
         """
-        validate(qasm3_alias_program)
+        load(qasm3_alias_program).validate()
 
 
 def test_invalid_alias_redefinition():
@@ -169,7 +173,7 @@ def test_invalid_alias_redefinition():
 
         x alias;
         """
-        validate(qasm3_alias_program)
+        load(qasm3_alias_program).validate()
 
 
 def test_alias_defined_before():
@@ -186,7 +190,7 @@ def test_alias_defined_before():
 
         let myqreg = q2[1];
         """
-        validate(qasm3_alias_program)
+        load(qasm3_alias_program).validate()
 
 
 def test_unsupported_alias():
@@ -203,7 +207,7 @@ def test_unsupported_alias():
 
         let myqreg = q[0] ++ q[1];
         """
-        validate(qasm3_alias_program)
+        load(qasm3_alias_program).validate()
 
 
 # def test_alias_in_scope_1():
@@ -271,32 +275,32 @@ def test_unsupported_alias():
 #     compare_reference_ir(result.bitcode, simple_file)
 
 
-# def test_alias_out_of_scope():
-#     """Test converting OpenQASM 3 program with alias out of scope."""
-#     with pytest.raises(
-#         ValidationError,
-#         match=r"Variable alias not in scope for operation .*",
-#     ):
-#         qasm = """
-#         OPENQASM 3;
-#         include "stdgates.inc";
-#         qubit[4] q;
-#         bit[4] c;
-
-#         h q;
-#         measure q -> c;
-#         if(c[0]){
-#             let alias = q[0:2];
-#             x alias[0];
-#             cx alias[0], alias[1];
-#         }
-
-#         if(c[1] == 1){
-#             cx alias[1], q[2];
-#         }
-
-#         if(!c[2]){
-#             h q[2];
-#         }
-#         """
-#         validate(qasm)
+def test_alias_out_of_scope():
+    """Test converting OpenQASM 3 program with alias out of scope."""
+    with pytest.raises(
+        ValidationError,
+        match=r"Variable alias not in scope for operation .*",
+    ):
+        qasm3_alias_program = """
+        OPENQASM 3;
+        include "stdgates.inc";
+        qubit[4] q;
+        bit[4] c;
+
+        h q;
+        measure q -> c;
+        if(c[0]){
+            let alias = q[0:2];
+            x alias[0];
+            cx alias[0], alias[1];
+        }
+
+        if(c[1] == 1){
+            cx alias[1], q[2];
+        }
+
+        if(!c[2]){
+            h q[2];
+        }
+        """
+        load(qasm3_alias_program).validate()
diff --git a/tests/qasm3/test_barrier.py b/tests/qasm3/test_barrier.py
index c909bc2..5d78768 100644
--- a/tests/qasm3/test_barrier.py
+++ b/tests/qasm3/test_barrier.py
@@ -14,7 +14,7 @@
 """
 import pytest
 
-from pyqasm.entrypoint import unroll, validate
+from pyqasm.entrypoint import load
 from pyqasm.exceptions import ValidationError
 from tests.utils import check_unrolled_qasm
 
@@ -60,8 +60,10 @@ def test_barrier():
     barrier q2[1];
     barrier q3[0];
     """
-    unrolled_qasm = unroll(qasm_str)
-    check_unrolled_qasm(unrolled_qasm, expected_qasm)
+    module = load(qasm_str)
+    module.unroll()
+
+    check_unrolled_qasm(module.unrolled_qasm, expected_qasm)
 
 
 def test_barrier_in_function():
@@ -85,8 +87,37 @@ def my_function(qubit[4] a) {
     barrier q[2];
     barrier q[3];
     """
-    unrolled_qasm = unroll(qasm_str)
-    check_unrolled_qasm(unrolled_qasm, expected_qasm)
+    module = load(qasm_str)
+    module.unroll()
+    check_unrolled_qasm(module.unrolled_qasm, expected_qasm)
+
+
+def test_remove_barriers():
+    qasm_str = """OPENQASM 3.0;
+    include "stdgates.inc";
+
+    qubit[2] q1;
+    qubit[3] q2;
+    qubit[1] q3;
+    
+    // full qubits
+    barrier q1, q2, q3; 
+    barrier q1[0], q1[1], q2[:], q3[0];
+
+    // subset of qubits
+    barrier q1, q2[0:2], q3[:];
+    """
+
+    expected_qasm = """OPENQASM 3.0;
+    include "stdgates.inc";
+    qubit[2] q1;
+    qubit[3] q2;
+    qubit[1] q3;
+    """
+    module = load(qasm_str)
+    module.remove_barriers()
+
+    check_unrolled_qasm(module.unrolled_qasm, expected_qasm)
 
 
 def test_incorrect_barrier():
@@ -100,7 +131,7 @@ def test_incorrect_barrier():
     """
 
     with pytest.raises(ValidationError, match=r"Missing register declaration for q2 .*"):
-        validate(undeclared)
+        load(undeclared).validate()
 
     out_of_bounds = """
     OPENQASM 3.0;
@@ -113,7 +144,7 @@ def test_incorrect_barrier():
     with pytest.raises(
         ValidationError, match="Index 3 out of range for register of size 2 in qubit"
     ):
-        validate(out_of_bounds)
+        load(out_of_bounds).validate()
 
     duplicate = """
     OPENQASM 3.0;
@@ -124,4 +155,4 @@ def test_incorrect_barrier():
     """
 
     with pytest.raises(ValidationError, match=r"Duplicate qubit .*argument"):
-        validate(duplicate)
+        load(duplicate).validate()
diff --git a/tests/qasm3/test_depth.py b/tests/qasm3/test_depth.py
new file mode 100644
index 0000000..edb746e
--- /dev/null
+++ b/tests/qasm3/test_depth.py
@@ -0,0 +1,421 @@
+# Copyright (C) 2024 qBraid
+#
+# This file is part of pyqasm
+#
+# Pyqasm is free software released under the GNU General Public License v3
+# or later. You can redistribute and/or modify it under the terms of the GPL v3.
+# See the LICENSE file in the project root or <https://www.gnu.org/licenses/gpl-3.0.html>.
+#
+# THERE IS NO WARRANTY for pyqasm, as per Section 15 of the GPL v3.
+
+"""
+Module containing unit tests for calculating program depth.
+
+"""
+import pytest
+
+from pyqasm.entrypoint import load
+
+
+def test_gate_depth():
+    qasm3_string = """
+    OPENQASM 3;
+    include "stdgates.inc";
+
+    gate my_gate_2(p) q {
+        ry(p * 2) q;
+    }
+
+    gate my_gate(a, b, c) q {
+        rx(5 * a) q;
+        rz(2 * b / a) q;
+        my_gate_2(a) q;
+        rx(!a) q; // not a = False
+        rx(c) q;
+    }
+
+    qubit q;
+    int[32] m = 3;
+    float[32] n = 6.0;
+    bool o = true;
+    my_gate(m, n, o) q;
+    """
+    result = load(qasm3_string)
+    result.unroll()
+    assert result.num_qubits == 1
+    assert result.num_clbits == 0
+    assert result.depth() == 5
+
+
+def test_pow_gate_depth():
+    qasm3_string = """
+    OPENQASM 3;
+    include "stdgates.inc";
+    qubit q;
+    inv @ pow(2) @ pow(4) @ h q;
+    pow(-2) @ h q;
+    """
+    result = load(qasm3_string)
+    result.unroll()
+    assert result.num_qubits == 1
+    assert result.num_clbits == 0
+    assert result.depth() == 10
+
+
+def test_inv_gate_depth():
+    qasm3_string = """
+    OPENQASM 3;
+    include "stdgates.inc";
+    qubit q;
+    inv @ h q;
+    inv @ y q;
+    inv @ rx(0.5) q;
+    inv @ s q;
+
+    qubit[2] q2;
+    inv @ cx q2;
+    inv @ ccx q[0], q2;
+    """
+    result = load(qasm3_string)
+    result.unroll()
+    assert result.num_qubits == 3
+    assert result.num_clbits == 0
+
+    # Q(0) ->  H  -> Y -> Rx(-0.5) -> Sdg -> CCX
+    # Q2(0)-> CX  -> .....................-> CCX
+    # Q2(1)-> CX  -> .....................-> CCX
+
+    assert result.depth() == 5
+
+
+def test_qubit_depth_with_unrelated_measure_op():
+    qasm3_string = """
+    OPENQASM 3;
+    include "stdgates.inc";
+    qubit q1;
+    qubit[3] q2;
+    h q2;
+    bit c; 
+    c[0] = measure q2[0];
+    c[0] = measure q2[1];
+    c[0] = measure q2[2];
+    // This should affect the depth as measurement will have to wait 
+    c[0] = measure q1; 
+    """
+    result = load(qasm3_string)
+    result.unroll()
+    assert result.num_qubits == 4
+    assert result.num_clbits == 1
+    assert result.depth() == 5
+
+
+def test_subroutine_depth():
+    """Test that multiple function calls are correctly parsed."""
+    qasm_str = """OPENQASM 3.0;
+    include "stdgates.inc";
+
+    def my_function(int[32] a, qubit q_arg) {
+        h q_arg;
+        rx (a) q_arg;
+        return;
+    }
+    qubit[3] q;
+    my_function(2, q[2]);
+    my_function(1, q[1]);
+    my_function(0, q[0]);
+    """
+
+    result = load(qasm_str)
+    result.unroll()
+    assert result.num_clbits == 0
+    assert result.num_qubits == 3
+    assert result.depth() == 2
+
+
+def test_depth_with_multi_control():
+    qasm3_string = """
+    OPENQASM 3;
+    include "stdgates.inc";
+    qubit[2] q;
+    cx q[0], q[1];
+
+    qubit[4] q2;
+    ccx q2[0], q2[1], q2[2];
+
+    h q2[0];
+    h q2[3];
+
+    cx q2[0], q[0];
+    
+    """
+    result = load(qasm3_string)
+    result.unroll()
+    assert result.num_qubits == 6
+    assert result.num_clbits == 0
+    assert result.depth() == 3
+
+
+def test_depth_with_no_ops():
+    qasm3_string = """
+    OPENQASM 3;
+    include "stdgates.inc";
+    qubit q;
+    """
+    result = load(qasm3_string)
+    result.unroll()
+    assert result.num_qubits == 1
+    assert result.num_clbits == 0
+    assert result.depth() == 0
+
+
+def test_after_removing_measurement():
+    qasm3_string = """
+    OPENQASM 3;
+    include "stdgates.inc";
+    qubit[3] q;
+    h q;
+    cx q[0], q[1];
+    bit[3] c;
+    c = measure q;
+    cx q[1], q[2];
+
+    """
+    result = load(qasm3_string)
+    result.unroll()
+    assert result.num_qubits == 3
+    assert result.num_clbits == 3
+    assert result.depth() == 4
+
+    result.remove_measurements()
+
+    assert result.depth() == 3
+
+
+def test_after_removing_barriers():
+    qasm3_string = """
+    OPENQASM 3;
+    include "stdgates.inc";
+    qubit[3] q;
+    h q;
+    barrier q;
+    cx q[0], q[1];
+    barrier q;
+    cx q[1], q[2];
+
+    """
+    result = load(qasm3_string)
+    result.unroll()
+    assert result.num_qubits == 3
+    assert result.num_clbits == 0
+    assert result.depth() == 5
+
+    result_copy = result.copy()  # save a copy for in_place=False test
+
+    result.remove_barriers()
+    assert result.depth() == 3
+
+    result_copy_2 = result_copy.remove_barriers(in_place=False)
+    assert result_copy_2.depth() == 3
+
+
+def test_qasm3_depth_sparse_operations():
+    """Test calculating depth of qasm3 circuit with sparse operations"""
+    qasm_string = """
+    OPENQASM 3.0;
+    gate iswap q0,q1 { s q0; s q1; h q0; cx q0,q1; cx q1,q0; h q1; }
+    bit[2] b;
+    qubit[2] q;
+    s q[0];
+    iswap q[0], q[1];
+    barrier q;
+    z q[1];
+    """
+    result = load(qasm_string)
+    result.unroll()
+
+    assert result.depth() == 8
+
+
+def test_qasm3_depth_measurement_direct():
+    """Test calculating depth of qasm3 circuit with direct measurements"""
+    qasm_string = """
+OPENQASM 3.0;
+gate iswap q0,q1 { s q0; s q1; h q0; cx q0,q1; cx q1,q0; h q1; }
+bit[2] b;
+qubit[2] q;
+s q[0];
+iswap q[0], q[1];
+z q[1];
+b[0] = measure q[0];
+b[1] = measure q[1];
+    """
+    result = load(qasm_string)
+    result.unroll()
+
+    assert result.depth() == 8
+
+
+def test_qasm3_depth_measurement_indirect():
+    """Test calculating depth of qasm3 circuit with indirect measurements"""
+    qasm_string = """
+OPENQASM 3.0;
+include "stdgates.inc";
+bit[3] c;
+qubit[3] q;
+cx q[1], q[2];
+x q[0];
+cx q[1], q[2];
+h q[0];
+rx(5.917500589065494) q[1];
+c[0] = measure q[0];
+c[1] = measure q[1];
+c[2] = measure q[2];
+    """
+    result = load(qasm_string)
+    result.unroll()
+
+    assert result.depth() == 4
+
+
+@pytest.mark.parametrize(
+    "program, expected_depth",
+    [
+        (
+            """
+OPENQASM 3.0;
+include "stdgates.inc";
+         
+qubit[2] q;
+qubit[2] r;
+qubit[2] s;
+         
+h q[0];
+h q[1];
+h r[0];
+""",
+            1,
+        ),
+        (
+            """
+OPENQASM 3.0;
+include "stdgates.inc";
+
+qreg q[2];
+creg c[2];
+
+h q[0];
+cx q[0], q[1];
+h q;
+
+measure q -> c;
+     """,
+            4,
+        ),
+        (
+            """
+OPENQASM 3.0;
+include "stdgates.inc";
+
+qreg q[2];
+
+reset q;
+reset q[0];
+""",
+            2,
+        ),
+        (
+            """
+OPENQASM 3.0;
+include "stdgates.inc";
+
+qreg q[2];
+
+h q[0];
+h q[0];
+h q[0];
+h q[0];
+
+barrier q;
+
+h q[1];
+""",
+            6,
+        ),
+    ],
+)
+def test_qasm3_depth_no_branching(program, expected_depth):
+    """Test calculating depth of qasm3 circuit"""
+    result = load(program)
+    result.unroll()
+    assert result.depth() == expected_depth
+
+
+@pytest.mark.skip(reason="Not implemented branching conditions depth")
+@pytest.mark.parametrize(
+    "program, expected_depth",
+    [
+        (
+            """
+OPENQASM 3.0;
+include "stdgates.inc";
+
+qreg q[2];
+creg c[2];
+
+h q[0];
+cx q[0], q[1];
+measure q[0] -> c[0];
+measure q[1] -> c[1];
+
+if (c==1) x q[0];
+""",
+            4,
+        ),
+        (
+            """
+OPENQASM 3.0;
+include "stdgates.inc";
+
+qreg q[2];
+creg c[2];
+
+h q[0];
+cx q[0], q[1];
+measure q[0] -> c[0];
+
+if (c==1) measure q[1] -> c[1];
+""",
+            4,
+        ),
+        (
+            """
+OPENQASM 3.0;
+include "stdgates.inc";
+qreg q1[3];
+qreg q2[3];
+creg c1[3];
+creg c2[3];
+
+gate big_gate a1, a2, a3, b1, b2, b3
+{
+    h a1;
+}
+x q1[0];
+barrier q1;
+big_gate q1[0],q1[1],q1[2],q2[0],q2[1],q2[2];
+x q1[0];
+measure q1 -> c1;
+if(c1==1) x q2[0];
+if(c1==2) x q2[2];
+if(c1==3) x q2[1];
+measure q2 -> c2;
+""",
+            8,
+        ),
+    ],
+)
+def test_qasm3_depth_branching(program, expected_depth):
+    """Test calculating depth of qasm3 circuit with branching conditions"""
+    result = load(program)
+    result.unroll()
+    assert result.depth() == expected_depth
diff --git a/tests/qasm3/test_expressions.py b/tests/qasm3/test_expressions.py
index 9523bf8..fd5a28c 100644
--- a/tests/qasm3/test_expressions.py
+++ b/tests/qasm3/test_expressions.py
@@ -14,7 +14,7 @@
 """
 import pytest
 
-from pyqasm.entrypoint import unroll, validate
+from pyqasm.entrypoint import load
 from pyqasm.exceptions import ValidationError
 from tests.utils import check_measure_op, check_single_qubit_gate_op, check_single_qubit_rotation_op
 
@@ -37,7 +37,8 @@ def test_correct_expressions():
     c[1] = c[0] + 2;
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
     assert result.num_qubits == 1
     assert result.num_clbits == 0
     rx_expression_values = [1.57, -1.57, 0]
@@ -59,7 +60,9 @@ def test_bit_in_expression():
     dummy_int = c3[0];
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
+
     assert result.num_qubits == 1
     assert result.num_clbits == 1
     check_single_qubit_gate_op(result.unrolled_ast, 1, [0], "h")
@@ -69,16 +72,16 @@ def test_bit_in_expression():
 
 def test_incorrect_expressions():
     with pytest.raises(ValidationError, match=r"Unsupported expression type .*"):
-        validate("OPENQASM 3; qubit q; rz(1 - 2 + 32im) q;")
+        load("OPENQASM 3; qubit q; rz(1 - 2 + 32im) q;").validate()
 
     with pytest.raises(ValidationError, match=r"Unsupported expression type .* in ~ operation"):
-        validate("OPENQASM 3; qubit q; rx(~1.3) q;")
+        load("OPENQASM 3; qubit q; rx(~1.3) q;").validate()
 
     with pytest.raises(ValidationError, match=r"Unsupported expression type .* in ~ operation"):
-        validate("OPENQASM 3; qubit q; rx(~1.3+5im) q;")
+        load("OPENQASM 3; qubit q; rx(~1.3+5im) q;").validate()
 
     with pytest.raises(ValidationError, match="Undefined identifier x in expression"):
-        validate("OPENQASM 3; qubit q; rx(x) q;")
+        load("OPENQASM 3; qubit q; rx(x) q;").validate()
 
     with pytest.raises(ValidationError, match="Uninitialized variable x in expression"):
-        validate("OPENQASM 3; qubit q; int x; rx(x) q;")
+        load("OPENQASM 3; qubit q; int x; rx(x) q;").validate()
diff --git a/tests/qasm3/test_gates.py b/tests/qasm3/test_gates.py
index a31d2ab..c170fc1 100644
--- a/tests/qasm3/test_gates.py
+++ b/tests/qasm3/test_gates.py
@@ -14,7 +14,7 @@
 """
 import pytest
 
-from pyqasm.entrypoint import unroll, validate
+from pyqasm.entrypoint import load
 from pyqasm.exceptions import ValidationError
 from tests.qasm3.resources.gates import (
     CUSTOM_GATE_INCORRECT_TESTS,
@@ -42,7 +42,8 @@ def test_single_qubit_qasm3_gates(circuit_name, request):
     gate_name = circuit_name.removeprefix("Fixture_")
 
     qasm3_string = request.getfixturevalue(circuit_name)
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
     assert result.num_qubits == 2
     assert result.num_clbits == 0
     check_single_qubit_gate_op(result.unrolled_ast, 5, qubit_list, gate_name)
@@ -54,9 +55,11 @@ def test_two_qubit_qasm3_gates(circuit_name, request):
     gate_name = circuit_name.removeprefix("Fixture_")
 
     qasm3_string = request.getfixturevalue(circuit_name)
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
     assert result.num_qubits == 2
     assert result.num_clbits == 0
+    print(result.unrolled_qasm)
     check_two_qubit_gate_op(result.unrolled_ast, 2, qubit_list, gate_name)
 
 
@@ -67,7 +70,8 @@ def test_rotation_qasm3_gates(circuit_name, request):
     gate_name = circuit_name.removeprefix("Fixture_")
 
     qasm3_string = request.getfixturevalue(circuit_name)
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
     assert result.num_qubits == 2
     assert result.num_clbits == 0
     check_single_qubit_rotation_op(result.unrolled_ast, 3, qubit_list, param_list, gate_name)
@@ -79,7 +83,8 @@ def test_three_qubit_qasm3_gates(circuit_name, request):
     gate_name = circuit_name.removeprefix("Fixture_")
 
     qasm3_string = request.getfixturevalue(circuit_name)
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
     assert result.num_qubits == 3
     assert result.num_clbits == 0
     check_three_qubit_gate_op(result.unrolled_ast, 2, qubit_list, gate_name)
@@ -108,7 +113,8 @@ def test_gate_body_param_expression():
     bool o = true;
     my_gate(m, n, o) q;
     """
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
     assert result.num_qubits == 1
     assert result.num_clbits == 0
 
@@ -125,7 +131,8 @@ def test_qasm_u3_gates():
     qubit[2] q1;
     u3(0.5, 0.5, 0.5) q1[0];
     """
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
     assert result.num_qubits == 2
     assert result.num_clbits == 0
     check_single_qubit_rotation_op(result.unrolled_ast, 1, [0], [0.5, 0.5, 0.5], "u3")
@@ -139,7 +146,8 @@ def test_qasm_u2_gates():
     qubit[2] q1;
     u2(0.5, 0.5) q1[0];
     """
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
     assert result.num_qubits == 2
     assert result.num_clbits == 0
     check_single_qubit_rotation_op(result.unrolled_ast, 1, [0], [0.5, 0.5], "u2")
@@ -149,14 +157,15 @@ def test_qasm_u2_gates():
 def test_incorrect_single_qubit_gates(test_name):
     qasm_input, error_message = SINGLE_QUBIT_GATE_INCORRECT_TESTS[test_name]
     with pytest.raises(ValidationError, match=error_message):
-        validate(qasm_input)
+        load(qasm_input).validate()
 
 
 @pytest.mark.parametrize("test_name", custom_op_tests)
 def test_custom_ops(test_name, request):
     qasm3_string = request.getfixturevalue(test_name)
     gate_type = test_name.removeprefix("Fixture_")
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
 
     assert result.num_qubits == 2
     assert result.num_clbits == 0
@@ -173,7 +182,8 @@ def test_pow_gate_modifier():
     inv @ pow(2) @ pow(4) @ h q;
     pow(-2) @ h q;
     """
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
     assert result.num_qubits == 1
     assert result.num_clbits == 0
     check_single_qubit_gate_op(result.unrolled_ast, 10, [0] * 10, "h")
@@ -193,8 +203,8 @@ def test_inv_gate_modifier():
     inv @ cx q2;
     inv @ ccx q[0], q2;
     """
-    result = unroll(qasm3_string, as_module=True)
-    print(result.unrolled_qasm)
+    result = load(qasm3_string)
+    result.unroll()
     assert result.num_qubits == 3
     assert result.num_clbits == 0
     check_single_qubit_gate_op(result.unrolled_ast, 1, [0], "h")
@@ -220,7 +230,8 @@ def test_nested_gate_modifiers():
     pow(1) @ inv @ pow(2) @ custom q;
     pow(-1) @ custom q;
     """
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
     assert result.num_qubits == 2
     assert result.num_clbits == 0
     check_single_qubit_gate_op(result.unrolled_ast, 3, [1, 1, 1], "z")
@@ -234,18 +245,18 @@ def test_unsupported_modifiers():
             NotImplementedError,
             match=r"Controlled modifier gates not yet supported .*",
         ):
-            validate(
+            load(
                 f"""
                 OPENQASM 3;
                 include "stdgates.inc";
                 qubit[2] q;
                 {modifier} @ h q[0], q[1];
                 """
-            )
+            ).validate()
 
 
 @pytest.mark.parametrize("test_name", CUSTOM_GATE_INCORRECT_TESTS.keys())
 def test_incorrect_custom_ops(test_name):
     qasm_input, error_message = CUSTOM_GATE_INCORRECT_TESTS[test_name]
     with pytest.raises(ValidationError, match=error_message):
-        validate(qasm_input)
+        load(qasm_input).validate()
diff --git a/tests/qasm3/test_if.py b/tests/qasm3/test_if.py
index 077d3f6..7ef5361 100644
--- a/tests/qasm3/test_if.py
+++ b/tests/qasm3/test_if.py
@@ -15,7 +15,7 @@
 
 import pytest
 
-from pyqasm.entrypoint import unroll, validate
+from pyqasm.entrypoint import load
 from pyqasm.exceptions import ValidationError
 from tests.utils import check_unrolled_qasm
 
@@ -62,7 +62,8 @@ def test_simple_if():
     }
     """
 
-    result = unroll(qasm, as_module=True)
+    result = load(qasm)
+    result.unroll()
     assert result.num_clbits == 4
     assert result.num_qubits == 4
 
@@ -128,7 +129,8 @@ def test_complex_if():
     h q[0];
     h q[1];
     """
-    result = unroll(qasm, as_module=True)
+    result = load(qasm)
+    result.unroll()
     assert result.num_clbits == 8
     assert result.num_qubits == 4
     print(result.unrolled_qasm)
@@ -138,7 +140,7 @@ def test_complex_if():
 def test_incorrect_if():
 
     with pytest.raises(ValidationError, match=r"Missing if block"):
-        validate(
+        load(
             """
             OPENQASM 3.0;
            include "stdgates.inc";
@@ -151,10 +153,10 @@ def test_incorrect_if():
            if(c[0]){
            }
            """
-        )
+        ).validate()
 
     with pytest.raises(ValidationError, match=r"Undefined identifier c2 in expression"):
-        validate(
+        load(
             """
             OPENQASM 3.0;
            include "stdgates.inc";
@@ -168,10 +170,10 @@ def test_incorrect_if():
             cx q;
            }
            """
-        )
+        ).validate()
 
     with pytest.raises(ValidationError, match=r"Only '!' supported .*"):
-        validate(
+        load(
             """
             OPENQASM 3.0;
            include "stdgates.inc";
@@ -185,9 +187,9 @@ def test_incorrect_if():
             cx q;
            }
            """
-        )
+        ).validate()
     with pytest.raises(ValidationError, match=r"Only '==' supported .*"):
-        validate(
+        load(
             """
             OPENQASM 3.0;
            include "stdgates.inc";
@@ -201,12 +203,12 @@ def test_incorrect_if():
             cx q;
            }
            """
-        )
+        ).validate()
     with pytest.raises(
         ValidationError,
         match=r"Only simple comparison supported .*",
     ):
-        validate(
+        load(
             """
             OPENQASM 3.0;
            include "stdgates.inc";
@@ -220,12 +222,12 @@ def test_incorrect_if():
             cx q;
            }
            """
-        )
+        ).validate()
     with pytest.raises(
         ValidationError,
         match=r"RangeDefinition not supported in branching condition",
     ):
-        validate(
+        load(
             """
             OPENQASM 3.0;
            include "stdgates.inc";
@@ -239,13 +241,13 @@ def test_incorrect_if():
             cx q;
            }
            """
-        )
+        ).validate()
 
     with pytest.raises(
         ValidationError,
         match=r"DiscreteSet not supported in branching condition",
     ):
-        validate(
+        load(
             """
             OPENQASM 3.0;
            include "stdgates.inc";
@@ -259,4 +261,4 @@ def test_incorrect_if():
             cx q;
            }
            """
-        )
+        ).validate()
diff --git a/tests/qasm3/test_loop.py b/tests/qasm3/test_loop.py
index f3f1c0d..3238938 100644
--- a/tests/qasm3/test_loop.py
+++ b/tests/qasm3/test_loop.py
@@ -15,7 +15,7 @@
 
 import pytest
 
-from pyqasm.entrypoint import unroll, validate
+from pyqasm.entrypoint import load
 from pyqasm.exceptions import ValidationError
 from tests.utils import (
     check_single_qubit_gate_op,
@@ -42,7 +42,7 @@
 
 def test_convert_qasm3_for_loop():
     """Test converting a QASM3 program that contains a for loop."""
-    result = unroll(
+    result = load(
         """
         OPENQASM 3.0;
         include "stdgates.inc";
@@ -55,9 +55,9 @@ def test_convert_qasm3_for_loop():
             cx q[i], q[i+1];
         }
         measure q->c;
-        """,
-        as_module=True,
+        """
     )
+    result.unroll()
     assert result.num_qubits == 4
     assert result.num_clbits == 4
 
@@ -69,7 +69,7 @@ def test_convert_qasm3_for_loop():
 
 def test_convert_qasm3_for_loop_shadow():
     """Test for loop where loop variable shadows variable from global scope."""
-    result = unroll(
+    result = load(
         """
         OPENQASM 3.0;
         include "stdgates.inc";
@@ -85,9 +85,10 @@ def test_convert_qasm3_for_loop_shadow():
         }
         h q[i];
         measure q->c;
-        """,
-        as_module=True,
+        """
     )
+    result.unroll()
+
     assert result.num_clbits == 4
     assert result.num_qubits == 4
 
@@ -97,7 +98,7 @@ def test_convert_qasm3_for_loop_shadow():
 
 def test_convert_qasm3_for_loop_enclosing():
     """Test for loop where variable from outer loop is accessed from inside the loop."""
-    result = unroll(
+    result = load(
         """
         OPENQASM 3.0;
         include "stdgates.inc";
@@ -113,9 +114,9 @@ def test_convert_qasm3_for_loop_enclosing():
             h q[j];
         }
         measure q->c;
-        """,
-        as_module=True,
+        """
     )
+    result.unroll()
 
     assert result.num_clbits == 4
     assert result.num_qubits == 4
@@ -126,7 +127,7 @@ def test_convert_qasm3_for_loop_enclosing():
 
 def test_convert_qasm3_for_loop_enclosing_modifying():
     """Test for loop where variable from outer loop is modified from inside the loop."""
-    result = unroll(
+    result = load(
         """
         OPENQASM 3.0;
         include "stdgates.inc";
@@ -144,10 +145,9 @@ def test_convert_qasm3_for_loop_enclosing_modifying():
         }
         h q[j];
         measure q->c;
-        """,
-        as_module=True,
+        """
     )
-    print(result.unrolled_qasm)
+    result.unroll()
 
     assert result.num_clbits == 4
     assert result.num_qubits == 4
@@ -158,7 +158,7 @@ def test_convert_qasm3_for_loop_enclosing_modifying():
 
 def test_convert_qasm3_for_loop_discrete_set():
     """Test converting a QASM3 program that contains a for loop initialized from a DiscreteSet."""
-    result = unroll(
+    result = load(
         """
         OPENQASM 3.0;
         include "stdgates.inc";
@@ -171,9 +171,10 @@ def test_convert_qasm3_for_loop_discrete_set():
             cx q[i], q[i+1];
         }
         measure q->c;
-        """,
-        as_module=True,
+        """
     )
+    result.unroll()
+
     assert result.num_clbits == 4
     assert result.num_qubits == 4
 
@@ -200,7 +201,9 @@ def my_function(qubit q_arg, float[32] b) {
     }
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
+
     assert result.num_qubits == 5
     assert result.num_clbits == 0
 
@@ -224,7 +227,8 @@ def my_function(qubit[3] q2) {
     my_function(q1);
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
 
     assert result.num_qubits == 3
     assert result.num_clbits == 0
@@ -255,7 +259,9 @@ def my_function(qubit q_arg, float[32] b) {
     my_function(q[0], 2*b);
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
+
     assert result.num_qubits == 5
     assert result.num_clbits == 0
 
@@ -286,7 +292,8 @@ def my_function_2(qubit q2, int[32] b){
     qubit q;
     my_function_2(q, 3);
     """
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
 
     assert result.num_clbits == 0
     assert result.num_qubits == 1
@@ -304,7 +311,7 @@ def test_convert_qasm3_for_loop_unsupported_type():
             " of set_declaration in loop."
         ),
     ):
-        validate(
+        load(
             """
             OPENQASM 3.0;
             include "stdgates.inc";
@@ -318,4 +325,4 @@ def test_convert_qasm3_for_loop_unsupported_type():
             }
             measure q->c;
             """,
-        )
+        ).validate()
diff --git a/tests/qasm3/test_measurement.py b/tests/qasm3/test_measurement.py
index 5d5fcb9..70220bb 100644
--- a/tests/qasm3/test_measurement.py
+++ b/tests/qasm3/test_measurement.py
@@ -9,12 +9,12 @@
 # THERE IS NO WARRANTY for pyqasm, as per Section 15 of the GPL v3.
 
 """
-Module containing unit tests for unrolling measurement operations.
+Module containing unit tests for loading measurement operations.
 
 """
 import pytest
 
-from pyqasm.entrypoint import unroll, validate
+from pyqasm.entrypoint import load
 from pyqasm.exceptions import ValidationError
 from tests.utils import check_unrolled_qasm
 
@@ -57,14 +57,81 @@ def test_measure():
     c1[0] = measure q2[1]; 
     """
 
-    unrolled_qasm = unroll(qasm3_string)
-    check_unrolled_qasm(unrolled_qasm, expected_qasm)
+    module = load(qasm3_string)
+    module.unroll()
+    check_unrolled_qasm(module.unrolled_qasm, expected_qasm)
+
+
+def test_has_measurements():
+    qasm3_string_with_measure = """
+    OPENQASM 3.0;
+
+    qubit[2] q1;
+    qubit[5] q2;
+    qubit q3;
+
+    bit[2] c1;
+    bit c2;
+
+    c1 = measure q1;
+    measure q1 -> c1;
+    c2[0] = measure q3[0];
+    measure q1[:1] -> c1[1];
+    measure q2[{0, 1}] -> c1[{1, 0}];
+
+    """
+    qasm_module = load(qasm3_string_with_measure)
+    assert qasm_module.has_measurements()
+
+    qasm3_string_without_measure = """
+    OPENQASM 3.0;
+
+    qubit[2] q1;
+    qubit[5] q2;
+    """
+    qasm_module = load(qasm3_string_without_measure)
+    assert not qasm_module.has_measurements()
+
+
+def test_remove_measurement():
+    qasm3_string = """
+    OPENQASM 3.0;
+
+    qubit[2] q1;
+    qubit[5] q2;
+    qubit q3;
+
+    bit[2] c1;
+    bit c2;
+
+    c1 = measure q1;
+    measure q1 -> c1;
+    c2[0] = measure q3[0];
+    measure q1[:1] -> c1[1];
+    measure q2[{0, 1}] -> c1[{1, 0}];
+
+    """
+
+    expected_qasm = """OPENQASM 3.0;
+    include 'stdgates.inc';
+    qubit[2] q1;
+    qubit[5] q2;
+    qubit[1] q3;
+    bit[2] c1;
+    bit[1] c2;
+    """
+
+    module = load(qasm3_string)
+    module.unroll()
+    module.remove_measurements()
+
+    check_unrolled_qasm(module.unrolled_qasm, expected_qasm)
 
 
 def test_incorrect_measure():
     def run_test(qasm3_code, error_message):
         with pytest.raises(ValidationError, match=error_message):
-            validate(qasm3_code)
+            load(qasm3_code).validate()
 
     # Test for undeclared register q2
     run_test(
diff --git a/tests/qasm3/test_reset.py b/tests/qasm3/test_reset.py
index 64d3e24..8e7bd54 100644
--- a/tests/qasm3/test_reset.py
+++ b/tests/qasm3/test_reset.py
@@ -14,7 +14,7 @@
 """
 import pytest
 
-from pyqasm.entrypoint import unroll, validate
+from pyqasm.entrypoint import load
 from pyqasm.exceptions import ValidationError
 from tests.utils import check_unrolled_qasm
 
@@ -50,13 +50,14 @@ def test_reset_operations():
     reset q3[1];
     """
 
-    unrolled_qasm = unroll(qasm3_string)
-    check_unrolled_qasm(unrolled_qasm, expected_qasm)
+    result = load(qasm3_string)
+    result.unroll()
+    check_unrolled_qasm(result.unrolled_qasm, expected_qasm)
 
 
 def test_reset_inside_function():
     """Test that a qubit reset inside a function is correctly parsed."""
-    qasm_str = """OPENQASM 3.0;
+    qasm3_string = """OPENQASM 3.0;
     include "stdgates.inc";
 
     def my_function(qubit a) {
@@ -73,8 +74,9 @@ def my_function(qubit a) {
     reset q[1];
     """
 
-    unrolled_qasm = unroll(qasm_str)
-    check_unrolled_qasm(unrolled_qasm, expected_qasm)
+    result = load(qasm3_string)
+    result.unroll()
+    check_unrolled_qasm(result.unrolled_qasm, expected_qasm)
 
 
 def test_incorrect_resets():
@@ -88,7 +90,7 @@ def test_incorrect_resets():
     reset q2[0];
     """
     with pytest.raises(ValidationError):
-        validate(undeclared)
+        load(undeclared).validate()
 
     index_error = """
     OPENQASM 3.0;
@@ -100,4 +102,4 @@ def test_incorrect_resets():
     reset q1[4];
     """
     with pytest.raises(ValidationError):
-        validate(index_error)
+        load(index_error).validate()
diff --git a/tests/qasm3/test_sizeof.py b/tests/qasm3/test_sizeof.py
index 868c6aa..9b34565 100644
--- a/tests/qasm3/test_sizeof.py
+++ b/tests/qasm3/test_sizeof.py
@@ -14,7 +14,7 @@
 """
 import pytest
 
-from pyqasm.entrypoint import unroll, validate
+from pyqasm.entrypoint import load
 from pyqasm.exceptions import ValidationError
 from tests.utils import check_single_qubit_rotation_op
 
@@ -42,7 +42,9 @@ def test_simple_sizeof():
     rx(size3) q[1];
     """
 
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
+
     assert result.num_qubits == 2
 
     check_single_qubit_rotation_op(result.unrolled_ast, 4, [0, 0, 1, 1], [3, 3, 2, 3], "rx")
@@ -66,7 +68,9 @@ def test_sizeof_multiple_types():
     rx(size2) q[1];
     """
 
-    result = unroll(qasm3_string, as_module=True)
+    result = load(qasm3_string)
+    result.unroll()
+
     assert result.num_qubits == 2
     check_single_qubit_rotation_op(result.unrolled_ast, 2, [1, 1], [2, 3], "rx")
 
@@ -82,7 +86,7 @@ def test_unsupported_target():
 
         int[32] size1 = sizeof(my_ints[0]); // this is invalid
         """
-        validate(qasm3_string)
+        load(qasm3_string).validate()
 
 
 def test_sizeof_on_non_array():
@@ -98,7 +102,7 @@ def test_sizeof_on_non_array():
 
         int[32] size1 = sizeof(my_int); // this is invalid
         """
-        validate(qasm3_string)
+        load(qasm3_string).validate()
 
 
 def test_out_of_bounds_reference():
@@ -114,4 +118,4 @@ def test_out_of_bounds_reference():
 
         int[32] size1 = sizeof(my_ints, 3); // this is invalid
         """
-        validate(qasm3_string)
+        load(qasm3_string).validate()
diff --git a/tests/qasm3/test_switch.py b/tests/qasm3/test_switch.py
index d968fc5..ca66f5f 100644
--- a/tests/qasm3/test_switch.py
+++ b/tests/qasm3/test_switch.py
@@ -17,7 +17,7 @@
 
 import pytest
 
-from pyqasm.entrypoint import unroll, validate
+from pyqasm.entrypoint import load
 from pyqasm.exceptions import ValidationError
 from tests.utils import check_single_qubit_gate_op, check_single_qubit_rotation_op
 
@@ -45,7 +45,9 @@ def test_switch():
     }
     """
 
-    result = unroll(qasm3_switch_program, as_module=True)
+    result = load(qasm3_switch_program)
+    result.unroll()
+
     assert result.num_clbits == 0
     assert result.num_qubits == 1
 
@@ -75,7 +77,9 @@ def test_switch_default():
     }
     """
 
-    result = unroll(qasm3_switch_program, as_module=True)
+    result = load(qasm3_switch_program)
+    result.unroll()
+
     assert result.num_clbits == 0
     assert result.num_qubits == 1
     check_single_qubit_gate_op(result.unrolled_ast, 1, [0], "z")
@@ -102,7 +106,9 @@ def test_switch_identifier_case():
     }
     """
 
-    result = unroll(qasm3_switch_program, as_module=True)
+    result = load(qasm3_switch_program)
+    result.unroll()
+
     assert result.num_clbits == 0
     assert result.num_qubits == 1
 
@@ -130,7 +136,9 @@ def test_switch_const_int():
     }
     """
 
-    result = unroll(qasm3_switch_program, as_module=True)
+    result = load(qasm3_switch_program)
+    result.unroll()
+
     assert result.num_clbits == 0
     assert result.num_qubits == 1
 
@@ -160,7 +168,7 @@ def test_switch_duplicate_cases():
         }
         """
 
-        validate(qasm3_switch_program)
+        load(qasm3_switch_program).validate()
 
 
 def test_no_case_switch():
@@ -183,7 +191,7 @@ def test_no_case_switch():
         }
         """
 
-        validate(qasm3_switch_program)
+        load(qasm3_switch_program).validate()
 
 
 def test_nested_switch():
@@ -221,7 +229,8 @@ def test_nested_switch():
     }
     """
 
-    result = unroll(qasm3_switch_program, as_module=True)
+    result = load(qasm3_switch_program)
+    result.unroll()
 
     assert result.num_clbits == 0
     assert result.num_qubits == 1
@@ -253,7 +262,8 @@ def my_function(qubit q, float[32] b) {
     }
     """
 
-    result = unroll(qasm_str, as_module=True)
+    result = load(qasm_str)
+    result.unroll()
     assert result.num_clbits == 0
     assert result.num_qubits == 2
 
@@ -287,7 +297,7 @@ def test_invalid_scalar_switch_target(invalid_type):
 
     with pytest.raises(ValidationError, match=re.escape("Switch target i must be of type int")):
         qasm3_switch_program = base_invalid_program
-        validate(qasm3_switch_program)
+        load(qasm3_switch_program).validate()
 
 
 @pytest.mark.parametrize("invalid_type", ["float", "bool"])
@@ -317,7 +327,7 @@ def test_invalid_array_switch_target(invalid_type):
 
     with pytest.raises(ValidationError, match=re.escape("Switch target i must be of type int")):
         qasm3_switch_program = base_invalid_program
-        validate(qasm3_switch_program)
+        load(qasm3_switch_program).validate()
 
 
 @pytest.mark.parametrize(
@@ -350,7 +360,7 @@ def test_unsupported_statements_in_case(invalid_stmt):
     )
     with pytest.raises(ValidationError, match=r"Unsupported statement .*"):
         qasm3_switch_program = base_invalid_program
-        validate(qasm3_switch_program)
+        load(qasm3_switch_program).validate()
 
 
 def test_non_int_expression_case():
@@ -377,7 +387,7 @@ def test_non_int_expression_case():
         match=r"Invalid value 4.3 with type .* for required type <class 'openqasm3.ast.IntType'>",
     ):
         qasm3_switch_program = base_invalid_program
-        validate(qasm3_switch_program)
+        load(qasm3_switch_program).validate()
 
 
 def test_non_int_variable_expression():
@@ -405,7 +415,7 @@ def test_non_int_variable_expression():
         match=r"Invalid type of variable .* for required type <class 'openqasm3.ast.IntType'>",
     ):
         qasm3_switch_program = base_invalid_program
-        validate(qasm3_switch_program)
+        load(qasm3_switch_program).validate()
 
 
 def test_non_constant_expression_case():
@@ -431,4 +441,4 @@ def test_non_constant_expression_case():
 
     with pytest.raises(ValidationError, match=r"Variable .* is not a constant in given expression"):
         qasm3_switch_program = base_invalid_program
-        validate(qasm3_switch_program)
+        load(qasm3_switch_program).validate()
diff --git a/tests/qasm3/test_version.py b/tests/qasm3/test_version.py
new file mode 100644
index 0000000..eaae35d
--- /dev/null
+++ b/tests/qasm3/test_version.py
@@ -0,0 +1,39 @@
+# Copyright (C) 2024 qBraid
+#
+# This file is part of pyqasm
+#
+# Pyqasm is free software released under the GNU General Public License v3
+# or later. You can redistribute and/or modify it under the terms of the GPL v3.
+# See the LICENSE file in the project root or <https://www.gnu.org/licenses/gpl-3.0.html>.
+#
+# THERE IS NO WARRANTY for pyqasm, as per Section 15 of the GPL v3.
+
+"""
+Module containing unit tests for version extraction
+
+"""
+
+import pytest
+
+from pyqasm.analyzer import Qasm3Analyzer
+from pyqasm.exceptions import QasmParsingError
+
+
+def test_extract_version():
+    """Test converting OpenQASM 3 program with openqasm3.ast.SwitchStatement."""
+
+    qasm3_program = """
+    OPENQASM 3.0;
+    qubit q;
+    """
+    assert Qasm3Analyzer.extract_qasm_version(qasm3_program) == 3
+
+
+def test_invalid_raises_raises_err():
+    """Test converting OpenQASM 3 program with openqasm3.ast.SwitchStatement."""
+
+    qasm3_program = """
+    random string
+    """
+    with pytest.raises(QasmParsingError):
+        Qasm3Analyzer.extract_qasm_version(qasm3_program)
diff --git a/tests/utils.py b/tests/utils.py
index 3b7216d..7c4d117 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -16,6 +16,8 @@
 
 from pyqasm.maps import CONSTANTS_MAP
 
+CONTROLLED_ROTATION_ANGLE_1 = 0.5
+
 
 def check_unrolled_qasm(unrolled_qasm, expected_qasm):
     """Check that the unrolled qasm matches the expected qasm.
@@ -34,7 +36,9 @@ def check_unrolled_qasm(unrolled_qasm, expected_qasm):
     assert len(unrolled_qasm) == len(expected_qasm)
 
     for unrolled_line, expected_line in zip(unrolled_qasm, expected_qasm):
-        print(unrolled_line, expected_line)
+        # replace ' with " for comparison
+        unrolled_line = unrolled_line.replace("'", '"')
+        expected_line = expected_line.replace("'", '"')
         assert unrolled_line.strip() == expected_line.strip()
 
 
@@ -50,19 +54,61 @@ def check_single_qubit_gate_op(unrolled_ast, num_gates, qubit_list, gate_name):
     assert gate_count == num_gates
 
 
+def _check_crx_gate_op(unrolled_ast, num_gates, qubits, theta):
+    num_u3_gates = 3 * num_gates
+    check_u3_gate_op(
+        unrolled_ast,
+        num_u3_gates,
+        [qubits[1]] * num_u3_gates,
+        [
+            [0, 0, CONSTANTS_MAP["pi"] / 2],
+            [-1 * theta / 2, 0, 0],
+            [theta / 2, -CONSTANTS_MAP["pi"] / 2, 0],
+        ]
+        * num_gates,
+    )
+
+    num_cx_gates = 2 * num_gates
+    check_two_qubit_gate_op(unrolled_ast, num_cx_gates, [qubits] * num_cx_gates, "cx")
+
+
+def _check_crz_gate_op(unrolled_ast, num_gates, qubits, theta):
+    num_u3_gates = 2 * num_gates
+    num_cx_gates = 2 * num_gates
+    check_u3_gate_op(
+        unrolled_ast,
+        num_u3_gates,
+        [qubits[1]] * num_u3_gates,
+        [
+            [0, 0, theta / 2],
+            [0, 0, -theta / 2],
+        ]
+        * num_gates,
+    )
+    check_two_qubit_gate_op(unrolled_ast, num_cx_gates, [qubits] * num_u3_gates, "cx")
+
+
 def check_two_qubit_gate_op(unrolled_ast, num_gates, qubit_list, gate_name):
     qubit_id, gate_count = 0, 0
     if gate_name == "cnot":
         gate_name = "cx"
-    for stmt in unrolled_ast.statements:
-        if isinstance(stmt, qasm3_ast.QuantumGate) and stmt.name.name == gate_name.lower():
-            assert len(stmt.qubits) == 2
-            assert stmt.qubits[0].indices[0][0].value == qubit_list[qubit_id][0]
-            assert stmt.qubits[1].indices[0][0].value == qubit_list[qubit_id][1]
-            qubit_id += 1
-            gate_count += 1
 
-    assert gate_count == num_gates
+    if gate_name == "crx":
+        param = CONTROLLED_ROTATION_ANGLE_1
+        _check_crx_gate_op(unrolled_ast, num_gates, qubit_list[0], param)
+    elif gate_name == "crz":
+        param = CONTROLLED_ROTATION_ANGLE_1
+        _check_crz_gate_op(unrolled_ast, num_gates, qubit_list[0], param)
+    else:
+        for stmt in unrolled_ast.statements:
+            if isinstance(stmt, qasm3_ast.QuantumGate) and stmt.name.name == gate_name.lower():
+                assert len(stmt.qubits) == 2
+                assert stmt.qubits[0].indices[0][0].value == qubit_list[qubit_id][0]
+                assert stmt.qubits[1].indices[0][0].value == qubit_list[qubit_id][1]
+                qubit_id += 1
+                gate_count += 1
+
+        assert gate_count == num_gates
 
 
 def check_three_qubit_gate_op(unrolled_ast, num_gates, qubit_list, gate_name):
@@ -80,24 +126,30 @@ def check_three_qubit_gate_op(unrolled_ast, num_gates, qubit_list, gate_name):
 
 
 def check_u3_gate_op(unrolled_ast, num_gates, qubit_list, param_list):
-    theta, phi, lam = param_list
     op_count = 0
     q_id = 0
     pi = CONSTANTS_MAP["pi"]
-    u3_param_list = [lam, pi / 2, theta + pi, pi / 2, phi + pi]
     u3_gate_list = ["rz", "rx", "rz", "rx", "rz"]
-    u3_gates_id = 0
 
-    for stmt in unrolled_ast.statements:
-        if isinstance(stmt, qasm3_ast.QuantumGate) and stmt.name.name == u3_gate_list[u3_gates_id]:
-            assert len(stmt.qubits) == 1
-            assert stmt.qubits[0].indices[0][0].value == qubit_list[q_id]
-            assert stmt.arguments[0].value == u3_param_list[u3_gates_id]
-            u3_gates_id += 1
-            if u3_gates_id == 5:
-                u3_gates_id = 0
-                op_count += 1
-                q_id += 1
+    for params in param_list:
+        theta, phi, lam = params
+        u3_param_list = [lam, pi / 2, theta + pi, pi / 2, phi + pi]
+        u3_gates_id = 0
+
+        for stmt in unrolled_ast.statements:
+            if (
+                isinstance(stmt, qasm3_ast.QuantumGate)
+                and stmt.name.name == u3_gate_list[u3_gates_id]
+                and len(stmt.qubits) == 1
+                and stmt.qubits[0].indices[0][0].value == qubit_list[q_id]
+                and stmt.arguments[0].value == u3_param_list[u3_gates_id]
+            ):
+                u3_gates_id += 1
+                if u3_gates_id == 5:
+                    u3_gates_id = 0
+                    op_count += 1
+                    q_id += 1
+                    break  # break out of the loop to check the next set of params
 
     assert op_count == num_gates
 
@@ -130,11 +182,11 @@ def check_measure_op(unrolled_ast, num_ops, meas_pairs):
 
 def check_single_qubit_rotation_op(unrolled_ast, num_gates, qubit_list, param_list, gate_name):
     if gate_name == "u3":
-        check_u3_gate_op(unrolled_ast, num_gates, qubit_list, param_list)
+        check_u3_gate_op(unrolled_ast, num_gates, qubit_list, [param_list])
         return
     if gate_name == "u2":
         param_list = [CONSTANTS_MAP["pi"] / 2, param_list[0], param_list[1]]
-        check_u3_gate_op(unrolled_ast, num_gates, qubit_list, param_list)
+        check_u3_gate_op(unrolled_ast, num_gates, qubit_list, [param_list])
         return
     qubit_id, param_id, gate_count = 0, 0, 0
     for stmt in unrolled_ast.statements: