aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author夏音 / natsuoto.exe <[email protected]>2026-07-01 17:31:04 +0530
committerGitHub <[email protected]>2026-07-01 17:31:04 +0530
commitad4eb75ddcadf263391bba8eed837cce3b860cf9 (patch)
treeb3ec15be9350eec74079c136d68dbd6c0239e32b
parent2d9e7395dc62ca7ff9c0bf508ca4fb5e823b538e (diff)
parenta2195e709d7c4f1fc3c75740446397f1b62c37a0 (diff)
downloadedify-ad4eb75ddcadf263391bba8eed837cce3b860cf9.tar.xz
edify-ad4eb75ddcadf263391bba8eed837cce3b860cf9.zip
fix!: quantifier validation — dangling and stacked quantifiers raise; Hypothesis property gate (#269)
Tightens quantifier semantics so the builder can no longer silently drop a quantifier from the emitted regex, and locks the invariant in with a Hypothesis property test. ## Dangling quantifier now raises `RegexBuilder().exactly(3).to_regex_string()` used to emit `^$` — the quantifier had no operand, so it was silently discarded. It now raises `DanglingQuantifierError` at emit time. ```python RegexBuilder().exactly(3).to_regex_string() # edify.errors.quantifier.DanglingQuantifierError: # Dangling quantifier with no operand. Append an element (e.g. .digit()) before compiling. ``` ## Stacked quantifiers now raise `RegexBuilder().one_or_more().exactly(3).digit()` used to emit `\d{3}` — the second quantifier overwrote the first, silently dropping `one_or_more`. It now raises `StackedQuantifierError` at chain-call time. ```python RegexBuilder().one_or_more().exactly(3).digit() # edify.errors.quantifier.StackedQuantifierError: # Cannot stack a quantifier on top of another pending quantifier. # Add an operand between the two quantifiers or drop one. ``` ## Property assertion Hypothesis-driven test in `tests/builder/properties.test.py` — for any list of `(quantifier method, element method)` pairs, the emitted regex is exactly the concatenation of `<element><suffix>` fragments in order. `hypothesis>=6.100` added to the `dev` dependency group. ## Breaking Any chain that previously silently emitted a wrong-but-parseable regex now raises. Correct chains are unaffected. Closes #109 Closes #110 Closes #111
-rw-r--r--edify/builder/mixins/quantifiers.py10
-rw-r--r--edify/builder/mixins/terminals.py9
-rw-r--r--edify/errors/quantifier.py35
-rw-r--r--pyproject.toml1
-rw-r--r--tests/builder/properties.test.py73
-rw-r--r--tests/errors/quantifier.test.py88
-rw-r--r--uv.lock23
7 files changed, 238 insertions, 1 deletions
diff --git a/edify/builder/mixins/quantifiers.py b/edify/builder/mixins/quantifiers.py
index f4572ec..e7e0cda 100644
--- a/edify/builder/mixins/quantifiers.py
+++ b/edify/builder/mixins/quantifiers.py
@@ -30,6 +30,7 @@ from edify.errors.input import (
MustBeLessThanError,
MustBePositiveIntegerError,
)
+from edify.errors.quantifier import StackedQuantifierError
class QuantifiersMixin(BuilderProtocol):
@@ -86,7 +87,14 @@ class QuantifiersMixin(BuilderProtocol):
def _set_pending(builder: BuilderProtocol, pending_quantifier: PendingQuantifier):
- """Replace the top frame with one carrying the given pending quantifier."""
+ """Replace the top frame with one carrying the given pending quantifier.
+
+ Raises :class:`StackedQuantifierError` when the top frame already carries
+ an unconsumed pending quantifier — stacking would silently drop the outer
+ one at emit time.
+ """
+ if builder._state.top_frame.quantifier is not None:
+ raise StackedQuantifierError()
new_top_frame = builder._state.top_frame.with_quantifier(pending_quantifier)
new_state = builder._state.with_top_frame_replaced(new_top_frame)
return builder._with_state(new_state)
diff --git a/edify/builder/mixins/terminals.py b/edify/builder/mixins/terminals.py
index 85b061a..b648b77 100644
--- a/edify/builder/mixins/terminals.py
+++ b/edify/builder/mixins/terminals.py
@@ -15,6 +15,7 @@ from edify.builder.types.protocol import BuilderProtocol
from edify.compile.dispatch import render_element
from edify.elements.types.root import RootElement
from edify.errors.internal import FailedToCompileRegexError
+from edify.errors.quantifier import DanglingQuantifierError
from edify.errors.structure import CannotCallSubexpressionError
_EMPTY_NON_CAPTURING_GROUP = "(?:)"
@@ -32,6 +33,7 @@ class TerminalsMixin(BuilderProtocol):
no surrounding ``/.../`` delimiters and no embedded flag suffix.
"""
_ensure_fully_specified(self)
+ _ensure_no_dangling_quantifier(self)
root_element = RootElement(children=self._state.top_frame.children)
rendered_pattern = render_element(root_element)
unescaped_pattern = rendered_pattern.replace(_ESCAPED_SPACE, _RAW_SPACE)
@@ -57,6 +59,13 @@ def _ensure_fully_specified(builder: BuilderProtocol) -> None:
raise CannotCallSubexpressionError(top_frame_type_name)
+def _ensure_no_dangling_quantifier(builder: BuilderProtocol) -> None:
+ """Raise :class:`DanglingQuantifierError` when any frame carries an unconsumed quantifier."""
+ for frame in builder._state.stack:
+ if frame.quantifier is not None:
+ raise DanglingQuantifierError()
+
+
def _build_flag_bitmask(flags: Flags) -> int:
"""Combine the True-valued fields of ``flags`` into a single :mod:`re` bitmask."""
bitmask = 0
diff --git a/edify/errors/quantifier.py b/edify/errors/quantifier.py
new file mode 100644
index 0000000..23e435e
--- /dev/null
+++ b/edify/errors/quantifier.py
@@ -0,0 +1,35 @@
+"""Exception classes raised for quantifier misuse in a builder chain.
+
+* :class:`DanglingQuantifierError` — a terminal was called with a pending
+ quantifier that never received an operand. Emitting silently would
+ drop the quantifier from the output.
+* :class:`StackedQuantifierError` — a quantifier chain method was called
+ while another quantifier was already pending. Emitting silently would
+ drop the outer quantifier.
+"""
+
+from __future__ import annotations
+
+from edify.errors.syntax import EdifySyntaxError
+
+
+class DanglingQuantifierError(EdifySyntaxError):
+ """Raised when a terminal is called while a quantifier is still pending."""
+
+ def __init__(self) -> None:
+ message = (
+ "Dangling quantifier with no operand. "
+ "Append an element (e.g. .digit()) before compiling."
+ )
+ super().__init__(message)
+
+
+class StackedQuantifierError(EdifySyntaxError):
+ """Raised when a quantifier chain method is called with another quantifier already pending."""
+
+ def __init__(self) -> None:
+ message = (
+ "Cannot stack a quantifier on top of another pending quantifier. "
+ "Add an operand between the two quantifiers or drop one."
+ )
+ super().__init__(message)
diff --git a/pyproject.toml b/pyproject.toml
index 6fb6c03..df02ef5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,6 +40,7 @@ Changelog = "https://edify.readthedocs.io/en/latest/changelog.html"
[dependency-groups]
dev = [
+ "hypothesis>=6.100",
"pytest>=8.0",
"pytest-cov>=5.0",
"ruff>=0.7",
diff --git a/tests/builder/properties.test.py b/tests/builder/properties.test.py
new file mode 100644
index 0000000..24f5d01
--- /dev/null
+++ b/tests/builder/properties.test.py
@@ -0,0 +1,73 @@
+"""Property assertion — no chain ever silently drops a quantifier.
+
+For any list of ``(quantifier method, args)`` calls, each immediately
+followed by a leaf-element call, the emitted regex is the concatenation
+of ``<element><suffix>`` fragments in the same order — no quantifier is
+lost, none appears twice.
+"""
+
+from hypothesis import given
+from hypothesis import strategies as st
+
+from edify import RegexBuilder
+
+_QUANTIFIER_STRATEGIES: list[st.SearchStrategy[tuple[str, tuple[int, ...], str]]] = [
+ st.just(("optional", (), "?")),
+ st.just(("zero_or_more", (), "*")),
+ st.just(("zero_or_more_lazy", (), "*?")),
+ st.just(("one_or_more", (), "+")),
+ st.just(("one_or_more_lazy", (), "+?")),
+ st.integers(min_value=1, max_value=8).map(lambda n: ("exactly", (n,), f"{{{n}}}")),
+ st.integers(min_value=1, max_value=8).map(lambda n: ("at_least", (n,), f"{{{n},}}")),
+ st.integers(min_value=1, max_value=8).map(lambda n: ("at_most", (n,), f"{{0,{n}}}")),
+ st.tuples(
+ st.integers(min_value=0, max_value=6),
+ st.integers(min_value=1, max_value=8),
+ )
+ .filter(lambda pair: pair[0] < pair[1])
+ .map(lambda pair: ("between", pair, f"{{{pair[0]},{pair[1]}}}")),
+ st.tuples(
+ st.integers(min_value=0, max_value=6),
+ st.integers(min_value=1, max_value=8),
+ )
+ .filter(lambda pair: pair[0] < pair[1])
+ .map(lambda pair: ("between_lazy", pair, f"{{{pair[0]},{pair[1]}}}?")),
+]
+
+_ELEMENT_STRATEGIES = [
+ st.just(("digit", (), "\\d")),
+ st.just(("word", (), "\\w")),
+ st.just(("whitespace_char", (), "\\s")),
+ st.just(("letter", (), "[a-zA-Z]")),
+ st.just(("uppercase", (), "[A-Z]")),
+ st.just(("lowercase", (), "[a-z]")),
+ st.just(("alphanumeric", (), "[a-zA-Z0-9]")),
+]
+
+_quantifier_element_pair = st.tuples(
+ st.one_of(*_QUANTIFIER_STRATEGIES),
+ st.one_of(*_ELEMENT_STRATEGIES),
+)
+
+
+@given(st.lists(_quantifier_element_pair, min_size=1, max_size=8))
+def test_every_quantifier_chain_call_produces_exactly_one_output_quantifier(pairs):
+ builder = RegexBuilder()
+ expected_fragments: list[str] = []
+ for quantifier_call, element_call in pairs:
+ quantifier_name, quantifier_args, quantifier_suffix = quantifier_call
+ element_name, element_args, element_regex = element_call
+ builder = getattr(builder, quantifier_name)(*quantifier_args)
+ builder = getattr(builder, element_name)(*element_args)
+ expected_fragments.append(f"{element_regex}{quantifier_suffix}")
+ assert builder.to_regex_string() == "".join(expected_fragments)
+
+
+@given(st.lists(st.one_of(*_ELEMENT_STRATEGIES), min_size=1, max_size=8))
+def test_bare_element_chain_emits_the_concatenation_of_element_fragments(elements):
+ builder = RegexBuilder()
+ expected_fragments: list[str] = []
+ for element_name, element_args, element_regex in elements:
+ builder = getattr(builder, element_name)(*element_args)
+ expected_fragments.append(element_regex)
+ assert builder.to_regex_string() == "".join(expected_fragments)
diff --git a/tests/errors/quantifier.test.py b/tests/errors/quantifier.test.py
new file mode 100644
index 0000000..27b5ddf
--- /dev/null
+++ b/tests/errors/quantifier.test.py
@@ -0,0 +1,88 @@
+"""Tests for the :mod:`edify.errors.quantifier` exception classes."""
+
+import pytest
+
+from edify import Pattern, RegexBuilder
+from edify.errors.quantifier import DanglingQuantifierError, StackedQuantifierError
+
+
+def test_to_regex_string_raises_when_a_bare_quantifier_has_no_operand():
+ with pytest.raises(DanglingQuantifierError):
+ RegexBuilder().exactly(3).to_regex_string()
+
+
+def test_to_regex_string_raises_for_optional_with_no_operand():
+ with pytest.raises(DanglingQuantifierError):
+ RegexBuilder().optional().to_regex_string()
+
+
+def test_to_regex_string_raises_for_zero_or_more_with_no_operand():
+ with pytest.raises(DanglingQuantifierError):
+ RegexBuilder().zero_or_more().to_regex_string()
+
+
+def test_to_regex_string_raises_for_one_or_more_with_no_operand():
+ with pytest.raises(DanglingQuantifierError):
+ RegexBuilder().one_or_more().to_regex_string()
+
+
+def test_to_regex_string_raises_for_between_with_no_operand():
+ with pytest.raises(DanglingQuantifierError):
+ RegexBuilder().between(2, 5).to_regex_string()
+
+
+def test_pattern_to_regex_string_raises_when_a_bare_quantifier_has_no_operand():
+ with pytest.raises(DanglingQuantifierError):
+ Pattern().exactly(3).to_regex_string()
+
+
+def test_to_regex_raises_when_a_bare_quantifier_has_no_operand():
+ with pytest.raises(DanglingQuantifierError):
+ RegexBuilder().at_least(2).to_regex()
+
+
+def test_dangling_message_hints_at_appending_an_operand():
+ with pytest.raises(DanglingQuantifierError, match="Append an element"):
+ RegexBuilder().exactly(3).to_regex_string()
+
+
+def test_dangling_quantifier_error_message_contains_expected_text():
+ error = DanglingQuantifierError()
+ assert "Dangling quantifier" in str(error)
+ assert "no operand" in str(error)
+
+
+def test_stacking_one_or_more_over_exactly_raises():
+ with pytest.raises(StackedQuantifierError):
+ RegexBuilder().one_or_more().exactly(3).digit()
+
+
+def test_stacking_optional_over_at_least_raises():
+ with pytest.raises(StackedQuantifierError):
+ RegexBuilder().optional().at_least(2).digit()
+
+
+def test_stacking_between_over_zero_or_more_raises():
+ with pytest.raises(StackedQuantifierError):
+ RegexBuilder().zero_or_more().between(1, 3).digit()
+
+
+def test_stacking_lazy_variants_also_raises():
+ with pytest.raises(StackedQuantifierError):
+ RegexBuilder().one_or_more_lazy().exactly(2).digit()
+
+
+def test_stacking_on_pattern_raises():
+ with pytest.raises(StackedQuantifierError):
+ Pattern().one_or_more().exactly(3).digit()
+
+
+def test_a_valid_quantifier_element_quantifier_element_chain_works():
+ expr = RegexBuilder().one_or_more().digit().exactly(3).word()
+ assert expr.to_regex_string() == "\\d+\\w{3}"
+
+
+def test_stacked_quantifier_error_message_contains_expected_text():
+ error = StackedQuantifierError()
+ assert "stack" in str(error)
+ assert "pending" in str(error)
diff --git a/uv.lock b/uv.lock
index 43c30fd..9e67009 100644
--- a/uv.lock
+++ b/uv.lock
@@ -235,6 +235,7 @@ source = { editable = "." }
[package.dev-dependencies]
dev = [
+ { name = "hypothesis" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
@@ -249,6 +250,7 @@ docs = [
[package.metadata.requires-dev]
dev = [
+ { name = "hypothesis", specifier = ">=6.100" },
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-cov", specifier = ">=5.0" },
{ name = "ruff", specifier = ">=0.7" },
@@ -259,6 +261,18 @@ docs = [
]
[[package]]
+name = "hypothesis"
+version = "6.155.7"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sortedcontainers" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f2/55/983b6bc1b6b343a5ff6020388f9d0680ab477be59a731517e6c4a0387100/hypothesis-6.155.7.tar.gz", hash = "sha256:d8d6091753d0669db3c90c5e5b346cb37c72f3dd9378c8413acb1fd5da63f7ea", size = 478291, upload-time = "2026-06-21T05:54:31.573Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/01/f8/c151e196d4f397ed9436a071e52666c70a2f021138dea828b0a461e245db/hypothesis-6.155.7-py3-none-any.whl", hash = "sha256:9f634bdb1f9e9b8ab6ba09431cf2deedb750c96978125a6fb3c5a0f6c6db4131", size = 544762, upload-time = "2026-06-21T05:54:29.506Z" },
+]
+
+[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
@@ -487,6 +501,15 @@ wheels = [
]
[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
+]
+
+[[package]]
name = "sphinx"
version = "9.0.4"
source = { registry = "https://pypi.org/simple" }