aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authornatsuoto <[email protected]>2026-07-10 16:19:08 +0530
committernatsuoto <[email protected]>2026-07-10 16:19:08 +0530
commit127471e2c2ed56cc7d862d0b960bd0333e2179fc (patch)
tree274c3ab7a0facaa550d6635c1129dc74341b3b36
parent1ca22160898b7c9bcc945ac8d59d71b72f8a7116 (diff)
downloadedify-127471e2c2ed56cc7d862d0b960bd0333e2179fc.tar.xz
edify-127471e2c2ed56cc7d862d0b960bd0333e2179fc.zip
feat(result): Regex wrapper with AST, delegation, and introspection API (#133, #145, #146, #147, #148)
-rw-r--r--edify/builder/mixins/terminals.py6
-rw-r--r--edify/introspect/__init__.py5
-rw-r--r--edify/introspect/ascii.py466
-rw-r--r--edify/introspect/explain.py571
-rw-r--r--edify/introspect/graphviz.py275
-rw-r--r--edify/introspect/types.py35
-rw-r--r--edify/introspect/verbose.py363
-rw-r--r--edify/introspect/visualize.py33
-rw-r--r--edify/result/regex.py85
-rw-r--r--tests/introspect/ascii.test.py561
-rw-r--r--tests/introspect/explain.test.py823
-rw-r--r--tests/introspect/graphviz.test.py359
-rw-r--r--tests/introspect/verbose.test.py459
-rw-r--r--tests/introspect/visualize.test.py92
-rw-r--r--tests/result/regex.test.py29
15 files changed, 4145 insertions, 17 deletions
diff --git a/edify/builder/mixins/terminals.py b/edify/builder/mixins/terminals.py
index 0887afa..decf204 100644
--- a/edify/builder/mixins/terminals.py
+++ b/edify/builder/mixins/terminals.py
@@ -79,7 +79,11 @@ class TerminalsMixin(BuilderProtocol):
compiled_pattern = re.compile(pattern_string, flags=flag_bitmask)
except re.error as compile_error:
raise FailedToCompileRegexError(str(compile_error)) from compile_error
- return Regex(source=pattern_string, compiled=compiled_pattern)
+ return Regex(
+ source=pattern_string,
+ compiled=compiled_pattern,
+ elements=tuple(self._state.top_frame.children),
+ )
def _ensure_fully_specified(builder: BuilderProtocol) -> None:
diff --git a/edify/introspect/__init__.py b/edify/introspect/__init__.py
new file mode 100644
index 0000000..061282e
--- /dev/null
+++ b/edify/introspect/__init__.py
@@ -0,0 +1,5 @@
+from edify.introspect.explain import explain_elements
+from edify.introspect.verbose import verbose_elements
+from edify.introspect.visualize import visualize_elements
+
+__all__ = ["explain_elements", "verbose_elements", "visualize_elements"]
diff --git a/edify/introspect/ascii.py b/edify/introspect/ascii.py
new file mode 100644
index 0000000..e9b2b67
--- /dev/null
+++ b/edify/introspect/ascii.py
@@ -0,0 +1,466 @@
+"""ASCII railroad-diagram renderer for edify AST elements."""
+
+from __future__ import annotations
+
+import re
+
+from edify.elements.types.base import BaseElement
+from edify.elements.types.captures import (
+ BackReferenceElement,
+ CaptureElement,
+ NamedBackReferenceElement,
+ NamedCaptureElement,
+)
+from edify.elements.types.chars import (
+ AnyOfCharsElement,
+ AnythingButCharsElement,
+ AnythingButRangeElement,
+ AnythingButStringElement,
+ CharElement,
+ RangeElement,
+ StringElement,
+)
+from edify.elements.types.groups import (
+ AnyOfElement,
+ AssertAheadElement,
+ AssertBehindElement,
+ AssertNotAheadElement,
+ AssertNotBehindElement,
+ GroupElement,
+ SubexpressionElement,
+)
+from edify.elements.types.leaves import (
+ AlphanumericElement,
+ AnyCharElement,
+ CarriageReturnElement,
+ DigitElement,
+ EndOfInputElement,
+ LetterElement,
+ LowercaseElement,
+ NewLineElement,
+ NonDigitElement,
+ NonWhitespaceCharElement,
+ NonWordBoundaryElement,
+ NonWordElement,
+ NoopElement,
+ NullByteElement,
+ StartOfInputElement,
+ TabElement,
+ UppercaseElement,
+ WhitespaceCharElement,
+ WordBoundaryElement,
+ WordElement,
+)
+from edify.elements.types.quantifiers import (
+ AtLeastElement,
+ AtMostElement,
+ BetweenElement,
+ BetweenLazyElement,
+ ExactlyElement,
+ OneOrMoreElement,
+ OneOrMoreLazyElement,
+ OptionalElement,
+ ZeroOrMoreElement,
+ ZeroOrMoreLazyElement,
+)
+from edify.introspect.types import Diagram
+
+_EDGE = "-->"
+_EDGE_WIDTH = len(_EDGE)
+_INDENT = 3
+
+
+def render_ascii(elements: tuple[BaseElement, ...]) -> str:
+ """Return an ASCII railroad diagram for ``elements`` as a multi-line string."""
+ start_box = _boxed("START")
+ end_box = _boxed("END")
+ parts: list[Diagram] = [start_box]
+ for element in elements:
+ parts.append(_element_diagram(element))
+ parts.append(end_box)
+ diagram = _join_horizontal(parts)
+ indented = _indent_left(diagram, _INDENT)
+ return "\n".join(indented.rows)
+
+
+def _boxed(label: str) -> Diagram:
+ """Return a three-row boxed diagram with ``label`` centered in a one-space margin."""
+ content = f" {label} "
+ border = "+" + "-" * len(content) + "+"
+ middle = "|" + content + "|"
+ return Diagram(rows=(border, middle, border), entry_row=1, width=len(border))
+
+
+def _element_diagram(element: BaseElement) -> Diagram:
+ """Return the diagram for a single AST ``element``."""
+ inline = _inline_label(element)
+ if inline is not None:
+ return _boxed(inline)
+ quantifier = _quantifier_diagram(element)
+ if quantifier is not None:
+ return quantifier
+ if isinstance(element, AnyOfElement):
+ return _alternation_diagram(element.children)
+ if isinstance(element, SubexpressionElement):
+ return _sequence_diagram(element.children)
+ if isinstance(element, GroupElement):
+ return _annotated_sequence(element.children, "grouped")
+ if isinstance(element, CaptureElement):
+ return _annotated_sequence(element.children, "captured")
+ if isinstance(element, NamedCaptureElement):
+ return _annotated_sequence(element.children, f'saved as "{element.name}"')
+ if isinstance(element, BackReferenceElement):
+ return _boxed(f"match same text as group {element.index}")
+ if isinstance(element, NamedBackReferenceElement):
+ return _boxed(f'match same text as "{element.name}"')
+ if isinstance(element, AssertAheadElement):
+ return _annotated_sequence(element.children, "must be followed by")
+ if isinstance(element, AssertNotAheadElement):
+ return _annotated_sequence(element.children, "must NOT be followed by")
+ if isinstance(element, AssertBehindElement):
+ return _annotated_sequence(element.children, "must be preceded by")
+ if isinstance(element, AssertNotBehindElement):
+ return _annotated_sequence(element.children, "must NOT be preceded by")
+ return _boxed(f"?{type(element).__name__}")
+
+
+def _inline_label(element: BaseElement) -> str | None:
+ """Return a short plain-English label for a leaf or character element, or ``None``."""
+ leaf = _leaf_label(element)
+ if leaf is not None:
+ return leaf
+ return _char_label(element)
+
+
+def _leaf_label(element: BaseElement) -> str | None:
+ """Return the plain-English label for a leaf element, or ``None``."""
+ if isinstance(element, StartOfInputElement):
+ return "text starts here"
+ if isinstance(element, EndOfInputElement):
+ return "text ends here"
+ if isinstance(element, AnyCharElement):
+ return "any character"
+ if isinstance(element, WhitespaceCharElement):
+ return "whitespace"
+ if isinstance(element, NonWhitespaceCharElement):
+ return "non-whitespace"
+ if isinstance(element, DigitElement):
+ return "digit"
+ if isinstance(element, NonDigitElement):
+ return "non-digit character"
+ if isinstance(element, WordElement):
+ return "word character"
+ if isinstance(element, NonWordElement):
+ return "non-word character"
+ if isinstance(element, WordBoundaryElement):
+ return "word boundary"
+ if isinstance(element, NonWordBoundaryElement):
+ return "non-word boundary"
+ if isinstance(element, NewLineElement):
+ return "newline"
+ if isinstance(element, CarriageReturnElement):
+ return "carriage return"
+ if isinstance(element, TabElement):
+ return "tab"
+ if isinstance(element, NullByteElement):
+ return "null byte"
+ if isinstance(element, LetterElement):
+ return "letter"
+ if isinstance(element, UppercaseElement):
+ return "uppercase letter"
+ if isinstance(element, LowercaseElement):
+ return "lowercase letter"
+ if isinstance(element, AlphanumericElement):
+ return "letter or digit"
+ if isinstance(element, NoopElement):
+ return "no-op"
+ return None
+
+
+def _char_label(element: BaseElement) -> str | None:
+ """Return the plain-English label for a character-shaped element, or ``None``."""
+ if isinstance(element, CharElement):
+ return f'"{_display_string(element.value)}"'
+ if isinstance(element, StringElement):
+ return f'"{_display_string(element.value)}"'
+ if isinstance(element, RangeElement):
+ return f'any character from "{element.start}" to "{element.end}"'
+ if isinstance(element, AnyOfCharsElement):
+ return f'any of "{_display_string(element.value)}"'
+ if isinstance(element, AnythingButCharsElement):
+ return f'anything except "{_display_string(element.value)}"'
+ if isinstance(element, AnythingButRangeElement):
+ return f'anything outside "{element.start}"-"{element.end}"'
+ if isinstance(element, AnythingButStringElement):
+ return f'anything except the string "{_display_string(element.value)}"'
+ return None
+
+
+def _display_string(value: str) -> str:
+ """Return ``value`` with regex-escape backslashes stripped for human display."""
+ return re.sub(r"\\(.)", r"\1", value)
+
+
+def _quantifier_diagram(element: BaseElement) -> Diagram | None:
+ """Return the diagram for a quantifier wrapping a child, or ``None`` when not a quantifier."""
+ label = _quantifier_label(element)
+ if label is None:
+ return None
+ return _boxed(label)
+
+
+def _quantifier_label(element: BaseElement) -> str | None:
+ """Return a single-line plain-English label for a quantifier, or ``None``."""
+ if isinstance(element, ExactlyElement):
+ return _phrase_count(element.times, element.child)
+ if isinstance(element, OneOrMoreElement):
+ return f"one or more {_child_plural(element.child)}"
+ if isinstance(element, OneOrMoreLazyElement):
+ return f"one or more {_child_plural(element.child)} (lazy)"
+ if isinstance(element, ZeroOrMoreElement):
+ return f"zero or more {_child_plural(element.child)}"
+ if isinstance(element, ZeroOrMoreLazyElement):
+ return f"zero or more {_child_plural(element.child)} (lazy)"
+ if isinstance(element, OptionalElement):
+ return f"optional {_child_singular(element.child)}"
+ if isinstance(element, AtLeastElement):
+ return f"at least {element.times} {_child_plural(element.child)}"
+ if isinstance(element, AtMostElement):
+ return f"at most {element.times} {_child_plural(element.child)}"
+ if isinstance(element, BetweenElement):
+ return (
+ f"{element.lower} to {element.upper} {_child_plural(element.child)}"
+ )
+ if isinstance(element, BetweenLazyElement):
+ return (
+ f"{element.lower} to {element.upper} {_child_plural(element.child)} (lazy)"
+ )
+ return None
+
+
+def _child_singular(child: BaseElement) -> str:
+ """Return the singular plain-English label for a quantifier's child."""
+ label = _inline_label(child)
+ if label is not None:
+ return label
+ return "group"
+
+
+def _child_plural(child: BaseElement) -> str:
+ """Return the plural plain-English label for a quantifier's child."""
+ return _pluralize(_child_singular(child))
+
+
+def _phrase_count(times: int, child: BaseElement) -> str:
+ """Return a ``"{times} {label}"`` phrase, pluralized when ``times`` is not one."""
+ label = _child_singular(child)
+ if times == 1:
+ return f"1 {label}"
+ return f"{times} {_pluralize(label)}"
+
+
+def _pluralize(label: str) -> str:
+ """Return an English plural of ``label`` (respecting literal-string quotes)."""
+ if label.startswith('"') and label.endswith('"'):
+ return label
+ if label.endswith(("s", "x", "z", "ch", "sh")):
+ return label + "es"
+ if len(label) >= 2 and label.endswith("y") and label[-2] not in "aeiou":
+ return label[:-1] + "ies"
+ return label + "s"
+
+
+def _sequence_diagram(children: tuple[BaseElement, ...]) -> Diagram:
+ """Return the horizontal join of ``children`` diagrams with edge connectors."""
+ if not children:
+ return _boxed("nothing")
+ diagrams = [_element_diagram(child) for child in children]
+ return _join_horizontal(diagrams)
+
+
+def _annotated_sequence(children: tuple[BaseElement, ...], annotation: str) -> Diagram:
+ """Return the diagram for ``children`` with ``annotation`` displayed under the middle."""
+ inner = _sequence_diagram(children)
+ caption = f"({annotation})"
+ return _caption_below(inner, caption)
+
+
+def _caption_below(diagram: Diagram, caption: str) -> Diagram:
+ """Return ``diagram`` with a caption row appended below and centered under it.
+
+ When ``caption`` is longer than ``diagram.width``, the diagram is widened; the
+ entry row is padded with dashes so the horizontal flow line continues to the
+ new right edge, and the caption is centered under the widened diagram.
+ """
+ new_width = max(diagram.width, len(caption))
+ extra = new_width - diagram.width
+ padded_rows: list[str] = []
+ for row_index, row in enumerate(diagram.rows):
+ if extra == 0:
+ padded_rows.append(row)
+ elif row_index == diagram.entry_row:
+ padded_rows.append(row + "-" * extra)
+ else:
+ padded_rows.append(row + " " * extra)
+ left_pad = (new_width - len(caption)) // 2
+ right_pad = new_width - left_pad - len(caption)
+ caption_row = " " * left_pad + caption + " " * right_pad
+ return Diagram(
+ rows=tuple(padded_rows) + (caption_row,),
+ entry_row=diagram.entry_row,
+ width=new_width,
+ )
+
+
+def _alternation_diagram(children: tuple[BaseElement, ...]) -> Diagram:
+ """Return a vertical fork/merge diagram over ``children`` alternatives."""
+ if not children:
+ return _boxed("nothing")
+ branches = [_element_diagram(child) for child in children]
+ branch_widths = [branch.width for branch in branches]
+ branch_width = max(branch_widths)
+ padded = [_widen(branch, branch_width) for branch in branches]
+ branch_entry_rows: list[int] = []
+ body_rows: list[str] = []
+ for index, branch in enumerate(padded):
+ base_row = len(body_rows)
+ for row_index, row in enumerate(branch.rows):
+ if row_index == branch.entry_row:
+ body_rows.append(f"+--->{row}----+")
+ else:
+ body_rows.append(f"| {row} |")
+ branch_entry_rows.append(base_row + branch.entry_row)
+ if index < len(padded) - 1:
+ body_rows.append(f"| {' ' * branch_width} |")
+ body_rows = _clip_trunk_above(body_rows, branch_entry_rows[0])
+ body_rows = _clip_trunk_below(body_rows, branch_entry_rows[-1])
+ entry_row = branch_entry_rows[len(branch_entry_rows) // 2]
+ return Diagram(
+ rows=tuple(body_rows),
+ entry_row=entry_row,
+ width=branch_width + 10,
+ )
+
+
+def _clip_trunk_above(rows: list[str], first_entry: int) -> list[str]:
+ """Replace trunk ``|`` characters in rows above ``first_entry`` with spaces."""
+ result = list(rows)
+ for index in range(first_entry):
+ row = result[index]
+ result[index] = " " + row[1:-1] + " "
+ return result
+
+
+def _clip_trunk_below(rows: list[str], last_entry: int) -> list[str]:
+ """Replace trunk ``|`` characters in rows below ``last_entry`` with spaces."""
+ result = list(rows)
+ for index in range(last_entry + 1, len(result)):
+ row = result[index]
+ result[index] = " " + row[1:-1] + " "
+ return result
+
+
+def _pad_right(diagram: Diagram, target_width: int) -> Diagram:
+ """Return ``diagram`` right-padded with spaces so every row reaches ``target_width``."""
+ if diagram.width >= target_width:
+ return diagram
+ extra = target_width - diagram.width
+ padded_rows_list = [row + " " * extra for row in diagram.rows]
+ new_rows = tuple(padded_rows_list)
+ return Diagram(rows=new_rows, entry_row=diagram.entry_row, width=target_width)
+
+
+def _widen(diagram: Diagram, target_width: int) -> Diagram:
+ """Return ``diagram`` widened to ``target_width``, extending single-box borders in place."""
+ if diagram.width >= target_width:
+ return diagram
+ if _looks_like_single_box(diagram):
+ return _widen_box(diagram, target_width)
+ return _pad_right(diagram, target_width)
+
+
+def _looks_like_single_box(diagram: Diagram) -> bool:
+ """Return ``True`` when ``diagram`` is a plain three-row ``+---+ | X | +---+`` box."""
+ if len(diagram.rows) != 3 or diagram.entry_row != 1:
+ return False
+ top, middle, bottom = diagram.rows
+ if top != bottom:
+ return False
+ if not (top.startswith("+") and top.endswith("+")):
+ return False
+ if not (middle.startswith("|") and middle.endswith("|")):
+ return False
+ return set(top[1:-1]) == {"-"}
+
+
+def _widen_box(diagram: Diagram, target_width: int) -> Diagram:
+ """Return a simple box ``diagram`` widened to ``target_width`` by extending its borders."""
+ extra = target_width - diagram.width
+ top, middle, _bottom = diagram.rows
+ new_border = "+" + "-" * (target_width - 2) + "+"
+ interior = middle[1:-1]
+ new_middle = "|" + interior + " " * extra + "|"
+ return Diagram(
+ rows=(new_border, new_middle, new_border),
+ entry_row=1,
+ width=target_width,
+ )
+
+
+def _join_horizontal(diagrams: list[Diagram]) -> Diagram:
+ """Return the horizontal concatenation of ``diagrams`` with edge connectors between."""
+ result = diagrams[0]
+ for next_diagram in diagrams[1:]:
+ result = _join_two(result, next_diagram)
+ return result
+
+
+def _join_two(left: Diagram, right: Diagram) -> Diagram:
+ """Return ``left`` and ``right`` joined on their shared entry row with an ``_EDGE`` connector."""
+ target_entry = max(left.entry_row, right.entry_row)
+ left_shifted = _align_entry_row(left, target_entry)
+ right_shifted = _align_entry_row(right, target_entry)
+ height = max(len(left_shifted.rows), len(right_shifted.rows))
+ left_expanded = _pad_below(left_shifted, height)
+ right_expanded = _pad_below(right_shifted, height)
+ new_rows: list[str] = []
+ for row_index in range(height):
+ connector = _EDGE if row_index == target_entry else " " * _EDGE_WIDTH
+ new_rows.append(
+ left_expanded.rows[row_index] + connector + right_expanded.rows[row_index]
+ )
+ combined_width = left_expanded.width + _EDGE_WIDTH + right_expanded.width
+ return Diagram(rows=tuple(new_rows), entry_row=target_entry, width=combined_width)
+
+
+def _align_entry_row(diagram: Diagram, target_entry_row: int) -> Diagram:
+ """Return ``diagram`` with blank rows above so its entry row lands at ``target_entry_row``."""
+ if diagram.entry_row >= target_entry_row:
+ return diagram
+ extra_rows_above = target_entry_row - diagram.entry_row
+ blank = " " * diagram.width
+ new_rows = (blank,) * extra_rows_above + diagram.rows
+ return Diagram(rows=new_rows, entry_row=target_entry_row, width=diagram.width)
+
+
+def _pad_below(diagram: Diagram, target_height: int) -> Diagram:
+ """Return ``diagram`` with blank rows appended so it reaches ``target_height`` rows total."""
+ current_height = len(diagram.rows)
+ if current_height >= target_height:
+ return diagram
+ extra_rows_below = target_height - current_height
+ blank = " " * diagram.width
+ new_rows = diagram.rows + (blank,) * extra_rows_below
+ return Diagram(rows=new_rows, entry_row=diagram.entry_row, width=diagram.width)
+
+
+def _indent_left(diagram: Diagram, spaces: int) -> Diagram:
+ """Return ``diagram`` with ``spaces`` blank columns prepended to every row."""
+ prefix = " " * spaces
+ indented_rows_list = [prefix + row for row in diagram.rows]
+ new_rows = tuple(indented_rows_list)
+ return Diagram(
+ rows=new_rows,
+ entry_row=diagram.entry_row,
+ width=diagram.width + spaces,
+ )
diff --git a/edify/introspect/explain.py b/edify/introspect/explain.py
new file mode 100644
index 0000000..c4a935b
--- /dev/null
+++ b/edify/introspect/explain.py
@@ -0,0 +1,571 @@
+"""Plain-English explanation renderer for edify AST elements."""
+
+from __future__ import annotations
+
+from edify.elements.types.base import BaseElement
+from edify.elements.types.captures import (
+ BackReferenceElement,
+ CaptureElement,
+ NamedBackReferenceElement,
+ NamedCaptureElement,
+)
+from edify.elements.types.chars import (
+ AnyOfCharsElement,
+ AnythingButCharsElement,
+ AnythingButRangeElement,
+ AnythingButStringElement,
+ CharElement,
+ RangeElement,
+ StringElement,
+)
+from edify.elements.types.groups import (
+ AnyOfElement,
+ AssertAheadElement,
+ AssertBehindElement,
+ AssertNotAheadElement,
+ AssertNotBehindElement,
+ GroupElement,
+ SubexpressionElement,
+)
+from edify.elements.types.leaves import (
+ AlphanumericElement,
+ AnyCharElement,
+ CarriageReturnElement,
+ DigitElement,
+ EndOfInputElement,
+ LetterElement,
+ LowercaseElement,
+ NewLineElement,
+ NonDigitElement,
+ NonWhitespaceCharElement,
+ NonWordBoundaryElement,
+ NonWordElement,
+ NoopElement,
+ NullByteElement,
+ StartOfInputElement,
+ TabElement,
+ UppercaseElement,
+ WhitespaceCharElement,
+ WordBoundaryElement,
+ WordElement,
+)
+from edify.elements.types.quantifiers import (
+ AtLeastElement,
+ AtMostElement,
+ BetweenElement,
+ BetweenLazyElement,
+ ExactlyElement,
+ OneOrMoreElement,
+ OneOrMoreLazyElement,
+ OptionalElement,
+ ZeroOrMoreElement,
+ ZeroOrMoreLazyElement,
+)
+
+
+def explain_elements(elements: tuple[BaseElement, ...]) -> str:
+ """Return a bullet-list explanation plus accept examples for ``elements``."""
+ if not elements:
+ return "This pattern is empty and matches an empty string."
+ flat_elements = _flatten(elements)
+ build_lines = _build_lines(flat_elements)
+ all_match_examples = _generate_match_examples(flat_elements)
+ non_empty_match_examples = [example for example in all_match_examples if example]
+ lines: list[str] = [f"- {description}" for description in build_lines]
+ if non_empty_match_examples:
+ lines.append("")
+ lines.append("Text this pattern accepts:")
+ lines.extend(f" {example}" for example in non_empty_match_examples)
+ return "\n".join(lines)
+
+
+def _flatten(elements: tuple[BaseElement, ...]) -> tuple[BaseElement, ...]:
+ """Return ``elements`` with every :class:`SubexpressionElement` recursively inlined."""
+ result: list[BaseElement] = []
+ for element in elements:
+ if isinstance(element, SubexpressionElement):
+ result.extend(_flatten(element.children))
+ else:
+ result.append(element)
+ return tuple(result)
+
+
+def _build_lines(elements: tuple[BaseElement, ...]) -> list[str]:
+ """Return one bullet description per top-level element after skipping the anchor pair."""
+ working = list(elements)
+ starts_with_start_anchor = bool(working) and isinstance(working[0], StartOfInputElement)
+ if starts_with_start_anchor:
+ working = working[1:]
+ if (
+ len(working) >= 2
+ and isinstance(working[-1], EndOfInputElement)
+ and isinstance(working[-2], AssertAheadElement)
+ ):
+ trailing_lookahead = working[-2]
+ working = working[:-2]
+ trailing_line = (
+ f"The text must end at {_describe_inline_children(trailing_lookahead.children)}."
+ )
+ elif working and isinstance(working[-1], EndOfInputElement):
+ working = working[:-1]
+ trailing_line = None
+ else:
+ trailing_line = None
+ lines: list[str] = []
+ for index, element in enumerate(working):
+ is_first = index == 0
+ lines.append(
+ _describe_step(
+ element,
+ is_first_step=is_first,
+ anchored_at_start=starts_with_start_anchor,
+ )
+ )
+ if trailing_line is not None:
+ lines.append(trailing_line)
+ return lines
+
+
+def _wrap_step(step_number: int, description: str) -> str:
+ """Return the description prefixed with the step number and 3-space continuation indent."""
+ prefix = f" {step_number}. "
+ continuation_prefix = " " * len(prefix)
+ words = description.split()
+ if not words:
+ return prefix.rstrip()
+ lines: list[str] = []
+ current_line = prefix + words[0]
+ max_width = 76
+ for word in words[1:]:
+ candidate = current_line + " " + word
+ if len(candidate) > max_width:
+ lines.append(current_line)
+ current_line = continuation_prefix + word
+ else:
+ current_line = candidate
+ lines.append(current_line)
+ return "\n".join(lines)
+
+
+def _describe_step(
+ element: BaseElement,
+ *,
+ is_first_step: bool,
+ anchored_at_start: bool,
+) -> str:
+ """Return a one-sentence plain-English description of a top-level ``element``."""
+ if isinstance(element, WordBoundaryElement):
+ return (
+ "This point must fall on a word boundary — where a letter, digit, "
+ "or underscore meets something else."
+ )
+ if isinstance(element, NonWordBoundaryElement):
+ return (
+ "This point must NOT fall on a word boundary — it must be inside a "
+ "run of letters, digits, or underscores, or between two other "
+ "characters."
+ )
+ starting_form = "The text must start with" if anchored_at_start else "The text must contain"
+ connector = starting_form if is_first_step else "Then the text must have"
+ if isinstance(element, OptionalElement):
+ return f"Optional: {_describe_optional_inner(element.child)}."
+ if isinstance(element, ZeroOrMoreElement):
+ return f"{connector} zero or more {_describe_plural(element.child)}."
+ if isinstance(element, ZeroOrMoreLazyElement):
+ return f"{connector} zero or more {_describe_plural(element.child)} (as few as possible)."
+ if isinstance(element, OneOrMoreElement):
+ return f"{connector} one or more {_describe_plural(element.child)}."
+ if isinstance(element, OneOrMoreLazyElement):
+ return f"{connector} one or more {_describe_plural(element.child)} (as few as possible)."
+ if isinstance(element, ExactlyElement):
+ return f"{connector} exactly {element.times} {_describe_plural(element.child)}."
+ if isinstance(element, AtLeastElement):
+ return f"{connector} at least {element.times} {_describe_plural(element.child)}."
+ if isinstance(element, AtMostElement):
+ return f"{connector} at most {element.times} {_describe_plural(element.child)}."
+ if isinstance(element, BetweenElement):
+ return (
+ f"{connector} between {element.lower} and {element.upper} "
+ f"{_describe_plural(element.child)}."
+ )
+ if isinstance(element, BetweenLazyElement):
+ return (
+ f"{connector} between {element.lower} and {element.upper} "
+ f"{_describe_plural(element.child)} (as few as possible)."
+ )
+ if isinstance(element, CaptureElement):
+ return f"{connector} {_describe_inline_children(element.children)}."
+ if isinstance(element, NamedCaptureElement):
+ return f"{connector} {_describe_inline_children(element.children)}."
+ if isinstance(element, BackReferenceElement):
+ return f"{connector} the exact same text that appeared earlier in group #{element.index}."
+ if isinstance(element, NamedBackReferenceElement):
+ return (
+ f"{connector} the exact same text that appeared earlier in the "
+ f'group labeled "{element.name}".'
+ )
+ if isinstance(element, AssertAheadElement):
+ return f"Then the text must be followed by {_describe_inline_children(element.children)}."
+ if isinstance(element, AssertNotAheadElement):
+ return (
+ f"Then the text must NOT be followed by {_describe_inline_children(element.children)}."
+ )
+ if isinstance(element, AssertBehindElement):
+ return (
+ f"Just before this point, {_describe_inline_children(element.children)} "
+ "must have appeared."
+ )
+ if isinstance(element, AssertNotBehindElement):
+ return (
+ f"Just before this point, {_describe_inline_children(element.children)} "
+ "must NOT have appeared."
+ )
+ if isinstance(element, GroupElement):
+ return f"{connector} {_describe_inline_children(element.children)}."
+ if isinstance(element, AnyOfElement):
+ return f"{connector} {_describe_alternatives(element.children)}."
+ inline = _describe_inline(element)
+ return f"{connector} {inline}."
+
+
+def _describe_optional_inner(element: BaseElement) -> str:
+ """Return the phrase used after "may optionally continue with"."""
+ if isinstance(element, SubexpressionElement | GroupElement):
+ return _describe_inline_children(element.children)
+ if isinstance(element, CharElement):
+ return f'"{_unescape_literal(element.value)}"'
+ if isinstance(element, StringElement):
+ return f'"{_unescape_literal(element.value)}"'
+ return _describe_inline(element)
+
+
+def _describe_plural(element: BaseElement) -> str:
+ """Return the plural-stem phrase for ``element`` used after a count word like "one or more"."""
+ if isinstance(element, AnyCharElement):
+ return "characters (any character)"
+ if isinstance(element, WhitespaceCharElement):
+ return "whitespace characters (space, tab, newline, etc.)"
+ if isinstance(element, NonWhitespaceCharElement):
+ return "non-whitespace characters"
+ if isinstance(element, DigitElement):
+ return "digits (0-9)"
+ if isinstance(element, NonDigitElement):
+ return "non-digit characters"
+ if isinstance(element, WordElement):
+ return "letters, digits, or underscores"
+ if isinstance(element, NonWordElement):
+ return "characters that are not letters, digits, or underscores"
+ if isinstance(element, NewLineElement):
+ return 'line-feed characters ("\\n")'
+ if isinstance(element, CarriageReturnElement):
+ return 'carriage-return characters ("\\r")'
+ if isinstance(element, TabElement):
+ return 'tab characters ("\\t")'
+ if isinstance(element, NullByteElement):
+ return 'null bytes ("\\0")'
+ if isinstance(element, LetterElement):
+ return "letters (a-z or A-Z)"
+ if isinstance(element, UppercaseElement):
+ return "uppercase letters (A-Z)"
+ if isinstance(element, LowercaseElement):
+ return "lowercase letters (a-z)"
+ if isinstance(element, AlphanumericElement):
+ return "letters or digits (a-z, A-Z, or 0-9)"
+ if isinstance(element, CharElement):
+ return f'copies of the character "{_unescape_literal(element.value)}"'
+ if isinstance(element, StringElement):
+ return f'copies of the text "{_unescape_literal(element.value)}"'
+ if isinstance(element, RangeElement):
+ return f'characters from "{element.start}" through "{element.end}"'
+ if isinstance(element, AnyOfCharsElement):
+ return f'characters from the set "{element.value}"'
+ if isinstance(element, AnythingButCharsElement):
+ return f'characters NOT from the set "{element.value}"'
+ if isinstance(element, AnythingButRangeElement):
+ return f'characters outside "{element.start}" through "{element.end}"'
+ return f"of {_describe_inline(element)}"
+
+
+def _describe_inline(element: BaseElement) -> str:
+ """Return an inline phrase for ``element`` suitable for embedding in a sentence."""
+ if isinstance(element, StartOfInputElement):
+ return "the very beginning of the text"
+ if isinstance(element, EndOfInputElement):
+ return "the very end of the text"
+ if isinstance(element, AnyCharElement):
+ return "any single character"
+ if isinstance(element, WhitespaceCharElement):
+ return "one whitespace character (space, tab, newline, etc.)"
+ if isinstance(element, NonWhitespaceCharElement):
+ return "one non-whitespace character"
+ if isinstance(element, DigitElement):
+ return "one digit (0-9)"
+ if isinstance(element, NonDigitElement):
+ return "one non-digit character"
+ if isinstance(element, WordElement):
+ return "one letter, digit, or underscore"
+ if isinstance(element, NonWordElement):
+ return "one character that is not a letter, digit, or underscore"
+ if isinstance(element, WordBoundaryElement):
+ return "a word boundary"
+ if isinstance(element, NonWordBoundaryElement):
+ return "a non-word-boundary position"
+ if isinstance(element, NewLineElement):
+ return 'a line-feed character (the newline "\\n")'
+ if isinstance(element, CarriageReturnElement):
+ return 'a carriage-return character ("\\r")'
+ if isinstance(element, TabElement):
+ return 'a tab character ("\\t")'
+ if isinstance(element, NullByteElement):
+ return 'the null byte ("\\0")'
+ if isinstance(element, LetterElement):
+ return "one letter (a-z or A-Z)"
+ if isinstance(element, UppercaseElement):
+ return "one uppercase letter (A-Z)"
+ if isinstance(element, LowercaseElement):
+ return "one lowercase letter (a-z)"
+ if isinstance(element, AlphanumericElement):
+ return "one letter or digit (a-z, A-Z, or 0-9)"
+ if isinstance(element, NoopElement):
+ return "nothing"
+ if isinstance(element, CharElement):
+ return f'"{_unescape_literal(element.value)}"'
+ if isinstance(element, StringElement):
+ return f'"{_unescape_literal(element.value)}"'
+ if isinstance(element, RangeElement):
+ return f'one character from "{element.start}" through "{element.end}"'
+ if isinstance(element, AnyOfCharsElement):
+ return f'one character from the set "{element.value}"'
+ if isinstance(element, AnythingButCharsElement):
+ return f'one character NOT from the set "{element.value}"'
+ if isinstance(element, AnythingButRangeElement):
+ return f'one character outside "{element.start}" through "{element.end}"'
+ if isinstance(element, AnythingButStringElement):
+ return (
+ f"a run of {len(element.value)} characters "
+ f'not matching "{element.value}" position-for-position'
+ )
+ if isinstance(element, OptionalElement):
+ return f"an optional {_describe_optional_inner(element.child)}"
+ if isinstance(element, ZeroOrMoreElement):
+ return f"zero or more {_describe_plural(element.child)}"
+ if isinstance(element, ZeroOrMoreLazyElement):
+ return f"zero or more {_describe_plural(element.child)} (as few as possible)"
+ if isinstance(element, OneOrMoreElement):
+ return f"one or more {_describe_plural(element.child)}"
+ if isinstance(element, OneOrMoreLazyElement):
+ return f"one or more {_describe_plural(element.child)} (as few as possible)"
+ if isinstance(element, ExactlyElement):
+ return f"exactly {element.times} {_describe_plural(element.child)}"
+ if isinstance(element, AtLeastElement):
+ return f"at least {element.times} {_describe_plural(element.child)}"
+ if isinstance(element, AtMostElement):
+ return f"at most {element.times} {_describe_plural(element.child)}"
+ if isinstance(element, BetweenElement):
+ return f"between {element.lower} and {element.upper} {_describe_plural(element.child)}"
+ if isinstance(element, BetweenLazyElement):
+ return (
+ f"between {element.lower} and {element.upper} "
+ f"{_describe_plural(element.child)} (as few as possible)"
+ )
+ if isinstance(element, CaptureElement):
+ return f"{_describe_inline_children(element.children)} (captured)"
+ if isinstance(element, NamedCaptureElement):
+ return (
+ f"{_describe_inline_children(element.children)} "
+ f'(captured under the label "{element.name}")'
+ )
+ if isinstance(element, GroupElement):
+ return _describe_inline_children(element.children)
+ if isinstance(element, AnyOfElement):
+ return _describe_alternatives(element.children)
+ if isinstance(element, SubexpressionElement):
+ return _describe_inline_children(element.children)
+ if isinstance(element, BackReferenceElement):
+ return f"the same text that group #{element.index} captured"
+ if isinstance(element, NamedBackReferenceElement):
+ return f'the same text that the "{element.name}" group captured'
+ return type(element).__name__
+
+
+def _describe_inline_children(children: tuple[BaseElement, ...]) -> str:
+ """Return the "A, then B, then C" phrase describing ``children`` in sequence."""
+ flat = _flatten(children)
+ if not flat:
+ return "nothing"
+ phrases = [_describe_inline(child) for child in flat]
+ if len(phrases) == 1:
+ return phrases[0]
+ return ", then ".join(phrases)
+
+
+def _describe_alternatives(children: tuple[BaseElement, ...]) -> str:
+ """Return the "either A or B or C" phrase describing an alternation."""
+ if not children:
+ return "nothing"
+ phrases = [_describe_inline(child) for child in children]
+ if len(phrases) == 1:
+ return phrases[0]
+ if len(phrases) == 2:
+ return f"either {phrases[0]} or {phrases[1]}"
+ joined = ", ".join(phrases[:-1])
+ return f"either {joined}, or {phrases[-1]}"
+
+
+def _generate_match_examples(elements: tuple[BaseElement, ...]) -> list[str]:
+ """Return a small set of concrete strings that the pattern matches."""
+ seeds = [_build_example(elements, alternative_index) for alternative_index in range(3)]
+ unique_seeds: list[str] = []
+ for seed in seeds:
+ if seed not in unique_seeds:
+ unique_seeds.append(seed)
+ return unique_seeds
+
+
+def _build_example(elements: tuple[BaseElement, ...], alternative_index: int) -> str:
+ """Return one matching example string using ``alternative_index`` where alternation applies."""
+ parts = [_example_for(element, alternative_index) for element in elements]
+ return "".join(parts)
+
+
+_LETTER_ROTATION = ("e", "o", "a", "i", "u")
+_DIGIT_ROTATION = ("1", "2", "3", "4", "5", "6", "7", "8", "9", "0")
+_UPPERCASE_ROTATION = ("A", "B", "C", "D", "E")
+_ALPHANUMERIC_ROTATION = ("a", "1", "b", "2", "c", "3")
+_WORD_ROTATION = ("a", "b", "1", "_", "c", "2", "d")
+
+
+def _example_for(element: BaseElement, alternative_index: int) -> str:
+ """Return a piece of text ``element`` accepts, using ``alternative_index`` where relevant."""
+ if isinstance(
+ element,
+ StartOfInputElement | EndOfInputElement | WordBoundaryElement | NonWordBoundaryElement,
+ ):
+ return ""
+ if isinstance(element, AnyCharElement):
+ return _LETTER_ROTATION[alternative_index % len(_LETTER_ROTATION)]
+ if isinstance(element, WhitespaceCharElement):
+ return " "
+ if isinstance(element, NonWhitespaceCharElement):
+ return _LETTER_ROTATION[alternative_index % len(_LETTER_ROTATION)]
+ if isinstance(element, DigitElement):
+ return _DIGIT_ROTATION[alternative_index % len(_DIGIT_ROTATION)]
+ if isinstance(element, NonDigitElement):
+ return _LETTER_ROTATION[alternative_index % len(_LETTER_ROTATION)]
+ if isinstance(element, WordElement):
+ return _WORD_ROTATION[alternative_index % len(_WORD_ROTATION)]
+ if isinstance(element, NonWordElement):
+ return "-"
+ if isinstance(element, NewLineElement):
+ return "\n"
+ if isinstance(element, CarriageReturnElement):
+ return "\r"
+ if isinstance(element, TabElement):
+ return "\t"
+ if isinstance(element, NullByteElement):
+ return "\0"
+ if isinstance(element, LetterElement):
+ return _LETTER_ROTATION[alternative_index % len(_LETTER_ROTATION)]
+ if isinstance(element, UppercaseElement):
+ return _UPPERCASE_ROTATION[alternative_index % len(_UPPERCASE_ROTATION)]
+ if isinstance(element, LowercaseElement):
+ return _LETTER_ROTATION[alternative_index % len(_LETTER_ROTATION)]
+ if isinstance(element, AlphanumericElement):
+ return _ALPHANUMERIC_ROTATION[alternative_index % len(_ALPHANUMERIC_ROTATION)]
+ if isinstance(element, NoopElement):
+ return ""
+ if isinstance(element, CharElement):
+ return _unescape_literal(element.value)
+ if isinstance(element, StringElement):
+ return _unescape_literal(element.value)
+ if isinstance(element, RangeElement):
+ return element.start
+ if isinstance(element, AnyOfCharsElement):
+ if not element.value:
+ return "a"
+ raw = _unescape_literal(element.value)
+ return raw[alternative_index % len(raw)]
+ if isinstance(element, AnythingButCharsElement):
+ return _pick_character_not_in(element.value)
+ if isinstance(element, AnythingButRangeElement):
+ return _pick_character_outside_range(element.start, element.end)
+ if isinstance(element, AnythingButStringElement):
+ return "x" * len(element.value) if element.value else ""
+ if isinstance(element, OptionalElement):
+ if alternative_index % 2 == 0:
+ return _example_for(element.child, alternative_index)
+ return ""
+ if isinstance(element, ZeroOrMoreElement | ZeroOrMoreLazyElement):
+ return _expand_variable(element.child, alternative_index, minimum=1)
+ if isinstance(element, OneOrMoreElement | OneOrMoreLazyElement):
+ return _expand_variable(element.child, alternative_index, minimum=1)
+ if isinstance(element, ExactlyElement):
+ return _expand_child_n_times(element.child, alternative_index, element.times)
+ if isinstance(element, AtLeastElement):
+ return _expand_child_n_times(element.child, alternative_index, element.times + 1)
+ if isinstance(element, AtMostElement):
+ take = max(1, min(element.times, 2 + alternative_index % max(1, element.times)))
+ return _expand_child_n_times(element.child, alternative_index, take)
+ if isinstance(element, BetweenElement | BetweenLazyElement):
+ take = element.lower + alternative_index % max(1, element.upper - element.lower + 1)
+ take = max(element.lower, min(element.upper, take))
+ return _expand_child_n_times(element.child, alternative_index, take)
+ if isinstance(
+ element, CaptureElement | NamedCaptureElement | GroupElement | SubexpressionElement
+ ):
+ return "".join(_example_for(child, alternative_index) for child in element.children)
+ if isinstance(element, AnyOfElement):
+ if not element.children:
+ return ""
+ chosen = element.children[alternative_index % len(element.children)]
+ return _example_for(chosen, alternative_index)
+ if isinstance(element, AssertAheadElement | AssertBehindElement):
+ return "".join(_example_for(child, alternative_index) for child in element.children)
+ if isinstance(element, AssertNotAheadElement | AssertNotBehindElement):
+ return ""
+ if isinstance(element, BackReferenceElement | NamedBackReferenceElement):
+ return "a"
+ return ""
+
+
+def _expand_variable(child: BaseElement, alternative_index: int, minimum: int) -> str:
+ """Return a run of ``child`` examples between 3 and 6 characters long depending on rotation."""
+ run_length = max(minimum, 3 + alternative_index % 3)
+ return _expand_child_n_times(child, alternative_index, run_length)
+
+
+def _expand_child_n_times(child: BaseElement, alternative_index: int, count: int) -> str:
+ """Return ``count`` example pieces of ``child`` back-to-back, varying content per position."""
+ pieces = [_example_for(child, alternative_index + position) for position in range(count)]
+ return "".join(pieces)
+
+
+def _unescape_literal(escaped_value: str) -> str:
+ """Return ``escaped_value`` stripped of the leading backslashes edify adds around metachars."""
+ result: list[str] = []
+ index = 0
+ while index < len(escaped_value):
+ character = escaped_value[index]
+ if character == "\\" and index + 1 < len(escaped_value):
+ result.append(escaped_value[index + 1])
+ index = index + 2
+ else:
+ result.append(character)
+ index = index + 1
+ return "".join(result)
+
+
+def _pick_character_not_in(disallowed: str) -> str:
+ """Return a single character that is not in ``disallowed``."""
+ for candidate in "abcdefghijklmnopqrstuvwxyz0123456789":
+ if candidate not in disallowed:
+ return candidate
+ return "!"
+
+
+def _pick_character_outside_range(start: str, end: str) -> str:
+ """Return a single character outside the ``start``-through-``end`` range."""
+ for candidate in "abcdefghijklmnopqrstuvwxyz0123456789":
+ if not (start <= candidate <= end):
+ return candidate
+ return "!"
diff --git a/edify/introspect/graphviz.py b/edify/introspect/graphviz.py
new file mode 100644
index 0000000..3e4b236
--- /dev/null
+++ b/edify/introspect/graphviz.py
@@ -0,0 +1,275 @@
+"""Graphviz DOT / SVG renderer for edify AST elements."""
+
+from __future__ import annotations
+
+from edify.elements.types.base import BaseElement
+from edify.elements.types.captures import (
+ BackReferenceElement,
+ CaptureElement,
+ NamedBackReferenceElement,
+ NamedCaptureElement,
+)
+from edify.elements.types.groups import (
+ AnyOfElement,
+ AssertAheadElement,
+ AssertBehindElement,
+ AssertNotAheadElement,
+ AssertNotBehindElement,
+ GroupElement,
+ SubexpressionElement,
+)
+from edify.elements.types.quantifiers import (
+ AtLeastElement,
+ AtMostElement,
+ BetweenElement,
+ BetweenLazyElement,
+ ExactlyElement,
+ OneOrMoreElement,
+ OneOrMoreLazyElement,
+ OptionalElement,
+ ZeroOrMoreElement,
+ ZeroOrMoreLazyElement,
+)
+from edify.errors.introspect import MissingGraphvizDependencyError
+from edify.introspect.ascii import _char_label, _leaf_label
+from edify.introspect.types import Emission
+
+try:
+ import graphviz as _graphviz_module
+except ImportError:
+ _graphviz_module = None
+
+
+def render_graphviz_svg(elements: tuple[BaseElement, ...]) -> str:
+ """Return an SVG rendering of ``elements`` produced by Graphviz."""
+ if _graphviz_module is None:
+ raise MissingGraphvizDependencyError()
+ dot_source = render_dot(elements)
+ source = _graphviz_module.Source(dot_source, format="svg")
+ piped_bytes = source.pipe(format="svg")
+ return piped_bytes.decode("utf-8")
+
+
+def render_dot(elements: tuple[BaseElement, ...]) -> str:
+ """Return the DOT source describing ``elements``."""
+ counter = _Counter()
+ entry_id, exit_id, sequence_lines = _emit_sequence(elements, counter)
+ body: list[str] = list(sequence_lines)
+ if entry_id is None:
+ body.append(" start -> end;")
+ else:
+ body.append(f" start -> {entry_id};")
+ body.append(f" {exit_id} -> end;")
+ header = [
+ "digraph edify_pattern {",
+ " rankdir=LR;",
+ ' node [shape=box, style=rounded, fontname="Menlo"];',
+ ' edge [fontname="Menlo"];',
+ ' start [label="START", shape=circle];',
+ ' end [label="END", shape=circle];',
+ ]
+ return "\n".join(header + body + ["}"])
+
+
+def _emit_sequence(
+ elements: tuple[BaseElement, ...], counter: _Counter
+) -> tuple[str | None, str | None, list[str]]:
+ """Return ``(entry_id, exit_id, lines)`` for a left-to-right sequence of ``elements``."""
+ if not elements:
+ return None, None, []
+ lines: list[str] = []
+ first_entry: str | None = None
+ prev_exit: str | None = None
+ for element in elements:
+ emission = _emit_element(element, counter)
+ lines.extend(emission.lines)
+ if first_entry is None:
+ first_entry = emission.entry_id
+ if prev_exit is not None:
+ lines.append(f" {prev_exit} -> {emission.entry_id};")
+ prev_exit = emission.exit_id
+ return first_entry, prev_exit, lines
+
+
+def _emit_element(element: BaseElement, counter: _Counter) -> Emission:
+ """Return the DOT emission for a single AST ``element``."""
+ inline_label = _inline_label(element)
+ if inline_label is not None:
+ return _emit_leaf(inline_label, counter)
+ if isinstance(element, AnyOfElement):
+ return _emit_alternation(element.children, counter)
+ if isinstance(element, SubexpressionElement):
+ return _emit_subexpression(element.children, counter)
+ if isinstance(element, GroupElement):
+ return _emit_cluster(element.children, "grouped", counter)
+ if isinstance(element, CaptureElement):
+ return _emit_cluster(element.children, "captured", counter)
+ if isinstance(element, NamedCaptureElement):
+ return _emit_cluster(element.children, f'saved as "{element.name}"', counter)
+ if isinstance(element, AssertAheadElement):
+ return _emit_cluster(element.children, "must be followed by", counter)
+ if isinstance(element, AssertNotAheadElement):
+ return _emit_cluster(element.children, "must NOT be followed by", counter)
+ if isinstance(element, AssertBehindElement):
+ return _emit_cluster(element.children, "must be preceded by", counter)
+ if isinstance(element, AssertNotBehindElement):
+ return _emit_cluster(element.children, "must NOT be preceded by", counter)
+ if isinstance(element, BackReferenceElement):
+ return _emit_leaf(f"match same text as group {element.index}", counter)
+ if isinstance(element, NamedBackReferenceElement):
+ return _emit_leaf(f'match same text as "{element.name}"', counter)
+ complex_quantifier = _emit_quantifier_cluster(element, counter)
+ if complex_quantifier is not None:
+ return complex_quantifier
+ return _emit_leaf(f"?{type(element).__name__}", counter)
+
+
+def _emit_leaf(label: str, counter: _Counter) -> Emission:
+ """Emit a single box node labelled with ``label`` and return its emission."""
+ node_id = counter.next("n")
+ lines = (f' {node_id} [label="{_escape_dot(label)}"];',)
+ return Emission(entry_id=node_id, exit_id=node_id, lines=lines)
+
+
+def _emit_alternation(
+ children: tuple[BaseElement, ...], counter: _Counter
+) -> Emission:
+ """Emit an alternation as fork/merge over ``children`` using junction points."""
+ if not children:
+ return _emit_leaf("nothing", counter)
+ fork_id = counter.next("fork")
+ merge_id = counter.next("merge")
+ lines: list[str] = [
+ f' {fork_id} [label="", shape=point, width=0.08];',
+ f' {merge_id} [label="", shape=point, width=0.08];',
+ ]
+ for child in children:
+ emission = _emit_element(child, counter)
+ lines.extend(emission.lines)
+ lines.append(f" {fork_id} -> {emission.entry_id};")
+ lines.append(f" {emission.exit_id} -> {merge_id};")
+ lines_tuple = tuple(lines)
+ return Emission(entry_id=fork_id, exit_id=merge_id, lines=lines_tuple)
+
+
+def _emit_subexpression(
+ children: tuple[BaseElement, ...], counter: _Counter
+) -> Emission:
+ """Emit a transparent subexpression as a plain sequence of ``children``."""
+ if not children:
+ return _emit_leaf("empty subexpression", counter)
+ entry_id, exit_id, lines = _emit_sequence(children, counter)
+ lines_tuple = tuple(lines)
+ return Emission(entry_id=entry_id or "", exit_id=exit_id or "", lines=lines_tuple)
+
+
+def _emit_cluster(
+ children: tuple[BaseElement, ...], cluster_label: str, counter: _Counter
+) -> Emission:
+ """Emit ``children`` inside a dashed rounded cluster labelled ``cluster_label``."""
+ if not children:
+ return _emit_leaf(f"{cluster_label} (empty)", counter)
+ cluster_id = counter.next("cluster")
+ entry_id, exit_id, inner_lines = _emit_sequence(children, counter)
+ lines: list[str] = [
+ f" subgraph cluster_{cluster_id} {{",
+ f' label="{_escape_dot(cluster_label)}";',
+ ' style="dashed,rounded";',
+ ' fontname="Menlo";',
+ ]
+ indented_inner_lines = [f" {line}" for line in inner_lines]
+ lines.extend(indented_inner_lines)
+ lines.append(" }")
+ lines_tuple = tuple(lines)
+ return Emission(entry_id=entry_id or "", exit_id=exit_id or "", lines=lines_tuple)
+
+
+def _emit_quantifier_cluster(element: BaseElement, counter: _Counter) -> Emission | None:
+ """Emit a quantifier wrapping a complex child as a dashed cluster; ``None`` if not one."""
+ quantifier_phrase = _quantifier_phrase(element)
+ if quantifier_phrase is None:
+ return None
+ child_emission = _emit_element(element.child, counter)
+ cluster_id = counter.next("cluster")
+ lines: list[str] = [
+ f" subgraph cluster_{cluster_id} {{",
+ f' label="{_escape_dot(quantifier_phrase)}";',
+ ' style="dashed,rounded";',
+ ' fontname="Menlo";',
+ ]
+ indented_child_lines = [f" {line}" for line in child_emission.lines]
+ lines.extend(indented_child_lines)
+ lines.append(" }")
+ lines_tuple = tuple(lines)
+ return Emission(
+ entry_id=child_emission.entry_id,
+ exit_id=child_emission.exit_id,
+ lines=lines_tuple,
+ )
+
+
+def _inline_label(element: BaseElement) -> str | None:
+ """Return a single-line label for a leaf / char / simple-quantifier element, else ``None``."""
+ leaf = _leaf_label(element)
+ if leaf is not None:
+ return leaf
+ char = _char_label(element)
+ if char is not None:
+ return char
+ return _simple_quantifier_label(element)
+
+
+def _simple_quantifier_label(element: BaseElement) -> str | None:
+ """Return a folded two-line label when a quantifier wraps a leaf / char element."""
+ quantifier_phrase = _quantifier_phrase(element)
+ if quantifier_phrase is None:
+ return None
+ child = element.child
+ child_label = _leaf_label(child) or _char_label(child)
+ if child_label is None:
+ return None
+ return f"{child_label}\\n({quantifier_phrase})"
+
+
+def _quantifier_phrase(element: BaseElement) -> str | None:
+ """Return the plain-English quantifier phrase (e.g. ``"one or more"``) or ``None``."""
+ if isinstance(element, ExactlyElement):
+ return f"exactly {element.times}"
+ if isinstance(element, OneOrMoreElement):
+ return "one or more"
+ if isinstance(element, OneOrMoreLazyElement):
+ return "one or more (lazy)"
+ if isinstance(element, ZeroOrMoreElement):
+ return "zero or more"
+ if isinstance(element, ZeroOrMoreLazyElement):
+ return "zero or more (lazy)"
+ if isinstance(element, OptionalElement):
+ return "optional"
+ if isinstance(element, AtLeastElement):
+ return f"at least {element.times}"
+ if isinstance(element, AtMostElement):
+ return f"at most {element.times}"
+ if isinstance(element, BetweenElement):
+ return f"{element.lower} to {element.upper}"
+ if isinstance(element, BetweenLazyElement):
+ return f"{element.lower} to {element.upper} (lazy)"
+ return None
+
+
+def _escape_dot(label: str) -> str:
+ """Return ``label`` with characters that break DOT double-quoted strings escaped."""
+ backslash_escaped = label.replace("\\", "\\\\")
+ quote_escaped = backslash_escaped.replace('"', '\\"')
+ return quote_escaped.replace("\\\\n", "\\n")
+
+
+class _Counter:
+ """Monotonically increasing counter used to mint unique DOT node identifiers."""
+
+ def __init__(self) -> None:
+ self._value = 0
+
+ def next(self, prefix: str) -> str:
+ """Return the next ``prefix_<n>`` identifier and advance the counter."""
+ self._value = self._value + 1
+ return f"{prefix}_{self._value}"
diff --git a/edify/introspect/types.py b/edify/introspect/types.py
new file mode 100644
index 0000000..5013c16
--- /dev/null
+++ b/edify/introspect/types.py
@@ -0,0 +1,35 @@
+"""Shared dataclasses used by the introspection renderers."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class Diagram:
+ """Rectangular block of ASCII rows with a designated horizontal entry row.
+
+ Attributes:
+ rows: Uniform-width lines that make up the diagram.
+ entry_row: 0-indexed row where left/right connectors attach.
+ width: Character width shared by every row.
+ """
+
+ rows: tuple[str, ...]
+ entry_row: int
+ width: int
+
+
+@dataclass(frozen=True)
+class Emission:
+ """DOT lines for one element plus the node ids where the horizontal flow enters and exits.
+
+ Attributes:
+ entry_id: DOT node id that upstream edges attach to.
+ exit_id: DOT node id that downstream edges leave from.
+ lines: Indented DOT source lines describing the subgraph.
+ """
+
+ entry_id: str
+ exit_id: str
+ lines: tuple[str, ...]
diff --git a/edify/introspect/verbose.py b/edify/introspect/verbose.py
new file mode 100644
index 0000000..49d1781
--- /dev/null
+++ b/edify/introspect/verbose.py
@@ -0,0 +1,363 @@
+"""Annotated re.VERBOSE-compatible rendering of edify AST elements."""
+
+from __future__ import annotations
+
+from edify.compile.dispatch import render_element
+from edify.elements.types.base import BaseElement
+from edify.elements.types.captures import (
+ BackReferenceElement,
+ CaptureElement,
+ NamedBackReferenceElement,
+ NamedCaptureElement,
+)
+from edify.elements.types.chars import (
+ AnyOfCharsElement,
+ AnythingButCharsElement,
+ AnythingButRangeElement,
+ AnythingButStringElement,
+ CharElement,
+ RangeElement,
+ StringElement,
+)
+from edify.elements.types.groups import (
+ AnyOfElement,
+ AssertAheadElement,
+ AssertBehindElement,
+ AssertNotAheadElement,
+ AssertNotBehindElement,
+ GroupElement,
+ SubexpressionElement,
+)
+from edify.elements.types.leaves import (
+ AlphanumericElement,
+ AnyCharElement,
+ CarriageReturnElement,
+ DigitElement,
+ EndOfInputElement,
+ LetterElement,
+ LowercaseElement,
+ NewLineElement,
+ NonDigitElement,
+ NonWhitespaceCharElement,
+ NonWordBoundaryElement,
+ NonWordElement,
+ NoopElement,
+ NullByteElement,
+ StartOfInputElement,
+ TabElement,
+ UppercaseElement,
+ WhitespaceCharElement,
+ WordBoundaryElement,
+ WordElement,
+)
+from edify.elements.types.quantifiers import (
+ AtLeastElement,
+ AtMostElement,
+ BetweenElement,
+ BetweenLazyElement,
+ ExactlyElement,
+ OneOrMoreElement,
+ OneOrMoreLazyElement,
+ OptionalElement,
+ ZeroOrMoreElement,
+ ZeroOrMoreLazyElement,
+)
+
+_INDENT_STEP = " "
+_COMMENT_COLUMN = 24
+
+
+def verbose_elements(elements: tuple[BaseElement, ...]) -> str:
+ """Return the pattern as an annotated ``re.VERBOSE``-compatible multi-line string."""
+ lines: list[str] = []
+ for element in elements:
+ lines.extend(_verbose(element, depth=0))
+ return "\n".join(lines)
+
+
+def _verbose(element: BaseElement, depth: int) -> list[str]:
+ """Return the ``re.VERBOSE`` lines for a single ``element`` at nesting ``depth``."""
+ prefix = _INDENT_STEP * depth
+ leaf_line = _leaf_line(element)
+ if leaf_line is not None:
+ fragment, comment = leaf_line
+ return [_render_line(prefix, fragment, comment)]
+ char_line = _char_line(element)
+ if char_line is not None:
+ fragment, comment = char_line
+ return [_render_line(prefix, fragment, comment)]
+ quantifier_lines = _quantifier_lines(element, depth)
+ if quantifier_lines is not None:
+ return quantifier_lines
+ group_lines = _group_lines(element, depth)
+ if group_lines is not None:
+ return group_lines
+ capture_lines = _capture_lines(element, depth)
+ if capture_lines is not None:
+ return capture_lines
+ assertion_lines = _assertion_lines(element, depth)
+ if assertion_lines is not None:
+ return assertion_lines
+ fragment = render_element(element)
+ return [_render_line(prefix, fragment, f"unrecognized ({type(element).__name__})")]
+
+
+def _leaf_line(element: BaseElement) -> tuple[str, str] | None:
+ """Return the ``(fragment, comment)`` pair for a leaf element, or ``None``."""
+ if isinstance(element, StartOfInputElement):
+ return ("^", "start of input")
+ if isinstance(element, EndOfInputElement):
+ return ("$", "end of input")
+ if isinstance(element, AnyCharElement):
+ return (".", "any single character")
+ if isinstance(element, WhitespaceCharElement):
+ return ("\\s", "any whitespace character")
+ if isinstance(element, NonWhitespaceCharElement):
+ return ("\\S", "any non-whitespace character")
+ if isinstance(element, DigitElement):
+ return ("\\d", "any digit (0-9)")
+ if isinstance(element, NonDigitElement):
+ return ("\\D", "any non-digit")
+ if isinstance(element, WordElement):
+ return ("\\w", "any word character")
+ if isinstance(element, NonWordElement):
+ return ("\\W", "any non-word character")
+ if isinstance(element, WordBoundaryElement):
+ return ("\\b", "word boundary")
+ if isinstance(element, NonWordBoundaryElement):
+ return ("\\B", "non-word boundary")
+ if isinstance(element, NewLineElement):
+ return ("\\n", "line feed")
+ if isinstance(element, CarriageReturnElement):
+ return ("\\r", "carriage return")
+ if isinstance(element, TabElement):
+ return ("\\t", "tab")
+ if isinstance(element, NullByteElement):
+ return ("\\0", "null byte")
+ if isinstance(element, LetterElement):
+ return ("[a-zA-Z]", "any ASCII letter")
+ if isinstance(element, UppercaseElement):
+ return ("[A-Z]", "any ASCII uppercase letter")
+ if isinstance(element, LowercaseElement):
+ return ("[a-z]", "any ASCII lowercase letter")
+ if isinstance(element, AlphanumericElement):
+ return ("[a-zA-Z0-9]", "any ASCII letter or digit")
+ if isinstance(element, NoopElement):
+ return ("", "no-op")
+ return None
+
+
+def _char_line(element: BaseElement) -> tuple[str, str] | None:
+ """Return the ``(fragment, comment)`` pair for a character-shaped element, or ``None``."""
+ if isinstance(element, CharElement):
+ return (element.value, f'literal "{element.value}"')
+ if isinstance(element, StringElement):
+ return (element.value, f'literal string "{element.value}"')
+ if isinstance(element, RangeElement):
+ return (f"[{element.start}-{element.end}]", f"range {element.start}-{element.end}")
+ if isinstance(element, AnyOfCharsElement):
+ return (f"[{element.value}]", f"one of {element.value!r}")
+ if isinstance(element, AnythingButCharsElement):
+ return (f"[^{element.value}]", f"none of {element.value!r}")
+ if isinstance(element, AnythingButRangeElement):
+ return (
+ f"[^{element.start}-{element.end}]",
+ f"none of range {element.start}-{element.end}",
+ )
+ if isinstance(element, AnythingButStringElement):
+ fragment = render_element(element)
+ return (fragment, f"per-position negation of {element.value!r}")
+ return None
+
+
+def _quantifier_lines(element: BaseElement, depth: int) -> list[str] | None:
+ """Return the ``re.VERBOSE`` lines for a quantifier element, or ``None``."""
+ prefix = _INDENT_STEP * depth
+ if isinstance(element, OptionalElement):
+ return _wrap_child(prefix, element.child, "?", "optional (zero or one)", depth)
+ if isinstance(element, ZeroOrMoreElement):
+ return _wrap_child(prefix, element.child, "*", "zero or more (greedy)", depth)
+ if isinstance(element, ZeroOrMoreLazyElement):
+ return _wrap_child(prefix, element.child, "*?", "zero or more (lazy)", depth)
+ if isinstance(element, OneOrMoreElement):
+ return _wrap_child(prefix, element.child, "+", "one or more (greedy)", depth)
+ if isinstance(element, OneOrMoreLazyElement):
+ return _wrap_child(prefix, element.child, "+?", "one or more (lazy)", depth)
+ if isinstance(element, ExactlyElement):
+ return _wrap_child(
+ prefix, element.child, f"{{{element.times}}}", f"exactly {element.times}", depth
+ )
+ if isinstance(element, AtLeastElement):
+ return _wrap_child(
+ prefix, element.child, f"{{{element.times},}}", f"at least {element.times}", depth
+ )
+ if isinstance(element, AtMostElement):
+ return _wrap_child(
+ prefix, element.child, f"{{0,{element.times}}}", f"at most {element.times}", depth
+ )
+ if isinstance(element, BetweenElement):
+ suffix = f"{{{element.lower},{element.upper}}}"
+ return _wrap_child(
+ prefix,
+ element.child,
+ suffix,
+ f"between {element.lower} and {element.upper} (greedy)",
+ depth,
+ )
+ if isinstance(element, BetweenLazyElement):
+ suffix = f"{{{element.lower},{element.upper}}}?"
+ return _wrap_child(
+ prefix,
+ element.child,
+ suffix,
+ f"between {element.lower} and {element.upper} (lazy)",
+ depth,
+ )
+ return None
+
+
+def _wrap_child(
+ prefix: str,
+ child: BaseElement,
+ suffix: str,
+ comment: str,
+ depth: int,
+) -> list[str]:
+ """Return the child's lines with ``suffix`` and ``comment`` attached to the emitted regex."""
+ child_lines = _verbose(child, depth)
+ if len(child_lines) == 1:
+ raw_child_source = _extract_source(child_lines[0])
+ combined_fragment = f"{raw_child_source}{suffix}"
+ if _needs_group(child):
+ combined_fragment = f"(?:{raw_child_source}){suffix}"
+ return [_render_line(prefix, combined_fragment, comment)]
+ return [
+ _render_line(prefix, "(?:", f"begin group for {comment}"),
+ *child_lines,
+ _render_line(prefix, f"){suffix}", f"end group; apply {comment}"),
+ ]
+
+
+def _group_lines(element: BaseElement, depth: int) -> list[str] | None:
+ """Return the ``re.VERBOSE`` lines for a group / alternation / subexpression, or ``None``."""
+ prefix = _INDENT_STEP * depth
+ if isinstance(element, GroupElement):
+ return [
+ _render_line(prefix, "(?:", "begin non-capturing group"),
+ *_render_children(element.children, depth + 1),
+ _render_line(prefix, ")", "end non-capturing group"),
+ ]
+ if isinstance(element, AnyOfElement):
+ return _alternation_lines(prefix, element, depth)
+ if isinstance(element, SubexpressionElement):
+ return _render_children(element.children, depth)
+ return None
+
+
+def _alternation_lines(prefix: str, element: AnyOfElement, depth: int) -> list[str]:
+ """Return the ``(?: a | b | c )`` layout for :class:`AnyOfElement`."""
+ if not element.children:
+ return [_render_line(prefix, "(?:)", "empty alternation")]
+ lines: list[str] = [_render_line(prefix, "(?:", "begin alternation")]
+ inner_depth = depth + 1
+ for index, child in enumerate(element.children):
+ alternative_prefix = _INDENT_STEP * inner_depth
+ lines.append(_render_line(alternative_prefix, "", f"alternative {index + 1}"))
+ lines.extend(_verbose(child, inner_depth))
+ if index < len(element.children) - 1:
+ lines.append(_render_line(alternative_prefix, "|", "or"))
+ lines.append(_render_line(prefix, ")", "end alternation"))
+ return lines
+
+
+def _capture_lines(element: BaseElement, depth: int) -> list[str] | None:
+ """Return the ``re.VERBOSE`` lines for a capture element, or ``None``."""
+ prefix = _INDENT_STEP * depth
+ if isinstance(element, CaptureElement):
+ return [
+ _render_line(prefix, "(", "begin captured group"),
+ *_render_children(element.children, depth + 1),
+ _render_line(prefix, ")", "end captured group"),
+ ]
+ if isinstance(element, NamedCaptureElement):
+ return [
+ _render_line(prefix, f"(?P<{element.name}>", f'begin group named "{element.name}"'),
+ *_render_children(element.children, depth + 1),
+ _render_line(prefix, ")", f'end group named "{element.name}"'),
+ ]
+ if isinstance(element, BackReferenceElement):
+ return [
+ _render_line(prefix, f"\\{element.index}", f"back-reference to group {element.index}")
+ ]
+ if isinstance(element, NamedBackReferenceElement):
+ return [
+ _render_line(
+ prefix,
+ f"(?P={element.name})",
+ f'back-reference to group "{element.name}"',
+ )
+ ]
+ return None
+
+
+def _assertion_lines(element: BaseElement, depth: int) -> list[str] | None:
+ """Return the ``re.VERBOSE`` lines for a lookaround assertion element, or ``None``."""
+ prefix = _INDENT_STEP * depth
+ if isinstance(element, AssertAheadElement):
+ return [
+ _render_line(prefix, "(?=", "begin positive lookahead"),
+ *_render_children(element.children, depth + 1),
+ _render_line(prefix, ")", "end positive lookahead"),
+ ]
+ if isinstance(element, AssertNotAheadElement):
+ return [
+ _render_line(prefix, "(?!", "begin negative lookahead"),
+ *_render_children(element.children, depth + 1),
+ _render_line(prefix, ")", "end negative lookahead"),
+ ]
+ if isinstance(element, AssertBehindElement):
+ return [
+ _render_line(prefix, "(?<=", "begin positive lookbehind"),
+ *_render_children(element.children, depth + 1),
+ _render_line(prefix, ")", "end positive lookbehind"),
+ ]
+ if isinstance(element, AssertNotBehindElement):
+ return [
+ _render_line(prefix, "(?<!", "begin negative lookbehind"),
+ *_render_children(element.children, depth + 1),
+ _render_line(prefix, ")", "end negative lookbehind"),
+ ]
+ return None
+
+
+def _render_children(children: tuple[BaseElement, ...], depth: int) -> list[str]:
+ """Return the ``re.VERBOSE`` lines for a sequence of children at ``depth``."""
+ lines: list[str] = []
+ for child in children:
+ lines.extend(_verbose(child, depth))
+ return lines
+
+
+def _render_line(prefix: str, fragment: str, comment: str) -> str:
+ """Return one aligned output line: ``<prefix><fragment> # <comment>``."""
+ left = f"{prefix}{fragment}"
+ if not comment:
+ return left.rstrip()
+ padding_needed = _COMMENT_COLUMN - len(left)
+ if padding_needed < 2:
+ padding_needed = 2
+ padding = " " * padding_needed
+ return f"{left}{padding}# {comment}"
+
+
+def _extract_source(rendered_line: str) -> str:
+ """Return the raw regex fragment from an already-rendered ``_render_line`` output."""
+ without_comment = rendered_line.split("#", 1)[0]
+ return without_comment.rstrip().lstrip()
+
+
+def _needs_group(child: BaseElement) -> bool:
+ """Return True when ``child``'s regex fragment needs grouping to apply a quantifier."""
+ if isinstance(child, StringElement):
+ return len(child.value) > 1
+ return False
diff --git a/edify/introspect/visualize.py b/edify/introspect/visualize.py
new file mode 100644
index 0000000..d777ce6
--- /dev/null
+++ b/edify/introspect/visualize.py
@@ -0,0 +1,33 @@
+"""Dispatch entry point for the :meth:`Regex.visualize` rendering formats."""
+
+from __future__ import annotations
+
+from edify.elements.types.base import BaseElement
+from edify.errors.introspect import (
+ UnsupportedVisualizationEngineError,
+ UnsupportedVisualizationFormatError,
+)
+from edify.introspect.ascii import render_ascii
+from edify.introspect.graphviz import render_graphviz_svg
+
+_FORMAT_ASCII = "ascii"
+_FORMAT_SVG = "svg"
+_ENGINE_ASCII = "ascii"
+_ENGINE_GRAPHVIZ = "graphviz"
+
+
+def visualize_elements(
+ elements: tuple[BaseElement, ...],
+ format: str = _FORMAT_ASCII,
+ engine: str = _ENGINE_ASCII,
+) -> str:
+ """Render ``elements`` in the requested ``format`` / ``engine`` combination."""
+ if format == _FORMAT_ASCII:
+ if engine != _ENGINE_ASCII:
+ raise UnsupportedVisualizationEngineError(format, engine)
+ return render_ascii(elements)
+ if format == _FORMAT_SVG:
+ if engine != _ENGINE_GRAPHVIZ:
+ raise UnsupportedVisualizationEngineError(format, engine)
+ return render_graphviz_svg(elements)
+ raise UnsupportedVisualizationFormatError(format)
diff --git a/edify/result/regex.py b/edify/result/regex.py
index 9dbcd63..b52e3ee 100644
--- a/edify/result/regex.py
+++ b/edify/result/regex.py
@@ -1,40 +1,57 @@
-""":class:`Regex` — composition wrapper over :class:`re.Pattern`.
-
-:class:`re.Pattern` is a CPython C type and cannot be subclassed, so we
-compose: :class:`Regex` holds both the source string and the compiled
-:class:`re.Pattern`, exposing them as ``.source`` and ``.compiled``, and
-delegates the eight canonical query methods (``match``, ``search``,
-``fullmatch``, ``findall``, ``finditer``, ``sub``, ``subn``, ``split``)
-to the underlying compiled pattern.
-
-This wrapper is what :meth:`edify.builder.mixins.terminals.TerminalsMixin.to_regex`
-returns; the raw :class:`re.Pattern` is still available via ``.compiled``.
-"""
+"""The :class:`Regex` composition wrapper over :class:`re.Pattern`."""
from __future__ import annotations
import re
import sys
-from collections.abc import Callable, Iterator
+from collections.abc import Callable, Iterator, Mapping
+
+from edify.elements.types.base import BaseElement
+from edify.introspect.explain import explain_elements
+from edify.introspect.verbose import verbose_elements
+from edify.introspect.visualize import visualize_elements
+
+_RePatternMethodReturn = (
+ re.Match[str]
+ | list[str]
+ | list[tuple[str, ...]]
+ | Iterator[re.Match[str]]
+ | str
+ | tuple[str, int]
+ | None
+)
+
+_RePatternAttribute = int | str | Mapping[str, int] | Callable[..., _RePatternMethodReturn]
class Regex:
"""A compiled edify pattern; wraps :class:`re.Pattern` by composition."""
- def __init__(self, source: str, compiled: re.Pattern[str]) -> None:
+ def __init__(
+ self,
+ source: str,
+ compiled: re.Pattern[str],
+ elements: tuple[BaseElement, ...] = (),
+ ) -> None:
self._source = source
self._compiled = compiled
+ self._elements = elements
@property
def source(self) -> str:
- """The regex string that produced this wrapper (identical to :meth:`re.Pattern.pattern`)."""
+ """The regex string this wrapper was built from."""
return self._source
@property
def compiled(self) -> re.Pattern[str]:
- """The underlying :class:`re.Pattern`; callers that need identity checks read this."""
+ """The underlying :class:`re.Pattern` for direct interop with :mod:`re`."""
return self._compiled
+ @property
+ def elements(self) -> tuple[BaseElement, ...]:
+ """The AST elements produced by the builder, in emission order."""
+ return self._elements
+
def match(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> re.Match[str] | None:
"""Delegate to :meth:`re.Pattern.match`."""
return self._compiled.match(string, pos, endpos)
@@ -83,6 +100,42 @@ class Regex:
"""Delegate to :meth:`re.Pattern.split`."""
return self._compiled.split(string, maxsplit=maxsplit)
+ def explain(self) -> str:
+ """Return a human-readable narrative describing what the pattern matches."""
+ return explain_elements(self._elements)
+
+ def to_verbose_string(self) -> str:
+ """Return the pattern as an annotated ``re.VERBOSE``-compatible string."""
+ return verbose_elements(self._elements)
+
+ def visualize(self, format: str = "ascii", engine: str = "ascii") -> str:
+ """Return a visual rendering of the pattern in ``format``.
+
+ Args:
+ format: ``"ascii"`` (default) for an ASCII railroad diagram, or
+ ``"svg"`` for a Graphviz-rendered SVG string.
+ engine: Rendering engine identifier; ``"ascii"`` for the built-in
+ ASCII layout, ``"graphviz"`` for the SVG renderer. Must match
+ ``format``.
+
+ Returns:
+ The rendered diagram as a string.
+
+ Raises:
+ edify.errors.introspect.UnsupportedVisualizationFormatError: when
+ ``format`` is not one of ``"ascii"`` or ``"svg"``.
+ edify.errors.introspect.UnsupportedVisualizationEngineError: when
+ ``engine`` does not match the requested ``format``.
+ edify.errors.introspect.MissingGraphvizDependencyError: when
+ ``format="svg"`` is requested but the ``graphviz`` extra is
+ not installed.
+ """
+ return visualize_elements(self._elements, format=format, engine=engine)
+
+ def __getattr__(self, name: str) -> _RePatternAttribute:
+ """Delegate any attribute access not explicitly defined here to the compiled pattern."""
+ return getattr(self._compiled, name)
+
def __repr__(self) -> str:
"""Return ``<Regex 'source-string'>``."""
return f"<Regex {self._source!r}>"
diff --git a/tests/introspect/ascii.test.py b/tests/introspect/ascii.test.py
new file mode 100644
index 0000000..0ebcc8f
--- /dev/null
+++ b/tests/introspect/ascii.test.py
@@ -0,0 +1,561 @@
+"""Tests for the ASCII railroad-diagram renderer in :mod:`edify.introspect.ascii`."""
+
+from edify import Pattern, RegexBuilder
+from edify.elements.types.captures import (
+ BackReferenceElement,
+ CaptureElement,
+ NamedBackReferenceElement,
+ NamedCaptureElement,
+)
+from edify.elements.types.chars import (
+ AnyOfCharsElement,
+ AnythingButCharsElement,
+ AnythingButRangeElement,
+ AnythingButStringElement,
+ CharElement,
+ RangeElement,
+ StringElement,
+)
+from edify.elements.types.groups import (
+ AnyOfElement,
+ AssertAheadElement,
+ AssertBehindElement,
+ AssertNotAheadElement,
+ AssertNotBehindElement,
+ GroupElement,
+ SubexpressionElement,
+)
+from edify.elements.types.leaves import (
+ AlphanumericElement,
+ AnyCharElement,
+ CarriageReturnElement,
+ DigitElement,
+ EndOfInputElement,
+ LetterElement,
+ LowercaseElement,
+ NewLineElement,
+ NonDigitElement,
+ NonWhitespaceCharElement,
+ NonWordBoundaryElement,
+ NonWordElement,
+ NoopElement,
+ NullByteElement,
+ StartOfInputElement,
+ TabElement,
+ UppercaseElement,
+ WhitespaceCharElement,
+ WordBoundaryElement,
+ WordElement,
+)
+from edify.elements.types.quantifiers import (
+ AtLeastElement,
+ AtMostElement,
+ BetweenElement,
+ BetweenLazyElement,
+ ExactlyElement,
+ OneOrMoreElement,
+ OneOrMoreLazyElement,
+ OptionalElement,
+ ZeroOrMoreElement,
+ ZeroOrMoreLazyElement,
+)
+from edify.introspect.ascii import (
+ _looks_like_single_box,
+ _pad_right,
+ _pluralize,
+ _widen,
+ render_ascii,
+)
+from edify.introspect.types import Diagram
+
+
+def _render(*elements) -> str:
+ return render_ascii(tuple(elements))
+
+
+def test_empty_pattern_renders_start_arrow_end():
+ assert _render() == (
+ " +-------+ +-----+\n"
+ " | START |-->| END |\n"
+ " +-------+ +-----+"
+ )
+
+
+def test_single_digit_pattern():
+ output = _render(DigitElement())
+ assert output == (
+ " +-------+ +-------+ +-----+\n"
+ " | START |-->| digit |-->| END |\n"
+ " +-------+ +-------+ +-----+"
+ )
+
+
+def test_one_or_more_digit_reads_as_plural_english():
+ output = _render(OneOrMoreElement(child=DigitElement()))
+ assert "one or more digits" in output
+ assert "START" in output
+ assert "END" in output
+
+
+def test_exactly_four_digits_reads_as_count_plural():
+ output = _render(ExactlyElement(times=4, child=DigitElement()))
+ assert "4 digits" in output
+
+
+def test_exactly_one_digit_stays_singular():
+ output = _render(ExactlyElement(times=1, child=DigitElement()))
+ assert "1 digit" in output
+ assert "1 digits" not in output
+
+
+def test_start_of_input_reads_as_text_starts_here():
+ output = _render(StartOfInputElement())
+ assert "text starts here" in output
+
+
+def test_end_of_input_reads_as_text_ends_here():
+ output = _render(EndOfInputElement())
+ assert "text ends here" in output
+
+
+def test_character_literal_strips_regex_escape_for_display():
+ output = _render(CharElement(value="\\-"))
+ assert '"-"' in output
+ assert '"\\-"' not in output
+
+
+def test_string_literal_renders_in_quotes():
+ output = _render(StringElement(value="hello"))
+ assert '"hello"' in output
+
+
+def test_alternation_lays_out_as_fork_and_merge():
+ output = _render(
+ AnyOfElement(
+ children=(
+ StringElement(value="cat"),
+ StringElement(value="dog"),
+ StringElement(value="fish"),
+ )
+ )
+ )
+ lines = output.splitlines()
+ assert any("+--->" in line for line in lines)
+ assert any("----+" in line for line in lines)
+ assert any('"cat"' in line for line in lines)
+ assert any('"dog"' in line for line in lines)
+ assert any('"fish"' in line for line in lines)
+
+
+def test_alternation_widens_smaller_boxes_to_match_widest():
+ output = _render(
+ AnyOfElement(
+ children=(
+ StringElement(value="cat"),
+ StringElement(value="fish"),
+ )
+ )
+ )
+ assert output.count("+--------+") == 4
+ assert '| "cat" |' in output
+ assert '| "fish" |' in output
+
+
+def test_alternation_with_single_branch_still_renders():
+ output = _render(AnyOfElement(children=(StringElement(value="only"),)))
+ assert '"only"' in output
+
+
+def test_named_capture_places_caption_below_child():
+ output = _render(
+ NamedCaptureElement(
+ name="year",
+ children=(ExactlyElement(times=4, child=DigitElement()),),
+ )
+ )
+ lines = output.splitlines()
+ assert any('(saved as "year")' in line for line in lines)
+ assert any("4 digits" in line for line in lines)
+
+
+def test_unnamed_capture_places_captured_caption_below_child():
+ output = _render(
+ CaptureElement(children=(DigitElement(),))
+ )
+ assert "(captured)" in output
+
+
+def test_group_places_grouped_caption_below_child():
+ output = _render(GroupElement(children=(DigitElement(),)))
+ assert "(grouped)" in output
+
+
+def test_subexpression_flattens_into_the_sequence():
+ output = _render(
+ SubexpressionElement(children=(DigitElement(), DigitElement()))
+ )
+ assert output.count("digit") == 2
+
+
+def test_backreference_reads_as_match_same_text_as_group_n():
+ output = _render(BackReferenceElement(index=1))
+ assert "match same text as group 1" in output
+
+
+def test_named_backreference_reads_as_match_same_text_as_name():
+ output = _render(NamedBackReferenceElement(name="year"))
+ assert 'match same text as "year"' in output
+
+
+def test_assert_ahead_caption():
+ output = _render(AssertAheadElement(children=(DigitElement(),)))
+ assert "(must be followed by)" in output
+
+
+def test_assert_not_ahead_caption():
+ output = _render(AssertNotAheadElement(children=(DigitElement(),)))
+ assert "(must NOT be followed by)" in output
+
+
+def test_assert_behind_caption():
+ output = _render(AssertBehindElement(children=(DigitElement(),)))
+ assert "(must be preceded by)" in output
+
+
+def test_assert_not_behind_caption():
+ output = _render(AssertNotBehindElement(children=(DigitElement(),)))
+ assert "(must NOT be preceded by)" in output
+
+
+def test_range_element_reads_as_from_x_to_y():
+ output = _render(RangeElement(start="a", end="z"))
+ assert 'from "a" to "z"' in output
+
+
+def test_any_of_chars_reads_as_any_of_value():
+ output = _render(AnyOfCharsElement(value="abc"))
+ assert 'any of "abc"' in output
+
+
+def test_anything_but_chars_reads_as_except_value():
+ output = _render(AnythingButCharsElement(value="abc"))
+ assert 'except "abc"' in output
+
+
+def test_anything_but_range_reads_as_outside_range():
+ output = _render(AnythingButRangeElement(start="a", end="z"))
+ assert 'outside "a"-"z"' in output
+
+
+def test_anything_but_string_reads_as_except_string():
+ output = _render(AnythingButStringElement(value="stop"))
+ assert 'except the string "stop"' in output
+
+
+def test_optional_digit_reads_as_optional_singular():
+ output = _render(OptionalElement(child=DigitElement()))
+ assert "optional digit" in output
+
+
+def test_zero_or_more_digits_reads_as_plural():
+ output = _render(ZeroOrMoreElement(child=DigitElement()))
+ assert "zero or more digits" in output
+
+
+def test_zero_or_more_lazy_marks_lazy():
+ output = _render(ZeroOrMoreLazyElement(child=DigitElement()))
+ assert "zero or more digits (lazy)" in output
+
+
+def test_one_or_more_lazy_marks_lazy():
+ output = _render(OneOrMoreLazyElement(child=DigitElement()))
+ assert "one or more digits (lazy)" in output
+
+
+def test_at_least_reads_as_at_least_n_plural():
+ output = _render(AtLeastElement(times=3, child=DigitElement()))
+ assert "at least 3 digits" in output
+
+
+def test_at_most_reads_as_at_most_n_plural():
+ output = _render(AtMostElement(times=2, child=DigitElement()))
+ assert "at most 2 digits" in output
+
+
+def test_between_reads_as_lower_to_upper_plural():
+ output = _render(BetweenElement(lower=2, upper=5, child=DigitElement()))
+ assert "2 to 5 digits" in output
+
+
+def test_between_lazy_marks_lazy():
+ output = _render(BetweenLazyElement(lower=2, upper=5, child=DigitElement()))
+ assert "2 to 5 digits (lazy)" in output
+
+
+def test_letter_labeled_letter():
+ output = _render(LetterElement())
+ assert "letter" in output
+
+
+def test_uppercase_labeled_uppercase_letter():
+ output = _render(UppercaseElement())
+ assert "uppercase letter" in output
+
+
+def test_lowercase_labeled_lowercase_letter():
+ output = _render(LowercaseElement())
+ assert "lowercase letter" in output
+
+
+def test_alphanumeric_labeled_letter_or_digit():
+ output = _render(AlphanumericElement())
+ assert "letter or digit" in output
+
+
+def test_any_char_labeled_any_character():
+ output = _render(AnyCharElement())
+ assert "any character" in output
+
+
+def test_whitespace_labeled_whitespace():
+ output = _render(WhitespaceCharElement())
+ assert "| whitespace |" in output
+
+
+def test_non_whitespace_labeled_non_whitespace():
+ output = _render(NonWhitespaceCharElement())
+ assert "non-whitespace" in output
+
+
+def test_non_digit_labeled_non_digit_character():
+ output = _render(NonDigitElement())
+ assert "non-digit character" in output
+
+
+def test_word_char_labeled_word_character():
+ output = _render(WordElement())
+ assert "word character" in output
+
+
+def test_non_word_char_labeled_non_word_character():
+ output = _render(NonWordElement())
+ assert "non-word character" in output
+
+
+def test_word_boundary_labeled_word_boundary():
+ output = _render(WordBoundaryElement())
+ assert "word boundary" in output
+
+
+def test_non_word_boundary_labeled_non_word_boundary():
+ output = _render(NonWordBoundaryElement())
+ assert "non-word boundary" in output
+
+
+def test_newline_labeled_newline():
+ output = _render(NewLineElement())
+ assert "| newline |" in output
+
+
+def test_carriage_return_labeled():
+ output = _render(CarriageReturnElement())
+ assert "carriage return" in output
+
+
+def test_tab_labeled_tab():
+ output = _render(TabElement())
+ assert "| tab |" in output
+
+
+def test_null_byte_labeled():
+ output = _render(NullByteElement())
+ assert "null byte" in output
+
+
+def test_noop_labeled_noop():
+ output = _render(NoopElement())
+ assert "no-op" in output
+
+
+def test_alternation_with_no_children_shows_nothing_placeholder():
+ output = _render(AnyOfElement(children=()))
+ assert "nothing" in output
+
+
+def test_regex_visualize_end_to_end_delegates_to_render_ascii():
+ regex = RegexBuilder().digit().to_regex()
+ output = regex.visualize()
+ assert "| digit |" in output
+ assert "| START |" in output
+ assert "| END |" in output
+
+
+def test_visualize_with_alternation_end_to_end():
+ regex = RegexBuilder().any_of("cat", "dog", "fish").to_regex()
+ output = regex.visualize()
+ assert '"cat"' in output
+ assert '"dog"' in output
+ assert '"fish"' in output
+ assert "+--->" in output
+
+
+def test_visualize_named_capture_end_to_end():
+ regex = (
+ RegexBuilder()
+ .named_capture("year")
+ .exactly(4)
+ .digit()
+ .end()
+ .to_regex()
+ )
+ output = regex.visualize()
+ assert '(saved as "year")' in output
+ assert "4 digits" in output
+
+
+def test_visualize_anchored_pattern_end_to_end():
+ regex = (
+ RegexBuilder()
+ .start_of_input()
+ .one_or_more()
+ .digit()
+ .end_of_input()
+ .to_regex()
+ )
+ output = regex.visualize()
+ assert "text starts here" in output
+ assert "text ends here" in output
+ assert "one or more digits" in output
+
+
+def test_singular_stays_singular_for_literal_labels():
+ output = _render(ExactlyElement(times=3, child=StringElement(value="ab")))
+ assert '3 "ab"' in output
+
+
+def test_pluralize_default_adds_s():
+ assert _pluralize("digit") == "digits"
+
+
+def test_pluralize_adds_es_after_ch_ending():
+ assert _pluralize("match") == "matches"
+
+
+def test_pluralize_adds_es_after_sh_ending():
+ assert _pluralize("fish") == "fishes"
+
+
+def test_pluralize_adds_es_after_x_ending():
+ assert _pluralize("box") == "boxes"
+
+
+def test_pluralize_adds_es_after_s_ending():
+ assert _pluralize("miss") == "misses"
+
+
+def test_pluralize_adds_es_after_z_ending():
+ assert _pluralize("buzz") == "buzzes"
+
+
+def test_pluralize_converts_y_to_ies_after_consonant():
+ assert _pluralize("cherry") == "cherries"
+
+
+def test_pluralize_adds_s_after_y_when_preceded_by_vowel():
+ assert _pluralize("boy") == "boys"
+
+
+def test_pluralize_leaves_quoted_literal_string_untouched():
+ assert _pluralize('"cat"') == '"cat"'
+
+
+def test_optional_of_capture_falls_back_to_group_label():
+ inner_capture = CaptureElement(children=(DigitElement(),))
+ output = _render(OptionalElement(child=inner_capture))
+ assert "optional group" in output
+
+
+def test_quantifier_around_alternation_falls_back_to_group_label():
+ inner_alt = AnyOfElement(children=(StringElement(value="a"), StringElement(value="b")))
+ output = _render(ExactlyElement(times=2, child=inner_alt))
+ assert "2 groups" in output
+
+
+def test_unknown_element_type_renders_placeholder_label():
+ class MysteryElement(DigitElement.__mro__[1]):
+ pass
+
+ output = _render(MysteryElement())
+ assert "?MysteryElement" in output
+
+
+def test_empty_group_shows_nothing_placeholder():
+ output = _render(GroupElement(children=()))
+ assert "nothing" in output
+
+
+def test_nested_alternation_pads_wider_branch_with_spaces():
+ outer = AnyOfElement(
+ children=(
+ AnyOfElement(
+ children=(
+ StringElement(value="a"),
+ StringElement(value="b"),
+ )
+ ),
+ StringElement(value="verylongword"),
+ )
+ )
+ output = _render(outer)
+ assert '"a"' in output
+ assert '"b"' in output
+ assert '"verylongword"' in output
+
+
+def test_single_branch_alternation_looks_like_single_branch():
+ output = _render(
+ AnyOfElement(children=(StringElement(value="only-one"),))
+ )
+ assert '"only-one"' in output
+ assert "+--->" in output
+
+
+def test_looks_like_single_box_rejects_multi_row_diagram():
+ multi = Diagram(rows=("+---+", "|abc|", "+---+", "extra"), entry_row=1, width=5)
+ assert _looks_like_single_box(multi) is False
+
+
+def test_looks_like_single_box_rejects_asymmetric_borders():
+ asymmetric = Diagram(
+ rows=("+---+", "| a |", "+xxx+"), entry_row=1, width=5
+ )
+ assert _looks_like_single_box(asymmetric) is False
+
+
+def test_looks_like_single_box_rejects_non_box_borders():
+ non_box = Diagram(rows=(" ", "| a |", " "), entry_row=1, width=5)
+ assert _looks_like_single_box(non_box) is False
+
+
+def test_looks_like_single_box_rejects_middle_without_pipes():
+ bad_middle = Diagram(
+ rows=("+---+", " a ", "+---+"), entry_row=1, width=5
+ )
+ assert _looks_like_single_box(bad_middle) is False
+
+
+def test_looks_like_single_box_rejects_border_with_non_dash_interior():
+ striped = Diagram(
+ rows=("+-x-+", "| a |", "+-x-+"), entry_row=1, width=5
+ )
+ assert _looks_like_single_box(striped) is False
+
+
+def test_pad_right_returns_diagram_untouched_when_already_wide_enough():
+ original = Diagram(rows=("abcde",), entry_row=0, width=5)
+ assert _pad_right(original, 3) is original
+
+
+def test_widen_returns_diagram_untouched_when_already_wide_enough():
+ original = Diagram(rows=("abcde",), entry_row=0, width=5)
+ assert _widen(original, 3) is original
diff --git a/tests/introspect/explain.test.py b/tests/introspect/explain.test.py
new file mode 100644
index 0000000..c782dfb
--- /dev/null
+++ b/tests/introspect/explain.test.py
@@ -0,0 +1,823 @@
+"""Tests for the plain-English explanation renderer in :mod:`edify.introspect.explain`."""
+
+from edify import RegexBuilder
+from edify.elements.types.captures import (
+ BackReferenceElement,
+ CaptureElement,
+ NamedBackReferenceElement,
+ NamedCaptureElement,
+)
+from edify.elements.types.chars import (
+ AnyOfCharsElement,
+ AnythingButCharsElement,
+ AnythingButRangeElement,
+ AnythingButStringElement,
+ CharElement,
+ RangeElement,
+ StringElement,
+)
+from edify.elements.types.groups import (
+ AnyOfElement,
+ AssertAheadElement,
+ AssertBehindElement,
+ AssertNotAheadElement,
+ AssertNotBehindElement,
+ GroupElement,
+ SubexpressionElement,
+)
+from edify.elements.types.leaves import (
+ AlphanumericElement,
+ AnyCharElement,
+ CarriageReturnElement,
+ DigitElement,
+ EndOfInputElement,
+ LetterElement,
+ LowercaseElement,
+ NewLineElement,
+ NonDigitElement,
+ NonWhitespaceCharElement,
+ NonWordBoundaryElement,
+ NonWordElement,
+ NoopElement,
+ NullByteElement,
+ StartOfInputElement,
+ TabElement,
+ UppercaseElement,
+ WhitespaceCharElement,
+ WordBoundaryElement,
+ WordElement,
+)
+from edify.elements.types.quantifiers import (
+ AtLeastElement,
+ AtMostElement,
+ BetweenElement,
+ BetweenLazyElement,
+ ExactlyElement,
+ OneOrMoreElement,
+ OneOrMoreLazyElement,
+ OptionalElement,
+ ZeroOrMoreElement,
+ ZeroOrMoreLazyElement,
+)
+from edify.introspect.explain import (
+ _describe_inline,
+ _describe_inline_children,
+ _describe_optional_inner,
+ _describe_plural,
+ _example_for,
+ _pick_character_not_in,
+ _pick_character_outside_range,
+ _wrap_step,
+ explain_elements,
+)
+
+
+def _explain(*elements) -> str:
+ return explain_elements(tuple(elements))
+
+
+def _accepted_examples(output: str) -> list[str]:
+ if "Text this pattern accepts:" not in output:
+ return []
+ accept_block = output.split("Text this pattern accepts:")[1]
+ stripped_block = accept_block.strip()
+ lines = stripped_block.splitlines()
+ return [line.strip() for line in lines if line.strip()]
+
+
+def test_empty_elements_produces_empty_string_notice():
+ assert _explain() == "This pattern is empty and matches an empty string."
+
+
+def test_single_digit_reads_as_must_contain_one_digit():
+ output = _explain(DigitElement())
+ assert output.startswith("- The text must contain one digit (0-9).")
+ assert "Text this pattern accepts:" in output
+
+
+def test_start_of_input_switches_lead_in_to_must_start_with():
+ output = _explain(StartOfInputElement(), DigitElement())
+ assert "The text must start with" in output
+
+
+def test_trailing_end_of_input_alone_is_absorbed_silently():
+ output = _explain(DigitElement(), EndOfInputElement())
+ assert "The text must contain one digit" in output
+ assert "very end" not in output
+
+
+def test_trailing_assert_ahead_then_end_becomes_must_end_at_line():
+ output = _explain(
+ DigitElement(),
+ AssertAheadElement(children=(CharElement(value="/"),)),
+ EndOfInputElement(),
+ )
+ assert 'The text must end at "/".' in output
+
+
+def test_second_element_uses_then_the_text_must_have():
+ output = _explain(DigitElement(), CharElement(value="-"))
+ assert "Then the text must have" in output
+
+
+def test_optional_element_gets_dedicated_optional_prefix():
+ output = _explain(OptionalElement(child=DigitElement()))
+ assert "Optional:" in output
+
+
+def test_zero_or_more_reads_as_zero_or_more_phrase():
+ output = _explain(ZeroOrMoreElement(child=DigitElement()))
+ assert "zero or more digits" in output
+
+
+def test_zero_or_more_lazy_reads_as_as_few_as_possible():
+ output = _explain(ZeroOrMoreLazyElement(child=DigitElement()))
+ assert "as few as possible" in output
+
+
+def test_one_or_more_reads_as_one_or_more_phrase():
+ output = _explain(OneOrMoreElement(child=DigitElement()))
+ assert "one or more digits" in output
+
+
+def test_one_or_more_lazy_reads_as_as_few_as_possible():
+ output = _explain(OneOrMoreLazyElement(child=DigitElement()))
+ assert "as few as possible" in output
+
+
+def test_exactly_reads_as_exactly_n_phrase():
+ output = _explain(ExactlyElement(times=4, child=DigitElement()))
+ assert "exactly 4 digits" in output
+
+
+def test_at_least_reads_as_at_least_n_phrase():
+ output = _explain(AtLeastElement(times=3, child=DigitElement()))
+ assert "at least 3 digits" in output
+
+
+def test_at_most_reads_as_at_most_n_phrase():
+ output = _explain(AtMostElement(times=5, child=DigitElement()))
+ assert "at most 5 digits" in output
+
+
+def test_between_reads_as_between_lower_and_upper_phrase():
+ output = _explain(BetweenElement(lower=2, upper=5, child=DigitElement()))
+ assert "between 2 and 5 digits" in output
+
+
+def test_between_lazy_reads_as_as_few_as_possible():
+ output = _explain(BetweenLazyElement(lower=2, upper=5, child=DigitElement()))
+ assert "as few as possible" in output
+
+
+def test_word_boundary_gets_a_dedicated_bullet():
+ output = _explain(WordBoundaryElement())
+ assert "word boundary" in output
+
+
+def test_non_word_boundary_gets_a_dedicated_bullet():
+ output = _explain(NonWordBoundaryElement())
+ assert "must NOT fall on a word boundary" in output
+
+
+def test_capture_step_describes_children_inline():
+ output = _explain(CaptureElement(children=(DigitElement(),)))
+ assert "one digit" in output
+
+
+def test_named_capture_step_describes_children_inline():
+ output = _explain(
+ NamedCaptureElement(name="year", children=(DigitElement(),))
+ )
+ assert "one digit" in output
+
+
+def test_backreference_step_names_the_group_index():
+ output = _explain(BackReferenceElement(index=2))
+ assert "group #2" in output
+
+
+def test_named_backreference_step_names_the_group_label():
+ output = _explain(NamedBackReferenceElement(name="year"))
+ assert 'group labeled "year"' in output
+
+
+def test_assert_ahead_reads_as_must_be_followed_by():
+ output = _explain(
+ DigitElement(), AssertAheadElement(children=(CharElement(value="/"),))
+ )
+ assert "must be followed by" in output
+
+
+def test_assert_not_ahead_reads_as_must_not_be_followed_by():
+ output = _explain(
+ DigitElement(),
+ AssertNotAheadElement(children=(CharElement(value="/"),)),
+ )
+ assert "must NOT be followed by" in output
+
+
+def test_assert_behind_reads_as_just_before_this_point():
+ output = _explain(
+ DigitElement(), AssertBehindElement(children=(CharElement(value="/"),))
+ )
+ assert "Just before this point" in output
+
+
+def test_assert_not_behind_reads_as_just_before_must_not_have_appeared():
+ output = _explain(
+ DigitElement(),
+ AssertNotBehindElement(children=(CharElement(value="/"),)),
+ )
+ assert "must NOT have appeared" in output
+
+
+def test_group_step_describes_children_inline():
+ output = _explain(GroupElement(children=(DigitElement(),)))
+ assert "one digit" in output
+
+
+def test_alternation_uses_either_or_for_two_alternatives():
+ output = _explain(
+ AnyOfElement(
+ children=(StringElement(value="cat"), StringElement(value="dog"))
+ )
+ )
+ assert 'either "cat" or "dog"' in output
+
+
+def test_alternation_uses_either_a_b_or_c_for_three_alternatives():
+ output = _explain(
+ AnyOfElement(
+ children=(
+ StringElement(value="cat"),
+ StringElement(value="dog"),
+ StringElement(value="fish"),
+ )
+ )
+ )
+ assert 'either "cat", "dog", or "fish"' in output
+
+
+def test_alternation_with_single_child_falls_through_to_single_phrase():
+ output = _explain(AnyOfElement(children=(StringElement(value="only"),)))
+ assert '"only"' in output
+
+
+def test_alternation_with_no_children_reads_as_nothing():
+ output = _explain(AnyOfElement(children=()))
+ assert "nothing" in output
+
+
+def test_examples_section_lists_up_to_three_accepted_strings():
+ output = _explain(DigitElement())
+ strings = _accepted_examples(output)
+ assert 1 <= len(strings) <= 3
+
+
+def test_examples_are_unique_across_the_three_seeds():
+ output = _explain(DigitElement())
+ strings = _accepted_examples(output)
+ assert len(set(strings)) == len(strings)
+
+
+def test_examples_section_omitted_when_only_empty_strings_are_generated():
+ output = _explain(StartOfInputElement(), EndOfInputElement())
+ assert "Text this pattern accepts:" not in output
+
+
+def test_subexpression_is_flattened_into_the_step_list():
+ output = _explain(
+ SubexpressionElement(children=(DigitElement(), CharElement(value="-")))
+ )
+ assert output.count("- ") >= 2
+
+
+def test_char_element_reads_as_quoted_literal():
+ output = _explain(CharElement(value="\\-"))
+ assert '"-"' in output
+
+
+def test_string_element_reads_as_quoted_literal():
+ output = _explain(StringElement(value="hi"))
+ assert '"hi"' in output
+
+
+def test_range_element_describes_from_start_to_end():
+ output = _explain(RangeElement(start="a", end="z"))
+ assert 'from "a" through "z"' in output
+
+
+def test_any_of_chars_describes_from_the_set():
+ output = _explain(AnyOfCharsElement(value="abc"))
+ assert 'from the set "abc"' in output
+
+
+def test_anything_but_chars_describes_not_from_the_set():
+ output = _explain(AnythingButCharsElement(value="abc"))
+ assert 'NOT from the set "abc"' in output
+
+
+def test_anything_but_range_describes_outside_range():
+ output = _explain(AnythingButRangeElement(start="a", end="z"))
+ assert 'outside "a" through "z"' in output
+
+
+def test_anything_but_string_describes_position_for_position_mismatch():
+ output = _explain(AnythingButStringElement(value="stop"))
+ assert "position-for-position" in output
+
+
+def test_any_char_reads_as_any_single_character():
+ output = _explain(AnyCharElement())
+ assert "any single character" in output
+
+
+def test_whitespace_char_mentions_space_tab_newline():
+ output = _explain(WhitespaceCharElement())
+ assert "whitespace character" in output
+
+
+def test_non_whitespace_char_reads_as_non_whitespace():
+ output = _explain(NonWhitespaceCharElement())
+ assert "non-whitespace character" in output
+
+
+def test_non_digit_char_reads_as_non_digit_character():
+ output = _explain(NonDigitElement())
+ assert "non-digit character" in output
+
+
+def test_word_element_reads_as_letter_digit_or_underscore():
+ output = _explain(WordElement())
+ assert "one letter, digit, or underscore" in output
+
+
+def test_non_word_element_reads_as_not_letter_digit_or_underscore():
+ output = _explain(NonWordElement())
+ assert "not a letter, digit, or underscore" in output
+
+
+def test_new_line_element_labeled_as_line_feed():
+ output = _explain(NewLineElement())
+ assert "line-feed character" in output
+
+
+def test_carriage_return_element_labeled_as_carriage_return():
+ output = _explain(CarriageReturnElement())
+ assert "carriage-return character" in output
+
+
+def test_tab_element_labeled_as_tab_character():
+ output = _explain(TabElement())
+ assert "tab character" in output
+
+
+def test_null_byte_element_labeled_as_null_byte():
+ output = _explain(NullByteElement())
+ assert "null byte" in output
+
+
+def test_letter_element_labeled_a_to_z_case_insensitive():
+ output = _explain(LetterElement())
+ assert "one letter" in output
+
+
+def test_uppercase_element_labeled_uppercase_range():
+ output = _explain(UppercaseElement())
+ assert "uppercase letter" in output
+
+
+def test_lowercase_element_labeled_lowercase_range():
+ output = _explain(LowercaseElement())
+ assert "lowercase letter" in output
+
+
+def test_alphanumeric_element_labeled_letter_or_digit():
+ output = _explain(AlphanumericElement())
+ assert "letter or digit" in output
+
+
+def test_noop_element_labeled_nothing_inline():
+ output = _explain(GroupElement(children=(NoopElement(),)))
+ assert "nothing" in output
+
+
+def test_regex_explain_end_to_end_via_builder():
+ output = RegexBuilder().digit().to_regex().explain()
+ assert "one digit" in output
+
+
+def test_examples_use_digit_rotation_for_a_pure_digit_pattern():
+ output = _explain(DigitElement())
+ strings = _accepted_examples(output)
+ assert all(text.isdigit() for text in strings)
+
+
+def test_examples_for_alternation_pick_a_different_branch_per_seed():
+ output = _explain(
+ AnyOfElement(
+ children=(
+ StringElement(value="cat"),
+ StringElement(value="dog"),
+ StringElement(value="fish"),
+ )
+ )
+ )
+ strings = set(_accepted_examples(output))
+ assert strings == {"cat", "dog", "fish"}
+
+
+def test_examples_for_optional_include_both_present_and_absent_forms():
+ output = _explain(
+ DigitElement(), OptionalElement(child=CharElement(value="x")), DigitElement()
+ )
+ strings = _accepted_examples(output)
+ assert any("x" in text for text in strings)
+
+
+def test_examples_for_exactly_produce_that_length_string():
+ output = _explain(ExactlyElement(times=4, child=DigitElement()))
+ strings = _accepted_examples(output)
+ assert all(len(text) == 4 for text in strings)
+
+
+def test_examples_for_at_least_produce_more_than_lower_bound_length():
+ output = _explain(AtLeastElement(times=2, child=DigitElement()))
+ strings = _accepted_examples(output)
+ assert all(len(text) >= 2 for text in strings)
+
+
+def test_examples_for_at_most_produce_bounded_length():
+ output = _explain(AtMostElement(times=3, child=DigitElement()))
+ strings = _accepted_examples(output)
+ assert all(1 <= len(text) <= 3 for text in strings)
+
+
+def test_examples_for_between_stay_within_range():
+ output = _explain(BetweenElement(lower=2, upper=4, child=DigitElement()))
+ strings = _accepted_examples(output)
+ assert all(2 <= len(text) <= 4 for text in strings)
+
+
+def test_examples_for_between_lazy_stay_within_range():
+ output = _explain(BetweenLazyElement(lower=2, upper=4, child=DigitElement()))
+ strings = _accepted_examples(output)
+ assert all(2 <= len(text) <= 4 for text in strings)
+
+
+def test_examples_for_backreference_use_a_placeholder():
+ output = _explain(
+ CaptureElement(children=(DigitElement(),)),
+ BackReferenceElement(index=1),
+ )
+ strings = _accepted_examples(output)
+ assert all("a" in text for text in strings)
+
+
+def test_examples_for_named_backreference_use_a_placeholder():
+ output = _explain(
+ NamedCaptureElement(name="a", children=(DigitElement(),)),
+ NamedBackReferenceElement(name="a"),
+ )
+ strings = _accepted_examples(output)
+ assert all("a" in text for text in strings)
+
+
+def test_examples_for_anything_but_chars_pick_a_char_not_in_set():
+ output = _explain(AnythingButCharsElement(value="abc"))
+ strings = _accepted_examples(output)
+ for text in strings:
+ for character in text:
+ assert character not in "abc"
+
+
+def test_examples_for_anything_but_range_pick_a_char_outside_range():
+ output = _explain(AnythingButRangeElement(start="a", end="z"))
+ strings = _accepted_examples(output)
+ for text in strings:
+ for character in text:
+ assert not ("a" <= character <= "z")
+
+
+def test_examples_for_anything_but_string_have_matching_length():
+ output = _explain(AnythingButStringElement(value="stop"))
+ strings = _accepted_examples(output)
+ assert all(len(text) == 4 for text in strings)
+
+
+def test_examples_for_anything_but_string_empty_produces_empty_seed():
+ output = _explain(AnythingButStringElement(value=""))
+ assert "Text this pattern accepts:" not in output
+
+
+def test_examples_for_any_of_chars_empty_uses_default_letter():
+ output = _explain(AnyOfCharsElement(value=""))
+ strings = _accepted_examples(output)
+ assert all(text == "a" for text in strings)
+
+
+def test_examples_for_negative_lookaround_contribute_nothing():
+ output = _explain(
+ DigitElement(),
+ AssertNotAheadElement(children=(CharElement(value="/"),)),
+ )
+ strings = _accepted_examples(output)
+ assert all(text.isdigit() for text in strings)
+
+
+def test_pick_character_not_in_uses_bang_when_set_exhausts_alphanumerics():
+ everything = "abcdefghijklmnopqrstuvwxyz0123456789"
+ assert _pick_character_not_in(everything) == "!"
+
+
+def test_pick_character_outside_range_uses_bang_when_range_covers_alphanumerics():
+ assert _pick_character_outside_range("0", "z") == "!"
+
+
+def test_step_wrap_returns_empty_prefix_for_empty_description():
+ wrapped = _wrap_step(1, "")
+ assert wrapped.strip() == "1."
+
+
+def test_step_wrap_breaks_long_descriptions_across_continuation_lines():
+ long_description = " ".join(["word"] * 40)
+ wrapped = _wrap_step(1, long_description)
+ assert "\n" in wrapped
+
+
+def test_optional_inner_wraps_char_literal():
+ assert _describe_optional_inner(CharElement(value="\\-")) == '"-"'
+
+
+def test_optional_inner_wraps_string_literal():
+ assert _describe_optional_inner(StringElement(value="hi")) == '"hi"'
+
+
+def test_optional_inner_delegates_to_inline_for_leaf_elements():
+ assert "one digit" in _describe_optional_inner(DigitElement())
+
+
+def test_describe_inline_falls_back_to_class_name_for_unknown_type():
+ class MysteryElement(DigitElement.__mro__[1]):
+ pass
+
+ mystery = MysteryElement()
+ description = _describe_inline(mystery)
+ assert description == "MysteryElement"
+
+
+def test_describe_plural_falls_back_to_of_inline_for_unknown_type():
+ class MysteryElement(DigitElement.__mro__[1]):
+ pass
+
+ mystery = MysteryElement()
+ plural = _describe_plural(mystery)
+ assert plural.startswith("of ")
+
+
+def test_step_wrapper_directly_wraps_first_line_at_width_boundary():
+ words = " ".join(["w"] * 100)
+ wrapped = _wrap_step(1, words)
+ for line in wrapped.splitlines():
+ stripped_line = line.strip()
+ assert len(line) <= 76 or stripped_line.startswith("w")
+
+
+def _plural(element) -> str:
+ return _describe_plural(element)
+
+
+def _inline(element) -> str:
+ return _describe_inline(element)
+
+
+def test_plural_any_char_reads_as_generic_characters():
+ assert _plural(AnyCharElement()) == "characters (any character)"
+
+
+def test_plural_whitespace_reads_as_whitespace_characters():
+ assert "whitespace characters" in _plural(WhitespaceCharElement())
+
+
+def test_plural_non_whitespace_reads_as_non_whitespace_characters():
+ assert _plural(NonWhitespaceCharElement()) == "non-whitespace characters"
+
+
+def test_plural_non_digit_reads_as_non_digit_characters():
+ assert _plural(NonDigitElement()) == "non-digit characters"
+
+
+def test_plural_word_reads_as_letters_digits_underscores():
+ assert _plural(WordElement()) == "letters, digits, or underscores"
+
+
+def test_plural_non_word_reads_as_not_letters_digits_or_underscores():
+ assert "not letters, digits, or underscores" in _plural(NonWordElement())
+
+
+def test_plural_new_line_reads_as_line_feed_characters():
+ assert "line-feed characters" in _plural(NewLineElement())
+
+
+def test_plural_carriage_return_reads_as_carriage_return_characters():
+ assert "carriage-return characters" in _plural(CarriageReturnElement())
+
+
+def test_plural_tab_reads_as_tab_characters():
+ assert "tab characters" in _plural(TabElement())
+
+
+def test_plural_null_byte_reads_as_null_bytes():
+ assert "null bytes" in _plural(NullByteElement())
+
+
+def test_plural_letter_reads_as_letters_a_to_z():
+ assert _plural(LetterElement()) == "letters (a-z or A-Z)"
+
+
+def test_plural_uppercase_reads_as_uppercase_letters():
+ assert "uppercase letters" in _plural(UppercaseElement())
+
+
+def test_plural_lowercase_reads_as_lowercase_letters():
+ assert "lowercase letters" in _plural(LowercaseElement())
+
+
+def test_plural_alphanumeric_reads_as_letters_or_digits():
+ assert "letters or digits" in _plural(AlphanumericElement())
+
+
+def test_plural_char_literal_reads_as_copies_of_character():
+ assert "copies of the character" in _plural(CharElement(value="\\-"))
+
+
+def test_plural_string_literal_reads_as_copies_of_text():
+ assert "copies of the text" in _plural(StringElement(value="hi"))
+
+
+def test_plural_range_reads_as_from_through():
+ assert 'from "a" through "z"' in _plural(RangeElement(start="a", end="z"))
+
+
+def test_plural_any_of_chars_reads_as_from_the_set():
+ assert 'from the set "abc"' in _plural(AnyOfCharsElement(value="abc"))
+
+
+def test_plural_anything_but_chars_reads_as_not_from_the_set():
+ assert 'NOT from the set "abc"' in _plural(AnythingButCharsElement(value="abc"))
+
+
+def test_plural_anything_but_range_reads_as_outside_range():
+ assert 'outside "a" through "z"' in _plural(
+ AnythingButRangeElement(start="a", end="z")
+ )
+
+
+def test_plural_unknown_element_falls_back_to_of_inline_form():
+ class MysteryElement(DigitElement.__mro__[1]):
+ pass
+
+ mystery = MysteryElement()
+ plural = _plural(mystery)
+ assert plural.startswith("of ")
+
+
+def test_inline_start_of_input_reads_as_very_beginning_of_text():
+ assert _inline(StartOfInputElement()) == "the very beginning of the text"
+
+
+def test_inline_end_of_input_reads_as_very_end_of_text():
+ assert _inline(EndOfInputElement()) == "the very end of the text"
+
+
+def test_inline_word_boundary_reads_as_word_boundary():
+ assert _inline(WordBoundaryElement()) == "a word boundary"
+
+
+def test_inline_non_word_boundary_reads_as_non_word_boundary_position():
+ assert _inline(NonWordBoundaryElement()) == "a non-word-boundary position"
+
+
+def test_inline_optional_reads_as_an_optional_x():
+ element = OptionalElement(child=DigitElement())
+
+ phrase = _inline(element)
+
+ assert phrase.startswith("an optional ")
+
+
+def test_inline_zero_or_more_reads_as_zero_or_more_x():
+ element = ZeroOrMoreElement(child=DigitElement())
+
+ phrase = _inline(element)
+
+ assert phrase.startswith("zero or more")
+
+
+def test_inline_zero_or_more_lazy_notes_as_few_as_possible():
+ assert "as few as possible" in _inline(ZeroOrMoreLazyElement(child=DigitElement()))
+
+
+def test_inline_one_or_more_reads_as_one_or_more_x():
+ element = OneOrMoreElement(child=DigitElement())
+
+ phrase = _inline(element)
+
+ assert phrase.startswith("one or more")
+
+
+def test_inline_one_or_more_lazy_notes_as_few_as_possible():
+ assert "as few as possible" in _inline(OneOrMoreLazyElement(child=DigitElement()))
+
+
+def test_inline_exactly_reads_as_exactly_n_x():
+ element = ExactlyElement(times=4, child=DigitElement())
+
+ phrase = _inline(element)
+
+ assert phrase.startswith("exactly 4")
+
+
+def test_inline_at_least_reads_as_at_least_n_x():
+ element = AtLeastElement(times=3, child=DigitElement())
+
+ phrase = _inline(element)
+
+ assert phrase.startswith("at least 3")
+
+
+def test_inline_at_most_reads_as_at_most_n_x():
+ element = AtMostElement(times=2, child=DigitElement())
+
+ phrase = _inline(element)
+
+ assert phrase.startswith("at most 2")
+
+
+def test_inline_between_reads_as_between_lower_and_upper():
+ assert "between 2 and 5" in _inline(
+ BetweenElement(lower=2, upper=5, child=DigitElement())
+ )
+
+
+def test_inline_between_lazy_notes_as_few_as_possible():
+ assert "as few as possible" in _inline(
+ BetweenLazyElement(lower=2, upper=5, child=DigitElement())
+ )
+
+
+def test_inline_capture_reads_as_child_captured_marker():
+ assert "(captured)" in _inline(CaptureElement(children=(DigitElement(),)))
+
+
+def test_inline_named_capture_includes_the_label():
+ assert 'label "year"' in _inline(
+ NamedCaptureElement(name="year", children=(DigitElement(),))
+ )
+
+
+def test_inline_group_reads_as_children_inline():
+ assert "one digit" in _inline(GroupElement(children=(DigitElement(),)))
+
+
+def test_inline_subexpression_reads_as_children_inline():
+ assert "one digit" in _inline(SubexpressionElement(children=(DigitElement(),)))
+
+
+def test_inline_alternation_reads_as_either_or_phrase():
+ assert "either" in _inline(
+ AnyOfElement(
+ children=(StringElement(value="a"), StringElement(value="b"))
+ )
+ )
+
+
+def test_inline_backreference_reads_as_same_text_that_group_captured():
+ assert "group #1 captured" in _inline(BackReferenceElement(index=1))
+
+
+def test_inline_named_backreference_reads_as_that_the_name_group_captured():
+ assert 'the "year" group captured' in _inline(NamedBackReferenceElement(name="year"))
+
+
+def test_optional_inner_reads_group_children_inline():
+ assert "one digit" in _describe_optional_inner(
+ GroupElement(children=(DigitElement(),))
+ )
+
+
+def test_describe_inline_children_reads_as_nothing_when_empty():
+ assert _describe_inline_children(()) == "nothing"
+
+
+def test_describe_inline_children_joins_multiple_phrases_with_then():
+ joined = _describe_inline_children((DigitElement(), StringElement(value="X")))
+ assert ", then " in joined
+
+
+def test_example_for_unknown_element_returns_empty_string():
+ class MysteryElement(DigitElement.__mro__[1]):
+ pass
+
+ assert _example_for(MysteryElement(), 0) == ""
diff --git a/tests/introspect/graphviz.test.py b/tests/introspect/graphviz.test.py
new file mode 100644
index 0000000..516d91b
--- /dev/null
+++ b/tests/introspect/graphviz.test.py
@@ -0,0 +1,359 @@
+"""Tests for the Graphviz DOT / SVG renderer in :mod:`edify.introspect.graphviz`."""
+
+import builtins
+import importlib
+import sys
+
+import pytest
+
+from edify import Pattern, RegexBuilder
+from edify.elements.types.captures import (
+ BackReferenceElement,
+ CaptureElement,
+ NamedBackReferenceElement,
+ NamedCaptureElement,
+)
+from edify.elements.types.chars import (
+ CharElement,
+ StringElement,
+)
+from edify.elements.types.groups import (
+ AnyOfElement,
+ AssertAheadElement,
+ AssertBehindElement,
+ AssertNotAheadElement,
+ AssertNotBehindElement,
+ GroupElement,
+ SubexpressionElement,
+)
+from edify.elements.types.leaves import (
+ DigitElement,
+ EndOfInputElement,
+ StartOfInputElement,
+ WordElement,
+)
+from edify.elements.types.quantifiers import (
+ AtLeastElement,
+ AtMostElement,
+ BetweenElement,
+ BetweenLazyElement,
+ ExactlyElement,
+ OneOrMoreElement,
+ OneOrMoreLazyElement,
+ OptionalElement,
+ ZeroOrMoreElement,
+ ZeroOrMoreLazyElement,
+)
+from edify.errors.introspect import MissingGraphvizDependencyError
+from edify.introspect import graphviz as graphviz_module
+from edify.introspect.graphviz import (
+ _Counter,
+ _escape_dot,
+ render_dot,
+ render_graphviz_svg,
+)
+
+
+def _dot(*elements) -> str:
+ return render_dot(tuple(elements))
+
+
+def test_empty_pattern_produces_direct_start_to_end_edge():
+ output = _dot()
+ assert "start -> end;" in output
+ assert "digraph edify_pattern" in output
+
+
+def test_single_digit_pattern_creates_one_labeled_node():
+ output = _dot(DigitElement())
+ assert 'label="digit"' in output
+ assert "start -> n_1" in output
+ assert "n_1 -> end" in output
+
+
+def test_header_configures_left_to_right_layout_and_menlo_font():
+ output = _dot(DigitElement())
+ assert "rankdir=LR" in output
+ assert 'fontname="Menlo"' in output
+
+
+def test_start_and_end_terminals_are_circles():
+ output = _dot(DigitElement())
+ assert 'start [label="START", shape=circle]' in output
+ assert 'end [label="END", shape=circle]' in output
+
+
+def test_one_or_more_digit_folds_quantifier_as_second_line():
+ output = _dot(OneOrMoreElement(child=DigitElement()))
+ assert 'label="digit\\n(one or more)"' in output
+
+
+def test_one_or_more_lazy_appends_lazy_marker_to_phrase():
+ output = _dot(OneOrMoreLazyElement(child=DigitElement()))
+ assert "one or more (lazy)" in output
+
+
+def test_zero_or_more_reads_as_zero_or_more_phrase():
+ output = _dot(ZeroOrMoreElement(child=DigitElement()))
+ assert "zero or more" in output
+
+
+def test_zero_or_more_lazy_appends_lazy_marker():
+ output = _dot(ZeroOrMoreLazyElement(child=DigitElement()))
+ assert "zero or more (lazy)" in output
+
+
+def test_optional_reads_as_optional_phrase():
+ output = _dot(OptionalElement(child=DigitElement()))
+ assert "optional" in output
+
+
+def test_exactly_reads_as_exactly_n_phrase():
+ output = _dot(ExactlyElement(times=4, child=DigitElement()))
+ assert "exactly 4" in output
+
+
+def test_at_least_reads_as_at_least_n_phrase():
+ output = _dot(AtLeastElement(times=3, child=DigitElement()))
+ assert "at least 3" in output
+
+
+def test_at_most_reads_as_at_most_n_phrase():
+ output = _dot(AtMostElement(times=5, child=DigitElement()))
+ assert "at most 5" in output
+
+
+def test_between_reads_as_lower_to_upper_phrase():
+ output = _dot(BetweenElement(lower=2, upper=5, child=DigitElement()))
+ assert "2 to 5" in output
+
+
+def test_between_lazy_appends_lazy_marker():
+ output = _dot(BetweenLazyElement(lower=2, upper=5, child=DigitElement()))
+ assert "2 to 5 (lazy)" in output
+
+
+def test_alternation_creates_fork_and_merge_junction_points():
+ output = _dot(
+ AnyOfElement(
+ children=(
+ StringElement(value="cat"),
+ StringElement(value="dog"),
+ StringElement(value="fish"),
+ )
+ )
+ )
+ assert 'shape=point, width=0.08' in output
+ assert output.count("shape=point") == 2
+ assert output.count("fork_") >= 1
+ assert output.count("merge_") >= 1
+
+
+def test_alternation_emits_edge_from_fork_and_to_merge_for_each_branch():
+ output = _dot(
+ AnyOfElement(
+ children=(
+ StringElement(value="cat"),
+ StringElement(value="dog"),
+ StringElement(value="fish"),
+ )
+ )
+ )
+ assert output.count("fork_1 ->") == 3
+ assert output.count("-> merge_2") == 3
+
+
+def test_empty_alternation_becomes_nothing_placeholder():
+ output = _dot(AnyOfElement(children=()))
+ assert 'label="nothing"' in output
+
+
+def test_named_capture_wraps_children_in_dashed_cluster():
+ output = _dot(
+ NamedCaptureElement(
+ name="year",
+ children=(ExactlyElement(times=4, child=DigitElement()),),
+ )
+ )
+ assert 'label="saved as \\"year\\""' in output
+ assert 'style="dashed,rounded"' in output
+ assert "subgraph cluster_" in output
+
+
+def test_unnamed_capture_wraps_children_in_captured_cluster():
+ output = _dot(CaptureElement(children=(DigitElement(),)))
+ assert 'label="captured"' in output
+
+
+def test_empty_capture_becomes_captured_empty_placeholder():
+ output = _dot(CaptureElement(children=()))
+ assert "captured (empty)" in output
+
+
+def test_group_wraps_children_in_grouped_cluster():
+ output = _dot(GroupElement(children=(DigitElement(),)))
+ assert 'label="grouped"' in output
+
+
+def test_subexpression_flattens_into_a_plain_sequence():
+ output = _dot(
+ SubexpressionElement(children=(DigitElement(), WordElement()))
+ )
+ assert "cluster_" not in output
+ assert 'label="digit"' in output
+ assert 'label="word character"' in output
+
+
+def test_empty_subexpression_becomes_empty_placeholder():
+ output = _dot(SubexpressionElement(children=()))
+ assert "empty subexpression" in output
+
+
+def test_start_of_input_reads_as_text_starts_here():
+ output = _dot(StartOfInputElement())
+ assert 'label="text starts here"' in output
+
+
+def test_end_of_input_reads_as_text_ends_here():
+ output = _dot(EndOfInputElement())
+ assert 'label="text ends here"' in output
+
+
+def test_backreference_reads_as_match_same_text_as_group_n():
+ output = _dot(BackReferenceElement(index=2))
+ assert "match same text as group 2" in output
+
+
+def test_named_backreference_reads_as_match_same_text_as_name():
+ output = _dot(NamedBackReferenceElement(name="year"))
+ assert 'match same text as \\"year\\"' in output
+
+
+def test_assert_ahead_wraps_children_in_must_be_followed_by_cluster():
+ output = _dot(AssertAheadElement(children=(DigitElement(),)))
+ assert '"must be followed by"' in output
+
+
+def test_assert_not_ahead_wraps_children_in_must_not_be_followed_by_cluster():
+ output = _dot(AssertNotAheadElement(children=(DigitElement(),)))
+ assert "must NOT be followed by" in output
+
+
+def test_assert_behind_wraps_children_in_must_be_preceded_by_cluster():
+ output = _dot(AssertBehindElement(children=(DigitElement(),)))
+ assert "must be preceded by" in output
+
+
+def test_assert_not_behind_wraps_children_in_must_not_be_preceded_by_cluster():
+ output = _dot(AssertNotBehindElement(children=(DigitElement(),)))
+ assert "must NOT be preceded by" in output
+
+
+def test_character_literal_display_strips_regex_escaping():
+ output = _dot(CharElement(value="\\-"))
+ assert '"\\"-\\""' in output
+
+
+def test_quantifier_wrapping_complex_child_uses_cluster_not_inline_label():
+ inner = AnyOfElement(
+ children=(StringElement(value="a"), StringElement(value="b"))
+ )
+ output = _dot(OneOrMoreElement(child=inner))
+ assert '"one or more"' in output
+ assert "subgraph cluster_" in output
+
+
+def test_unknown_element_type_produces_question_mark_label():
+ class MysteryElement(DigitElement.__mro__[1]):
+ pass
+
+ output = _dot(MysteryElement())
+ assert "?MysteryElement" in output
+
+
+def test_counter_advances_and_produces_unique_ids():
+ counter = _Counter()
+ first = counter.next("n")
+ second = counter.next("n")
+ third = counter.next("fork")
+ assert first == "n_1"
+ assert second == "n_2"
+ assert third == "fork_3"
+
+
+def test_escape_dot_escapes_backslashes_and_quotes():
+ assert _escape_dot('a"b') == 'a\\"b'
+ assert _escape_dot("a\\b") == "a\\\\b"
+
+
+def test_escape_dot_preserves_newline_escape_for_two_line_labels():
+ assert _escape_dot("digit\\n(one or more)") == "digit\\n(one or more)"
+
+
+def test_render_dot_end_to_end_via_builder():
+ dot = RegexBuilder().any_of("cat", "dog", "fish").to_regex()
+ output = render_dot(dot.elements)
+ assert 'label="\\"cat\\""' in output
+ assert 'label="\\"dog\\""' in output
+ assert 'label="\\"fish\\""' in output
+
+
+def test_render_graphviz_svg_returns_svg_string_when_graphviz_available():
+ regex = RegexBuilder().digit().to_regex()
+ output = render_graphviz_svg(regex.elements)
+ assert output.startswith("<?xml") or output.startswith("<svg")
+ assert "</svg>" in output
+
+
+def test_render_graphviz_svg_raises_missing_dependency_when_graphviz_absent(monkeypatch):
+ monkeypatch.setattr(graphviz_module, "_graphviz_module", None)
+ regex = RegexBuilder().digit().to_regex()
+ with pytest.raises(MissingGraphvizDependencyError):
+ render_graphviz_svg(regex.elements)
+
+
+def test_module_level_import_falls_back_to_none_when_graphviz_missing(monkeypatch):
+ saved_module = sys.modules.pop("graphviz", None)
+ real_import = builtins.__import__
+
+ def blocking_import(name, *args, **kwargs):
+ if name == "graphviz":
+ raise ImportError("graphviz missing")
+ return real_import(name, *args, **kwargs)
+
+ monkeypatch.setattr(builtins, "__import__", blocking_import)
+ monkeypatch.setitem(sys.modules, "graphviz", None)
+ reloaded = importlib.reload(graphviz_module)
+ try:
+ assert reloaded._graphviz_module is None
+ finally:
+ monkeypatch.undo()
+ if saved_module is not None:
+ sys.modules["graphviz"] = saved_module
+ importlib.reload(graphviz_module)
+
+
+def test_nested_capture_inside_alternation_renders_cluster_inside_fork_merge():
+ outer = AnyOfElement(
+ children=(
+ NamedCaptureElement(name="left", children=(DigitElement(),)),
+ NamedCaptureElement(name="right", children=(WordElement(),)),
+ )
+ )
+ output = _dot(outer)
+ assert output.count("subgraph cluster_") == 2
+ assert '"saved as \\"left\\""' in output
+ assert '"saved as \\"right\\""' in output
+
+
+def test_lookaround_inside_capture_renders_nested_clusters():
+ inner = AssertAheadElement(children=(DigitElement(),))
+ output = _dot(CaptureElement(children=(inner,)))
+ assert '"captured"' in output
+ assert '"must be followed by"' in output
+
+
+def test_visualize_svg_end_to_end_delegates_to_renderer():
+ regex = RegexBuilder().digit().to_regex()
+ svg = regex.visualize(format="svg", engine="graphviz")
+ assert "</svg>" in svg
diff --git a/tests/introspect/verbose.test.py b/tests/introspect/verbose.test.py
new file mode 100644
index 0000000..1f339a2
--- /dev/null
+++ b/tests/introspect/verbose.test.py
@@ -0,0 +1,459 @@
+"""Tests for the re.VERBOSE-compatible renderer in :mod:`edify.introspect.verbose`."""
+
+import re
+
+from edify import RegexBuilder
+from edify.elements.types.captures import (
+ BackReferenceElement,
+ CaptureElement,
+ NamedBackReferenceElement,
+ NamedCaptureElement,
+)
+from edify.elements.types.chars import (
+ AnyOfCharsElement,
+ AnythingButCharsElement,
+ AnythingButRangeElement,
+ AnythingButStringElement,
+ CharElement,
+ RangeElement,
+ StringElement,
+)
+from edify.elements.types.groups import (
+ AnyOfElement,
+ AssertAheadElement,
+ AssertBehindElement,
+ AssertNotAheadElement,
+ AssertNotBehindElement,
+ GroupElement,
+ SubexpressionElement,
+)
+from edify.elements.types.leaves import (
+ AlphanumericElement,
+ AnyCharElement,
+ CarriageReturnElement,
+ DigitElement,
+ EndOfInputElement,
+ LetterElement,
+ LowercaseElement,
+ NewLineElement,
+ NonDigitElement,
+ NonWhitespaceCharElement,
+ NonWordBoundaryElement,
+ NonWordElement,
+ NoopElement,
+ NullByteElement,
+ StartOfInputElement,
+ TabElement,
+ UppercaseElement,
+ WhitespaceCharElement,
+ WordBoundaryElement,
+ WordElement,
+)
+from edify.elements.types.quantifiers import (
+ AtLeastElement,
+ AtMostElement,
+ BetweenElement,
+ BetweenLazyElement,
+ ExactlyElement,
+ OneOrMoreElement,
+ OneOrMoreLazyElement,
+ OptionalElement,
+ ZeroOrMoreElement,
+ ZeroOrMoreLazyElement,
+)
+from edify.introspect import verbose as verbose_module
+from edify.introspect.verbose import _render_line, verbose_elements
+
+
+def _verbose(*elements) -> str:
+ return verbose_elements(tuple(elements))
+
+
+def _strip_pattern(output: str) -> str:
+ fragments = []
+ for line in output.splitlines():
+ without_comment = line.split("#", 1)[0]
+ code = without_comment.strip()
+ if code:
+ fragments.append(code)
+ return "".join(fragments)
+
+
+def test_empty_elements_produces_empty_string():
+ assert _verbose() == ""
+
+
+def test_single_digit_reads_as_backslash_d_with_comment():
+ output = _verbose(DigitElement())
+ assert output.startswith("\\d")
+ assert "any digit (0-9)" in output
+
+
+def test_start_of_input_reads_as_caret_with_comment():
+ output = _verbose(StartOfInputElement())
+ assert output.startswith("^")
+ assert "start of input" in output
+
+
+def test_end_of_input_reads_as_dollar_with_comment():
+ output = _verbose(EndOfInputElement())
+ assert output.startswith("$")
+ assert "end of input" in output
+
+
+def test_any_char_reads_as_dot_with_comment():
+ output = _verbose(AnyCharElement())
+ assert output.startswith(".")
+ assert "any single character" in output
+
+
+def test_whitespace_reads_as_backslash_s():
+ output = _verbose(WhitespaceCharElement())
+ assert output.startswith("\\s")
+
+
+def test_non_whitespace_reads_as_backslash_S():
+ output = _verbose(NonWhitespaceCharElement())
+ assert output.startswith("\\S")
+
+
+def test_non_digit_reads_as_backslash_D():
+ output = _verbose(NonDigitElement())
+ assert output.startswith("\\D")
+
+
+def test_word_reads_as_backslash_w():
+ output = _verbose(WordElement())
+ assert output.startswith("\\w")
+
+
+def test_non_word_reads_as_backslash_W():
+ output = _verbose(NonWordElement())
+ assert output.startswith("\\W")
+
+
+def test_word_boundary_reads_as_backslash_b():
+ output = _verbose(WordBoundaryElement())
+ assert output.startswith("\\b")
+
+
+def test_non_word_boundary_reads_as_backslash_B():
+ output = _verbose(NonWordBoundaryElement())
+ assert output.startswith("\\B")
+
+
+def test_new_line_reads_as_backslash_n():
+ output = _verbose(NewLineElement())
+ assert output.startswith("\\n")
+
+
+def test_carriage_return_reads_as_backslash_r():
+ output = _verbose(CarriageReturnElement())
+ assert output.startswith("\\r")
+
+
+def test_tab_reads_as_backslash_t():
+ output = _verbose(TabElement())
+ assert output.startswith("\\t")
+
+
+def test_null_byte_reads_as_backslash_zero():
+ output = _verbose(NullByteElement())
+ assert output.startswith("\\0")
+
+
+def test_letter_reads_as_a_z_A_Z_class():
+ output = _verbose(LetterElement())
+ assert output.startswith("[a-zA-Z]")
+
+
+def test_uppercase_reads_as_A_Z_class():
+ output = _verbose(UppercaseElement())
+ assert output.startswith("[A-Z]")
+
+
+def test_lowercase_reads_as_a_z_class():
+ output = _verbose(LowercaseElement())
+ assert output.startswith("[a-z]")
+
+
+def test_alphanumeric_reads_as_a_z_A_Z_0_9_class():
+ output = _verbose(AlphanumericElement())
+ assert output.startswith("[a-zA-Z0-9]")
+
+
+def test_noop_reads_as_no_op_comment_and_empty_pattern():
+ output = _verbose(NoopElement())
+ assert "no-op" in output
+ assert _strip_pattern(output) == ""
+
+
+def test_char_literal_shows_raw_value_and_literal_comment():
+ output = _verbose(CharElement(value="a"))
+ assert output.startswith("a")
+ assert 'literal "a"' in output
+
+
+def test_string_literal_shows_raw_value_and_literal_string_comment():
+ output = _verbose(StringElement(value="hello"))
+ assert output.startswith("hello")
+ assert 'literal string "hello"' in output
+
+
+def test_range_reads_as_bracketed_range_class():
+ output = _verbose(RangeElement(start="a", end="z"))
+ assert output.startswith("[a-z]")
+ assert "range a-z" in output
+
+
+def test_any_of_chars_reads_as_bracketed_class():
+ output = _verbose(AnyOfCharsElement(value="abc"))
+ assert output.startswith("[abc]")
+
+
+def test_anything_but_chars_reads_as_negated_bracketed_class():
+ output = _verbose(AnythingButCharsElement(value="abc"))
+ assert output.startswith("[^abc]")
+
+
+def test_anything_but_range_reads_as_negated_range_class():
+ output = _verbose(AnythingButRangeElement(start="a", end="z"))
+ assert output.startswith("[^a-z]")
+
+
+def test_anything_but_string_produces_per_position_negation():
+ output = _verbose(AnythingButStringElement(value="ab"))
+ assert "per-position negation" in output
+
+
+def test_optional_digit_reads_as_question_mark_with_comment():
+ output = _verbose(OptionalElement(child=DigitElement()))
+ assert output.startswith("\\d?")
+ assert "optional (zero or one)" in output
+
+
+def test_zero_or_more_digit_reads_as_star_greedy():
+ output = _verbose(ZeroOrMoreElement(child=DigitElement()))
+ assert output.startswith("\\d*")
+ assert "zero or more (greedy)" in output
+
+
+def test_zero_or_more_lazy_reads_as_star_lazy():
+ output = _verbose(ZeroOrMoreLazyElement(child=DigitElement()))
+ assert output.startswith("\\d*?")
+ assert "zero or more (lazy)" in output
+
+
+def test_one_or_more_digit_reads_as_plus_greedy():
+ output = _verbose(OneOrMoreElement(child=DigitElement()))
+ assert output.startswith("\\d+")
+ assert "one or more (greedy)" in output
+
+
+def test_one_or_more_lazy_reads_as_plus_lazy():
+ output = _verbose(OneOrMoreLazyElement(child=DigitElement()))
+ assert output.startswith("\\d+?")
+
+
+def test_exactly_reads_as_curly_braces_with_count():
+ output = _verbose(ExactlyElement(times=4, child=DigitElement()))
+ assert output.startswith("\\d{4}")
+ assert "exactly 4" in output
+
+
+def test_at_least_reads_as_curly_braces_with_open_upper():
+ output = _verbose(AtLeastElement(times=3, child=DigitElement()))
+ assert output.startswith("\\d{3,}")
+ assert "at least 3" in output
+
+
+def test_at_most_reads_as_curly_braces_with_zero_lower():
+ output = _verbose(AtMostElement(times=5, child=DigitElement()))
+ assert output.startswith("\\d{0,5}")
+ assert "at most 5" in output
+
+
+def test_between_reads_as_curly_braces_with_lower_upper():
+ output = _verbose(BetweenElement(lower=2, upper=5, child=DigitElement()))
+ assert output.startswith("\\d{2,5}")
+ assert "between 2 and 5 (greedy)" in output
+
+
+def test_between_lazy_reads_as_curly_braces_with_question_mark():
+ output = _verbose(BetweenLazyElement(lower=2, upper=5, child=DigitElement()))
+ assert output.startswith("\\d{2,5}?")
+ assert "between 2 and 5 (lazy)" in output
+
+
+def test_quantifier_around_multi_char_string_wraps_child_in_non_capturing_group():
+ output = _verbose(OneOrMoreElement(child=StringElement(value="ab")))
+ assert output.startswith("(?:ab)+")
+
+
+def test_quantifier_around_single_char_string_does_not_add_grouping():
+ output = _verbose(OneOrMoreElement(child=StringElement(value="a")))
+ assert output.startswith("a+")
+
+
+def test_quantifier_around_group_uses_multi_line_wrap():
+ inner = GroupElement(children=(DigitElement(), WordElement()))
+ output = _verbose(OneOrMoreElement(child=inner))
+ assert "begin group for one or more (greedy)" in output
+ assert "end group; apply one or more (greedy)" in output
+
+
+def test_non_capturing_group_reads_with_begin_and_end_comments():
+ output = _verbose(GroupElement(children=(DigitElement(),)))
+ assert "begin non-capturing group" in output
+ assert "end non-capturing group" in output
+
+
+def test_alternation_with_multiple_alternatives_lists_each_with_pipe():
+ output = _verbose(
+ AnyOfElement(
+ children=(
+ StringElement(value="cat"),
+ StringElement(value="dog"),
+ StringElement(value="fish"),
+ )
+ )
+ )
+ assert "begin alternation" in output
+ assert "end alternation" in output
+ assert "alternative 1" in output
+ assert "alternative 2" in output
+ assert "alternative 3" in output
+ assert output.count(" or") >= 2
+
+
+def test_alternation_with_no_children_reads_as_empty_alternation():
+ output = _verbose(AnyOfElement(children=()))
+ assert "empty alternation" in output
+ assert "(?:)" in output
+
+
+def test_subexpression_inlines_its_children_without_extra_scoping():
+ output = _verbose(
+ SubexpressionElement(children=(DigitElement(), CharElement(value="-")))
+ )
+ assert "begin non-capturing group" not in output
+ lines = [line for line in output.splitlines() if line.strip()]
+ assert len(lines) == 2
+
+
+def test_capture_reads_with_begin_captured_group_comment():
+ output = _verbose(CaptureElement(children=(DigitElement(),)))
+ assert "begin captured group" in output
+ assert "end captured group" in output
+
+
+def test_named_capture_includes_the_name_in_both_comments():
+ output = _verbose(
+ NamedCaptureElement(name="year", children=(DigitElement(),))
+ )
+ assert 'begin group named "year"' in output
+ assert 'end group named "year"' in output
+ assert "(?P<year>" in output
+
+
+def test_backreference_reads_as_backslash_index_with_comment():
+ output = _verbose(BackReferenceElement(index=1))
+ assert output.startswith("\\1")
+ assert "back-reference to group 1" in output
+
+
+def test_named_backreference_reads_as_named_backref_syntax():
+ output = _verbose(NamedBackReferenceElement(name="year"))
+ assert "(?P=year)" in output
+ assert 'back-reference to group "year"' in output
+
+
+def test_assert_ahead_reads_as_positive_lookahead():
+ output = _verbose(AssertAheadElement(children=(DigitElement(),)))
+ assert "begin positive lookahead" in output
+ assert "(?=" in output
+
+
+def test_assert_not_ahead_reads_as_negative_lookahead():
+ output = _verbose(AssertNotAheadElement(children=(DigitElement(),)))
+ assert "begin negative lookahead" in output
+ assert "(?!" in output
+
+
+def test_assert_behind_reads_as_positive_lookbehind():
+ output = _verbose(AssertBehindElement(children=(DigitElement(),)))
+ assert "begin positive lookbehind" in output
+ assert "(?<=" in output
+
+
+def test_assert_not_behind_reads_as_negative_lookbehind():
+ output = _verbose(AssertNotBehindElement(children=(DigitElement(),)))
+ assert "begin negative lookbehind" in output
+ assert "(?<!" in output
+
+
+def test_unrecognized_element_falls_back_to_render_element_with_marker(monkeypatch):
+ class MysteryElement(DigitElement.__mro__[1]):
+ pass
+
+ monkeypatch.setattr(verbose_module, "render_element", lambda element: "?mystery")
+ mystery = MysteryElement()
+ output = _verbose(mystery)
+ assert "unrecognized (MysteryElement)" in output
+ assert "?mystery" in output
+
+
+def test_output_is_a_re_verbose_compatible_pattern_that_compiles():
+ output = _verbose(
+ StartOfInputElement(),
+ OneOrMoreElement(child=DigitElement()),
+ EndOfInputElement(),
+ )
+ compiled = re.compile(output, flags=re.VERBOSE)
+ assert compiled.match("42") is not None
+ assert compiled.match("abc") is None
+
+
+def test_output_compiles_and_matches_for_alternation_pattern():
+ output = _verbose(
+ AnyOfElement(
+ children=(
+ StringElement(value="cat"),
+ StringElement(value="dog"),
+ StringElement(value="fish"),
+ )
+ )
+ )
+ compiled = re.compile(output, flags=re.VERBOSE)
+ for word in ("cat", "dog", "fish"):
+ assert compiled.match(word) is not None
+ assert compiled.match("bird") is None
+
+
+def test_alignment_uses_common_comment_column_across_lines():
+ output = _verbose(
+ DigitElement(), CharElement(value="-"), DigitElement()
+ )
+ hash_columns = [line.index("#") for line in output.splitlines() if "#" in line]
+ assert len(set(hash_columns)) == 1
+
+
+def test_line_without_comment_returns_bare_fragment():
+ assert _render_line("", "\\d", "") == "\\d"
+
+
+def test_line_with_long_fragment_still_leaves_two_space_gap_before_comment():
+ long_fragment = "a" * 30
+ output = _render_line("", long_fragment, "note")
+ assert " # note" in output
+
+
+def test_regex_to_verbose_string_end_to_end_via_builder():
+ output = RegexBuilder().digit().to_regex().to_verbose_string()
+ assert output.startswith("\\d")
+
+
+def test_regex_verbose_output_is_accepted_by_re_verbose_mode():
+ output = RegexBuilder().string("hello").to_regex().to_verbose_string()
+ compiled = re.compile(output, flags=re.VERBOSE)
+ assert compiled.match("hello") is not None
diff --git a/tests/introspect/visualize.test.py b/tests/introspect/visualize.test.py
new file mode 100644
index 0000000..a1e2ef5
--- /dev/null
+++ b/tests/introspect/visualize.test.py
@@ -0,0 +1,92 @@
+"""Tests for the visualize dispatcher in :mod:`edify.introspect.visualize`."""
+
+import pytest
+
+from edify import RegexBuilder
+from edify.elements.types.leaves import DigitElement
+from edify.errors.introspect import (
+ UnsupportedVisualizationEngineError,
+ UnsupportedVisualizationFormatError,
+)
+from edify.introspect.visualize import visualize_elements
+
+
+def _digit_regex():
+ return RegexBuilder().digit().to_regex()
+
+
+def test_default_format_and_engine_produce_ascii_railroad_output():
+ output = visualize_elements((DigitElement(),))
+ assert "| START |" in output
+ assert "| digit |" in output
+ assert "| END |" in output
+
+
+def test_explicit_ascii_format_and_engine_produce_ascii_railroad_output():
+ output = visualize_elements((DigitElement(),), format="ascii", engine="ascii")
+ assert "| digit |" in output
+
+
+def test_ascii_format_with_wrong_engine_raises_engine_error():
+ with pytest.raises(UnsupportedVisualizationEngineError):
+ visualize_elements((DigitElement(),), format="ascii", engine="graphviz")
+
+
+def test_svg_format_with_graphviz_engine_produces_svg_output():
+ regex = _digit_regex()
+ output = visualize_elements(regex.elements, format="svg", engine="graphviz")
+ assert "</svg>" in output
+
+
+def test_svg_format_with_wrong_engine_raises_engine_error():
+ with pytest.raises(UnsupportedVisualizationEngineError):
+ visualize_elements((DigitElement(),), format="svg", engine="ascii")
+
+
+def test_unknown_format_raises_format_error():
+ with pytest.raises(UnsupportedVisualizationFormatError):
+ visualize_elements((DigitElement(),), format="pdf")
+
+
+def test_unknown_format_error_names_the_received_format_in_message():
+ with pytest.raises(UnsupportedVisualizationFormatError) as excinfo:
+ visualize_elements((DigitElement(),), format="mermaid")
+ assert "'mermaid'" in str(excinfo.value)
+
+
+def test_engine_error_names_both_format_and_engine_in_message():
+ with pytest.raises(UnsupportedVisualizationEngineError) as excinfo:
+ visualize_elements((DigitElement(),), format="ascii", engine="mermaid")
+ message = str(excinfo.value)
+ assert "'ascii'" in message
+ assert "'mermaid'" in message
+
+
+def test_regex_visualize_end_to_end_defaults_to_ascii():
+ regex = _digit_regex()
+ output = regex.visualize()
+ assert "| digit |" in output
+
+
+def test_regex_visualize_svg_end_to_end():
+ regex = _digit_regex()
+ output = regex.visualize(format="svg", engine="graphviz")
+ assert "</svg>" in output
+
+
+def test_regex_visualize_rejects_unknown_format_end_to_end():
+ regex = _digit_regex()
+ with pytest.raises(UnsupportedVisualizationFormatError):
+ regex.visualize(format="dot")
+
+
+def test_regex_visualize_rejects_engine_mismatch_end_to_end():
+ regex = _digit_regex()
+ with pytest.raises(UnsupportedVisualizationEngineError):
+ regex.visualize(format="svg", engine="ascii")
+
+
+def test_empty_elements_still_render_start_and_end():
+ output = visualize_elements(())
+ assert "START" in output
+ assert "END" in output
diff --git a/tests/result/regex.test.py b/tests/result/regex.test.py
index d6f2a64..b6cefbe 100644
--- a/tests/result/regex.test.py
+++ b/tests/result/regex.test.py
@@ -95,3 +95,32 @@ def test_hash_matches_when_equal():
def test_equality_with_non_regex_returns_not_implemented():
a = Regex("\\d+", re.compile("\\d+"))
assert a.__eq__("foo") is NotImplemented
+
+
+def test_pattern_attribute_delegates_via_getattr(digit_regex):
+ assert digit_regex.pattern == "\\d+"
+
+
+def test_flags_attribute_delegates_via_getattr(digit_regex):
+ assert isinstance(digit_regex.flags, int)
+
+
+def test_groups_attribute_delegates_via_getattr():
+ with_groups = Regex("(\\d)(\\w)", re.compile("(\\d)(\\w)"))
+ assert with_groups.groups == 2
+
+
+def test_groupindex_attribute_delegates_via_getattr():
+ with_named = Regex(
+ "(?P<num>\\d)(?P<char>\\w)",
+ re.compile("(?P<num>\\d)(?P<char>\\w)"),
+ )
+ assert dict(with_named.groupindex) == {"num": 0, "char": 1} or dict(with_named.groupindex) == {
+ "num": 1,
+ "char": 2,
+ }
+
+
+def test_missing_attribute_raises_attribute_error(digit_regex):
+ with pytest.raises(AttributeError):
+ _ = digit_regex.no_such_attribute