aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authornatsuoto <[email protected]>2026-07-10 16:19:01 +0530
committernatsuoto <[email protected]>2026-07-10 16:19:01 +0530
commit1ca22160898b7c9bcc945ac8d59d71b72f8a7116 (patch)
tree15ce457d4d7fd243c153d4b8e60e6e75bd714988
parent74d6b084993feb9c9dc1a72abeb3cf2497ac9171 (diff)
downloadedify-1ca22160898b7c9bcc945ac8d59d71b72f8a7116.tar.xz
edify-1ca22160898b7c9bcc945ac8d59d71b72f8a7116.zip
feat(builder): lazy Regex compile cache and __eq__/__hash__ diagnose unfinished builders (#127, #137)
-rw-r--r--edify/builder/core.py61
-rw-r--r--edify/builder/diagnose.py106
-rw-r--r--edify/builder/mixins/matcher.py25
-rw-r--r--tests/builder/cache.test.py69
-rw-r--r--tests/builder/equality.test.py87
5 files changed, 316 insertions, 32 deletions
diff --git a/edify/builder/core.py b/edify/builder/core.py
index 333c7ad..6b3ac46 100644
--- a/edify/builder/core.py
+++ b/edify/builder/core.py
@@ -1,19 +1,18 @@
-"""Shared immutable-state plumbing for :class:`RegexBuilder` and :class:`Pattern`.
-
-Both fluent surfaces carry the same :class:`BuilderState` and use the same
-clone-and-replace pattern for chain methods. :class:`BuilderCore` provides
-the state attribute, the constructor, the ``_with_state`` helper that
-mixins call to produce new instances, the interactive ``__repr__``
-that shows the pattern-so-far, and value-based ``__eq__`` / ``__hash__``
-that make two builders equal when their underlying immutable state matches.
-"""
+"""Shared immutable-state plumbing for :class:`RegexBuilder` and :class:`Pattern`."""
from __future__ import annotations
from typing import Self
+from edify.builder.diagnose import diagnose_unfinished
from edify.builder.types.state import BuilderState
+from edify.errors.comparison import (
+ CannotCompareUnfinishedBuilderError,
+ CannotHashUnfinishedBuilderError,
+)
+from edify.errors.quantifier import DanglingQuantifierError
from edify.errors.structure import CannotCallSubexpressionError
+from edify.result.regex import Regex
_UNCLOSED_FRAME_MARKER = "<unclosed>"
@@ -22,24 +21,31 @@ class BuilderCore:
"""Holds the immutable :class:`BuilderState` and clones it on chain steps."""
_state: BuilderState
+ _cached_regex: Regex | None
def __init__(self) -> None:
self._state = BuilderState()
+ self._cached_regex = None
def _with_state(self, new_state: BuilderState) -> Self:
"""Return a fresh instance of the same concrete type carrying ``new_state``."""
concrete_class = type(self)
new_instance = concrete_class.__new__(concrete_class)
new_instance._state = new_state
+ new_instance._cached_regex = None
return new_instance
- def fork(self) -> Self:
- """Return a fresh builder with the same immutable state.
+ def _lazy_regex(self) -> Regex:
+ """Return the memoised :class:`Regex` for this builder, compiling once on first call."""
+ cached = self._cached_regex
+ if cached is None:
+ to_regex = self.to_regex
+ cached = to_regex()
+ self._cached_regex = cached
+ return cached
- Chain methods already return new instances so implicit forking works
- (``root.digit()`` and ``root.word()`` share nothing after the split);
- this method makes the intent explicit and discoverable via autocomplete.
- """
+ def fork(self) -> Self:
+ """Return a fresh builder with the same immutable state."""
return self._with_state(self._state)
def copy(self) -> Self:
@@ -52,14 +58,29 @@ class BuilderCore:
return f"<{type(self).__name__} {rendered!r}>"
def __eq__(self, other: object) -> bool:
- """Return True when ``other`` is a builder whose immutable state matches ``self``."""
+ """Return True when ``other`` is a builder with the same emitted pattern and flags."""
if not isinstance(other, BuilderCore):
return NotImplemented
- return self._state == other._state
+ problems: list = []
+ left_problem = diagnose_unfinished(self._state, "left operand")
+ right_problem = diagnose_unfinished(other._state, "right operand")
+ if left_problem is not None:
+ problems.append(left_problem)
+ if right_problem is not None:
+ problems.append(right_problem)
+ if problems:
+ raise CannotCompareUnfinishedBuilderError(problems)
+ self_source = self.to_regex_string()
+ other_source = other.to_regex_string()
+ return self_source == other_source and self._state.flags == other._state.flags
def __hash__(self) -> int:
- """Return a hash derived from the immutable state; matches :meth:`__eq__`."""
- return hash(self._state)
+ """Return a hash derived from ``(emitted_pattern, flags)``."""
+ problem = diagnose_unfinished(self._state, "builder")
+ if problem is not None:
+ raise CannotHashUnfinishedBuilderError(problem)
+ source = self.to_regex_string()
+ return hash((source, self._state.flags))
def _render_or_marker(builder: BuilderCore) -> str:
@@ -69,5 +90,5 @@ def _render_or_marker(builder: BuilderCore) -> str:
return _UNCLOSED_FRAME_MARKER
try:
return to_regex_string()
- except CannotCallSubexpressionError:
+ except (CannotCallSubexpressionError, DanglingQuantifierError):
return _UNCLOSED_FRAME_MARKER
diff --git a/edify/builder/diagnose.py b/edify/builder/diagnose.py
new file mode 100644
index 0000000..60a5f5a
--- /dev/null
+++ b/edify/builder/diagnose.py
@@ -0,0 +1,106 @@
+"""Diagnostic helpers that describe why a builder is unfinished."""
+
+from __future__ import annotations
+
+from edify.builder.types.frame import StackFrame
+from edify.builder.types.state import BuilderState
+from edify.elements.types.captures import CaptureElement, NamedCaptureElement
+from edify.elements.types.groups import (
+ AnyOfElement,
+ AssertAheadElement,
+ AssertBehindElement,
+ AssertNotAheadElement,
+ AssertNotBehindElement,
+ GroupElement,
+)
+from edify.errors.context import CallerContext
+from edify.errors.formatting import FixInsertion, Problem
+
+_END_INSERTION_TEXT = ".end()"
+_DANGLING_INSERTION_TEXT = ".digit()"
+
+
+def diagnose_unfinished(state: BuilderState, subject: str) -> Problem | None:
+ """Return a :class:`Problem` for the first unfinished thing in ``state``, or ``None``."""
+ if len(state.stack) > 1:
+ return _diagnose_open_frame(state.top_frame, subject)
+ for frame in state.stack:
+ if frame.quantifier is not None:
+ return _diagnose_dangling_quantifier(frame, subject)
+ return None
+
+
+def _diagnose_open_frame(frame: StackFrame, subject: str) -> Problem:
+ """Return a :class:`Problem` describing an unclosed frame."""
+ frame_name = _frame_display_name(frame)
+ problem_context = _fallback(frame.call_site)
+ fix_context = _fallback(frame.last_child_call_site or frame.call_site)
+ fix_insertion = FixInsertion(
+ column=fix_context.end_colno,
+ text=_END_INSERTION_TEXT,
+ )
+ return Problem(
+ description=f"`{subject}` has an open `{frame_name}` frame",
+ problem_context=problem_context,
+ problem_hint="frame opened here, never closed",
+ help_summary=f"close the frame with `{_END_INSERTION_TEXT}`",
+ fix_context=fix_context,
+ fix_insertion=fix_insertion,
+ )
+
+
+def _diagnose_dangling_quantifier(frame: StackFrame, subject: str) -> Problem:
+ """Return a :class:`Problem` describing a pending quantifier with no operand."""
+ quantifier_name = frame.quantifier_name or "quantifier"
+ problem_context = _fallback(frame.quantifier_call_site)
+ fix_context = _fallback(frame.quantifier_call_site)
+ fix_insertion = FixInsertion(
+ column=fix_context.end_colno,
+ text=_DANGLING_INSERTION_TEXT,
+ )
+ return Problem(
+ description=f"`{subject}` has a pending `.{quantifier_name}` quantifier",
+ problem_context=problem_context,
+ problem_hint="quantifier set here — no element follows",
+ help_summary=(
+ f"append an element to repeat, e.g. `{_DANGLING_INSERTION_TEXT}` "
+ '(or `.word()`, `.char("x")`)'
+ ),
+ fix_context=fix_context,
+ fix_insertion=fix_insertion,
+ )
+
+
+def _frame_display_name(frame: StackFrame) -> str:
+ """Return the human-facing name of the chain call that opened ``frame``."""
+ type_node = frame.type_node
+ if isinstance(type_node, AnyOfElement):
+ return "any_of()"
+ if isinstance(type_node, GroupElement):
+ return "group()"
+ if isinstance(type_node, AssertAheadElement):
+ return "assert_ahead()"
+ if isinstance(type_node, AssertNotAheadElement):
+ return "assert_not_ahead()"
+ if isinstance(type_node, AssertBehindElement):
+ return "assert_behind()"
+ if isinstance(type_node, AssertNotBehindElement):
+ return "assert_not_behind()"
+ if isinstance(type_node, CaptureElement):
+ return "capture()"
+ if isinstance(type_node, NamedCaptureElement):
+ return f'named_capture("{type_node.name}")'
+ return type(type_node).__name__
+
+
+def _fallback(context: CallerContext | None) -> CallerContext:
+ """Return ``context`` if not ``None``, else a placeholder context marking unknown location."""
+ if context is not None:
+ return context
+ return CallerContext(
+ filename="<unknown>",
+ lineno=0,
+ colno=1,
+ end_colno=1,
+ source_line="",
+ )
diff --git a/edify/builder/mixins/matcher.py b/edify/builder/mixins/matcher.py
index b4106af..3ed74fa 100644
--- a/edify/builder/mixins/matcher.py
+++ b/edify/builder/mixins/matcher.py
@@ -1,8 +1,9 @@
"""The :class:`MatcherMixin` — direct :mod:`re` method proxies on any fluent surface.
-Compiles ``self`` lazily via ``self.to_regex()`` on every call and delegates
-to the returned :class:`re.Pattern`. Adding this mixin to a class lets users
-write ``pattern.match("10001")`` instead of ``pattern.to_regex().match("10001")``.
+Compiles ``self`` on first use via :meth:`edify.builder.core.BuilderCore._lazy_regex`
+and reuses the cached :class:`edify.result.regex.Regex` on every subsequent call,
+so ``pattern.test("a")`` followed by ``pattern.match("b")`` compiles the underlying
+regex exactly once.
The signatures mirror :class:`re.Pattern` exactly so IDE autocomplete and
type inference stay unchanged. :meth:`test` is the one non-:mod:`re` method:
@@ -24,33 +25,33 @@ class MatcherMixin(BuilderProtocol):
def test(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> bool:
"""Return ``True`` when the pattern matches anywhere in ``string``, else ``False``."""
- return self.to_regex().search(string, pos, endpos) is not None
+ return self._lazy_regex().search(string, pos, endpos) is not None
def match(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> re.Match[str] | None:
"""Delegate to :meth:`re.Pattern.match`."""
- return self.to_regex().match(string, pos, endpos)
+ return self._lazy_regex().match(string, pos, endpos)
def search(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> re.Match[str] | None:
"""Delegate to :meth:`re.Pattern.search`."""
- return self.to_regex().search(string, pos, endpos)
+ return self._lazy_regex().search(string, pos, endpos)
def fullmatch(
self, string: str, pos: int = 0, endpos: int = sys.maxsize
) -> re.Match[str] | None:
"""Delegate to :meth:`re.Pattern.fullmatch`."""
- return self.to_regex().fullmatch(string, pos, endpos)
+ return self._lazy_regex().fullmatch(string, pos, endpos)
def findall(
self, string: str, pos: int = 0, endpos: int = sys.maxsize
) -> list[str] | list[tuple[str, ...]]:
"""Delegate to :meth:`re.Pattern.findall`."""
- return self.to_regex().findall(string, pos, endpos)
+ return self._lazy_regex().findall(string, pos, endpos)
def finditer(
self, string: str, pos: int = 0, endpos: int = sys.maxsize
) -> Iterator[re.Match[str]]:
"""Delegate to :meth:`re.Pattern.finditer`."""
- return self.to_regex().finditer(string, pos, endpos)
+ return self._lazy_regex().finditer(string, pos, endpos)
def sub(
self,
@@ -59,7 +60,7 @@ class MatcherMixin(BuilderProtocol):
count: int = 0,
) -> str:
"""Delegate to :meth:`re.Pattern.sub`."""
- return self.to_regex().sub(replacement, string, count=count)
+ return self._lazy_regex().sub(replacement, string, count=count)
def subn(
self,
@@ -68,8 +69,8 @@ class MatcherMixin(BuilderProtocol):
count: int = 0,
) -> tuple[str, int]:
"""Delegate to :meth:`re.Pattern.subn`."""
- return self.to_regex().subn(replacement, string, count=count)
+ return self._lazy_regex().subn(replacement, string, count=count)
def split(self, string: str, maxsplit: int = 0) -> list[str | None]:
"""Delegate to :meth:`re.Pattern.split`."""
- return self.to_regex().split(string, maxsplit=maxsplit)
+ return self._lazy_regex().split(string, maxsplit=maxsplit)
diff --git a/tests/builder/cache.test.py b/tests/builder/cache.test.py
new file mode 100644
index 0000000..80862d3
--- /dev/null
+++ b/tests/builder/cache.test.py
@@ -0,0 +1,69 @@
+"""Tests for the lazy :class:`Regex` cache on :class:`BuilderCore`."""
+
+from edify import Pattern, RegexBuilder
+
+
+def test_repeated_lazy_regex_calls_return_the_same_instance():
+ builder = RegexBuilder().digit()
+ first = builder._lazy_regex()
+ second = builder._lazy_regex()
+ third = builder._lazy_regex()
+ assert first is second
+ assert second is third
+
+
+def test_repeated_matcher_calls_reuse_the_cached_regex():
+ builder = RegexBuilder().digit()
+ builder.match("1")
+ cached = builder._cached_regex
+ builder.search("2")
+ builder.findall("3")
+ builder.test("4")
+ assert builder._cached_regex is cached
+
+
+def test_pattern_lazy_regex_is_memoised_too():
+ pattern = Pattern().word()
+ first = pattern._lazy_regex()
+ second = pattern._lazy_regex()
+ assert first is second
+
+
+def test_a_forked_builder_gets_a_fresh_cache_slot():
+ original = RegexBuilder().digit()
+ original.match("1")
+ assert original._cached_regex is not None
+ forked = original.fork()
+ assert forked._cached_regex is None
+
+
+def test_a_copied_builder_gets_a_fresh_cache_slot():
+ original = RegexBuilder().digit()
+ original.match("1")
+ copied = original.copy()
+ assert copied._cached_regex is None
+
+
+def test_a_chain_extension_gets_a_fresh_cache_slot():
+ original = RegexBuilder().digit()
+ original.match("1")
+ extended = original.word()
+ assert extended._cached_regex is None
+
+
+def test_a_fresh_builder_starts_without_a_cached_regex():
+ builder = RegexBuilder()
+ assert builder._cached_regex is None
+
+
+def test_calling_to_regex_directly_does_not_populate_the_cache():
+ builder = RegexBuilder().digit()
+ _ = builder.to_regex()
+ assert builder._cached_regex is None
+
+
+def test_lazy_regex_produces_a_regex_that_matches_the_pattern():
+ builder = RegexBuilder().digit()
+ result = builder._lazy_regex()
+ assert result.source == "\\d"
+ assert result.match("7") is not None
diff --git a/tests/builder/equality.test.py b/tests/builder/equality.test.py
index 717d1ae..8482b51 100644
--- a/tests/builder/equality.test.py
+++ b/tests/builder/equality.test.py
@@ -1,6 +1,12 @@
"""Tests for value-based ``__eq__`` and ``__hash__`` on :class:`BuilderCore`."""
+import pytest
+
from edify import Pattern, RegexBuilder
+from edify.errors.comparison import (
+ CannotCompareUnfinishedBuilderError,
+ CannotHashUnfinishedBuilderError,
+)
def test_two_builders_with_the_same_chain_are_equal():
@@ -48,3 +54,84 @@ def test_hash_of_two_flags_that_differ_are_distinct():
b = RegexBuilder().digit()
assert a != b
assert hash(a) != hash(b)
+
+
+def test_equality_uses_emitted_pattern_not_underlying_state():
+ from_chain = RegexBuilder().string("hi")
+ from_string_kwarg = RegexBuilder().string("hi")
+ assert from_chain.to_regex_string() == from_string_kwarg.to_regex_string()
+ assert from_chain == from_string_kwarg
+
+
+def test_hash_uses_emitted_pattern_and_flags_tuple():
+ a = RegexBuilder().string("hi")
+ b = RegexBuilder().string("hi")
+ assert hash(a) == hash((a.to_regex_string(), a._state.flags))
+ assert hash(a) == hash(b)
+
+
+def test_equality_raises_when_left_operand_has_open_frames():
+ left = RegexBuilder().any_of()
+ right = RegexBuilder().digit()
+ with pytest.raises(CannotCompareUnfinishedBuilderError):
+ _ = left == right
+
+
+def test_equality_raises_when_right_operand_has_open_frames():
+ left = RegexBuilder().digit()
+ right = RegexBuilder().any_of()
+ with pytest.raises(CannotCompareUnfinishedBuilderError):
+ _ = left == right
+
+
+def test_equality_raises_when_both_operands_are_unfinished():
+ left = RegexBuilder().any_of()
+ right = RegexBuilder().exactly(3)
+ with pytest.raises(CannotCompareUnfinishedBuilderError):
+ _ = left == right
+
+
+def test_hash_raises_when_frames_are_open():
+ with pytest.raises(CannotHashUnfinishedBuilderError):
+ hash(RegexBuilder().any_of())
+
+
+def test_equality_raises_when_a_quantifier_is_dangling():
+ left = RegexBuilder().exactly(3)
+ right = RegexBuilder().digit()
+ with pytest.raises(CannotCompareUnfinishedBuilderError):
+ _ = left == right
+
+
+def test_hash_raises_when_a_quantifier_is_dangling():
+ with pytest.raises(CannotHashUnfinishedBuilderError):
+ hash(RegexBuilder().exactly(3))
+
+
+def test_compare_error_message_names_the_open_frame():
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = RegexBuilder().named_capture("domain") == RegexBuilder().digit()
+ assert 'named_capture("domain")' in str(excinfo.value)
+ assert "frame opened here" in str(excinfo.value)
+ assert ".end()" in str(excinfo.value)
+
+
+def test_compare_error_message_names_the_dangling_quantifier():
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = RegexBuilder().digit() == RegexBuilder().exactly(4)
+ assert "exactly(4)" in str(excinfo.value)
+ assert "no element follows" in str(excinfo.value)
+ assert ".digit()" in str(excinfo.value)
+
+
+def test_hash_error_message_names_the_specific_problem():
+ with pytest.raises(CannotHashUnfinishedBuilderError) as excinfo:
+ hash(RegexBuilder().at_least(2))
+ assert "at_least(2)" in str(excinfo.value)
+
+
+def test_error_message_shows_a_source_pointer_when_called_from_user_code():
+ with pytest.raises(CannotHashUnfinishedBuilderError) as excinfo:
+ hash(RegexBuilder().any_of())
+ assert "-->" in str(excinfo.value)
+ assert __file__ in str(excinfo.value)