aboutsummaryrefslogtreecommitdiff
path: root/tests/builder/properties.test.py
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-07-10 16:59:00 +0530
committerGitHub <[email protected]>2026-07-10 16:59:00 +0530
commit189bcdabe3865a0d52aea580d3fdbcc1cd48e896 (patch)
tree8db00421de426b98a86d4b65665c71be7adcf0bf /tests/builder/properties.test.py
parentc757ea1fea447df3ed69e74b407ca9710a9f85e0 (diff)
parent174eb521850550830d97994719ca3d09c467cb5a (diff)
downloadedify-189bcdabe3865a0d52aea580d3fdbcc1cd48e896.tar.xz
edify-189bcdabe3865a0d52aea580d3fdbcc1cd48e896.zip
feat: regex introspection API — explain, verbose export, ASCII + SVG visualize + annotated errors (#272)
Ships the regex introspection API on the `Regex` wrapper — three new methods that turn a compiled pattern into something a human can read, plus a full retrofit of every error message to a shape that shows the caller *where* the failure happened and *how* to fix it. ## `Regex.explain()` Plain-English bullet list of what the pattern accepts, followed by a small set of concrete accepted strings. ``` - The text must start with either "http" or "https". - Then the text must have "://". - Then the text must have one or more letters, digits, or underscores. Text this pattern accepts: http://ab1 https://b1_c http://1_c2d ``` ## `Regex.to_verbose_string()` An `re.VERBOSE`-compatible export where every fragment is annotated inline. Copy-pasting the output into `re.compile(..., re.VERBOSE)` compiles to the same pattern. ``` (?P<year> # begin group named "year" \d{4} # exactly 4 ) # end group named "year" ``` ## `Regex.visualize(format='ascii')` ASCII railroad diagram — `START` and `END` boxes flanking the pattern, arrows between elements, fork/merge junctions for alternation, dashed-caption blocks for captures and lookarounds. ``` +--------+ +--->| "cat" |----+ | +--------+ | | | +-------+ | +--------+ | +-----+ | START |------>+--->| "dog" |----+-->| END | +-------+ | +--------+ | +-----+ | | | +--------+ | +--->| "fish" |----+ +--------+ ``` ## `Regex.visualize(format='svg', engine='graphviz')` SVG rendered by Graphviz. Junction-point fork/merge for alternation, dashed rounded clusters for captures/lookarounds, folded two-line node labels for quantifiers (e.g. `digit\n(one or more)`). Requires the optional `graphviz` extra; the missing-dependency error names the install command. ## Annotated error messages Every exception across `edify.errors` — anchors, captures, input, internal, naming, quantifier, structure — now emits a message that names the offending call in the caller's source, explains the invariant that was violated, and prescribes the fix: ``` error: start_of_input has already been added to this pattern --> user_code.py:14:26 | 14 | pattern = RegexBuilder().start_of_input().digit().start_of_input() | ^^^^^^^^^^^^^^^^ second start_of_input added here | = note: a pattern can carry at most one start_of_input anchor; the earlier .start_of_input() call already set it. help: remove the duplicate .start_of_input() call from the chain. ``` Caller source location is captured via `sys._getframe()` walking to the first non-edify frame and `co_positions()` for precise column spans. ## Other landings - `Regex` retains the AST elements the builder produced; every introspection method operates on the AST directly rather than parsing the emitted regex string. - Lazy compile cache on `BuilderCore` — a builder's `to_regex()` result is memoised so `.match()` / `.search()` / `.explain()` share one compiled pattern. - `Regex.__getattr__` delegates any attribute not on the wrapper to the underlying `re.Pattern`, so `.pattern`, `.flags`, `.groups`, `.groupindex` continue to work. - `Regex.__eq__` / `Regex.__hash__` raise when called on an unfinished builder, pointing at both the compare/hash call site and the still-open frame or dangling quantifier. - Property test extended: every recursively-built composition of quantifiers, groups, captures, named captures, and subexpressions emits its expected regex exactly, no fragment dropped or duplicated.
Diffstat (limited to 'tests/builder/properties.test.py')
-rw-r--r--tests/builder/properties.test.py215
1 files changed, 186 insertions, 29 deletions
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