Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Types of changes:
## Unreleased

### Added
- Added support for OpenQASM 3 `end;` statements. Unrolling stops after an unconditional `end;` in global or nested scopes and keeps `end;` inside runtime-dependent branches. ([#396](https://github.com/qBraid/pyqasm/issues/396))
- Added support for OpenQASM 2 `opaque` declarations, which previously failed at parse time and blocked vendor include files such as Quantinuum's `hqslib1.inc`. An opaque gate is treated as a black box: emitted as written, counted as one layer of depth. `to_qasm3()` rejects such a program. ([#370](https://github.com/qBraid/pyqasm/issues/370))
- Added an `include_dir` kwarg to `loads()` and `load()`, naming the directory custom `include` statements resolve against. A program given as a string could not resolve includes at all, and failed later naming the gate rather than the include. Resolution is opt-in: without the kwarg, no files are read. ([#368](https://github.com/qBraid/pyqasm/issues/368))
- Added a `compact_gate_arguments` setting, passed to `loads()` or set on the module, which prints gate arguments without spaces around `*`, `/` and `**`: `rx(pi/2)` instead of `rx(pi / 2)`. Vendors such as Diraq match rotation angles textually and reject the spaced form. ([#427](https://github.com/qBraid/pyqasm/pull/427))
Expand Down
27 changes: 27 additions & 0 deletions src/pyqasm/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@
import numpy as np
from openqasm3.ast import (
BinaryExpression,
Box,
BranchingStatement,
DiscreteSet,
EndStatement,
Expression,
Identifier,
IndexedIdentifier,
Expand All @@ -36,6 +39,7 @@
QuantumMeasurementStatement,
RangeDefinition,
Span,
Statement,
UnaryExpression,
)

Expand All @@ -49,6 +53,29 @@
class Qasm3Analyzer:
"""Class with utility functions for analyzing QASM3 elements"""

@classmethod
def terminates_program(cls, statement: Statement) -> bool:
"""Check whether a statement always reaches an ``end`` statement.

Args:
statement (Statement): The final statement in a visited block.

Returns:
bool: Whether the statement terminates every path through it.
"""
if isinstance(statement, EndStatement):
return True
if isinstance(statement, Box):
return bool(statement.body) and cls.terminates_program(statement.body[-1])
if isinstance(statement, BranchingStatement):
return (
bool(statement.if_block)
and bool(statement.else_block)
and cls.terminates_program(statement.if_block[-1])
and cls.terminates_program(statement.else_block[-1])
)
return False

@staticmethod
def analyze_classical_indices(
indices: list[Any], var: Variable, expr_evaluator: Qasm3ExprEvaluator
Expand Down
50 changes: 43 additions & 7 deletions src/pyqasm/visitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ def _construct_visit_map(self):
qasm3_ast.IODeclaration: lambda x: [],
qasm3_ast.BreakStatement: self._visit_break,
qasm3_ast.ContinueStatement: self._visit_continue,
qasm3_ast.EndStatement: self._visit_end_statement,
qasm3_ast.DelayInstruction: self._visit_delay_statement,
qasm3_ast.Box: self._visit_box_statement,
qasm3_ast.Pragma: self._visit_pragma,
Expand Down Expand Up @@ -2545,9 +2546,10 @@ def _visit_forin_loop(self, statement: qasm3_ast.ForInLoop) -> list[qasm3_ast.St

if statement_block != statement.block:
statement_block = copy.deepcopy(statement.block)
result.extend(self.visit_basic_block(statement_block))
iteration_statements = self.visit_basic_block(statement_block)
else:
result.extend(self.visit_basic_block(statement.block))
iteration_statements = self.visit_basic_block(statement.block)
result.extend(iteration_statements)

# scope not persistent between loop iterations
self._scope_manager.pop_scope()
Expand All @@ -2557,6 +2559,8 @@ def _visit_forin_loop(self, statement: qasm3_ast.ForInLoop) -> list[qasm3_ast.St
# not runtime errors, we can break here
if self._check_only:
return []
if iteration_statements and Qasm3Analyzer.terminates_program(iteration_statements[-1]):
break
return result

def _visit_subroutine_definition(
Expand Down Expand Up @@ -2717,9 +2721,14 @@ def _visit_function_call(
return_statement = copy.copy(function_op)
break
try:
result.extend(self.visit_statement(copy.copy(function_op)))
function_statements = self.visit_statement(copy.copy(function_op))
except (TypeError, copy.Error):
result.extend(self.visit_statement(copy.deepcopy(function_op)))
function_statements = self.visit_statement(copy.deepcopy(function_op))
result.extend(function_statements)
if function_statements and Qasm3Analyzer.terminates_program(
function_statements[-1]
):
break

if return_statement:
return_value, stmts = Qasm3ExprEvaluator.evaluate_expression(
Expand Down Expand Up @@ -2779,8 +2788,10 @@ def _visit_while_loop(self, statement: qasm3_ast.WhileLoop) -> list[qasm3_ast.St
self._scope_manager.push_context(Context.BLOCK)
self._scope_manager.push_scope({})

loop_statements = []
try:
result.extend(self.visit_basic_block(statement.block))
loop_statements = self.visit_basic_block(statement.block)
result.extend(loop_statements)
except LoopControlSignal as lcs:
self._scope_manager.pop_scope()
self._scope_manager.restore_context()
Expand All @@ -2792,6 +2803,9 @@ def _visit_while_loop(self, statement: qasm3_ast.WhileLoop) -> list[qasm3_ast.St
self._scope_manager.pop_scope()
self._scope_manager.restore_context()

if loop_statements and Qasm3Analyzer.terminates_program(loop_statements[-1]):
break

loop_counter += 1
if loop_counter >= max_iterations:
raise_qasm3_error(
Expand Down Expand Up @@ -2968,7 +2982,10 @@ def _evaluate_case(statements):
result = []
for stmt in statements:
Qasm3Validator.validate_statement_type(SWITCH_BLACKLIST_STMTS, stmt, "switch")
result.extend(self.visit_statement(stmt))
case_statements = self.visit_statement(stmt)
result.extend(case_statements)
if case_statements and Qasm3Analyzer.terminates_program(case_statements[-1]):
break

self._scope_manager.pop_scope()
self._scope_manager.restore_context()
Expand Down Expand Up @@ -3505,6 +3522,22 @@ def _visit_include(self, include: qasm3_ast.Include) -> list[qasm3_ast.Statement

return [include]

def _visit_end_statement(
self, statement: qasm3_ast.EndStatement
) -> list[qasm3_ast.EndStatement]:
"""Visit a statement that terminates the program.

Args:
statement (EndStatement): The terminating statement to visit.

Returns:
list[EndStatement]: The statement in a list, or an empty list if
self._check_only is True.
"""
if self._check_only:
return []
return [statement]

def visit_statement(
self, statement: qasm3_ast.Statement | qasm3_ast.Pragma
) -> list[qasm3_ast.Statement]:
Expand Down Expand Up @@ -3554,7 +3587,10 @@ def visit_basic_block(
"""
result = []
for stmt in stmt_list:
result.extend(self.visit_statement(stmt))
statements = self.visit_statement(stmt)
result.extend(statements)
if statements and Qasm3Analyzer.terminates_program(statements[-1]):
break
return result

def finalize(self, unrolled_stmts: list[qasm3_ast.Statement]) -> list[qasm3_ast.Statement]:
Expand Down
207 changes: 207 additions & 0 deletions tests/qasm3/test_end.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# Copyright 2026 qBraid
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Tests for terminating an OpenQASM 3 program with ``end``."""

import pytest

from pyqasm.entrypoint import dumps, loads
from tests.utils import check_unrolled_qasm

H_THEN_END = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[1] q;
h q[0];
end;
"""


def test_end_stops_global_unrolling_and_bookkeeping():
"""Statements after a global ``end`` are unreachable."""
module = loads("""
OPENQASM 3.0;
include "stdgates.inc";
qubit q;
h q;
end;
x q;
qubit[2] unreachable;
""")

module.validate()
module.unroll()

check_unrolled_qasm(dumps(module), H_THEN_END)
assert module.num_qubits == 1
assert module.depth() == 1

round_tripped = loads(dumps(module))
round_tripped.unroll()
check_unrolled_qasm(dumps(round_tripped), H_THEN_END)


@pytest.mark.parametrize(
"control_flow",
[
"if (true) { h q; end; x q; }",
"if (false) { x q; } else { h q; end; x q; }",
"for int i in [0:2] { h q; end; x q; }",
"int i = 1; switch (i) { case 1 { h q; end; x q; } default { x q; } }",
],
)
def test_end_propagates_from_static_control_flow(control_flow):
"""A reachable ``end`` in static control flow terminates the program."""
module = loads(f"""
OPENQASM 3.0;
include "stdgates.inc";
qubit q;
{control_flow}
x q;
""")

module.unroll()
check_unrolled_qasm(dumps(module), H_THEN_END)


def test_end_stops_while_loop_and_program():
"""A terminating while-loop body is not expanded again."""
module = loads("""
OPENQASM 3.0;
include "stdgates.inc";
qubit q;
int i = 0;
while (i < 2) {
h q;
end;
i += 1;
}
x q;
""")

module.unroll(max_loop_iters=2)
check_unrolled_qasm(dumps(module), H_THEN_END)


def test_end_propagates_from_inlined_subroutine():
"""An ``end`` reached in an inlined subroutine terminates its caller."""
module = loads("""
OPENQASM 3.0;
include "stdgates.inc";
def stop(qubit q) {
h q;
end;
x q;
}
qubit q;
stop(q);
x q;
""")

module.unroll()
check_unrolled_qasm(dumps(module), H_THEN_END)


def test_end_propagates_from_box():
"""A box preserves its ``end`` and terminates the surrounding block."""
module = loads("""
OPENQASM 3.0;
include "stdgates.inc";
qubit q;
box {
h q;
end;
x q;
}
x q;
""")

module.unroll()

expected = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[1] q;
box {
h q[0];
end;
}
"""
check_unrolled_qasm(dumps(module), expected)


def test_runtime_conditional_end_remains_conditional():
"""A runtime-dependent ``end`` does not truncate the surrounding block."""
module = loads("""
OPENQASM 3.0;
include "stdgates.inc";
qubit q;
bit[1] c;
if (c[0]) {
end;
x q;
}
h q;
""")

module.unroll()

expected = """
OPENQASM 3.0;
include "stdgates.inc";
qubit[1] q;
bit[1] c;
if (c[0] == true) {
end;
}
h q[0];
"""
check_unrolled_qasm(dumps(module), expected)


def test_end_in_both_runtime_branches_terminates_program():
"""Later statements are unreachable when every runtime branch terminates."""
module = loads("""
OPENQASM 3.0;
include "stdgates.inc";
bit[1] c;
qubit q;
if (c[0]) {
h q;
end;
} else {
z q;
end;
}
x q;
qubit unreachable;
""")

module.unroll()

expected = """
OPENQASM 3.0;
include "stdgates.inc";
bit[1] c;
qubit[1] q;
if (c[0] == true) {
h q[0];
end;
} else {
z q[0];
end;
}
"""
check_unrolled_qasm(dumps(module), expected)
assert module.num_qubits == 1
Loading