diff --git a/CHANGELOG.md b/CHANGELOG.md index 56ca77c6..cb1393bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Types of changes: ### Removed ### Fixed +- Fixed bare OpenQASM expression statements raising `AttributeError` or `KeyError`. Their values are now evaluated and discarded, and unknown gate errors name the gate using the original source line. ([#388](https://github.com/qBraid/pyqasm/issues/388)) - Fixed qubit register ranges dropping their final qubit during unrolling. Endpoints are now inclusive for gates, aliases, barriers, and measurements. Code using an exclusive upper bound must lower it by one, for example `q[0:1]` instead of `q[0:2]` to select two qubits. ([#432](https://github.com/qBraid/pyqasm/issues/432)) - Fixed `cs` and `csdg` from `stdgates.inc` being rejected during unrolling. They now decompose to controlled phase shifts. ([#439](https://github.com/qBraid/pyqasm/issues/439)) - Fixed Clifford+T rebasing for exact `rx`, `ry`, and `rz` rotations at multiples of π/4. These gates now decompose instead of disappearing, while angles outside the exact basis raise `RebaseError` instead of producing an incorrect result. ([#428](https://github.com/qBraid/pyqasm/issues/428)) diff --git a/src/pyqasm/pulse/visitor.py b/src/pyqasm/pulse/visitor.py index 6128aa69..a5da182a 100644 --- a/src/pyqasm/pulse/visitor.py +++ b/src/pyqasm/pulse/visitor.py @@ -773,6 +773,41 @@ def _visit_function_call( # pylint: disable=too-many-branches, too-many-stateme return _return_value, [statement] + def _visit_expression_statement( + self, statement: qasm3_ast.ExpressionStatement + ) -> list[qasm3_ast.Statement]: + """Visit an expression statement in an OpenPulse block. + + OpenPulse functions retain their specialized validation and output. + Other expressions use the main visitor's evaluator and discard their + value. + + Args: + statement (ExpressionStatement): The expression statement to visit. + + Returns: + list[Statement]: Statements produced while evaluating the expression. + """ + expression = statement.expression + pulse_functions = { + *OPENPULSE_FRAME_FUNCTION_MAP, + *OPENPULSE_WAVEFORM_FUNCTION_MAP, + *OPENPULSE_CAPTURE_FUNCTION_MAP, + # Keep these names in sync with the special cases in _visit_function_call. + "get_phase", + "get_frequency", + "newframe", + "play", + } + if ( + isinstance(expression, qasm3_ast.FunctionCall) + and expression.name.name in pulse_functions + ): + _, statements = self._visit_function_call(expression) + return statements # type: ignore[return-value] + _, statements = Qasm3ExprEvaluator.evaluate_expression(expression) + return statements + def visit_statement( self, statement: qasm3_ast.Statement | qasm3_ast.Pragma ) -> list[qasm3_ast.Statement]: @@ -789,7 +824,7 @@ def visit_statement( visit_map = { qasm3_ast.QuantumBarrier: self._visit_barrier, qasm3_ast.ClassicalDeclaration: self._visit_classical_declaration, - qasm3_ast.ExpressionStatement: lambda x: self._visit_function_call(x.expression), + qasm3_ast.ExpressionStatement: self._visit_expression_statement, qasm3_ast.DelayInstruction: self._qasm_visitor._visit_delay_statement, qasm3_ast.ClassicalAssignment: self._visit_classical_assignment, qasm3_ast.ConstantDeclaration: self._visit_classical_declaration, @@ -799,12 +834,7 @@ def visit_statement( visitor_function = visit_map.get(type(statement)) if visitor_function: - 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: - result.extend(visitor_function(statement)) # type: ignore[operator] + result.extend(visitor_function(statement)) # type: ignore[operator] else: if isinstance(statement, qasm3_ast.ReturnStatement): if statement.expression: diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index abf0f595..8c2e203b 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -208,7 +208,7 @@ def _construct_visit_map(self): qasm3_ast.SwitchStatement: self._visit_switch_statement, qasm3_ast.SubroutineDefinition: self._visit_subroutine_definition, qasm3_ast.ExternDeclaration: self._visit_subroutine_definition, - qasm3_ast.ExpressionStatement: lambda x: self._visit_function_call(x.expression), + qasm3_ast.ExpressionStatement: self._visit_expression_statement, qasm3_ast.IODeclaration: lambda x: [], qasm3_ast.BreakStatement: self._visit_break, qasm3_ast.ContinueStatement: self._visit_continue, @@ -1650,6 +1650,15 @@ def _visit_generic_gate_operation( # pylint: disable=too-many-branches, too-man ) return stmts # type: ignore + if ( + isinstance(operation, qasm3_ast.QuantumGate) + and operation.name.name not in self._custom_gates + and not self._is_black_box_gate(operation.name.name) + ): + # Resolve the operation before its operands so an unknown gate is + # reported even when one of its qubits is also undeclared. + map_qasm_op_to_callable(operation) + self._in_generic_gate_op_scope += 1 # only needs to be done once for a gate operation @@ -3506,6 +3515,24 @@ def _visit_include(self, include: qasm3_ast.Include) -> list[qasm3_ast.Statement return [include] + @staticmethod + def _visit_expression_statement( + statement: qasm3_ast.ExpressionStatement, + ) -> list[qasm3_ast.Statement]: + """Evaluate an expression statement and discard its value. + + Statements produced while evaluating the expression are retained so + that calls to user-defined and external functions keep their effects. + + Args: + statement (ExpressionStatement): The expression statement to visit. + + Returns: + list[Statement]: Statements produced while evaluating the expression. + """ + _, statements = Qasm3ExprEvaluator.evaluate_expression(statement.expression) + return statements + def _visit_end_statement( self, statement: qasm3_ast.EndStatement ) -> list[qasm3_ast.EndStatement]: @@ -3544,12 +3571,7 @@ def visit_statement( visitor_function = self._visit_map.get(type(statement)) if visitor_function: - 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: - result.extend(visitor_function(statement)) # type: ignore[operator] + result.extend(visitor_function(statement)) # type: ignore[operator] else: raise_qasm3_error( f"Unsupported statement of type {type(statement)}", diff --git a/tests/qasm3/openpulse/test_general.py b/tests/qasm3/openpulse/test_general.py index 2d06a116..978f171d 100644 --- a/tests/qasm3/openpulse/test_general.py +++ b/tests/qasm3/openpulse/test_general.py @@ -17,12 +17,55 @@ """ +import openqasm3.ast as qasm3_ast import pytest from pyqasm.entrypoint import loads from pyqasm.exceptions import ValidationError +@pytest.mark.parametrize("expression", ["1 + 2;", "i;", "sin(1.0);"]) +def test_pure_expression_statements_are_discarded(expression: str): + """Pure expressions in calibration blocks do not emit statements. + + Args: + expression (str): The expression statement to evaluate. + """ + module = loads(f""" + OPENQASM 3.0; + defcalgrammar "openpulse"; + cal {{ + int i = 1; + {expression} + }} + """) + + module.validate() + module.unroll() + + calibration = next( + statement + for statement in module.unrolled_ast.statements + if isinstance(statement, qasm3_ast.CalibrationStatement) + ) + assert [line.strip() for line in calibration.body.splitlines() if line.strip()] == [ + "int i = 1;" + ] + + +@pytest.mark.parametrize("operation", ["validate", "unroll"]) +def test_unknown_expression_statement_call_raises_validation_error(operation: str): + """Unknown calls in calibration blocks use the public error type. + + Args: + operation (str): The module method to call. + """ + module = loads('OPENQASM 3.0; defcalgrammar "openpulse"; cal { unknown(); }') + + with pytest.raises(ValidationError, match="Undefined subroutine 'unknown'"): + getattr(module, operation)() + + @pytest.mark.parametrize( "qasm_code,error_message,error_span", [ diff --git a/tests/qasm3/resources/gates.py b/tests/qasm3/resources/gates.py index 1cf8b7de..cb4e2754 100644 --- a/tests/qasm3/resources/gates.py +++ b/tests/qasm3/resources/gates.py @@ -396,7 +396,7 @@ def test_fixture(): "Unsupported / undeclared QASM operation: custom_gate", 6, 8, - "custom_gate q1[0], q1[1];", # expanded line + "custom_gate q1;", ), "parameter_mismatch_1": ( """ diff --git a/tests/qasm3/test_expressions.py b/tests/qasm3/test_expressions.py index ef549f51..b0c9cb04 100644 --- a/tests/qasm3/test_expressions.py +++ b/tests/qasm3/test_expressions.py @@ -17,6 +17,7 @@ """ +import openqasm3.ast as qasm3_ast import pytest from pyqasm.entrypoint import loads @@ -105,3 +106,102 @@ def test_incorrect_expressions(caplog): loads("OPENQASM 3; qubit q; int x; rx(x) q;").validate() assert "Error at line 1" in caplog.text assert "x" in caplog.text + + +@pytest.mark.parametrize( + "expression", + [ + "1;", + "value;", + "value + 2;", + "-value;", + "values[0];", + "sin(1.0);", + ], +) +def test_expression_statements_are_evaluated_and_discarded(expression: str): + """Pure expression statements are valid but do not emit operations. + + Args: + expression (str): The expression statement to evaluate. + """ + module = loads(f""" + OPENQASM 3.0; + include "stdgates.inc"; + int value = 1; + array[int[32], 2] values = {{1, 2}}; + qubit q; + {expression} + x q; + """) + + module.validate() + module.unroll() + + assert not any( + isinstance(statement, qasm3_ast.ExpressionStatement) + for statement in module.unrolled_ast.statements + ) + check_single_qubit_gate_op(module.unrolled_ast, 1, [0], "x") + + +def test_subroutine_expression_statement_retains_operations(): + """Statements produced by evaluating a subroutine call are retained.""" + module = loads(""" + OPENQASM 3.0; + include "stdgates.inc"; + def apply_x(qubit target) { + x target; + } + qubit q; + apply_x(q); + """) + + module.validate() + module.unroll() + + check_single_qubit_gate_op(module.unrolled_ast, 1, [0], "x") + + +@pytest.mark.parametrize("operation", ["validate", "unroll"]) +def test_unknown_expression_statement_call_raises_validation_error(operation: str): + """Unknown calls in expression statements use the public error type. + + Args: + operation (str): The module method to call. + """ + module = loads("OPENQASM 3.0; unknown();") + + with pytest.raises(ValidationError, match="Undefined subroutine 'unknown'"): + getattr(module, operation)() + + +@pytest.mark.parametrize( + "source,error", + [ + ("OPENQASM 3.0; unknown;", "Undefined identifier 'unknown'"), + ( + "OPENQASM 3.0; unknown missing_qubit;", + "Unsupported / undeclared QASM operation: unknown", + ), + ( + "OPENQASM 3.0; qubit q; unknown q;", + "Unsupported / undeclared QASM operation: unknown", + ), + ], +) +@pytest.mark.parametrize("operation", ["validate", "unroll"]) +def test_unknown_gate_reports_its_name_before_checking_operands( + source: str, error: str, operation: str +): + """Unknown gate names are reported even when an operand is undeclared. + + Args: + source (str): The OpenQASM program to validate or unroll. + error (str): The expected error message. + operation (str): The module method to call. + """ + module = loads(source) + + with pytest.raises(ValidationError, match=error): + getattr(module, operation)()