aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authornatsuoto <[email protected]>2026-07-01 17:13:47 +0530
committernatsuoto <[email protected]>2026-07-01 17:13:47 +0530
commit080d5ac837ea8039b857d41dc9b258486748e5fe (patch)
tree52f2021db055cbb04cd8039217a8361cce9b68ce
parent2d9e7395dc62ca7ff9c0bf508ca4fb5e823b538e (diff)
downloadedify-080d5ac837ea8039b857d41dc9b258486748e5fe.tar.xz
edify-080d5ac837ea8039b857d41dc9b258486748e5fe.zip
fix!: dangling quantifier raises DanglingQuantifierError at emit
-rw-r--r--edify/builder/mixins/terminals.py9
-rw-r--r--edify/errors/quantifier.py35
-rw-r--r--tests/errors/dangling_quantifier.test.py52
3 files changed, 96 insertions, 0 deletions
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..643a0e9
--- /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) \ No newline at end of file
diff --git a/tests/errors/dangling_quantifier.test.py b/tests/errors/dangling_quantifier.test.py
new file mode 100644
index 0000000..7823996
--- /dev/null
+++ b/tests/errors/dangling_quantifier.test.py
@@ -0,0 +1,52 @@
+"""Tests for :class:`DanglingQuantifierError` raised at emit time."""
+
+import pytest
+
+from edify import Pattern, RegexBuilder
+from edify.errors.quantifier import DanglingQuantifierError
+
+
+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_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) \ No newline at end of file