aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
author夏音 / natsuoto.exe <[email protected]>2026-07-01 16:21:15 +0530
committerGitHub <[email protected]>2026-07-01 16:21:15 +0530
commite859ce24e5ffc95653023669f9edf4d3a0344a8e (patch)
treeea2d0971292c1a70737f1f068ec27d308974b88f
parent42509f3a1d0c93c5fd43b2e2bdc42201d24a643e (diff)
parent1860f7396e997c785d2299c421852909a0ee2070 (diff)
downloadedify-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.py99
-rw-r--r--edify/errors/input.py8
-rw-r--r--tests/builder/varargs.test.py72
-rw-r--r--tests/errors/errors.test.py6
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)