diff options
| -rw-r--r-- | edify/builder/mixins/terminals.py | 9 | ||||
| -rw-r--r-- | edify/errors/quantifier.py | 35 | ||||
| -rw-r--r-- | tests/errors/dangling_quantifier.test.py | 52 |
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 |
