aboutsummaryrefslogtreecommitdiff
path: root/tests/builder
diff options
context:
space:
mode:
Diffstat (limited to 'tests/builder')
-rw-r--r--tests/builder/cache.test.py69
-rw-r--r--tests/builder/diagnose.test.py105
-rw-r--r--tests/builder/equality.test.py87
-rw-r--r--tests/builder/properties.test.py215
4 files changed, 447 insertions, 29 deletions
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/diagnose.test.py b/tests/builder/diagnose.test.py
new file mode 100644
index 0000000..c61b3a9
--- /dev/null
+++ b/tests/builder/diagnose.test.py
@@ -0,0 +1,105 @@
+"""Tests for the diagnostic helpers in :mod:`edify.builder.diagnose`."""
+
+from types import SimpleNamespace
+
+import pytest
+
+from edify import Pattern, RegexBuilder
+from edify.builder.diagnose import _fallback, _frame_display_name, diagnose_unfinished
+from edify.errors.comparison import CannotCompareUnfinishedBuilderError
+
+
+def _first_pointer_hint(text: str) -> str | None:
+ for line in text.splitlines():
+ if "->" in line and ".py:" in line:
+ return line
+ return None
+
+
+def test_diagnose_returns_none_when_state_is_fully_specified():
+ finished = RegexBuilder().digit()
+ problem = diagnose_unfinished(finished._state, "left operand")
+ assert problem is None
+
+
+def test_open_group_frame_names_group_in_the_problem_description():
+ unfinished = RegexBuilder().group().digit()
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = unfinished == RegexBuilder()
+ text = str(excinfo.value)
+ assert "open `group()`" in text
+
+
+def test_open_capture_frame_names_capture_in_the_problem_description():
+ unfinished = RegexBuilder().capture().digit()
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = unfinished == RegexBuilder()
+ text = str(excinfo.value)
+ assert "open `capture()`" in text
+
+
+def test_open_named_capture_frame_names_the_name_in_the_problem_description():
+ unfinished = RegexBuilder().named_capture("year").digit()
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = unfinished == RegexBuilder()
+ text = str(excinfo.value)
+ assert 'named_capture("year")' in text
+
+
+def test_open_assert_ahead_frame_names_lookahead():
+ unfinished = RegexBuilder().assert_ahead().digit()
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = unfinished == RegexBuilder()
+ text = str(excinfo.value)
+ assert "assert_ahead()" in text
+
+
+def test_open_assert_not_ahead_frame_names_negative_lookahead():
+ unfinished = RegexBuilder().assert_not_ahead().digit()
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = unfinished == RegexBuilder()
+ text = str(excinfo.value)
+ assert "assert_not_ahead()" in text
+
+
+def test_open_assert_behind_frame_names_lookbehind():
+ unfinished = RegexBuilder().assert_behind().digit()
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = unfinished == RegexBuilder()
+ text = str(excinfo.value)
+ assert "assert_behind()" in text
+
+
+def test_open_assert_not_behind_frame_names_negative_lookbehind():
+ unfinished = RegexBuilder().assert_not_behind().digit()
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = unfinished == RegexBuilder()
+ text = str(excinfo.value)
+ assert "assert_not_behind()" in text
+
+
+def test_frame_display_name_falls_back_to_class_name_for_unknown_container():
+ mystery_class = type("MysteryContainer", (), {})
+ mystery_element = mystery_class()
+ fake_frame = SimpleNamespace(type_node=mystery_element)
+ assert _frame_display_name(fake_frame) == "MysteryContainer"
+
+
+def test_fallback_returns_context_when_provided():
+ dummy_context = "sentinel"
+ assert _fallback(dummy_context) == "sentinel"
+
+
+def test_fallback_returns_placeholder_when_context_is_none():
+ placeholder = _fallback(None)
+ assert placeholder.filename == "<unknown>"
+ assert placeholder.lineno == 0
+ assert placeholder.source_line == ""
+
+
+def test_dangling_quantifier_on_pattern_reports_quantifier_name():
+ unfinished_pattern = Pattern().one_or_more()
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = unfinished_pattern == Pattern()
+ text = str(excinfo.value)
+ assert "pending `.one_or_more()`" in text
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)
diff --git a/tests/builder/properties.test.py b/tests/builder/properties.test.py
index 24f5d01..87770fa 100644
--- a/tests/builder/properties.test.py
+++ b/tests/builder/properties.test.py
@@ -1,15 +1,57 @@
-"""Property assertion — no chain ever silently drops a quantifier.
+"""Property assertions — no chain ever silently drops or duplicates an element.
-For any list of ``(quantifier method, args)`` calls, each immediately
-followed by a leaf-element call, the emitted regex is the concatenation
-of ``<element><suffix>`` fragments in the same order — no quantifier is
-lost, none appears twice.
+Every quantifier, group, capture, named capture, and subexpression in a
+randomly-generated composition emits its own fragment exactly once and in
+the correct place in the output regex string.
"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
from hypothesis import given
from hypothesis import strategies as st
-from edify import RegexBuilder
+from edify import Pattern, RegexBuilder
+
+
+@dataclass(frozen=True)
+class LeafNode:
+ """A single element with an optional quantifier attached before it."""
+
+ element_name: str
+ element_args: tuple[int, ...]
+ element_regex: str
+ quantifier: tuple[str, tuple[int, ...], str] | None
+
+
+@dataclass(frozen=True)
+class GroupNode:
+ """A non-capturing group wrapping ``children``."""
+
+ children: tuple[object, ...]
+
+
+@dataclass(frozen=True)
+class CaptureNode:
+ """An unnamed capture group wrapping ``children``."""
+
+ children: tuple[object, ...]
+
+
+@dataclass(frozen=True)
+class NamedCaptureNode:
+ """A named-capture group wrapping ``children``; the name is assigned deterministically."""
+
+ children: tuple[object, ...]
+
+
+@dataclass(frozen=True)
+class SubexpressionNode:
+ """A subexpression built as a separate ``Pattern`` and merged into the parent."""
+
+ children: tuple[object, ...]
+
_QUANTIFIER_STRATEGIES: list[st.SearchStrategy[tuple[str, tuple[int, ...], str]]] = [
st.just(("optional", (), "?")),
@@ -44,30 +86,145 @@ _ELEMENT_STRATEGIES = [
st.just(("alphanumeric", (), "[a-zA-Z0-9]")),
]
-_quantifier_element_pair = st.tuples(
- st.one_of(*_QUANTIFIER_STRATEGIES),
- st.one_of(*_ELEMENT_STRATEGIES),
-)
+_element_pick = st.one_of(*_ELEMENT_STRATEGIES)
+_quantifier_pick_or_none = st.one_of(st.none(), *_QUANTIFIER_STRATEGIES)
+
+
+_ElementCall = tuple[str, tuple[int, ...], str]
+_QuantifierCall = tuple[str, tuple[int, ...], str]
+
+
+def _build_leaf(pair: tuple[_ElementCall, _QuantifierCall | None]) -> LeafNode:
+ element, quantifier = pair
+ element_name, element_args, element_regex = element
+ return LeafNode(
+ element_name=element_name,
+ element_args=element_args,
+ element_regex=element_regex,
+ quantifier=quantifier,
+ )
+
+
+_leaf_node_strategy = st.tuples(_element_pick, _quantifier_pick_or_none).map(_build_leaf)
+
+def _extend(children_strategy: st.SearchStrategy) -> st.SearchStrategy:
+ children_list = st.lists(children_strategy, min_size=1, max_size=4)
+ group_nodes = children_list.map(lambda children: GroupNode(tuple(children)))
+ capture_nodes = children_list.map(lambda children: CaptureNode(tuple(children)))
+ named_capture_nodes = children_list.map(lambda children: NamedCaptureNode(tuple(children)))
+ subexpression_nodes = children_list.map(lambda children: SubexpressionNode(tuple(children)))
+ return st.one_of(group_nodes, capture_nodes, named_capture_nodes, subexpression_nodes)
-@given(st.lists(_quantifier_element_pair, min_size=1, max_size=8))
-def test_every_quantifier_chain_call_produces_exactly_one_output_quantifier(pairs):
+
+_node_strategy = st.recursive(_leaf_node_strategy, _extend, max_leaves=6)
+
+
+def _apply_sequence(
+ builder,
+ nodes: list[object],
+ name_counter: int,
+) -> tuple[object, str, int]:
+ """Apply ``nodes`` to ``builder`` in order; return the updated triple."""
+ fragments: list[str] = []
+ for node in nodes:
+ builder, fragment, name_counter = _apply_node(builder, node, name_counter)
+ fragments.append(fragment)
+ combined_regex = "".join(fragments)
+ return builder, combined_regex, name_counter
+
+
+def _apply_node(builder, node, name_counter: int) -> tuple[object, str, int]:
+ """Dispatch on ``node`` type, applying it to ``builder`` and returning the fragment."""
+ if isinstance(node, LeafNode):
+ return _apply_leaf(builder, node, name_counter)
+ if isinstance(node, GroupNode):
+ return _apply_group(builder, node, name_counter)
+ if isinstance(node, CaptureNode):
+ return _apply_capture(builder, node, name_counter)
+ if isinstance(node, NamedCaptureNode):
+ return _apply_named_capture(builder, node, name_counter)
+ return _apply_subexpression(builder, node, name_counter)
+
+
+def _apply_leaf(builder, node: LeafNode, name_counter: int) -> tuple[object, str, int]:
+ """Apply a single leaf element, optionally preceded by a quantifier."""
+ if node.quantifier is None:
+ builder_after_element = getattr(builder, node.element_name)(*node.element_args)
+ return builder_after_element, node.element_regex, name_counter
+ quantifier_name, quantifier_args, quantifier_suffix = node.quantifier
+ builder_after_quantifier = getattr(builder, quantifier_name)(*quantifier_args)
+ builder_after_element = getattr(builder_after_quantifier, node.element_name)(*node.element_args)
+ fragment = f"{node.element_regex}{quantifier_suffix}"
+ return builder_after_element, fragment, name_counter
+
+
+def _apply_group(builder, node: GroupNode, name_counter: int) -> tuple[object, str, int]:
+ """Apply a non-capturing group around ``node.children``."""
+ builder_opened = builder.group()
+ builder_inner, inner_regex, name_counter = _apply_sequence(
+ builder_opened, list(node.children), name_counter
+ )
+ builder_closed = builder_inner.end()
+ return builder_closed, f"(?:{inner_regex})", name_counter
+
+
+def _apply_capture(builder, node: CaptureNode, name_counter: int) -> tuple[object, str, int]:
+ """Apply an unnamed capture group around ``node.children``."""
+ builder_opened = builder.capture()
+ builder_inner, inner_regex, name_counter = _apply_sequence(
+ builder_opened, list(node.children), name_counter
+ )
+ builder_closed = builder_inner.end()
+ return builder_closed, f"({inner_regex})", name_counter
+
+
+def _apply_named_capture(
+ builder,
+ node: NamedCaptureNode,
+ name_counter: int,
+) -> tuple[object, str, int]:
+ """Apply a named-capture group with a deterministically-assigned name."""
+ assigned_name = f"n{name_counter}"
+ next_counter = name_counter + 1
+ builder_opened = builder.named_capture(assigned_name)
+ builder_inner, inner_regex, next_counter = _apply_sequence(
+ builder_opened, list(node.children), next_counter
+ )
+ builder_closed = builder_inner.end()
+ return builder_closed, f"(?P<{assigned_name}>{inner_regex})", next_counter
+
+
+def _apply_subexpression(
+ builder,
+ node: SubexpressionNode,
+ name_counter: int,
+) -> tuple[object, str, int]:
+ """Apply a subexpression built as an independent ``Pattern`` and merged in."""
+ sub_pattern = Pattern()
+ sub_pattern_finished, sub_regex, next_counter = _apply_sequence(
+ sub_pattern, list(node.children), name_counter
+ )
+ builder_after_merge = builder.subexpression(sub_pattern_finished)
+ return builder_after_merge, sub_regex, next_counter
+
+
+@given(st.lists(_leaf_node_strategy, min_size=1, max_size=8))
+def test_bare_element_chain_emits_the_concatenation_of_element_fragments(nodes):
+ builder = RegexBuilder()
+ builder_after, expected_regex, _ = _apply_sequence(builder, list(nodes), 0)
+ assert builder_after.to_regex_string() == expected_regex
+
+
+@given(st.lists(_leaf_node_strategy, min_size=1, max_size=8))
+def test_every_quantifier_chain_call_produces_exactly_one_output_quantifier(nodes):
builder = RegexBuilder()
- expected_fragments: list[str] = []
- for quantifier_call, element_call in pairs:
- quantifier_name, quantifier_args, quantifier_suffix = quantifier_call
- element_name, element_args, element_regex = element_call
- builder = getattr(builder, quantifier_name)(*quantifier_args)
- builder = getattr(builder, element_name)(*element_args)
- expected_fragments.append(f"{element_regex}{quantifier_suffix}")
- assert builder.to_regex_string() == "".join(expected_fragments)
-
-
-@given(st.lists(st.one_of(*_ELEMENT_STRATEGIES), min_size=1, max_size=8))
-def test_bare_element_chain_emits_the_concatenation_of_element_fragments(elements):
+ builder_after, expected_regex, _ = _apply_sequence(builder, list(nodes), 0)
+ assert builder_after.to_regex_string() == expected_regex
+
+
+@given(st.lists(_node_strategy, min_size=1, max_size=6))
+def test_composition_over_groups_captures_and_subexpressions_is_faithful(nodes):
builder = RegexBuilder()
- expected_fragments: list[str] = []
- for element_name, element_args, element_regex in elements:
- builder = getattr(builder, element_name)(*element_args)
- expected_fragments.append(element_regex)
- assert builder.to_regex_string() == "".join(expected_fragments)
+ builder_after, expected_regex, _ = _apply_sequence(builder, list(nodes), 0)
+ assert builder_after.to_regex_string() == expected_regex