diff --git a/src/pyqasm/pulse/visitor.py b/src/pyqasm/pulse/visitor.py index 6128aa69..2a6e4fa1 100644 --- a/src/pyqasm/pulse/visitor.py +++ b/src/pyqasm/pulse/visitor.py @@ -773,6 +773,40 @@ 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, + "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 +823,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 +833,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 bd45db75..5a3ff044 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -196,7 +196,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, @@ -1637,6 +1637,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 @@ -3505,6 +3514,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_statement( self, statement: qasm3_ast.Statement | qasm3_ast.Pragma ) -> list[qasm3_ast.Statement]: @@ -3527,12 +3554,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..75924061 100644 --- a/tests/qasm3/openpulse/test_general.py +++ b/tests/qasm3/openpulse/test_general.py @@ -17,12 +17,47 @@ """ +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): + """Pure expressions in calibration blocks do not emit statements.""" + 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): + """Unknown calls in calibration blocks use the public error type.""" + 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 73fd6565..626b2a00 100644 --- a/tests/qasm3/resources/gates.py +++ b/tests/qasm3/resources/gates.py @@ -394,7 +394,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..2851b1e9 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,86 @@ 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): + """Pure expression statements are valid but do not emit operations.""" + 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): + """Unknown calls in expression statements use the public error type.""" + 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, error, operation): + """Unknown gate names are reported even when an operand is undeclared.""" + module = loads(source) + + with pytest.raises(ValidationError, match=error): + getattr(module, operation)()