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
2 changes: 2 additions & 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
- Negative indices are now honored across arrays, `bit[n]`, `qubit[n]`, and `let` aliases, including ranges: `myArray[-1]`, `a[-1] = 10`, `h q[-1]`, `bit c = b[-1]`, `let last_three = two[-4:-1]`. An index still outside `[-size, size)` after normalization raises `ValidationError` and names the index as written. ([#391](https://github.com/qBraid/pyqasm/issues/391))

### Improved / Modified

Expand All @@ -26,6 +27,7 @@ Types of changes:
- 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 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))
- Fixed `|`, `&`, `^`, `~`, `<<`, `>>` and indexing on `bit[n]` escaping a raw `TypeError`, since the value was stored as a Python `str`. A `bit[n]` now carries its width internally, so these operators evaluate and re-mask to `n` bits, `b[i]` and `b[a:c]` read, and mismatched widths raise a `ValidationError`. The `"1010"` literal form still round-trips through `dumps()`. ([#385](https://github.com/qBraid/pyqasm/issues/385))

### Dependencies

Expand Down
159 changes: 140 additions & 19 deletions src/pyqasm/analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,131 @@
from pyqasm.expressions import Qasm3ExprEvaluator


def bits_to_int(value: Any, width: int) -> int:
"""Convert a ``bit[n]`` value in any legacy form to a masked ``int``.

Accepts the historical representations (``str`` bitstring, ``numpy.ndarray`` of
0/1, plain ``int``/``bool``) and returns a Python ``int`` with only the low
``width`` bits set. Bit 0 of a ``bit[n]`` register is the most-significant bit
of the resulting integer.

Args:
value: The bit value to convert. Empty string yields ``0``.
width: The register width, in bits. Must be non-negative.

Returns:
int: The width-masked integer representation.
"""
if width <= 0:
return 0
mask = (1 << width) - 1
if isinstance(value, str):
if value == "":
return 0
return int(value, 2) & mask
if isinstance(value, np.ndarray):
flat = value.flatten()
if flat.size == 0:
return 0
return int("".join(str(int(b)) for b in flat), 2) & mask
return int(value) & mask


def int_to_bits(value: int, width: int) -> str:
"""Serialize an integer to a zero-padded, width-`n` bit string.

Args:
value: The integer value; only the low ``width`` bits are kept.
width: The register width, in bits. Must be non-negative.

Returns:
str: The zero-padded binary representation. Empty string when ``width == 0``.
"""
if width <= 0:
return ""
mask = (1 << width) - 1
return format(int(value) & mask, f"0{width}b")


def slice_positions(start: int, end: int, step: int) -> range:
"""Return the positions selected by an inclusive OpenQASM range.

OpenQASM ranges include both endpoints, so the stop bound is pushed one past
``end`` in the direction of travel.

Args:
start: The first position of the range.
end: The last position of the range, inclusive.
step: The stride; negative for a descending range. Must be non-zero.

Returns:
range: The selected positions, in traversal order.
"""
return range(start, end + (1 if step > 0 else -1), step)


class Qasm3Analyzer:
"""Class with utility functions for analyzing QASM3 elements"""

@staticmethod
def analyze_classical_indices(
def normalize_index( # pylint: disable=too-many-arguments
source_index: int,
size: int,
var_name: str,
index_node: Any,
dim_num: Optional[int] = None,
qubit: bool = False,
) -> int:
"""Normalize an index against a register or dimension of size ``size``.

Applies the OpenQASM 3 rule that a negative index counts from the end:
``-1`` is the last element, ``-size`` is the first. After normalization the
index must satisfy ``0 <= idx < size``; otherwise the caller-facing error
reports the *source* index as it appears in the program.

Args:
source_index: The index value as evaluated from source (may be negative).
size: The size of the register or dimension being indexed.
var_name: The register or variable name (used in error messages).
index_node: The AST node used for span attribution on error.
dim_num: Optional zero-based dimension number for multi-dim arrays; if
given, the error message mentions it.
qubit: ``True`` for a qubit register (used for message phrasing).

Returns:
int: The normalized non-negative index.

Raises:
ValidationError: If the index is out of the range
``[-size, size - 1]`` after normalization.
"""
idx = source_index + size if source_index < 0 else source_index
if 0 <= idx < size:
return idx
register_kind = "qubit" if qubit else "clbit"
span = getattr(index_node, "span", None)
if dim_num is not None:
message = (
f"Index {source_index} out of bounds for dimension {dim_num} "
f"of variable '{var_name}'. Expected index in range "
f"[-{size}, {size - 1}]"
)
else:
message = (
f"Index {source_index} out of range for register of size {size} in "
f"{register_kind}"
)
raise_qasm3_error(
message=message,
err_type=ValidationError,
error_node=index_node,
span=span,
)
# pragma: no cover - raise_qasm3_error never returns
raise ValidationError(message)

@staticmethod
def analyze_classical_indices( # pylint: disable=too-many-locals
indices: list[Any], var: Variable, expr_evaluator: Qasm3ExprEvaluator
) -> list:
"""Validate the indices for a classical variable.
Expand Down Expand Up @@ -88,16 +208,6 @@ def analyze_classical_indices(
span=indices[0].span,
)

def _validate_index(index, dimension, var_name, index_node, dim_num):
if index < 0 or index >= dimension:
raise_qasm3_error(
message=f"Index {index} out of bounds for dimension {dim_num} "
f"of variable '{var_name}'. Expected index in range [0, {dimension-1}]",
err_type=ValidationError,
error_node=index_node,
span=index_node.span,
)

def _validate_step(start_id, end_id, step, index_node):
if (step < 0 and start_id < end_id) or (step > 0 and start_id > end_id):
direction = "less than" if step < 0 else "greater than"
Expand All @@ -121,29 +231,40 @@ def _validate_step(start_id, end_id, step, index_node):

if isinstance(index, RangeDefinition):
assert var_dimensions is not None
dim_size = var_dimensions[i]

start_id = 0
if index.start is not None:
start_id = expr_evaluator.evaluate_expression(index.start, reqd_type=IntType)[0]
raw_start = expr_evaluator.evaluate_expression(index.start, reqd_type=IntType)[
0
]
start_id = Qasm3Analyzer.normalize_index(
raw_start, dim_size, var.name, index, dim_num=i
)
else:
start_id = 0

end_id = var_dimensions[i] - 1
if index.end is not None:
end_id = expr_evaluator.evaluate_expression(index.end, reqd_type=IntType)[0]
raw_end = expr_evaluator.evaluate_expression(index.end, reqd_type=IntType)[0]
end_id = Qasm3Analyzer.normalize_index(
raw_end, dim_size, var.name, index, dim_num=i
)
else:
end_id = dim_size - 1

step = 1
if index.step is not None:
step = expr_evaluator.evaluate_expression(index.step, reqd_type=IntType)[0]

_validate_index(start_id, var_dimensions[i], var.name, index, i)
_validate_index(end_id, var_dimensions[i], var.name, index, i)
_validate_step(start_id, end_id, step, index)

indices_list.append((start_id, end_id, step))

if isinstance(index, (Identifier, IntegerLiteral, Expression)):
index_value = expr_evaluator.evaluate_expression(index, reqd_type=IntType)[0]
raw_value = expr_evaluator.evaluate_expression(index, reqd_type=IntType)[0]
curr_dimension = var_dimensions[i] # type: ignore[index]
_validate_index(index_value, curr_dimension, var.name, index, i)
index_value = Qasm3Analyzer.normalize_index(
raw_value, curr_dimension, var.name, index, dim_num=i
)

indices_list.append((index_value, index_value, 1))

Expand Down
37 changes: 37 additions & 0 deletions src/pyqasm/elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,43 @@ def is_physical_qubit(qubit_name: str) -> bool:
return qubit_name.startswith(PHYSICAL_QUBIT_PREFIX) and qubit_name[1:].isdigit()


class BitValue(int):
"""Internal representation of a ``bit`` or ``bit[n]`` classical register value.

A ``BitValue`` is an ``int`` masked to ``width`` bits, plus the ``width`` itself.
The width lets bitwise operators enforce the OpenQASM 3 rule that both operands
of a binary bitwise op (``|``, ``&``, ``^``) have equal width. Bit 0 of the
register is the most-significant bit of the underlying int, matching the
``BitstringLiteral`` convention used by :func:`~pyqasm.dumps` and by
``format(v, f"0{width}b")``.

``BitValue`` is immutable (as ``int`` is); write paths that mutate a single bit
of a ``bit[n]`` register construct a fresh ``BitValue`` and rebind the variable.
"""

# ``int`` uses a variable-length storage layout, so ``__slots__`` is not
# permitted on subclasses; the ``width`` attribute lives on the instance
# ``__dict__``. Declared here so type checkers see it as a proper attribute.
width: int

def __new__(cls, value: int, width: int) -> "BitValue":
if width < 0:
raise ValueError(f"BitValue width must be non-negative, got {width}")
mask = (1 << width) - 1 if width > 0 else 0
obj = int.__new__(cls, int(value) & mask)
obj.width = width
return obj

def to_bitstring(self) -> str:
"""Return the zero-padded, width-`n` binary string for this register."""
if self.width == 0:
return ""
return format(int(self), f"0{self.width}b")

def __repr__(self) -> str: # pragma: no cover - diagnostic aid only
return f"BitValue({int(self)}, width={self.width})"


class InversionOp(Enum):
"""
Enum for specifying the inversion action of a gate.
Expand Down
63 changes: 46 additions & 17 deletions src/pyqasm/expressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@
UnaryExpression,
)

from pyqasm.analyzer import Qasm3Analyzer
from pyqasm.elements import Variable
from pyqasm.analyzer import Qasm3Analyzer, bits_to_int, slice_positions
from pyqasm.elements import BitValue, Variable
from pyqasm.exceptions import ValidationError, raise_qasm3_error
from pyqasm.maps.expressions import (
CONSTANTS_MAP,
Expand Down Expand Up @@ -169,7 +169,7 @@ def _check_var_initialized(cls, var_name, var_value, expression):
)

@classmethod
def _get_var_value(cls, var_name, indices, expression):
def _get_var_value(cls, var_name, indices, expression): # pylint: disable=too-many-locals
"""Retrieves the value of a variable.

Args:
Expand All @@ -180,18 +180,35 @@ def _get_var_value(cls, var_name, indices, expression):
var_value: The value of the variable.
"""

var_value = None
var = cls.visitor_obj._scope_manager.get_from_visible_scope(var_name)
if isinstance(expression, Identifier):
var_value = cls.visitor_obj._scope_manager.get_from_visible_scope(var_name).value
else:
validated_indices = Qasm3Analyzer.analyze_classical_indices(
indices, cls.visitor_obj._scope_manager.get_from_visible_scope(var_name), cls
)
var_value = Qasm3Analyzer.find_array_element(
cls.visitor_obj._scope_manager.get_from_visible_scope(var_name).value,
validated_indices,
)
return var_value
return var.value

validated_indices = Qasm3Analyzer.analyze_classical_indices(indices, var, cls)

# ``bit`` / ``bit[n]`` values are stored as a width-carrying ``BitValue``
# (an ``int``), not an ndarray. Extract the selected bits with shift and
# mask so ``b[i]`` yields a single-bit ``int`` and ``b[a:c]`` yields a
# ``BitValue`` of width ``c - a + 1`` (spec-inclusive range).
if isinstance(var.base_type, BitType):
start, end, step = validated_indices[0]
width = var.base_size
source_int = bits_to_int(var.value, width)
if start == end:
# Single bit: bit 0 is the most-significant bit of the int, per
# the ``format(v, f"0{n}b")`` convention that ``dumps()`` uses.
return (source_int >> (width - 1 - start)) & 1
# Ranged read — build the sub-bitstring by iterating in the step
# order (already validated non-empty by ``analyze_classical_indices``).
selected_positions = slice_positions(start, end, step)
slice_width = len(selected_positions)
result = 0
for pos in selected_positions:
bit = (source_int >> (width - 1 - pos)) & 1
result = (result << 1) | bit
return BitValue(result, slice_width)

return Qasm3Analyzer.find_array_element(var.value, validated_indices)

@classmethod
# pylint: disable-next=too-many-return-statements,too-many-branches,too-many-statements,too-many-locals,too-many-arguments
Expand Down Expand Up @@ -476,9 +493,21 @@ def _get_external_function_return_type(expression):
return (None, [])

statements.extend(rhs_statements)
return _check_and_return_value(
qasm3_expression_op_map(expression.op.name, lhs_value, rhs_value)
)
try:
op_result = qasm3_expression_op_map(expression.op.name, lhs_value, rhs_value)
except ValidationError as err:
# ``qasm3_expression_op_map`` has no access to the source span
# (e.g. it raises for a ``bit[n]`` width mismatch); attach it
# here so the caller sees a properly-located error rather than a
# bare message.
raise_qasm3_error(
str(err),
err_type=ValidationError,
error_node=expression,
span=expression.span,
raised_from=err,
)
return _check_and_return_value(op_result)

if isinstance(expression, FunctionCall):
# function will not return a reqd / const type
Expand Down
Loading
Loading