diff options
| author | Bobby <[email protected]> | 2026-07-10 16:59:00 +0530 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-07-10 16:59:00 +0530 |
| commit | 189bcdabe3865a0d52aea580d3fdbcc1cd48e896 (patch) | |
| tree | 8db00421de426b98a86d4b65665c71be7adcf0bf /tests/builder/properties.test.py | |
| parent | c757ea1fea447df3ed69e74b407ca9710a9f85e0 (diff) | |
| parent | 174eb521850550830d97994719ca3d09c467cb5a (diff) | |
| download | edify-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.py | 215 |
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 |
