Skip to content
Open
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 @@ -24,6 +24,7 @@ Types of changes:

### Fixed
- Fixed `pyqasm validate` wrapping its diagnostics at the console width, which split a file path longer than the width across lines mid-token and left it neither copyable nor clickable. The error console now uses `soft_wrap`, keeping one diagnostic per line.
- Fixed gate broadcasting over multiple registers unrolling to the wrong circuit. `cx q, r` on two `qubit[4]` registers emitted the linear chunking `cx q[0],q[1]; cx q[2],q[3]; ...`, and `cx q, r[0]` raised. Operands now zip element-wise per the OpenQASM 3 spec — `cx q[i], r[i]` — repeating single-qubit operands. Mismatched register lengths raise a `ValidationError` naming both operands, and an ambiguous shape such as `cx q, r, s` (three registers, arity 2) is rejected rather than silently chunked. ([#384](https://github.com/qBraid/pyqasm/issues/384))
- Fixed an indirect cycle between gate definitions exhausting the Python stack: `gate a q { b q; }` with `gate b q { a q; }` raised a bare `RecursionError` naming nothing, while the direct case was already reported cleanly. The guard compared the body's gate name against one name, so it saw only a cycle of length one. It now tests membership of the whole expansion chain, and names the path: `Recursive definitions not allowed for gate 'a' (a -> b -> a)`. A gate reached twice down separate paths is a diamond, not a cycle, and still expands. ([#369](https://github.com/qBraid/pyqasm/issues/369))
- Fixed a nested external custom gate counting the depth of the decomposition it skipped, the shape the [#352](https://github.com/qBraid/pyqasm/issues/352) fix did not reach: `unroll(external_gates=["outer"])` on a gate whose body calls another custom gate emitted one statement but reported `depth() == 13`. The suppression flag was assigned and cleared without save-restore, so the inner gate clobbered the outer gate's state in both directions. It is now saved and restored, and the depth is recorded once, from the outermost external gate. ([#367](https://github.com/qBraid/pyqasm/issues/367))

Expand Down
21 changes: 21 additions & 0 deletions src/pyqasm/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
UnaryExpression,
)

from pyqasm.elements import QubitRef
from pyqasm.exceptions import QasmParsingError, ValidationError, raise_qasm3_error

if TYPE_CHECKING:
Expand Down Expand Up @@ -288,6 +289,26 @@ def extract_qubit_key(qubit: IndexedIdentifier | Identifier) -> tuple[str, int]:
assert isinstance(qubit, IndexedIdentifier)
return (qubit.name.name, qubit.indices[0][0].value) # type: ignore

@staticmethod
def extract_operand_name(group: list[QubitRef]) -> str:
"""Return the register (or physical qubit) name behind a resolved operand.

Every qubit in a group comes from one source-level operand and so shares a
name; the first suffices. Used to name operands in error messages.

Args:
group (list[QubitRef]): The resolved qubits of a single operand.

Returns:
str: The operand's name, or "<empty>" if the group holds no qubits.
"""
if not group:
return "<empty>"
first = group[0]
if isinstance(first, Identifier):
return first.name
return first.name.name

@staticmethod
def verify_gate_qubits(gate: QuantumGate, span: Optional[Span] = None):
"""
Expand Down
11 changes: 11 additions & 0 deletions src/pyqasm/elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@
from typing import Any, Optional

import numpy as np
from openqasm3.ast import Identifier, IndexedIdentifier

QubitRef = IndexedIdentifier | Identifier
"""A single resolved qubit: an indexed register element, or a physical qubit ("$n")."""

OperandGroups = list[list[QubitRef]]
"""Resolved qubits grouped by the source-level operand they came from.

``cx q, r[0]`` with ``qubit[2] q`` groups as ``[[q[0], q[1]], [r[0]]]``. Keeping the
boundary is what lets broadcasting zip register operands and repeat single-qubit ones.
"""

INTERNAL_QUBIT_REGISTER = "__PYQASM_QUBITS__"
"""Reserved register that qubits are consolidated onto, and that physical qubits ("$n")
Expand Down
26 changes: 25 additions & 1 deletion src/pyqasm/transformer.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
UnaryOperator,
)

from pyqasm.elements import INTERNAL_QUBIT_REGISTER, Variable
from pyqasm.elements import INTERNAL_QUBIT_REGISTER, OperandGroups, Variable
from pyqasm.exceptions import raise_qasm3_error
from pyqasm.expressions import Qasm3ExprEvaluator
from pyqasm.maps.expressions import VARIABLE_TYPE_MAP
Expand Down Expand Up @@ -164,6 +164,30 @@ def get_qubits_from_range_definition(
)
return list(range(start_qid, end_qid, step))

@staticmethod
def drop_leading_qubits(groups: OperandGroups, count: int) -> OperandGroups:
"""Drop the first ``count`` resolved qubits, keeping operand boundaries.

A boundary landing inside a register splits that group, so the surviving
tail stays a usable operand: ``ctrl @ x q2`` consumes ``q2[0]`` as the
control and leaves ``[[q2[1]]]`` as the target.

Args:
groups (OperandGroups): The resolved qubits, grouped by operand.
count (int): How many qubits to drop from the front.

Returns:
OperandGroups: The remaining groups, in order.
"""
remaining: OperandGroups = []
for group in groups:
if count >= len(group):
count -= len(group)
else:
remaining.append(group[count:])
count = 0
return remaining

@staticmethod
def transform_gate_qubits(
gate_op: QuantumGate | QuantumPhase,
Expand Down
Loading
Loading