diff options
| author | 夏音 / natsuoto.exe <[email protected]> | 2026-07-01 16:21:15 +0530 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-07-01 16:21:15 +0530 |
| commit | e859ce24e5ffc95653023669f9edf4d3a0344a8e (patch) | |
| tree | ea2d0971292c1a70737f1f068ec27d308974b88f | |
| parent | 42509f3a1d0c93c5fd43b2e2bdc42201d24a643e (diff) | |
| parent | 1860f7396e997c785d2299c421852909a0ee2070 (diff) | |
| download | edify-e859ce24e5ffc95653023669f9edf4d3a0344a8e.tar.xz edify-e859ce24e5ffc95653023669f9edf4d3a0344a8e.zip | |
feat: varargs .one_of() and .any_of(*literals) chain-level shorthand (#266)
Adds a varargs shorthand for literal alternation on both fluent surfaces
(`RegexBuilder` and `Pattern`):
- **`.any_of(*literals: str)`** — dual-mode overload. Zero args keeps
the existing frame-opener behavior
(`.any_of().string("cat").string("dog").end()`). One or more string args
skips the frame dance and appends the alternation directly.
- **`.one_of(*literals: str)`** — new method, always the varargs form.
Requires at least one literal (raises `MustBeAtLeastOneLiteralError` on
zero).
Literals go through the same escape + `CharElement`/`StringElement`
branching that `.char()` / `.string()` use, so the compile-path fusion
into `[...]` for single-character sets still applies:
```python
RegexBuilder().any_of("+", "-").to_regex_string() # [\+\-]
RegexBuilder().one_of("cat", "dog", "fish").to_regex_string() # (?:cat|dog|fish)
```
The pre-existing top-level factory `any_of(*operands: Pattern)` from
#263 stays untouched — it takes `Pattern` operands, not literal strings,
and serves the compositional (humre-style) API rather than the
chain-level shorthand this PR delivers.
Closes #131
| -rw-r--r-- | edify/builder/mixins/groups.py | 99 | ||||
| -rw-r--r-- | edify/errors/input.py | 8 | ||||
| -rw-r--r-- | tests/builder/varargs.test.py | 72 | ||||
| -rw-r--r-- | tests/errors/errors.test.py | 6 |
4 files changed, 172 insertions, 13 deletions
diff --git a/edify/builder/mixins/groups.py b/edify/builder/mixins/groups.py index 2ee0f14..b0bf698 100644 --- a/edify/builder/mixins/groups.py +++ b/edify/builder/mixins/groups.py @@ -1,8 +1,13 @@ -"""The :class:`GroupsMixin` — chain methods that open non-capturing groups. +"""The :class:`GroupsMixin` — chain methods for non-capturing groups and alternation. -Both methods push a new frame onto the builder's stack; the frame closes -when the user calls ``.end()`` later. The accumulated children are wrapped -in the appropriate element class at close time. +* :meth:`GroupsMixin.any_of` — dual-mode. Called with no arguments it opens + an alternation frame that :meth:`.end` closes later; called with literal + string arguments it appends an :class:`AnyOfElement` built directly from + those literals (the varargs shorthand for the common case). +* :meth:`GroupsMixin.one_of` — always the varargs form; ``.one_of("a", "b")`` + is the canonical way to alternate between literal strings. +* :meth:`GroupsMixin.group` — opens a non-capturing-group frame that + :meth:`.end` closes later. """ from __future__ import annotations @@ -11,20 +16,88 @@ from typing import Self from edify.builder.types.frame import StackFrame from edify.builder.types.protocol import BuilderProtocol +from edify.compile.escape import escape_special +from edify.elements.types.base import BaseElement +from edify.elements.types.chars import CharElement, StringElement from edify.elements.types.groups import AnyOfElement, GroupElement +from edify.errors.input import ( + MustBeAStringError, + MustBeAtLeastOneLiteralError, + MustBeOneCharacterError, +) class GroupsMixin(BuilderProtocol): - """Provides the ``any_of`` and ``group`` frame-opening chain methods.""" + """Provides the ``any_of``/``one_of``/``group`` chain methods.""" - def any_of(self) -> Self: - """Return a new builder with an alternation frame opened.""" - new_frame = StackFrame(type_node=AnyOfElement()) - new_state = self._state.with_frame_pushed(new_frame) - return self._with_state(new_state) + def any_of(self, *literals: str) -> Self: + """Return a new builder with alternation appended. + + With no arguments this opens an alternation frame that :meth:`.end` + closes later. With one or more string arguments each literal is + wrapped as :class:`CharElement` or :class:`StringElement` and the + whole set is appended as one :class:`AnyOfElement`. + """ + if not literals: + return _open_frame(self, AnyOfElement()) + return _add_literal_alternation(self, literals) + + def one_of(self, *literals: str) -> Self: + """Return a new builder with a literal ``AnyOfElement`` appended. + + Requires at least one literal; unlike :meth:`any_of` this method + never opens a frame. + """ + _ensure_at_least_one_literal(literals) + return _add_literal_alternation(self, literals) def group(self) -> Self: """Return a new builder with a non-capturing-group frame opened.""" - new_frame = StackFrame(type_node=GroupElement()) - new_state = self._state.with_frame_pushed(new_frame) - return self._with_state(new_state) + return _open_frame(self, GroupElement()) + + +def _open_frame(builder: BuilderProtocol, type_node: BaseElement): + """Push a new frame anchored at ``type_node`` and return the updated builder.""" + new_frame = StackFrame(type_node=type_node) + new_state = builder._state.with_frame_pushed(new_frame) + return builder._with_state(new_state) + + +def _add_literal_alternation(builder: BuilderProtocol, literals: tuple[str, ...]): + """Append a single :class:`AnyOfElement` built from ``literals`` to the top frame.""" + children = tuple(_literal_to_element(literal) for literal in literals) + element = AnyOfElement(children=children) + new_state = builder._state.with_element_added_to_top(element) + return builder._with_state(new_state) + + +def _literal_to_element(literal: str) -> CharElement | StringElement: + """Validate and escape ``literal``, returning the char- or string-shaped element.""" + _ensure_is_string("Literal", literal) + _ensure_non_empty("Literal", literal) + escaped = escape_special(literal) + if len(literal) == 1: + return CharElement(value=escaped) + return StringElement(value=escaped) + + +def _ensure_is_string(label: str, value: object) -> None: + """Raise :class:`MustBeAStringError` when ``value`` is not a string.""" + if isinstance(value, str): + return + actual_type_name = type(value).__name__ + raise MustBeAStringError(label, actual_type_name) + + +def _ensure_non_empty(label: str, value: str) -> None: + """Raise :class:`MustBeOneCharacterError` when ``value`` has length zero.""" + if len(value) > 0: + return + raise MustBeOneCharacterError(label) + + +def _ensure_at_least_one_literal(literals: tuple[str, ...]) -> None: + """Raise :class:`MustBeAtLeastOneLiteralError` when ``literals`` is empty.""" + if literals: + return + raise MustBeAtLeastOneLiteralError("one_of") diff --git a/edify/errors/input.py b/edify/errors/input.py index 30f1c93..f560ba6 100644 --- a/edify/errors/input.py +++ b/edify/errors/input.py @@ -86,3 +86,11 @@ class MustBeAtLeastTwoOperandsError(EdifySyntaxError): def __init__(self, label: str) -> None: message = f"{label} requires at least two operands." super().__init__(message) + + +class MustBeAtLeastOneLiteralError(EdifySyntaxError): + """Raised when a variadic literal-alternation chain method got zero literals.""" + + def __init__(self, label: str) -> None: + message = f"{label} requires at least one literal." + super().__init__(message) diff --git a/tests/builder/varargs.test.py b/tests/builder/varargs.test.py new file mode 100644 index 0000000..6a30636 --- /dev/null +++ b/tests/builder/varargs.test.py @@ -0,0 +1,72 @@ +"""Tests for the varargs shorthand on :meth:`GroupsMixin.any_of` and :meth:`GroupsMixin.one_of`.""" + +import pytest + +from edify import Pattern, RegexBuilder +from edify.errors.input import ( + MustBeAStringError, + MustBeAtLeastOneLiteralError, + MustBeOneCharacterError, +) + + +def test_any_of_no_args_still_opens_a_frame(): + expr = RegexBuilder().any_of().string("cat").string("dog").end() + assert expr.to_regex_string() == "(?:cat|dog)" + + +def test_any_of_varargs_fuses_single_character_literals_into_a_char_class(): + expr = RegexBuilder().any_of("+", "-") + assert expr.to_regex_string() == "[\\+\\-]" + + +def test_any_of_varargs_uses_alternation_for_multi_character_literals(): + expr = RegexBuilder().any_of("http", "https") + assert expr.to_regex_string() == "(?:http|https)" + + +def test_any_of_varargs_returns_a_builder_of_the_same_type(): + expr = RegexBuilder().any_of("a", "b") + assert isinstance(expr, RegexBuilder) + + +def test_any_of_varargs_leaves_the_original_builder_untouched(): + original = RegexBuilder().digit() + _ = original.any_of("a", "b") + assert original.to_regex_string() == "\\d" + + +def test_any_of_varargs_on_pattern_returns_a_pattern(): + pattern = Pattern().any_of("cat", "dog") + assert isinstance(pattern, Pattern) + assert pattern.to_regex_string() == "(?:cat|dog)" + + +def test_one_of_fuses_single_character_literals_into_a_char_class(): + expr = RegexBuilder().one_of("+", "-") + assert expr.to_regex_string() == "[\\+\\-]" + + +def test_one_of_uses_alternation_for_multi_character_literals(): + expr = RegexBuilder().one_of("cat", "dog", "fish") + assert expr.to_regex_string() == "(?:cat|dog|fish)" + + +def test_one_of_accepts_a_single_literal_and_wraps_it(): + expr = RegexBuilder().one_of("cat") + assert expr.to_regex_string() == "(?:cat)" + + +def test_one_of_zero_args_raises_must_be_at_least_one_literal(): + with pytest.raises(MustBeAtLeastOneLiteralError): + RegexBuilder().one_of() + + +def test_any_of_varargs_rejects_empty_string_literal(): + with pytest.raises(MustBeOneCharacterError): + RegexBuilder().any_of("cat", "") + + +def test_one_of_rejects_non_string_literal(): + with pytest.raises(MustBeAStringError): + RegexBuilder().one_of("cat", 42) diff --git a/tests/errors/errors.test.py b/tests/errors/errors.test.py index 0f39228..6472469 100644 --- a/tests/errors/errors.test.py +++ b/tests/errors/errors.test.py @@ -6,6 +6,7 @@ from edify.errors.anchors import ( from edify.errors.captures import InvalidTotalCaptureGroupsIndexError from edify.errors.input import ( MustBeAStringError, + MustBeAtLeastOneLiteralError, MustBeAtLeastTwoOperandsError, MustBeInstanceError, MustBeIntegerGreaterThanZeroError, @@ -113,6 +114,11 @@ def test_must_be_at_least_two_operands(): assert "any_of requires at least two operands" in str(error) +def test_must_be_at_least_one_literal(): + error = MustBeAtLeastOneLiteralError("one_of") + assert "one_of requires at least one literal" in str(error) + + def test_name_not_valid(): error = NameNotValidError("bad name") assert "Name bad name is not valid" in str(error) |
