From 22ea5e4f2af98468d67278e28b40e8e5b0599309 Mon Sep 17 00:00:00 2001 From: TheGupta2012 Date: Tue, 18 Aug 2026 11:55:26 +0530 Subject: [PATCH 1/4] feat: support OpenQASM 2 opaque declarations as black-box gates Any program containing an opaque declaration failed to parse, so no compiled program from Quantinuum H-series hardware could be loaded: the primitives in hqslib1.inc are all declared opaque. opaque is OpenQASM 2 syntax that OpenQASM 3 removed, and pyqasm routes qasm2 through the openqasm3 parser, which has no grammar production for it. The failure therefore happens before any visitor code runs, and a fix in the visitor cannot reach it. Rewrite the declaration in source preprocessing instead, gated on the OPENQASM 2 header so that opaque in a qasm3 program keeps failing to parse. An opaque gate has no decomposition by definition, so it is carried as a gate definition with an empty body -- a marker for its name and arity -- and its name is recorded on the module. A call to it is routed to the external-gate path and emitted as written, counting as one layer of depth. The names live on the module rather than in unroll()'s kwargs, because an opaque gate is a property of the program and has no decomposition for unroll() to fall back on when it flushes external_gates. to_qasm3() now raises for such a program. A body-less gate means the identity in OpenQASM 3, so converting one would silently turn a hardware primitive into a no-op. Fixes #370 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + src/README.md | 29 ++++ src/pyqasm/entrypoint.py | 10 +- src/pyqasm/modules/base.py | 3 + src/pyqasm/modules/qasm2.py | 12 ++ src/pyqasm/preprocess.py | 34 +++++ src/pyqasm/visitor.py | 29 +++- tests/qasm2/test_opaque.py | 259 ++++++++++++++++++++++++++++++++++++ 8 files changed, 374 insertions(+), 3 deletions(-) create mode 100644 tests/qasm2/test_opaque.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a2eb0c68..e06d531e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Types of changes: ## Unreleased ### Added +- 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)) ### Improved / Modified diff --git a/src/README.md b/src/README.md index fe470714..a32785ce 100644 --- a/src/README.md +++ b/src/README.md @@ -41,6 +41,35 @@ Source code for OpenQASM 3 program validator and semantic analyzer | ComplexType | ✅ | Completed | | AngleType | ✅ | Completed | | ExternDeclaration | ✅ | Completed | +| opaque (OpenQASM 2 only) | ✅ | Emitted as written | + +## Opaque gates + +`opaque NAME(params) qubits;` is OpenQASM 2 syntax that was removed in OpenQASM 3. It +declares a hardware primitive: a gate with a name and an arity and no decomposition. +Quantinuum's `hqslib1.inc` opens with six of them. + +pyqasm rewrites the declaration before parsing and records the name, so `validate()`, +`depth()`, `has_measurements()` and the qubit-renumbering passes all work. A call to an +opaque gate is emitted as written rather than unrolled, and counts as one layer of depth, +exactly as an external gate does. `unroll()` never drops that treatment: unlike +`external_gates`, which the caller sets per call, an opaque gate is a property of the +program and has no decomposition to fall back on. + +Two things to know: + +- **The declaration is not re-emitted.** Unrolling drops it, the same way it drops the + `gate` definition of an external gate, so the output carries calls to a gate it does + not declare and does not load back into pyqasm on its own. +- **`opaque` in an OpenQASM 3 program is still a parse error.** The rewrite is gated on + the `OPENQASM 2` header, because the keyword is not OpenQASM 3 syntax. +- **`rebase()` reports it as unsupported.** An opaque primitive has no decomposition, so + it cannot be rewritten onto a standard basis set; it reaches the existing + unsupported-gate path and is named there. +- **`to_qasm3()` refuses a program that declares one.** OpenQASM 3 removed `opaque` and + has no equivalent for a gate with no decomposition, and a body-less `gate` in + OpenQASM 3 means the identity — so converting would silently turn each hardware + primitive into a no-op. ## Pragmas diff --git a/src/pyqasm/entrypoint.py b/src/pyqasm/entrypoint.py index 49565f0c..83d0b296 100644 --- a/src/pyqasm/entrypoint.py +++ b/src/pyqasm/entrypoint.py @@ -27,7 +27,11 @@ from pyqasm.exceptions import ValidationError from pyqasm.maps import SUPPORTED_QASM_VERSIONS from pyqasm.modules import Qasm2Module, Qasm3Module, QasmModule -from pyqasm.preprocess import process_include_sources, process_include_statements +from pyqasm.preprocess import ( + process_include_sources, + process_include_statements, + rewrite_opaque_declarations, +) if TYPE_CHECKING: import openqasm3.ast @@ -165,9 +169,12 @@ def loads(program: openqasm3.ast.Program | str, **kwargs) -> QasmModule: """ _validate_kwargs(kwargs) include_dir = kwargs.pop("include_dir", None) + opaque_gates: set[str] = set() if isinstance(program, str): if include_dir is not None: program = process_include_sources(program, include_dir) + # after include resolution, so an opaque in a vendor include is rewritten too + program, opaque_gates = rewrite_opaque_declarations(program) try: program = openqasm3.parse(program) except openqasm3.parser.QASM3ParsingError as err: @@ -191,6 +198,7 @@ def loads(program: openqasm3.ast.Program | str, **kwargs) -> QasmModule: qasm_module = Qasm3Module if program.version.startswith("3") else Qasm2Module module = qasm_module("main", program) + module._opaque_gates = opaque_gates # `is not None`, not truthiness: a falsy value is a caller value, not an omission. # An explicit None means "not passed", so defaults like extern_functions={} and # frame_in_def_cal=True are never clobbered. diff --git a/src/pyqasm/modules/base.py b/src/pyqasm/modules/base.py index 9d65b07a..61bd6016 100644 --- a/src/pyqasm/modules/base.py +++ b/src/pyqasm/modules/base.py @@ -200,6 +200,9 @@ def __init__(self, name: str, program: Program): self._validated_program = False self._unrolled_ast = Program(statements=[]) self._external_gates: list[str] = [] + # declared `opaque` in the source: a property of the program, not of one unroll() + # call, so unroll() never flushes these the way it flushes _external_gates + self._opaque_gates: set[str] = set() self._decompose_native_gates: Optional[bool] = None self._device_qubits: Optional[int] = None self._consolidate_qubits: Optional[bool] = False diff --git a/src/pyqasm/modules/qasm2.py b/src/pyqasm/modules/qasm2.py index fd176d9d..031a108d 100644 --- a/src/pyqasm/modules/qasm2.py +++ b/src/pyqasm/modules/qasm2.py @@ -135,9 +135,21 @@ def to_qasm3(self, as_str: bool = False) -> str | Qasm3Module: as_str (bool): Flag to indicate if the conversion should be to a string or to a Qasm3Module object. Default is False. + Raises: + ValidationError: If the program declares an `opaque` gate. OpenQASM 3 removed + `opaque` and has no equivalent, so there is no correct translation. + Returns: str | Qasm3Module: The module in openqasm3 format. """ + if self._opaque_gates: + # an opaque gate is carried as a body-less gate definition, which OpenQASM 3 + # reads as the identity -- refuse rather than silently change the program + raise ValidationError( + "Cannot convert to OpenQASM 3: the program declares the opaque gate(s) " + f"{', '.join(sorted(self._opaque_gates))}. OpenQASM 3 removed 'opaque' and " + "has no equivalent for a gate with no decomposition." + ) qasm_program = deepcopy(self._original_program) # replace the include with stdgates.inc for stmt in qasm_program.statements: diff --git a/src/pyqasm/preprocess.py b/src/pyqasm/preprocess.py index b903b059..c0eb60e2 100644 --- a/src/pyqasm/preprocess.py +++ b/src/pyqasm/preprocess.py @@ -46,9 +46,43 @@ class IncludeContext: r'^\s*include\s+"(?:stdgates\.inc|qelib1\.inc)";\s*', re.MULTILINE ), "include": re.compile(r'^\s*include\s+"([^"]+)";\s*', re.MULTILINE), + # OPENQASM 2 only. Captures indent, name, the parenthesised parameter list if any, + # and the qubit list: "opaque Rz(lam) q;", "opaque ZZ() q1,q2;", "opaque zz q1,q2;" + "opaque": re.compile( + r"^([ \t]*)opaque\s+([A-Za-z_][A-Za-z0-9_]*)\s*(\([^)]*\))?\s*([^;{}]*?)\s*;", + re.MULTILINE, + ), + "openqasm2": re.compile(r"^\s*OPENQASM\s+2(?:\.\d+)?;", re.MULTILINE), } +def rewrite_opaque_declarations(program: str) -> tuple[str, set[str]]: + """Rewrite OpenQASM 2 ``opaque`` declarations into gate definitions with empty bodies. + + The ``openqasm3`` parser pyqasm routes qasm2 through has no production for ``opaque``, + so this runs before parsing. The empty body carries the gate's name and arity only, + which is all an opaque gate has (issue #370). Gated on the qasm2 header, since + ``opaque`` is not OpenQASM 3 syntax. + + Args: + program (str): The OpenQASM program text. + + Returns: + tuple[str, set[str]]: The rewritten program, and the names declared opaque. + """ + if not PATTERNS["openqasm2"].search(program): + return program, set() + + names: set[str] = set() + + def _replace(match: re.Match) -> str: + indent, name, params, qubits = match.groups() + names.add(name) + return f"{indent}gate {name}{params or ''} {qubits} {{ }}" + + return PATTERNS["opaque"].sub(_replace, program), names + + def process_include_statements(filename: str, include_dir: str | None = None) -> str: """ Recursively processes include statements in an OpenQASM file, replacing them with the diff --git a/src/pyqasm/visitor.py b/src/pyqasm/visitor.py index d837f149..bd45db75 100644 --- a/src/pyqasm/visitor.py +++ b/src/pyqasm/visitor.py @@ -128,6 +128,9 @@ def __init__( # pylint: disable=too-many-arguments # cycle of any length between gate definitions (issue #369) self._gate_expansion_chain: list[str] = [] self._external_gates: list[str] = [] if external_gates is None else external_gates + # opaque gates come from the program itself, so they are read off the module + # rather than taken from unroll()'s kwargs (issue #370) + self._opaque_gates: set[str] = getattr(module, "_opaque_gates", set()) self._subroutine_defns: dict[ str, qasm3_ast.SubroutineDefinition | qasm3_ast.ExternDeclaration ] = {} @@ -1287,6 +1290,24 @@ def _visit_continue(self, statement: qasm3_ast.ContinueStatement) -> None: error_node=statement, ) + def _is_black_box_gate(self, gate_name: str) -> bool: + """Check whether a gate must be emitted as written instead of being unrolled. + + True for a gate the caller named in ``external_gates``, any gate inside a verbatim + box, and one the program declared ``opaque`` (issue #370). + + Args: + gate_name (str): The name of the gate being applied. + + Returns: + bool: True if the gate is emitted as written. + """ + return ( + self._in_verbatim_box + or gate_name in self._external_gates + or gate_name in self._opaque_gates + ) + def _visit_custom_gate_operation( self, operation: qasm3_ast.QuantumGate, @@ -1344,7 +1365,11 @@ def _visit_custom_gate_operation( # 'or', not '=': a nested gate must stay suppressed inside an enclosing external # gate rather than re-enable recording for the body its parent skips (issue #367) prev_recording = self._recording_ext_gate_depth - is_external = self._in_verbatim_box or gate_name in self._external_gates + # 'or', not '=': a nested gate must stay suppressed inside an enclosing external + # gate rather than re-enable recording for the body its parent skips (issue #367) + prev_recording = self._recording_ext_gate_depth + is_external = self._is_black_box_gate(gate_name) + self._recording_ext_gate_depth = prev_recording or is_external self._recording_ext_gate_depth = prev_recording or is_external result = [] @@ -1703,7 +1728,7 @@ def _visit_generic_gate_operation( # pylint: disable=too-many-branches, too-man for _ in range(power_value): if isinstance(operation, qasm3_ast.QuantumPhase): result.extend(self._visit_phase_operation(operation, inverse_value, ctrls)) - elif self._in_verbatim_box or operation.name.name in self._external_gates: + elif self._is_black_box_gate(operation.name.name): result.extend(self._visit_external_gate_operation(operation, inverse_value, ctrls)) elif operation.name.name in self._custom_gates: result.extend(self._visit_custom_gate_operation(operation, inverse_value, ctrls)) diff --git a/tests/qasm2/test_opaque.py b/tests/qasm2/test_opaque.py new file mode 100644 index 00000000..d59a2bca --- /dev/null +++ b/tests/qasm2/test_opaque.py @@ -0,0 +1,259 @@ +# Copyright 2025 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. + +""" +Module containing unit tests for OpenQASM 2 `opaque` declarations (issue #370). + +An opaque gate is a hardware primitive: it is declared with a name and an arity and has +no decomposition. pyqasm parses the declaration and emits a call to it as written. + +""" + +import pytest + +from pyqasm.elements import BasisSet +from pyqasm.entrypoint import dumps, load, loads +from pyqasm.exceptions import RebaseError, ValidationError +from tests.utils import check_unrolled_qasm + +# The six opaque primitives Quantinuum's hqslib1.inc opens with, plus the two gates it +# defines in terms of them. Written out here rather than vendored, so the test does not +# depend on pytket being installed. +HQSLIB1_LIKE_INC = """ +opaque Rz(lam) q; +opaque U1q(theta, phi) q; +opaque ZZ() q1,q2; +opaque RZZ(theta) q1,q2; +opaque Rxxyyzz(alpha, beta, gamma) q1,q2; +opaque Rxxyyzz_zphase(alpha, beta, gamma, z0, z1) q1,q2; + +gate U(a,b,c) q { U1q(a, b) q; Rz(c) q; } +gate CX c,t { ZZ c,t; } +""" + +OPAQUE_PROGRAM = """ +OPENQASM 2.0; +include "qelib1.inc"; +opaque ZZ() q1,q2; +opaque Rz(lam) q; +qreg q[3]; +""" + + +def test_opaque_declaration_parses(): + """The reported failure was at parse time: every program carrying an opaque + declaration raised `Failed to parse OpenQASM string` (issue #370).""" + qasm_str = """ + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + opaque custom_gate(a,b,c) p,q,r; + """ + module = loads(qasm_str) + module.validate() + assert module._opaque_gates == {"custom_gate"} + + +@pytest.mark.parametrize( + "declaration, name", + [ + ("opaque Rz(lam) q;", "Rz"), + ("opaque ZZ() q1,q2;", "ZZ"), + ("opaque zz q1,q2;", "zz"), + ("opaque Rxxyyzz(alpha, beta, gamma) q1,q2;", "Rxxyyzz"), + ], +) +def test_opaque_declaration_forms(declaration, name): + """All three qasm2 spellings parse: parameters, empty parentheses, and no + parentheses at all.""" + module = loads(f'OPENQASM 2.0;\ninclude "qelib1.inc";\n{declaration}\nqreg q[2];\n') + module.validate() + assert module._opaque_gates == {name} + + +def test_opaque_gate_is_emitted_as_written(): + """An opaque gate has no decomposition, so unrolling emits the call unchanged.""" + expected_qasm_str = """ + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[3]; + ZZ q[0], q[1]; + Rz(0.5) q[2]; + """ + module = loads(OPAQUE_PROGRAM + "ZZ q[0],q[1];\nRz(0.5) q[2];\n") + module.unroll() + check_unrolled_qasm(dumps(module), expected_qasm_str) + + +def test_opaque_gate_arity_is_validated(): + """The declared arity is the whole of what an opaque declaration carries, so a call + that does not match it must be rejected.""" + module = loads(OPAQUE_PROGRAM + "ZZ q[0];\n") + with pytest.raises(ValidationError, match="Qubit count mismatch for gate 'ZZ'"): + module.validate() + + +@pytest.mark.parametrize("external_gates", [None, [], ["h"], ["ZZ"]]) +def test_opaque_gate_is_never_flushed_by_unroll(external_gates): + """`unroll()` resets `external_gates` on every call, so opaque gates are tracked + separately: an opaque gate has no decomposition to fall back on, and must survive + an unroll that names other gates, or none.""" + module = loads(OPAQUE_PROGRAM + "ZZ q[0],q[1];\n") + module.unroll(external_gates=external_gates) + assert "ZZ q[0], q[1];" in dumps(module) + + +def test_opaque_gate_counts_as_one_towards_depth(): + """One emitted statement is one layer, the same contract an external gate has.""" + module = loads(OPAQUE_PROGRAM + "h q[0];\nZZ q[0],q[1];\n") + module.unroll() + assert module.depth() == 2 + + +def test_opaque_gate_inside_a_custom_gate_body(): + """A custom gate that calls an opaque primitive unrolls down to the primitive and + stops there, rather than failing on an undeclared operation.""" + expected_qasm_str = """ + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[3]; + ZZ q[0], q[1]; + Rz(0.5) q[0]; + """ + module = loads(OPAQUE_PROGRAM + "gate wrap a,b { ZZ a,b; Rz(0.5) a; }\nwrap q[0],q[1];\n") + module.unroll() + check_unrolled_qasm(dumps(module), expected_qasm_str) + + +@pytest.mark.parametrize( + "transform, expected", + [ + ("remove_idle_qubits", "ZZ q[0], q[1];"), + ("reverse_qubit_order", "ZZ q[2], q[0];"), + ], +) +def test_opaque_gate_survives_qubit_transformations(transform, expected): + """An opaque call is an ordinary gate statement, so the passes that renumber qubits + must rewrite its operands like any other.""" + module = loads(OPAQUE_PROGRAM + "ZZ q[0],q[2];\n") + module.unroll() + getattr(module, transform)() + assert expected in dumps(module) + + +def test_opaque_gate_survives_qubit_consolidation(): + """Consolidation rewrites the operands onto the internal register.""" + module = loads(OPAQUE_PROGRAM + "ZZ q[0],q[1];\n") + module.unroll(consolidate_qubits=True) + assert "ZZ __PYQASM_QUBITS__[0], __PYQASM_QUBITS__[1];" in dumps(module) + + +def test_opaque_is_not_qasm3_syntax(): + """`opaque` was removed in OpenQASM 3, so a qasm3 program carrying one must keep + failing to parse. The rewrite is gated on the OPENQASM 2 header.""" + qasm_str = """ + OPENQASM 3.0; + include "stdgates.inc"; + opaque foo q; + qubit[1] q; + """ + with pytest.raises(ValidationError, match="Failed to parse OpenQASM string"): + loads(qasm_str) + + +def test_quantinuum_style_program_loads_from_a_string(tmp_path): + """The driver for the issue: a bell state compiled for H-series hardware, whose + include file declares its primitives opaque. It could not be loaded at all. The + program is a string, so `include_dir` locates the include (issue #368) -- both + changes are needed to load a compiled program.""" + (tmp_path / "hqslib1.inc").write_text(HQSLIB1_LIKE_INC, encoding="utf-8") + program = """ + OPENQASM 2.0; + include "hqslib1.inc"; + + qreg q[2]; + creg c[2]; + rz(1.0*pi) q[0]; + rz(3.5*pi) q[1]; + U1q(0.5*pi,0.5*pi) q[0]; + U1q(2.5*pi,0.0*pi) q[1]; + rz(0.5*pi) q[0]; + RZZ(0.5*pi) q[0],q[1]; + measure q[0] -> c[0]; + U1q(3.5*pi,0.5*pi) q[1]; + measure q[1] -> c[1]; + """ + module = loads(program, include_dir=str(tmp_path)) + module.unroll() + + assert module.num_qubits == 2 + assert module.num_clbits == 2 + assert module.has_measurements() + unrolled = dumps(module) + # the three opaque primitives the program calls survive as written + assert "U1q(1.5707963267948966, 1.5707963267948966) q[0];" in unrolled + assert "RZZ(1.5707963267948966) q[0], q[1];" in unrolled + assert unrolled.count("measure") == 2 + + +def test_load_resolves_opaque_declarations_from_a_file(tmp_path): + """The rewrite runs after include inlining, so an opaque declaration inside an + included file is reached by load() too.""" + (tmp_path / "hqslib1.inc").write_text(HQSLIB1_LIKE_INC, encoding="utf-8") + path = tmp_path / "prog.qasm" + path.write_text( + 'OPENQASM 2.0;\ninclude "hqslib1.inc";\nqreg q[2];\nRZZ(0.5) q[0],q[1];\n', + encoding="utf-8", + ) + + module = load(str(path)) + module.unroll() + assert "RZZ(0.5) q[0], q[1];" in dumps(module) + + +@pytest.mark.parametrize("basis_set", [BasisSet.ROTATIONAL_CX, BasisSet.CLIFFORD_T]) +def test_rebase_reports_an_opaque_gate_by_name(basis_set): + """An opaque primitive has no decomposition, so it cannot be rebased onto a standard + basis set. It reaches the existing unsupported-gate path and is named there, rather + than crashing inside the decomposer.""" + module = loads(OPAQUE_PROGRAM + "ZZ q[0],q[1];\n") + module.unroll() + with pytest.raises(RebaseError, match="Gate 'ZZ' is not supported"): + module.rebase(basis_set) + + +@pytest.mark.parametrize("as_str", [True, False]) +def test_to_qasm3_refuses_a_program_with_opaque_gates(as_str): + """OpenQASM 3 removed `opaque` and has no equivalent. pyqasm carries an opaque gate + as a body-less gate definition, and OpenQASM 3 reads a body-less gate as the + identity, so converting would silently turn each hardware primitive into a no-op. + Refusing is loud; the alternative is not.""" + module = loads(OPAQUE_PROGRAM + "ZZ q[0],q[1];\n") + with pytest.raises(ValidationError, match="OpenQASM 3 removed 'opaque'"): + module.to_qasm3(as_str=as_str) + + +def test_to_qasm3_still_works_without_opaque_gates(): + """The guard must be scoped to programs that actually declare an opaque gate.""" + module = loads('OPENQASM 2.0;\ninclude "qelib1.inc";\nqreg q[2];\nh q[0];\n') + assert "OPENQASM 3.0" in module.to_qasm3(as_str=True) + + +def test_opaque_declaration_is_dropped_from_the_unrolled_output(): + """Documented limitation: like an external gate's definition, the declaration is not + re-emitted, so the unrolled output does not load back into pyqasm on its own. Pinned + so the day it changes is a deliberate one.""" + module = loads(OPAQUE_PROGRAM + "ZZ q[0],q[1];\n") + module.unroll() + assert "opaque" not in dumps(module) From 9d775cf35201318ff2cf9c63f13d58af29806ff3 Mon Sep 17 00:00:00 2001 From: TheGupta2012 Date: Tue, 18 Aug 2026 13:49:22 +0530 Subject: [PATCH 2/4] fix: match opaque declarations against code, not comments The rewrite ran over raw source, so 'opaque foo q;' inside a /* */ block comment was recorded in _opaque_gates. A real gate of that name would then be emitted as written instead of unrolled. Blank comments to spaces before matching, preserving length so the match spans still index the original text. Co-Authored-By: Claude Opus 5 (1M context) --- src/pyqasm/modules/base.py | 3 +-- src/pyqasm/modules/qasm2.py | 3 +-- src/pyqasm/preprocess.py | 50 +++++++++++++++++++++++++++++++++---- tests/qasm2/test_opaque.py | 30 ++++++++++++++++++++++ 4 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/pyqasm/modules/base.py b/src/pyqasm/modules/base.py index 61bd6016..a28b6184 100644 --- a/src/pyqasm/modules/base.py +++ b/src/pyqasm/modules/base.py @@ -200,8 +200,7 @@ def __init__(self, name: str, program: Program): self._validated_program = False self._unrolled_ast = Program(statements=[]) self._external_gates: list[str] = [] - # declared `opaque` in the source: a property of the program, not of one unroll() - # call, so unroll() never flushes these the way it flushes _external_gates + # declared `opaque` in the source, so unroll() never flushes these self._opaque_gates: set[str] = set() self._decompose_native_gates: Optional[bool] = None self._device_qubits: Optional[int] = None diff --git a/src/pyqasm/modules/qasm2.py b/src/pyqasm/modules/qasm2.py index 031a108d..26604ace 100644 --- a/src/pyqasm/modules/qasm2.py +++ b/src/pyqasm/modules/qasm2.py @@ -143,8 +143,7 @@ def to_qasm3(self, as_str: bool = False) -> str | Qasm3Module: str | Qasm3Module: The module in openqasm3 format. """ if self._opaque_gates: - # an opaque gate is carried as a body-less gate definition, which OpenQASM 3 - # reads as the identity -- refuse rather than silently change the program + # OpenQASM 3 removed `opaque` from the language raise ValidationError( "Cannot convert to OpenQASM 3: the program declares the opaque gate(s) " f"{', '.join(sorted(self._opaque_gates))}. OpenQASM 3 removed 'opaque' and " diff --git a/src/pyqasm/preprocess.py b/src/pyqasm/preprocess.py index c0eb60e2..bf2c7038 100644 --- a/src/pyqasm/preprocess.py +++ b/src/pyqasm/preprocess.py @@ -56,6 +56,40 @@ class IncludeContext: } +def _blank_comments(program: str) -> str: + """Overwrite `//` and `/* */` comments with spaces, keeping length and line breaks. + + Offsets in the result line up with the input, so a pattern can be matched against + code alone and the match spans still index the original text. + + Args: + program (str): The OpenQASM program text. + + Returns: + str: The text with comment characters replaced by spaces. + """ + out = list(program) + idx, end = 0, len(program) + in_line = in_block = False + while idx < end: + char, following = program[idx], program[idx + 1 : idx + 2] + if in_line: + in_line = char != "\n" + out[idx] = char if char == "\n" else " " + elif in_block: + in_block = not (char == "*" and following == "/") + out[idx] = char if char == "\n" else " " + if not in_block: + out[idx + 1] = " " + idx += 1 + elif char == "/" and following in ("/", "*"): + in_line, in_block = following == "/", following == "*" + out[idx] = out[idx + 1] = " " + idx += 1 + idx += 1 + return "".join(out) + + def rewrite_opaque_declarations(program: str) -> tuple[str, set[str]]: """Rewrite OpenQASM 2 ``opaque`` declarations into gate definitions with empty bodies. @@ -70,17 +104,23 @@ def rewrite_opaque_declarations(program: str) -> tuple[str, set[str]]: Returns: tuple[str, set[str]]: The rewritten program, and the names declared opaque. """ - if not PATTERNS["openqasm2"].search(program): + # match against code only, so a declaration commented out with `//` or `/* */` is + # neither rewritten nor recorded + code = _blank_comments(program) + if not PATTERNS["openqasm2"].search(code): return program, set() names: set[str] = set() - - def _replace(match: re.Match) -> str: + pieces, cursor = [], 0 + for match in PATTERNS["opaque"].finditer(code): indent, name, params, qubits = match.groups() names.add(name) - return f"{indent}gate {name}{params or ''} {qubits} {{ }}" + pieces.append(program[cursor : match.start()]) + pieces.append(f"{indent}gate {name}{params or ''} {qubits} {{ }}") + cursor = match.end() + pieces.append(program[cursor:]) - return PATTERNS["opaque"].sub(_replace, program), names + return "".join(pieces), names def process_include_statements(filename: str, include_dir: str | None = None) -> str: diff --git a/tests/qasm2/test_opaque.py b/tests/qasm2/test_opaque.py index d59a2bca..e61bec2e 100644 --- a/tests/qasm2/test_opaque.py +++ b/tests/qasm2/test_opaque.py @@ -82,6 +82,36 @@ def test_opaque_declaration_forms(declaration, name): assert module._opaque_gates == {name} +@pytest.mark.parametrize( + "commented", + [ + "// opaque hidden q;", + " //opaque hidden q;", + "/* opaque hidden q; */", + "/*\nopaque hidden q;\n*/", + ], +) +def test_commented_out_opaque_is_not_declared(commented): + """The rewrite runs on source text, so it must see code only. A declaration inside a + `//` or `/* */` comment is neither rewritten nor recorded -- recording it would make + a real gate of that name be emitted as written instead of unrolled.""" + module = loads(f'OPENQASM 2.0;\ninclude "qelib1.inc";\n{commented}\nqreg q[1];\nh q[0];\n') + module.unroll() + assert not module._opaque_gates + assert "h q[0];" in dumps(module) + + +def test_opaque_declaration_with_a_trailing_comment(): + """Blanking comments must not disturb the declaration they sit beside.""" + module = loads( + 'OPENQASM 2.0;\ninclude "qelib1.inc";\n' + "opaque ZZ() a,b; // the native two-qubit gate\nqreg q[2];\nZZ q[0],q[1];\n" + ) + module.unroll() + assert module._opaque_gates == {"ZZ"} + assert "ZZ q[0], q[1];" in dumps(module) + + def test_opaque_gate_is_emitted_as_written(): """An opaque gate has no decomposition, so unrolling emits the call unchanged.""" expected_qasm_str = """ From 90476c3e68f723b5c1dcb8985f42396f9d21665f Mon Sep 17 00:00:00 2001 From: TheGupta2012 Date: Wed, 19 Aug 2026 13:44:28 +0530 Subject: [PATCH 3/4] refactor: parse the comment-blanked text instead of splicing rewrites back The blanked copy was only used to locate matches, which meant carrying offsets and splicing each rewrite into the original. Its one consumer is openqasm3.parse on the next line, and the parser discards comments, so the blanked text can be returned directly and the splice loop dropped. Blanking rather than deleting still matters: the parser reports spans against this text, so removing a comment would shift every line and column after it. Co-Authored-By: Claude Opus 5 (1M context) --- src/pyqasm/preprocess.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/pyqasm/preprocess.py b/src/pyqasm/preprocess.py index bf2c7038..9eb736fe 100644 --- a/src/pyqasm/preprocess.py +++ b/src/pyqasm/preprocess.py @@ -57,10 +57,11 @@ class IncludeContext: def _blank_comments(program: str) -> str: - """Overwrite `//` and `/* */` comments with spaces, keeping length and line breaks. + """Overwrite `//` and `/* */` comments with spaces. - Offsets in the result line up with the input, so a pattern can be matched against - code alone and the match spans still index the original text. + Blanked rather than deleted so line and column numbers are unchanged: the parser + reports spans against this text, and deleting a comment would shift every position + after it. Args: program (str): The OpenQASM program text. @@ -98,29 +99,28 @@ def rewrite_opaque_declarations(program: str) -> tuple[str, set[str]]: which is all an opaque gate has (issue #370). Gated on the qasm2 header, since ``opaque`` is not OpenQASM 3 syntax. + Comments are blanked first, so a declaration commented out with ``/* */`` is neither + rewritten nor recorded. The result goes straight to the parser, which discards + comments anyway. + Args: program (str): The OpenQASM program text. Returns: tuple[str, set[str]]: The rewritten program, and the names declared opaque. """ - # match against code only, so a declaration commented out with `//` or `/* */` is - # neither rewritten nor recorded code = _blank_comments(program) if not PATTERNS["openqasm2"].search(code): return program, set() names: set[str] = set() - pieces, cursor = [], 0 - for match in PATTERNS["opaque"].finditer(code): + + def _replace(match: re.Match) -> str: indent, name, params, qubits = match.groups() names.add(name) - pieces.append(program[cursor : match.start()]) - pieces.append(f"{indent}gate {name}{params or ''} {qubits} {{ }}") - cursor = match.end() - pieces.append(program[cursor:]) + return f"{indent}gate {name}{params or ''} {qubits} {{ }}" - return "".join(pieces), names + return PATTERNS["opaque"].sub(_replace, code), names def process_include_statements(filename: str, include_dir: str | None = None) -> str: From b532e5c8962b1d259d86876290cb0508defdbb12 Mon Sep 17 00:00:00 2001 From: TheGupta2012 Date: Wed, 19 Aug 2026 13:49:54 +0530 Subject: [PATCH 4/4] refactor: blank comments with a regex, and leave string literals alone Replaces the hand-written character scanner. Differential-tested against it over 20k inputs, including hqslib1.inc and adversarial cases -- unterminated block comments, '//' beating '/*', nested-looking markers -- with no mismatches. Also fixes a case the scanner shared and that mattered once the blanked text started going to the parser: a '//' or '/*' inside an include path was blanked, truncating the statement. Matching string literals first consumes them before either comment alternative can. Co-Authored-By: Claude Opus 5 (1M context) --- src/pyqasm/preprocess.py | 31 +++++++++++-------------------- tests/qasm2/test_opaque.py | 9 +++++++++ 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/src/pyqasm/preprocess.py b/src/pyqasm/preprocess.py index 9eb736fe..62afcb4a 100644 --- a/src/pyqasm/preprocess.py +++ b/src/pyqasm/preprocess.py @@ -53,6 +53,10 @@ class IncludeContext: re.MULTILINE, ), "openqasm2": re.compile(r"^\s*OPENQASM\s+2(?:\.\d+)?;", re.MULTILINE), + # a string literal, a `//` line comment, or a `/* */` block comment (unterminated + # one included). The string alternative comes first so a `//` inside an include path + # is consumed as a string and never mistaken for a comment. + "string_or_comment": re.compile(r'"[^"\n]*"|//[^\n]*|/\*.*?(?:\*/|\Z)', re.DOTALL), } @@ -69,26 +73,13 @@ def _blank_comments(program: str) -> str: Returns: str: The text with comment characters replaced by spaces. """ - out = list(program) - idx, end = 0, len(program) - in_line = in_block = False - while idx < end: - char, following = program[idx], program[idx + 1 : idx + 2] - if in_line: - in_line = char != "\n" - out[idx] = char if char == "\n" else " " - elif in_block: - in_block = not (char == "*" and following == "/") - out[idx] = char if char == "\n" else " " - if not in_block: - out[idx + 1] = " " - idx += 1 - elif char == "/" and following in ("/", "*"): - in_line, in_block = following == "/", following == "*" - out[idx] = out[idx + 1] = " " - idx += 1 - idx += 1 - return "".join(out) + + def blank(match: re.Match) -> str: + text = match.group() + # keep string literals as they are; only comments are blanked + return text if text.startswith('"') else re.sub(r"[^\n]", " ", text) + + return PATTERNS["string_or_comment"].sub(blank, program) def rewrite_opaque_declarations(program: str) -> tuple[str, set[str]]: diff --git a/tests/qasm2/test_opaque.py b/tests/qasm2/test_opaque.py index e61bec2e..55e15cdf 100644 --- a/tests/qasm2/test_opaque.py +++ b/tests/qasm2/test_opaque.py @@ -101,6 +101,15 @@ def test_commented_out_opaque_is_not_declared(commented): assert "h q[0];" in dumps(module) +@pytest.mark.parametrize("path", ["dir//lib.inc", "dir/*x*/lib.inc"]) +def test_comment_markers_inside_a_string_are_not_blanked(path): + """Blanking runs over source text, so a `//` or `/*` inside an include path must be + recognised as a string and left alone -- blanking it would truncate the statement.""" + module = loads(f'OPENQASM 2.0;\ninclude "{path}";\nqreg q[1];\nh q[0];\n') + module.unroll() + assert f'include "{path}";' in dumps(module) + + def test_opaque_declaration_with_a_trailing_comment(): """Blanking comments must not disturb the declaration they sit beside.""" module = loads(