aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/coverage.yml2
-rw-r--r--.github/workflows/github-actions.yml2
-rw-r--r--edify/builder/builder.py12
-rw-r--r--edify/builder/core.py61
-rw-r--r--edify/builder/diagnose.py106
-rw-r--r--edify/builder/mixins/flags.py3
-rw-r--r--edify/builder/mixins/matcher.py25
-rw-r--r--edify/builder/mixins/operators.py11
-rw-r--r--edify/builder/mixins/quantifiers.py68
-rw-r--r--edify/builder/mixins/terminals.py6
-rw-r--r--edify/builder/types/frame.py63
-rw-r--r--edify/builder/types/protocol.py13
-rw-r--r--edify/builder/types/state.py43
-rw-r--r--edify/compile/escape.py6
-rw-r--r--edify/compile/leaves.py7
-rw-r--r--edify/elements/types/base.py14
-rw-r--r--edify/elements/types/captures.py4
-rw-r--r--edify/elements/types/chars.py7
-rw-r--r--edify/elements/types/groups.py3
-rw-r--r--edify/elements/types/leaves.py5
-rw-r--r--edify/elements/types/quantifiers.py4
-rw-r--r--edify/elements/types/union.py9
-rw-r--r--edify/errors/anchors.py73
-rw-r--r--edify/errors/captures.py35
-rw-r--r--edify/errors/comparison.py66
-rw-r--r--edify/errors/context.py82
-rw-r--r--edify/errors/formatting.py133
-rw-r--r--edify/errors/input.py210
-rw-r--r--edify/errors/internal.py87
-rw-r--r--edify/errors/introspect.py89
-rw-r--r--edify/errors/naming.py65
-rw-r--r--edify/errors/quantifier.py41
-rw-r--r--edify/errors/structure.py40
-rw-r--r--edify/introspect/__init__.py5
-rw-r--r--edify/introspect/ascii.py458
-rw-r--r--edify/introspect/explain.py571
-rw-r--r--edify/introspect/graphviz.py271
-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/pattern/anchors.py6
-rw-r--r--edify/result/regex.py85
-rw-r--r--pyproject.toml4
-rw-r--r--tests/builder/cache.test.py69
-rw-r--r--tests/builder/diagnose.test.py105
-rw-r--r--tests/builder/equality.test.py87
-rw-r--r--tests/builder/properties.test.py215
-rw-r--r--tests/errors/context.test.py107
-rw-r--r--tests/errors/errors.test.py149
-rw-r--r--tests/errors/formatting.test.py127
-rw-r--r--tests/errors/quantifier.test.py12
-rw-r--r--tests/introspect/ascii.test.py531
-rw-r--r--tests/introspect/explain.test.py801
-rw-r--r--tests/introspect/graphviz.test.py355
-rw-r--r--tests/introspect/verbose.test.py453
-rw-r--r--tests/introspect/visualize.test.py92
-rw-r--r--tests/result/regex.test.py29
-rw-r--r--uv.lock18
58 files changed, 6010 insertions, 366 deletions
diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml
index 80cf274..3076588 100644
--- a/.github/workflows/coverage.yml
+++ b/.github/workflows/coverage.yml
@@ -11,6 +11,8 @@ jobs:
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39
with:
python-version: '3.11'
+ - name: install graphviz system package
+ run: sudo apt-get update && sudo apt-get install -y graphviz
- name: install dependencies
run: uv sync --frozen
- name: run tests with coverage
diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml
index 58f0b80..2f4c9e5 100644
--- a/.github/workflows/github-actions.yml
+++ b/.github/workflows/github-actions.yml
@@ -44,6 +44,8 @@ jobs:
- uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39
with:
python-version: ${{ matrix.python }}
+ - name: install graphviz system package
+ run: sudo apt-get update && sudo apt-get install -y graphviz
- name: install dependencies
run: uv sync --frozen --all-groups
- name: ${{ matrix.name }}
diff --git a/edify/builder/builder.py b/edify/builder/builder.py
index 40f103e..0e775f1 100644
--- a/edify/builder/builder.py
+++ b/edify/builder/builder.py
@@ -1,14 +1,4 @@
-"""The composition root for :class:`RegexBuilder` — the fluent regex-builder class.
-
-The class itself defines no chain methods; every method comes from one of
-the mixins under :mod:`edify.builder.mixins`. The composition order does
-not matter for behavior because the mixins do not override each other's
-methods, but they are listed alphabetically by mixin name for predictability.
-
-The immutable-state plumbing (``_state`` attribute + ``_with_state`` helper)
-lives on :class:`edify.builder.core.BuilderCore`, which every fluent surface
-in the package inherits from.
-"""
+"""The composition root for :class:`RegexBuilder` — the fluent regex-builder class."""
from __future__ import annotations
diff --git a/edify/builder/core.py b/edify/builder/core.py
index 333c7ad..6b3ac46 100644
--- a/edify/builder/core.py
+++ b/edify/builder/core.py
@@ -1,19 +1,18 @@
-"""Shared immutable-state plumbing for :class:`RegexBuilder` and :class:`Pattern`.
-
-Both fluent surfaces carry the same :class:`BuilderState` and use the same
-clone-and-replace pattern for chain methods. :class:`BuilderCore` provides
-the state attribute, the constructor, the ``_with_state`` helper that
-mixins call to produce new instances, the interactive ``__repr__``
-that shows the pattern-so-far, and value-based ``__eq__`` / ``__hash__``
-that make two builders equal when their underlying immutable state matches.
-"""
+"""Shared immutable-state plumbing for :class:`RegexBuilder` and :class:`Pattern`."""
from __future__ import annotations
from typing import Self
+from edify.builder.diagnose import diagnose_unfinished
from edify.builder.types.state import BuilderState
+from edify.errors.comparison import (
+ CannotCompareUnfinishedBuilderError,
+ CannotHashUnfinishedBuilderError,
+)
+from edify.errors.quantifier import DanglingQuantifierError
from edify.errors.structure import CannotCallSubexpressionError
+from edify.result.regex import Regex
_UNCLOSED_FRAME_MARKER = "<unclosed>"
@@ -22,24 +21,31 @@ class BuilderCore:
"""Holds the immutable :class:`BuilderState` and clones it on chain steps."""
_state: BuilderState
+ _cached_regex: Regex | None
def __init__(self) -> None:
self._state = BuilderState()
+ self._cached_regex = None
def _with_state(self, new_state: BuilderState) -> Self:
"""Return a fresh instance of the same concrete type carrying ``new_state``."""
concrete_class = type(self)
new_instance = concrete_class.__new__(concrete_class)
new_instance._state = new_state
+ new_instance._cached_regex = None
return new_instance
- def fork(self) -> Self:
- """Return a fresh builder with the same immutable state.
+ def _lazy_regex(self) -> Regex:
+ """Return the memoised :class:`Regex` for this builder, compiling once on first call."""
+ cached = self._cached_regex
+ if cached is None:
+ to_regex = self.to_regex
+ cached = to_regex()
+ self._cached_regex = cached
+ return cached
- Chain methods already return new instances so implicit forking works
- (``root.digit()`` and ``root.word()`` share nothing after the split);
- this method makes the intent explicit and discoverable via autocomplete.
- """
+ def fork(self) -> Self:
+ """Return a fresh builder with the same immutable state."""
return self._with_state(self._state)
def copy(self) -> Self:
@@ -52,14 +58,29 @@ class BuilderCore:
return f"<{type(self).__name__} {rendered!r}>"
def __eq__(self, other: object) -> bool:
- """Return True when ``other`` is a builder whose immutable state matches ``self``."""
+ """Return True when ``other`` is a builder with the same emitted pattern and flags."""
if not isinstance(other, BuilderCore):
return NotImplemented
- return self._state == other._state
+ problems: list = []
+ left_problem = diagnose_unfinished(self._state, "left operand")
+ right_problem = diagnose_unfinished(other._state, "right operand")
+ if left_problem is not None:
+ problems.append(left_problem)
+ if right_problem is not None:
+ problems.append(right_problem)
+ if problems:
+ raise CannotCompareUnfinishedBuilderError(problems)
+ self_source = self.to_regex_string()
+ other_source = other.to_regex_string()
+ return self_source == other_source and self._state.flags == other._state.flags
def __hash__(self) -> int:
- """Return a hash derived from the immutable state; matches :meth:`__eq__`."""
- return hash(self._state)
+ """Return a hash derived from ``(emitted_pattern, flags)``."""
+ problem = diagnose_unfinished(self._state, "builder")
+ if problem is not None:
+ raise CannotHashUnfinishedBuilderError(problem)
+ source = self.to_regex_string()
+ return hash((source, self._state.flags))
def _render_or_marker(builder: BuilderCore) -> str:
@@ -69,5 +90,5 @@ def _render_or_marker(builder: BuilderCore) -> str:
return _UNCLOSED_FRAME_MARKER
try:
return to_regex_string()
- except CannotCallSubexpressionError:
+ except (CannotCallSubexpressionError, DanglingQuantifierError):
return _UNCLOSED_FRAME_MARKER
diff --git a/edify/builder/diagnose.py b/edify/builder/diagnose.py
new file mode 100644
index 0000000..60a5f5a
--- /dev/null
+++ b/edify/builder/diagnose.py
@@ -0,0 +1,106 @@
+"""Diagnostic helpers that describe why a builder is unfinished."""
+
+from __future__ import annotations
+
+from edify.builder.types.frame import StackFrame
+from edify.builder.types.state import BuilderState
+from edify.elements.types.captures import CaptureElement, NamedCaptureElement
+from edify.elements.types.groups import (
+ AnyOfElement,
+ AssertAheadElement,
+ AssertBehindElement,
+ AssertNotAheadElement,
+ AssertNotBehindElement,
+ GroupElement,
+)
+from edify.errors.context import CallerContext
+from edify.errors.formatting import FixInsertion, Problem
+
+_END_INSERTION_TEXT = ".end()"
+_DANGLING_INSERTION_TEXT = ".digit()"
+
+
+def diagnose_unfinished(state: BuilderState, subject: str) -> Problem | None:
+ """Return a :class:`Problem` for the first unfinished thing in ``state``, or ``None``."""
+ if len(state.stack) > 1:
+ return _diagnose_open_frame(state.top_frame, subject)
+ for frame in state.stack:
+ if frame.quantifier is not None:
+ return _diagnose_dangling_quantifier(frame, subject)
+ return None
+
+
+def _diagnose_open_frame(frame: StackFrame, subject: str) -> Problem:
+ """Return a :class:`Problem` describing an unclosed frame."""
+ frame_name = _frame_display_name(frame)
+ problem_context = _fallback(frame.call_site)
+ fix_context = _fallback(frame.last_child_call_site or frame.call_site)
+ fix_insertion = FixInsertion(
+ column=fix_context.end_colno,
+ text=_END_INSERTION_TEXT,
+ )
+ return Problem(
+ description=f"`{subject}` has an open `{frame_name}` frame",
+ problem_context=problem_context,
+ problem_hint="frame opened here, never closed",
+ help_summary=f"close the frame with `{_END_INSERTION_TEXT}`",
+ fix_context=fix_context,
+ fix_insertion=fix_insertion,
+ )
+
+
+def _diagnose_dangling_quantifier(frame: StackFrame, subject: str) -> Problem:
+ """Return a :class:`Problem` describing a pending quantifier with no operand."""
+ quantifier_name = frame.quantifier_name or "quantifier"
+ problem_context = _fallback(frame.quantifier_call_site)
+ fix_context = _fallback(frame.quantifier_call_site)
+ fix_insertion = FixInsertion(
+ column=fix_context.end_colno,
+ text=_DANGLING_INSERTION_TEXT,
+ )
+ return Problem(
+ description=f"`{subject}` has a pending `.{quantifier_name}` quantifier",
+ problem_context=problem_context,
+ problem_hint="quantifier set here — no element follows",
+ help_summary=(
+ f"append an element to repeat, e.g. `{_DANGLING_INSERTION_TEXT}` "
+ '(or `.word()`, `.char("x")`)'
+ ),
+ fix_context=fix_context,
+ fix_insertion=fix_insertion,
+ )
+
+
+def _frame_display_name(frame: StackFrame) -> str:
+ """Return the human-facing name of the chain call that opened ``frame``."""
+ type_node = frame.type_node
+ if isinstance(type_node, AnyOfElement):
+ return "any_of()"
+ if isinstance(type_node, GroupElement):
+ return "group()"
+ if isinstance(type_node, AssertAheadElement):
+ return "assert_ahead()"
+ if isinstance(type_node, AssertNotAheadElement):
+ return "assert_not_ahead()"
+ if isinstance(type_node, AssertBehindElement):
+ return "assert_behind()"
+ if isinstance(type_node, AssertNotBehindElement):
+ return "assert_not_behind()"
+ if isinstance(type_node, CaptureElement):
+ return "capture()"
+ if isinstance(type_node, NamedCaptureElement):
+ return f'named_capture("{type_node.name}")'
+ return type(type_node).__name__
+
+
+def _fallback(context: CallerContext | None) -> CallerContext:
+ """Return ``context`` if not ``None``, else a placeholder context marking unknown location."""
+ if context is not None:
+ return context
+ return CallerContext(
+ filename="<unknown>",
+ lineno=0,
+ colno=1,
+ end_colno=1,
+ source_line="",
+ )
diff --git a/edify/builder/mixins/flags.py b/edify/builder/mixins/flags.py
index a4443ee..c46c29e 100644
--- a/edify/builder/mixins/flags.py
+++ b/edify/builder/mixins/flags.py
@@ -2,8 +2,7 @@
Each method returns a new builder whose flags snapshot has the corresponding
field enabled. Flags are pattern-global — position in the chain does not
-matter — so the methods read identically whether placed at the start, the
-end, or anywhere in between.
+matter.
"""
from __future__ import annotations
diff --git a/edify/builder/mixins/matcher.py b/edify/builder/mixins/matcher.py
index b4106af..3ed74fa 100644
--- a/edify/builder/mixins/matcher.py
+++ b/edify/builder/mixins/matcher.py
@@ -1,8 +1,9 @@
"""The :class:`MatcherMixin` — direct :mod:`re` method proxies on any fluent surface.
-Compiles ``self`` lazily via ``self.to_regex()`` on every call and delegates
-to the returned :class:`re.Pattern`. Adding this mixin to a class lets users
-write ``pattern.match("10001")`` instead of ``pattern.to_regex().match("10001")``.
+Compiles ``self`` on first use via :meth:`edify.builder.core.BuilderCore._lazy_regex`
+and reuses the cached :class:`edify.result.regex.Regex` on every subsequent call,
+so ``pattern.test("a")`` followed by ``pattern.match("b")`` compiles the underlying
+regex exactly once.
The signatures mirror :class:`re.Pattern` exactly so IDE autocomplete and
type inference stay unchanged. :meth:`test` is the one non-:mod:`re` method:
@@ -24,33 +25,33 @@ class MatcherMixin(BuilderProtocol):
def test(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> bool:
"""Return ``True`` when the pattern matches anywhere in ``string``, else ``False``."""
- return self.to_regex().search(string, pos, endpos) is not None
+ return self._lazy_regex().search(string, pos, endpos) is not None
def match(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> re.Match[str] | None:
"""Delegate to :meth:`re.Pattern.match`."""
- return self.to_regex().match(string, pos, endpos)
+ return self._lazy_regex().match(string, pos, endpos)
def search(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> re.Match[str] | None:
"""Delegate to :meth:`re.Pattern.search`."""
- return self.to_regex().search(string, pos, endpos)
+ return self._lazy_regex().search(string, pos, endpos)
def fullmatch(
self, string: str, pos: int = 0, endpos: int = sys.maxsize
) -> re.Match[str] | None:
"""Delegate to :meth:`re.Pattern.fullmatch`."""
- return self.to_regex().fullmatch(string, pos, endpos)
+ return self._lazy_regex().fullmatch(string, pos, endpos)
def findall(
self, string: str, pos: int = 0, endpos: int = sys.maxsize
) -> list[str] | list[tuple[str, ...]]:
"""Delegate to :meth:`re.Pattern.findall`."""
- return self.to_regex().findall(string, pos, endpos)
+ return self._lazy_regex().findall(string, pos, endpos)
def finditer(
self, string: str, pos: int = 0, endpos: int = sys.maxsize
) -> Iterator[re.Match[str]]:
"""Delegate to :meth:`re.Pattern.finditer`."""
- return self.to_regex().finditer(string, pos, endpos)
+ return self._lazy_regex().finditer(string, pos, endpos)
def sub(
self,
@@ -59,7 +60,7 @@ class MatcherMixin(BuilderProtocol):
count: int = 0,
) -> str:
"""Delegate to :meth:`re.Pattern.sub`."""
- return self.to_regex().sub(replacement, string, count=count)
+ return self._lazy_regex().sub(replacement, string, count=count)
def subn(
self,
@@ -68,8 +69,8 @@ class MatcherMixin(BuilderProtocol):
count: int = 0,
) -> tuple[str, int]:
"""Delegate to :meth:`re.Pattern.subn`."""
- return self.to_regex().subn(replacement, string, count=count)
+ return self._lazy_regex().subn(replacement, string, count=count)
def split(self, string: str, maxsplit: int = 0) -> list[str | None]:
"""Delegate to :meth:`re.Pattern.split`."""
- return self.to_regex().split(string, maxsplit=maxsplit)
+ return self._lazy_regex().split(string, maxsplit=maxsplit)
diff --git a/edify/builder/mixins/operators.py b/edify/builder/mixins/operators.py
index eb3baba..a5b8fa7 100644
--- a/edify/builder/mixins/operators.py
+++ b/edify/builder/mixins/operators.py
@@ -1,16 +1,9 @@
"""The :class:`OperatorsMixin` — ``+`` concatenation and ``|`` alternation.
-Both operators produce a new instance of the same concrete fluent surface
-(a :class:`edify.builder.builder.RegexBuilder` or a
-:class:`edify.pattern.composition.Pattern`), leaving both operands untouched
-per the immutable-builder contract.
-
-Semantically:
-
-* ``a + b`` embeds ``b`` at the end of ``a`` — the algebra equivalent of
+* ``a + b`` embeds ``b`` at the end of ``a`` — equivalent to
``a.subexpression(b)``.
* ``a | b`` produces a fresh instance whose sole element is an
- ``AnyOfElement`` containing ``a`` and ``b`` — the algebra equivalent of
+ ``AnyOfElement`` containing ``a`` and ``b`` — equivalent to
``fresh.any_of().subexpression(a).subexpression(b).end()``.
"""
diff --git a/edify/builder/mixins/quantifiers.py b/edify/builder/mixins/quantifiers.py
index e7e0cda..9a1364a 100644
--- a/edify/builder/mixins/quantifiers.py
+++ b/edify/builder/mixins/quantifiers.py
@@ -1,10 +1,4 @@
-"""The :class:`QuantifiersMixin` — chain methods that set the pending quantifier.
-
-A quantifier method does NOT consume the previous element — it stores a
-deferred factory on the top frame. The next call that appends an element
-sees the pending quantifier and wraps the element with it before storing
-the result.
-"""
+"""The :class:`QuantifiersMixin` — chain methods that set the pending quantifier."""
from __future__ import annotations
@@ -25,6 +19,7 @@ from edify.elements.types.quantifiers import (
ZeroOrMoreElement,
ZeroOrMoreLazyElement,
)
+from edify.errors.context import capture_caller_context
from edify.errors.input import (
MustBeIntegerGreaterThanZeroError,
MustBeLessThanError,
@@ -38,64 +33,83 @@ class QuantifiersMixin(BuilderProtocol):
def optional(self) -> Self:
"""Return a new builder with ``?`` queued as the pending quantifier."""
- return _set_pending(self, _optional_factory)
+ call_site = capture_caller_context()
+ return _set_pending(self, _optional_factory, call_site, "optional()")
def zero_or_more(self) -> Self:
"""Return a new builder with ``*`` queued as the pending quantifier."""
- return _set_pending(self, _zero_or_more_factory)
+ call_site = capture_caller_context()
+ return _set_pending(self, _zero_or_more_factory, call_site, "zero_or_more()")
def zero_or_more_lazy(self) -> Self:
"""Return a new builder with ``*?`` queued as the pending quantifier."""
- return _set_pending(self, _zero_or_more_lazy_factory)
+ call_site = capture_caller_context()
+ return _set_pending(self, _zero_or_more_lazy_factory, call_site, "zero_or_more_lazy()")
def one_or_more(self) -> Self:
"""Return a new builder with ``+`` queued as the pending quantifier."""
- return _set_pending(self, _one_or_more_factory)
+ call_site = capture_caller_context()
+ return _set_pending(self, _one_or_more_factory, call_site, "one_or_more()")
def one_or_more_lazy(self) -> Self:
"""Return a new builder with ``+?`` queued as the pending quantifier."""
- return _set_pending(self, _one_or_more_lazy_factory)
+ call_site = capture_caller_context()
+ return _set_pending(self, _one_or_more_lazy_factory, call_site, "one_or_more_lazy()")
def exactly(self, count: int) -> Self:
"""Return a new builder with ``{count}`` queued as the pending quantifier."""
_ensure_positive_integer("count", count)
- return _set_pending(self, _exactly_factory(count))
+ call_site = capture_caller_context()
+ return _set_pending(self, _exactly_factory(count), call_site, f"exactly({count})")
def at_least(self, count: int) -> Self:
"""Return a new builder with ``{count,}`` queued as the pending quantifier."""
_ensure_positive_integer("count", count)
- return _set_pending(self, _at_least_factory(count))
+ call_site = capture_caller_context()
+ return _set_pending(self, _at_least_factory(count), call_site, f"at_least({count})")
def at_most(self, count: int) -> Self:
"""Return a new builder with ``{0,count}`` queued as the pending quantifier."""
_ensure_positive_integer("count", count)
- return _set_pending(self, _at_most_factory(count))
+ call_site = capture_caller_context()
+ return _set_pending(self, _at_most_factory(count), call_site, f"at_most({count})")
def between(self, lower: int, upper: int) -> Self:
"""Return a new builder with ``{lower,upper}`` queued as the pending quantifier."""
_ensure_non_negative_integer("x", lower)
_ensure_positive_integer("y", upper)
_ensure_strictly_ascending("X", "Y", lower, upper)
- return _set_pending(self, _between_factory(lower, upper))
+ call_site = capture_caller_context()
+ return _set_pending(
+ self, _between_factory(lower, upper), call_site, f"between({lower}, {upper})"
+ )
def between_lazy(self, lower: int, upper: int) -> Self:
"""Return a new builder with ``{lower,upper}?`` queued as the pending quantifier."""
_ensure_non_negative_integer("x", lower)
_ensure_positive_integer("y", upper)
_ensure_strictly_ascending("X", "Y", lower, upper)
- return _set_pending(self, _between_lazy_factory(lower, upper))
-
-
-def _set_pending(builder: BuilderProtocol, pending_quantifier: PendingQuantifier):
- """Replace the top frame with one carrying the given pending quantifier.
-
- Raises :class:`StackedQuantifierError` when the top frame already carries
- an unconsumed pending quantifier — stacking would silently drop the outer
- one at emit time.
- """
+ call_site = capture_caller_context()
+ return _set_pending(
+ self,
+ _between_lazy_factory(lower, upper),
+ call_site,
+ f"between_lazy({lower}, {upper})",
+ )
+
+
+def _set_pending(
+ builder: BuilderProtocol,
+ pending_quantifier: PendingQuantifier,
+ call_site,
+ quantifier_name: str,
+):
+ """Replace the top frame with one carrying the given pending quantifier."""
if builder._state.top_frame.quantifier is not None:
raise StackedQuantifierError()
- new_top_frame = builder._state.top_frame.with_quantifier(pending_quantifier)
+ new_top_frame = builder._state.top_frame.with_quantifier(
+ pending_quantifier, call_site, quantifier_name
+ )
new_state = builder._state.with_top_frame_replaced(new_top_frame)
return builder._with_state(new_state)
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/builder/types/frame.py b/edify/builder/types/frame.py
index 071c3ec..dbfc1fc 100644
--- a/edify/builder/types/frame.py
+++ b/edify/builder/types/frame.py
@@ -1,13 +1,4 @@
-"""The :class:`StackFrame` dataclass — one entry on the builder's frame stack.
-
-A frame represents an in-progress grouping construct. It carries the element
-class that will wrap its children once the frame closes (the ``type_node``),
-an optional pending quantifier that will be applied to the next element to
-land, and the ordered list of children accumulated so far.
-
-The whole frame is frozen; chain methods produce new frames with
-:func:`dataclasses.replace` rather than mutating in place.
-"""
+"""The :class:`StackFrame` dataclass."""
from __future__ import annotations
@@ -16,6 +7,7 @@ from dataclasses import dataclass, field, replace
from edify.elements.types.base import BaseElement
from edify.elements.types.union import QuantifierElement
+from edify.errors.context import CallerContext
PendingQuantifier = Callable[[BaseElement], QuantifierElement]
@@ -25,21 +17,56 @@ class StackFrame:
"""One frame of the builder's open-construct stack.
Attributes:
- type_node: The element that anchors this frame (root, capture, group, etc.).
+ type_node: The element that anchors this frame.
quantifier: A callable that wraps the next child element in a
quantifier, or ``None`` when no quantifier is pending.
children: The ordered children accumulated in this frame so far.
+ call_site: Source-location snapshot of the chain call that opened
+ this frame; ``None`` on the root frame or when unavailable.
+ last_child_call_site: Source-location snapshot of the chain call
+ that appended the most recent child; ``None`` when no child
+ has been appended yet or the location is unavailable.
+ quantifier_call_site: Source-location snapshot of the chain call
+ that set :attr:`quantifier`; ``None`` when no quantifier is
+ pending.
+ quantifier_name: Human-readable name of the pending quantifier
+ (e.g. ``"exactly(3)"``); ``None`` when no quantifier is pending.
"""
type_node: BaseElement
- quantifier: PendingQuantifier | None = None
+ quantifier: PendingQuantifier | None = field(default=None, compare=False, hash=False)
children: tuple[BaseElement, ...] = field(default_factory=tuple)
+ call_site: CallerContext | None = field(default=None, compare=False, hash=False)
+ last_child_call_site: CallerContext | None = field(default=None, compare=False, hash=False)
+ quantifier_call_site: CallerContext | None = field(default=None, compare=False, hash=False)
+ quantifier_name: str | None = None
- def with_appended_child(self, child: BaseElement) -> StackFrame:
- """Return a new frame whose ``children`` includes ``child`` at the end."""
+ def with_appended_child(
+ self,
+ child: BaseElement,
+ call_site: CallerContext | None,
+ ) -> StackFrame:
+ """Return a new frame with ``child`` appended and pending quantifier cleared."""
appended_children = (*self.children, child)
- return replace(self, children=appended_children, quantifier=None)
+ return replace(
+ self,
+ children=appended_children,
+ quantifier=None,
+ last_child_call_site=call_site,
+ quantifier_call_site=None,
+ quantifier_name=None,
+ )
- def with_quantifier(self, quantifier: PendingQuantifier) -> StackFrame:
- """Return a new frame with the given pending quantifier set."""
- return replace(self, quantifier=quantifier)
+ def with_quantifier(
+ self,
+ quantifier: PendingQuantifier,
+ call_site: CallerContext | None,
+ quantifier_name: str,
+ ) -> StackFrame:
+ """Return a new frame carrying the pending quantifier and its source location."""
+ return replace(
+ self,
+ quantifier=quantifier,
+ quantifier_call_site=call_site,
+ quantifier_name=quantifier_name,
+ )
diff --git a/edify/builder/types/protocol.py b/edify/builder/types/protocol.py
index 062ab0a..cffab06 100644
--- a/edify/builder/types/protocol.py
+++ b/edify/builder/types/protocol.py
@@ -11,6 +11,7 @@ from __future__ import annotations
from typing import Protocol, Self, runtime_checkable
from edify.builder.types.state import BuilderState
+from edify.result.regex import Regex
@runtime_checkable
@@ -22,3 +23,15 @@ class BuilderProtocol(Protocol):
def _with_state(self, new_state: BuilderState) -> Self:
"""Return a new builder carrying the given state, leaving ``self`` untouched."""
...
+
+ def _lazy_regex(self) -> Regex:
+ """Return the memoised :class:`Regex` produced from ``self``, compiling once."""
+ ...
+
+ def to_regex_string(self) -> str:
+ """Return the emitted regex string for ``self``."""
+ ...
+
+ def to_regex(self) -> Regex:
+ """Compile ``self`` into a :class:`Regex` wrapper."""
+ ...
diff --git a/edify/builder/types/state.py b/edify/builder/types/state.py
index 72ead98..f4f474d 100644
--- a/edify/builder/types/state.py
+++ b/edify/builder/types/state.py
@@ -1,10 +1,4 @@
-"""The :class:`BuilderState` dataclass — the entire mutable state of a builder.
-
-State is frozen; chain methods produce new states with
-:func:`dataclasses.replace` rather than mutating in place. That immutability
-is what makes ``b0.digit()`` and ``b0.word()`` produce independent chains
-from the same starting point.
-"""
+"""The :class:`BuilderState` dataclass."""
from __future__ import annotations
@@ -14,6 +8,7 @@ from edify.builder.types.flags import Flags
from edify.builder.types.frame import StackFrame
from edify.elements.types.base import BaseElement
from edify.elements.types.root import RootElement
+from edify.errors.context import CallerContext, capture_caller_context
def _initial_stack() -> tuple[StackFrame, ...]:
@@ -32,8 +27,7 @@ class BuilderState:
flags: The current pattern-global flag snapshot.
stack: Ordered frames; the root frame is always at index 0.
named_groups: Names declared by ``named_capture`` so far.
- total_capture_groups: Total number of capture groups declared so far
- (numbered + named).
+ total_capture_groups: Total number of capture groups declared so far.
"""
has_defined_start: bool = False
@@ -67,8 +61,18 @@ class BuilderState:
return replace(self, stack=new_stack)
def with_frame_pushed(self, frame: StackFrame) -> BuilderState:
- """Return a new state with ``frame`` pushed onto the top of the stack."""
- new_stack = (*self.stack, frame)
+ """Return a new state with ``frame`` pushed onto the top of the stack.
+
+ When ``frame.call_site`` is ``None`` this helper captures the caller's
+ source location automatically so error messages can point at the chain
+ method that opened the frame.
+ """
+ stamped_frame = (
+ frame
+ if frame.call_site is not None
+ else replace(frame, call_site=capture_caller_context())
+ )
+ new_stack = (*self.stack, stamped_frame)
return replace(self, stack=new_stack)
def with_top_frame_popped(self) -> tuple[BuilderState, StackFrame]:
@@ -93,16 +97,27 @@ class BuilderState:
new_total = self.total_capture_groups + count
return replace(self, total_capture_groups=new_total)
- def with_element_added_to_top(self, element: BaseElement) -> BuilderState:
+ def with_element_added_to_top(
+ self,
+ element: BaseElement,
+ call_site: CallerContext | None = None,
+ ) -> BuilderState:
"""Return a new state with ``element`` added to the top frame's children.
If the top frame carries a pending quantifier, it wraps ``element``
before appending and the quantifier slot is cleared.
+
+ Args:
+ element: The element to append to the top frame's children.
+ call_site: The source location of the chain call responsible for
+ appending ``element``. When ``None`` this helper captures the
+ caller's source location automatically.
"""
+ effective_call_site = call_site if call_site is not None else capture_caller_context()
top = self.top_frame
if top.quantifier is not None:
wrapped_element = top.quantifier(element)
- new_top_frame = top.with_appended_child(wrapped_element)
+ new_top_frame = top.with_appended_child(wrapped_element, effective_call_site)
else:
- new_top_frame = top.with_appended_child(element)
+ new_top_frame = top.with_appended_child(element, effective_call_site)
return self.with_top_frame_replaced(new_top_frame)
diff --git a/edify/compile/escape.py b/edify/compile/escape.py
index c82a345..b78454c 100644
--- a/edify/compile/escape.py
+++ b/edify/compile/escape.py
@@ -1,8 +1,4 @@
-"""Escape user-provided string fragments for safe insertion into a regex pattern.
-
-Single-purpose wrapper around :func:`re.escape` so the builder never embeds
-user input directly into a compiled pattern.
-"""
+"""Escape user-provided string fragments for safe insertion into a regex pattern."""
from __future__ import annotations
diff --git a/edify/compile/leaves.py b/edify/compile/leaves.py
index ed60f5b..3e705ac 100644
--- a/edify/compile/leaves.py
+++ b/edify/compile/leaves.py
@@ -1,9 +1,4 @@
-"""Render leaf elements to their regex string.
-
-Every leaf in :mod:`edify.elements.types.leaves` maps to a fixed string that
-never depends on children or quantifier state, so the renderer is a single
-``match`` over the leaf union.
-"""
+"""Render leaf elements to their regex string."""
from __future__ import annotations
diff --git a/edify/elements/types/base.py b/edify/elements/types/base.py
index 9896740..2e7265e 100644
--- a/edify/elements/types/base.py
+++ b/edify/elements/types/base.py
@@ -1,15 +1,9 @@
"""Base class shared by every concrete element in the edify AST.
-Provides a common type that recursive children can reference
-(``tuple[BaseElement, ...]`` for capture children, ``BaseElement`` for the
-quantifier's single child) without forcing each module to import the full
-:data:`edify.elements.types.Element` union — which would form an import
-cycle since the union lists every concrete subclass.
-
-Function signatures that consume or return AST nodes should still use the
-precise :data:`Element` union from :mod:`edify.elements.types`; this base
-exists only so that the dataclass field annotations type-check without a
-circular import.
+Provides a common type that recursive dataclass fields can reference —
+``tuple[BaseElement, ...]`` for capture children, ``BaseElement`` for the
+quantifier's single child. Function signatures that consume or return AST
+nodes should use the precise :data:`edify.elements.types.Element` union.
"""
from __future__ import annotations
diff --git a/edify/elements/types/captures.py b/edify/elements/types/captures.py
index 1f585fa..128d339 100644
--- a/edify/elements/types/captures.py
+++ b/edify/elements/types/captures.py
@@ -9,10 +9,6 @@ capture group already matched, addressed by index (``\\1``) or by name
* :class:`NamedCaptureElement` — ``(?P<name>...)`` named capture.
* :class:`BackReferenceElement` — ``\\<index>`` numbered back-reference.
* :class:`NamedBackReferenceElement` — ``(?P=name)`` named back-reference.
-
-Recursive ``children`` references use :class:`BaseElement` so the dataclass
-field annotations type-check without importing the full ``Element`` union
-(which would form a cycle).
"""
from __future__ import annotations
diff --git a/edify/elements/types/chars.py b/edify/elements/types/chars.py
index 6f7c491..5545cd9 100644
--- a/edify/elements/types/chars.py
+++ b/edify/elements/types/chars.py
@@ -11,9 +11,6 @@ bounds) and emit the corresponding regex fragment at compile time.
* :class:`AnythingButCharsElement` — an inline ``[^abc]`` negated class.
* :class:`AnythingButRangeElement` — a negated ``[^a-z]`` range.
* :class:`AnythingButStringElement` — a sequence of negated single-character classes.
-
-Every class inherits from :class:`edify.elements.types.base.BaseElement` so
-it participates in the ``Element`` union and in :func:`isinstance` dispatch.
"""
from __future__ import annotations
@@ -38,10 +35,6 @@ class CharElement(BaseElement):
class StringElement(BaseElement):
"""A multi-character literal string.
- A literal string needs to be wrapped in a non-capturing group when a
- quantifier is applied to it, hence the dedicated class separate from
- :class:`CharElement`.
-
Attributes:
value: The string to match; already escaped for the compile path.
"""
diff --git a/edify/elements/types/groups.py b/edify/elements/types/groups.py
index 3934b7b..54ecc0f 100644
--- a/edify/elements/types/groups.py
+++ b/edify/elements/types/groups.py
@@ -11,9 +11,6 @@ construct that does not introduce a numbered capture:
* :class:`AssertNotAheadElement` — ``(?!...)`` negative lookahead.
* :class:`AssertBehindElement` — ``(?<=...)`` positive lookbehind.
* :class:`AssertNotBehindElement` — ``(?<!...)`` negative lookbehind.
-
-Recursive ``children`` references use :class:`BaseElement` so the dataclass
-field annotations type-check without importing the full ``Element`` union.
"""
from __future__ import annotations
diff --git a/edify/elements/types/leaves.py b/edify/elements/types/leaves.py
index 5a1a678..d776929 100644
--- a/edify/elements/types/leaves.py
+++ b/edify/elements/types/leaves.py
@@ -4,11 +4,6 @@ Each leaf represents a regex construct that emits a fixed string and carries
no parameters: ``\\d`` for :class:`DigitElement`, ``\\b`` for
:class:`WordBoundaryElement`, ``^`` for :class:`StartOfInputElement`, etc.
-Every leaf is :func:`dataclasses.dataclass` with ``frozen=True`` so the whole
-AST is immutable; the builder produces new branches by constructing new
-elements rather than mutating existing ones. All leaves inherit from
-:class:`edify.elements.types.base.BaseElement` so they participate in the
-``Element`` union and in :func:`isinstance` dispatch.
"""
from __future__ import annotations
diff --git a/edify/elements/types/quantifiers.py b/edify/elements/types/quantifiers.py
index 41d82c8..9fbd8e4 100644
--- a/edify/elements/types/quantifiers.py
+++ b/edify/elements/types/quantifiers.py
@@ -19,10 +19,6 @@ Lazy variants:
* :class:`ZeroOrMoreLazyElement` — ``*?``.
* :class:`OneOrMoreLazyElement` — ``+?``.
* :class:`BetweenLazyElement` — ``{lower,upper}?``.
-
-The wrapped ``child`` is annotated as :class:`BaseElement` to avoid forming
-a cycle with the full ``Element`` union; the compile path dispatches on the
-concrete child type.
"""
from __future__ import annotations
diff --git a/edify/elements/types/union.py b/edify/elements/types/union.py
index 27aed96..8cf1cb5 100644
--- a/edify/elements/types/union.py
+++ b/edify/elements/types/union.py
@@ -1,11 +1,4 @@
-"""The sealed :data:`Element` union — every concrete element class spelled out.
-
-Function signatures that consume or return AST nodes use this union so the
-type checker enforces that every reachable case is one of the listed
-classes. Internal recursive references inside the dataclass definitions use
-:class:`edify.elements.types.base.BaseElement` to avoid forming an import
-cycle with this module.
-"""
+"""The sealed :data:`Element` union — every concrete element class spelled out."""
from __future__ import annotations
diff --git a/edify/errors/anchors.py b/edify/errors/anchors.py
index ce3507f..1f3114a 100644
--- a/edify/errors/anchors.py
+++ b/edify/errors/anchors.py
@@ -1,29 +1,36 @@
-"""Exception classes raised when start/end-of-input anchors are misused.
-
-Each anchor (``start_of_input``, ``end_of_input``) may be declared at most
-once per pattern, and ``start_of_input`` may not be added after
-``end_of_input``. When a subexpression carries its own anchors and merges
-into a parent that already has the corresponding anchor, the raised error
-also points at the ``ignore_start_and_end`` opt-out.
-"""
+"""Exception classes raised when start/end-of-input anchors are misused."""
from __future__ import annotations
+from edify.errors.formatting import compose_annotated_message
from edify.errors.syntax import EdifySyntaxError
-_IGNORE_START_AND_END_HINT = (
- " You can ignore a subexpression's start_of_input/end_of_input markers "
- "with the ignore_start_and_end option"
-)
-
class StartInputAlreadyDefinedError(EdifySyntaxError):
- """Raised when ``start_of_input`` is added to a pattern that already has one."""
+ """Raised when ``start_of_input`` is added to a pattern that already has one.
+
+ Args:
+ in_subexpression: True when the duplicate came from a subexpression being merged
+ into a parent that already carries the anchor.
+ """
def __init__(self, in_subexpression: bool = False) -> None:
- message = "Regex already has a start of input."
if in_subexpression:
- message = message + _IGNORE_START_AND_END_HINT
+ help_line = (
+ "help: pass ignore_start_and_end=True when merging the subexpression "
+ "so its own start_of_input is dropped."
+ )
+ else:
+ help_line = "help: remove the duplicate .start_of_input() call from the chain."
+ message = compose_annotated_message(
+ summary="start_of_input has already been added to this pattern",
+ trigger_hint="second start_of_input added here",
+ note=(
+ "a pattern can carry at most one start_of_input anchor; "
+ "the earlier .start_of_input() call already set it."
+ ),
+ help_line=help_line,
+ )
super().__init__(message)
@@ -31,15 +38,41 @@ class CannotDefineStartAfterEndError(EdifySyntaxError):
"""Raised when ``start_of_input`` is added after ``end_of_input``."""
def __init__(self) -> None:
- message = "Can not define a start of input after defining an end of input."
+ message = compose_annotated_message(
+ summary="start_of_input cannot follow end_of_input in the chain",
+ trigger_hint="start_of_input added here",
+ note=(
+ "start_of_input must come before any content; the pattern already declared "
+ "end_of_input, so the anchor order is inverted."
+ ),
+ help_line="help: move .start_of_input() to before the .end_of_input() call.",
+ )
super().__init__(message)
class EndInputAlreadyDefinedError(EdifySyntaxError):
- """Raised when ``end_of_input`` is added to a pattern that already has one."""
+ """Raised when ``end_of_input`` is added to a pattern that already has one.
+
+ Args:
+ in_subexpression: True when the duplicate came from a subexpression being merged
+ into a parent that already carries the anchor.
+ """
def __init__(self, in_subexpression: bool = False) -> None:
- message = "Regex already has an end of input."
if in_subexpression:
- message = message + _IGNORE_START_AND_END_HINT
+ help_line = (
+ "help: pass ignore_start_and_end=True when merging the subexpression "
+ "so its own end_of_input is dropped."
+ )
+ else:
+ help_line = "help: remove the duplicate .end_of_input() call from the chain."
+ message = compose_annotated_message(
+ summary="end_of_input has already been added to this pattern",
+ trigger_hint="second end_of_input added here",
+ note=(
+ "a pattern can carry at most one end_of_input anchor; "
+ "the earlier .end_of_input() call already set it."
+ ),
+ help_line=help_line,
+ )
super().__init__(message)
diff --git a/edify/errors/captures.py b/edify/errors/captures.py
index a18674d..409885c 100644
--- a/edify/errors/captures.py
+++ b/edify/errors/captures.py
@@ -1,18 +1,37 @@
-"""Exception class raised for invalid capture-group references.
-
-``back_reference(index)`` accepts a 1-based index that must already exist on
-the builder. Passing an out-of-range index raises this error rather than
-silently emitting a broken regex.
-"""
+"""Exception class raised for invalid capture-group references."""
from __future__ import annotations
+from edify.errors.formatting import compose_annotated_message
from edify.errors.syntax import EdifySyntaxError
class InvalidTotalCaptureGroupsIndexError(EdifySyntaxError):
- """Raised when ``back_reference(index)`` is given an index out of range."""
+ """Raised when ``back_reference(index)`` is given an index out of range.
+
+ Args:
+ index: The 1-based index the caller requested.
+ total_capture_groups: How many capture groups exist on the builder so far.
+ """
def __init__(self, index: int, total_capture_groups: int) -> None:
- message = f"Invalid index #{index}. There are only {total_capture_groups} capture groups."
+ if total_capture_groups == 0:
+ note = f"the pattern has no capture groups yet; index {index} refers to nothing."
+ help_line = "help: add a .capture() call before this back_reference."
+ else:
+ plural = "s" if total_capture_groups != 1 else ""
+ note = (
+ f"the pattern currently has {total_capture_groups} capture group{plural} "
+ f"(valid indices are 1 to {total_capture_groups}); index {index} is out of range."
+ )
+ help_line = (
+ f"help: use an index between 1 and {total_capture_groups}, "
+ "or add another .capture() first."
+ )
+ message = compose_annotated_message(
+ summary=f"back_reference index #{index} is out of range",
+ trigger_hint=f"index {index} referenced here",
+ note=note,
+ help_line=help_line,
+ )
super().__init__(message)
diff --git a/edify/errors/comparison.py b/edify/errors/comparison.py
new file mode 100644
index 0000000..c4a7587
--- /dev/null
+++ b/edify/errors/comparison.py
@@ -0,0 +1,66 @@
+"""Exceptions raised for equality / hash operations on unfinished builders."""
+
+from __future__ import annotations
+
+from edify.errors.context import capture_caller_context
+from edify.errors.formatting import (
+ Problem,
+ format_error,
+ format_note_line,
+ format_pointer_block,
+ format_problem,
+)
+from edify.errors.syntax import EdifySyntaxError
+
+
+class CannotCompareUnfinishedBuilderError(EdifySyntaxError):
+ """Raised by ``==`` when either operand has open frames or a pending quantifier.
+
+ Args:
+ problems: One :class:`Problem` per unfinished operand.
+ """
+
+ def __init__(self, problems: list[Problem]) -> None:
+ caller_context = capture_caller_context()
+ trigger_block = ""
+ if caller_context is not None:
+ trigger_block = format_pointer_block(caller_context, "comparison rejected here")
+ note_line = format_note_line(
+ "value equality on RegexBuilder is (emitted_pattern, flags); "
+ "both operands must render their regex, but edify sees "
+ f"{len(problems)} problem{'s' if len(problems) != 1 else ''} below."
+ )
+ trigger_with_note = trigger_block + "\n" + note_line if trigger_block else note_line
+ problem_blocks = [format_problem(problem) for problem in problems]
+ message = format_error(
+ "cannot compare an unfinished builder",
+ trigger_with_note,
+ *problem_blocks,
+ )
+ super().__init__(message)
+
+
+class CannotHashUnfinishedBuilderError(EdifySyntaxError):
+ """Raised by ``hash(...)`` when the builder has open frames or a pending quantifier.
+
+ Args:
+ problem: The single :class:`Problem` describing what is unfinished.
+ """
+
+ def __init__(self, problem: Problem) -> None:
+ caller_context = capture_caller_context()
+ trigger_block = ""
+ if caller_context is not None:
+ trigger_block = format_pointer_block(caller_context, "hash rejected here")
+ note_line = format_note_line(
+ "the value hash on RegexBuilder is derived from (emitted_pattern, flags); "
+ "the builder must be renderable, but edify sees the problem below."
+ )
+ trigger_with_note = trigger_block + "\n" + note_line if trigger_block else note_line
+ problem_block = format_problem(problem)
+ message = format_error(
+ "cannot hash an unfinished builder",
+ trigger_with_note,
+ problem_block,
+ )
+ super().__init__(message)
diff --git a/edify/errors/context.py b/edify/errors/context.py
new file mode 100644
index 0000000..1109e7f
--- /dev/null
+++ b/edify/errors/context.py
@@ -0,0 +1,82 @@
+"""Caller-source-location capture for :mod:`edify.errors`."""
+
+from __future__ import annotations
+
+import linecache
+import os
+import sys
+from dataclasses import dataclass
+
+_EDIFY_PACKAGE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_EDIFY_PACKAGE_PREFIX = _EDIFY_PACKAGE_ROOT + os.sep
+
+
+@dataclass(frozen=True)
+class CallerContext:
+ """A source-location snapshot of a specific call in the caller's code.
+
+ Attributes:
+ filename: Absolute path to the file that contains the offending call.
+ lineno: 1-indexed line number of the offending call.
+ colno: 1-indexed column at which the offending call starts.
+ end_colno: 1-indexed column one past where the offending call ends.
+ source_line: The verbatim source line, trailing whitespace stripped.
+ """
+
+ filename: str
+ lineno: int
+ colno: int
+ end_colno: int
+ source_line: str
+
+
+def capture_caller_context() -> CallerContext | None:
+ """Return a :class:`CallerContext` for the first non-``edify`` frame on the stack.
+
+ Returns ``None`` when every frame on the stack lives inside the ``edify/``
+ package tree.
+ """
+ current_frame = sys._getframe(1)
+ while current_frame is not None:
+ filename = current_frame.f_code.co_filename
+ absolute_filename = (
+ os.path.abspath(filename) if os.path.isabs(filename) is False else filename
+ )
+ if not absolute_filename.startswith(_EDIFY_PACKAGE_PREFIX):
+ return _context_for_frame(current_frame)
+ current_frame = current_frame.f_back
+ return None
+
+
+def _context_for_frame(frame) -> CallerContext:
+ """Return a :class:`CallerContext` describing ``frame``'s current instruction."""
+ filename = frame.f_code.co_filename
+ positions = list(frame.f_code.co_positions())
+ instruction_index = frame.f_lasti // 2
+ if 0 <= instruction_index < len(positions):
+ raw_position = positions[instruction_index]
+ else:
+ raw_position = (frame.f_lineno, frame.f_lineno, None, None)
+ start_line, end_line, start_col, end_col = raw_position
+ if start_line is None:
+ start_line = frame.f_lineno
+ if end_line is None:
+ end_line = start_line
+ if start_col is None:
+ start_col = 0
+ if end_col is None:
+ end_col = start_col
+ source_line = _read_source_line(filename, start_line)
+ return CallerContext(
+ filename=filename,
+ lineno=start_line,
+ colno=start_col + 1,
+ end_colno=end_col + 1,
+ source_line=source_line,
+ )
+
+
+def _read_source_line(filename: str, lineno: int) -> str:
+ """Return the verbatim source line at ``lineno``, or an empty string when unreadable."""
+ raw_line = linecache.getline(filename, lineno)
+ return raw_line.rstrip()
diff --git a/edify/errors/formatting.py b/edify/errors/formatting.py
new file mode 100644
index 0000000..f5bc044
--- /dev/null
+++ b/edify/errors/formatting.py
@@ -0,0 +1,133 @@
+"""Message formatter helpers for the edify error classes."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from edify.errors.context import CallerContext, capture_caller_context
+
+
+@dataclass(frozen=True)
+class FixInsertion:
+ """A single insertion into a source line.
+
+ Attributes:
+ column: 1-indexed column where the insertion begins.
+ text: The text to insert; also the width used to draw the ``+`` marks.
+ """
+
+ column: int
+ text: str
+
+
+@dataclass(frozen=True)
+class Problem:
+ """A single named problem with a pointer at its creation site and a suggested fix.
+
+ Attributes:
+ description: Full-sentence text that follows the ``problem:`` label.
+ problem_context: Source-location snapshot of the offending chain call.
+ problem_hint: Inline label rendered next to the problem's caret.
+ help_summary: One-line text that follows the ``help:`` label.
+ fix_context: Source-location snapshot of the line that should be patched.
+ fix_insertion: The text to insert into the source line and where.
+ """
+
+ description: str
+ problem_context: CallerContext
+ problem_hint: str
+ help_summary: str
+ fix_context: CallerContext
+ fix_insertion: FixInsertion
+
+
+def format_error(summary: str, *blocks: str) -> str:
+ """Return the full message: ``error: <summary>`` followed by ``blocks``."""
+ non_empty_blocks = [block for block in blocks if block]
+ body = "\n\n".join(non_empty_blocks)
+ if body:
+ return f"error: {summary}\n\n{body}"
+ return f"error: {summary}"
+
+
+def format_pointer_block(context: CallerContext, hint: str) -> str:
+ """Return a ``-->`` pointer block underlining the offending call in the source line."""
+ lineno_prefix = str(context.lineno)
+ left_pad = " " * len(lineno_prefix)
+ caret_width = max(1, context.end_colno - context.colno)
+ caret_indent = " " * (context.colno - 1)
+ caret_line = f"{left_pad} | {caret_indent}{'^' * caret_width} {hint}".rstrip()
+ header_line = f"{left_pad}--> {context.filename}:{context.lineno}:{context.colno}"
+ bar_line = f"{left_pad} |"
+ source_line = f"{lineno_prefix} | {context.source_line}"
+ lines = [header_line, bar_line, source_line, caret_line, bar_line]
+ return "\n".join(lines)
+
+
+def format_fix_block(context: CallerContext, insertion: FixInsertion) -> str:
+ """Return a pointer-style block that shows the corrected line with ``+`` marks."""
+ lineno_prefix = str(context.lineno)
+ left_pad = " " * len(lineno_prefix)
+ original_line = context.source_line
+ insertion_zero_col = insertion.column - 1
+ corrected_line = (
+ original_line[:insertion_zero_col] + insertion.text + original_line[insertion_zero_col:]
+ )
+ plus_indent = " " * insertion_zero_col
+ plus_marks = "+" * len(insertion.text)
+ bar_line = f"{left_pad} |"
+ source_line = f"{lineno_prefix} | {corrected_line}"
+ marker_line = f"{left_pad} | {plus_indent}{plus_marks}"
+ return "\n".join([bar_line, source_line, marker_line, bar_line])
+
+
+def format_note_line(note: str) -> str:
+ """Return an aligned ``= note:`` continuation line."""
+ return f" = note: {note}"
+
+
+def format_problem_header(description: str) -> str:
+ """Return the ``problem: <description>`` header line."""
+ return f"problem: {description}"
+
+
+def format_help_header(help: str) -> str:
+ """Return the ``help: <help>`` header line."""
+ return f"help: {help}"
+
+
+def format_problem(problem: Problem) -> str:
+ """Return a full problem block: header, pointer, help header, fix pointer."""
+ header = format_problem_header(problem.description)
+ problem_pointer = format_pointer_block(problem.problem_context, problem.problem_hint)
+ help_header = format_help_header(problem.help_summary)
+ fix_pointer = format_fix_block(problem.fix_context, problem.fix_insertion)
+ return "\n\n".join([header, problem_pointer, help_header, fix_pointer])
+
+
+def compose_annotated_message(
+ summary: str,
+ trigger_hint: str,
+ note: str,
+ help_line: str,
+) -> str:
+ """Return a full annotated error message.
+
+ Captures the caller context, draws a pointer block with ``trigger_hint`` at the
+ caret, adds a ``= note:`` line explaining ``note``, and appends the pre-formatted
+ ``help_line`` (which must start with ``help:``).
+
+ Args:
+ summary: One-line summary printed after ``error:``.
+ trigger_hint: Inline label placed next to the caret in the pointer block.
+ note: Sentence explaining why the invariant was violated.
+ help_line: Pre-formatted help line, starting with ``help:``.
+ """
+ caller_context = capture_caller_context()
+ note_line = format_note_line(note)
+ if caller_context is None:
+ body = note_line
+ else:
+ trigger_block = format_pointer_block(caller_context, trigger_hint)
+ body = trigger_block + "\n" + note_line
+ return format_error(summary, body, help_line)
diff --git a/edify/errors/input.py b/edify/errors/input.py
index f560ba6..31a6333 100644
--- a/edify/errors/input.py
+++ b/edify/errors/input.py
@@ -1,96 +1,248 @@
-"""Exception classes raised when a builder method receives invalid input.
-
-Each class is a thin :class:`EdifySyntaxError` subclass whose ``__init__``
-formats the message internally. Classes that report what was passed take
-the already-extracted type name as a string (``type(value).__name__``) so
-the exception surface stays strongly typed end-to-end.
-"""
+"""Exception classes raised when a builder method receives invalid input."""
from __future__ import annotations
+from edify.errors.formatting import compose_annotated_message
from edify.errors.syntax import EdifySyntaxError
class MustBeAStringError(EdifySyntaxError):
- """Raised when a builder argument is required to be a string but is not."""
+ """Raised when a builder argument is required to be a string but is not.
+
+ Args:
+ label: What the argument is called in the API (e.g. ``"Name"``).
+ actual_type_name: The class name of the value the caller passed.
+ """
def __init__(self, label: str, actual_type_name: str) -> None:
- message = f"{label} must be a string. (got {actual_type_name})"
+ lowered = label.lower()
+ message = compose_annotated_message(
+ summary=f"{label} must be a string (got {actual_type_name})",
+ trigger_hint=f"{lowered} passed here",
+ note=(
+ f"this builder method expects {lowered} to be a str, but the caller "
+ f"passed a value of type {actual_type_name}."
+ ),
+ help_line=(
+ f"help: convert the value with str(...) or pass a string literal for {lowered}."
+ ),
+ )
super().__init__(message)
class MustBeOneCharacterError(EdifySyntaxError):
- """Raised when a builder argument must have a length of at least one character."""
+ """Raised when a builder argument must have a length of at least one character.
+
+ Args:
+ label: What the argument is called in the API.
+ """
def __init__(self, label: str) -> None:
- message = f"{label} must be one character long."
+ lowered = label.lower()
+ message = compose_annotated_message(
+ summary=f"{label} must be one character long",
+ trigger_hint=f"empty {lowered} passed here",
+ note=(
+ f"this builder method needs {lowered} to be a non-empty string; an "
+ "empty string has no character to match."
+ ),
+ help_line=(f'help: pass a single-character string for {lowered} (e.g. "a").'),
+ )
super().__init__(message)
class MustBeSingleCharacterError(EdifySyntaxError):
- """Raised when a builder argument must be exactly one character long."""
+ """Raised when a builder argument must be exactly one character long.
+
+ Args:
+ label: What the argument is called in the API.
+ actual_type_name: The class name of the value the caller passed.
+ """
def __init__(self, label: str, actual_type_name: str) -> None:
- message = f"{label} must be a single character. (got {actual_type_name})"
+ lowered = label.lower()
+ message = compose_annotated_message(
+ summary=f"{label} must be a single character (got {actual_type_name})",
+ trigger_hint=f"{lowered} passed here",
+ note=(
+ f"this builder method expects {lowered} to be a one-character string; "
+ f"a value of type {actual_type_name} does not qualify."
+ ),
+ help_line=(
+ f"help: pass a single-character string for {lowered} "
+ '(e.g. "a" — not "ab" or a non-string value).'
+ ),
+ )
super().__init__(message)
class MustBePositiveIntegerError(EdifySyntaxError):
- """Raised when a builder argument must be a positive integer (``> 0``)."""
+ """Raised when a builder argument must be a positive integer (``> 0``).
+
+ Args:
+ label: What the argument is called in the API.
+ """
def __init__(self, label: str) -> None:
- message = f"{label} must be a positive integer."
+ lowered = label.lower()
+ message = compose_annotated_message(
+ summary=f"{label} must be a positive integer",
+ trigger_hint=f"{lowered} passed here",
+ note=(
+ f"this quantifier bound needs {lowered} to be an int greater than or "
+ "equal to 1; zero and negative counts have no matching semantics."
+ ),
+ help_line=(
+ f"help: pass an int >= 1 for {lowered}; use .optional() if you meant zero-or-one."
+ ),
+ )
super().__init__(message)
class MustBeIntegerGreaterThanZeroError(EdifySyntaxError):
- """Raised when a builder argument must be an integer strictly greater than zero."""
+ """Raised when a builder argument must be an integer strictly greater than zero.
+
+ Args:
+ label: What the argument is called in the API.
+ """
def __init__(self, label: str) -> None:
- message = f"{label} must be an integer greater than zero."
+ lowered = label.lower()
+ message = compose_annotated_message(
+ summary=f"{label} must be an integer greater than zero",
+ trigger_hint=f"{lowered} passed here",
+ note=(
+ f"this quantifier bound needs {lowered} to be an int strictly greater "
+ "than 0; zero and negative counts have no matching semantics."
+ ),
+ help_line=(
+ f"help: pass an int > 0 for {lowered}; use .optional() if you meant zero-or-one."
+ ),
+ )
super().__init__(message)
class MustBeInstanceError(EdifySyntaxError):
- """Raised when a builder argument must be an instance of a specific class."""
+ """Raised when a builder argument must be an instance of a specific class.
+
+ Args:
+ label: What the argument is called in the API.
+ actual_type_name: The class name of the value the caller passed.
+ expected_class_name: The class name the caller was expected to pass.
+ """
def __init__(self, label: str, actual_type_name: str, expected_class_name: str) -> None:
- message = f"{label} must be an instance of {expected_class_name}. (got {actual_type_name})"
+ lowered = label.lower()
+ message = compose_annotated_message(
+ summary=(
+ f"{label} must be an instance of {expected_class_name} (got {actual_type_name})"
+ ),
+ trigger_hint=f"{lowered} passed here",
+ note=(
+ f"this method requires {lowered} to be an instance of {expected_class_name}; "
+ f"a value of type {actual_type_name} cannot participate in the composition."
+ ),
+ help_line=(
+ f"help: construct {lowered} via {expected_class_name}(...) or one of the "
+ "top-level factory functions before passing it here."
+ ),
+ )
super().__init__(message)
class MustHaveASmallerValueError(EdifySyntaxError):
- """Raised when the first character argument must order before the second."""
+ """Raised when the first character argument must order before the second.
+
+ Args:
+ first: The lower-bound character supplied.
+ second: The upper-bound character supplied.
+ """
def __init__(self, first: str, second: str) -> None:
first_codepoint = ord(first)
second_codepoint = ord(second)
- message = (
- f"{first} must have a smaller character value than {second}. "
- f"(a = {first_codepoint}, b = {second_codepoint})"
+ message = compose_annotated_message(
+ summary=(
+ f"range bounds are inverted: {first!r} must come before {second!r} "
+ "in codepoint order"
+ ),
+ trigger_hint="inverted range declared here",
+ note=(
+ f"a character range needs the first bound to have a strictly smaller "
+ f"codepoint than the second; {first!r} = {first_codepoint} and "
+ f"{second!r} = {second_codepoint}."
+ ),
+ help_line=(
+ f"help: swap the bounds so the smaller character comes first "
+ f"(e.g. range_of({second!r}, {first!r}))."
+ ),
)
super().__init__(message)
class MustBeLessThanError(EdifySyntaxError):
- """Raised when a numeric argument must order strictly before another."""
+ """Raised when a numeric argument must order strictly before another.
+
+ Args:
+ first_label: What the smaller-bound argument is called in the API.
+ second_label: What the larger-bound argument is called in the API.
+ """
def __init__(self, first_label: str, second_label: str) -> None:
- message = f"{first_label} must be less than {second_label}."
+ message = compose_annotated_message(
+ summary=f"{first_label} must be less than {second_label}",
+ trigger_hint="inverted bounds declared here",
+ note=(
+ f"the pair ({first_label}, {second_label}) forms a range; "
+ f"{first_label} must be strictly less than {second_label} for the range "
+ "to contain any matches."
+ ),
+ help_line=(f"help: swap the two arguments so {first_label} is the smaller value."),
+ )
super().__init__(message)
class MustBeAtLeastTwoOperandsError(EdifySyntaxError):
- """Raised when a variadic factory needs at least two operands but got fewer."""
+ """Raised when a variadic factory needs at least two operands but got fewer.
+
+ Args:
+ label: The name of the factory function (e.g. ``"any_of"``).
+ """
def __init__(self, label: str) -> None:
- message = f"{label} requires at least two operands."
+ message = compose_annotated_message(
+ summary=f"{label} requires at least two operands",
+ trigger_hint=f"{label} called here",
+ note=(
+ f"{label} builds an alternation between two-or-more branches; passing "
+ "fewer than two operands leaves nothing to alternate between."
+ ),
+ help_line=(
+ f"help: pass at least two Pattern operands to {label}(...); "
+ "if you have only one alternative, use it directly without an alternation."
+ ),
+ )
super().__init__(message)
class MustBeAtLeastOneLiteralError(EdifySyntaxError):
- """Raised when a variadic literal-alternation chain method got zero literals."""
+ """Raised when a variadic literal-alternation chain method got zero literals.
+
+ Args:
+ label: The name of the chain method (e.g. ``"one_of"``).
+ """
def __init__(self, label: str) -> None:
- message = f"{label} requires at least one literal."
+ message = compose_annotated_message(
+ summary=f"{label} requires at least one literal",
+ trigger_hint=f"{label} called here",
+ note=(
+ f"{label} builds a literal alternation; without any string literal, "
+ "the chain method has no branch to add."
+ ),
+ help_line=(
+ f"help: pass one or more string literals to {label}(...) "
+ "(e.g. .one_of('cat', 'dog'))."
+ ),
+ )
super().__init__(message)
diff --git a/edify/errors/internal.py b/edify/errors/internal.py
index 7484e47..04881d5 100644
--- a/edify/errors/internal.py
+++ b/edify/errors/internal.py
@@ -1,42 +1,101 @@
-"""Exception classes for internal-consistency violations inside edify.
-
-These errors should never fire under correct builder use; they exist so that
-unrecognised AST shapes raise loudly with a specific class instead of slipping
-through with a generic message.
-"""
+"""Exception classes for internal-consistency violations inside edify."""
from __future__ import annotations
+from edify.errors.formatting import compose_annotated_message
from edify.errors.syntax import EdifySyntaxError
class UnknownElementTypeError(EdifySyntaxError):
- """Raised when the compile dispatcher receives an element class it does not recognise."""
+ """Raised when the compile dispatcher receives an element class it does not recognise.
+
+ Args:
+ element_type_name: The class name that was not registered on the dispatch table.
+ """
def __init__(self, element_type_name: str) -> None:
- message = f"Cannot render unknown element type {element_type_name}"
+ message = compose_annotated_message(
+ summary=f"unknown element type {element_type_name!r} reached the compiler",
+ trigger_hint="pattern compiled here",
+ note=(
+ f"the compile dispatcher has no rule for {element_type_name}; either the "
+ "element is misconstructed or a compile handler is missing for this class."
+ ),
+ help_line=(
+ "help: this is an internal edify bug; please file it at "
+ "https://github.com/luciferreeves/edify/issues with the pattern that "
+ "triggered the error."
+ ),
+ )
super().__init__(message)
class NonFusableElementError(EdifySyntaxError):
- """Raised when the char-class fuser is handed an element it cannot fuse."""
+ """Raised when the char-class fuser is handed an element it cannot fuse.
+
+ Args:
+ element_type_name: The class name that the fuser refused.
+ """
def __init__(self, element_type_name: str) -> None:
- message = f"Cannot fuse element of type {element_type_name} into a char class"
+ message = compose_annotated_message(
+ summary=f"cannot fuse element {element_type_name!r} into a character class",
+ trigger_hint="pattern compiled here",
+ note=(
+ "the character-class fuser accepts only character-shaped elements "
+ "(CharElement, RangeElement, AnyOfCharsElement); "
+ f"{element_type_name} is not one of them."
+ ),
+ help_line=(
+ "help: this is an internal edify bug; please file it at "
+ "https://github.com/luciferreeves/edify/issues with the pattern that "
+ "triggered the error."
+ ),
+ )
super().__init__(message)
class UnexpectedFrameTypeError(EdifySyntaxError):
- """Raised when a stack frame's anchor element is not a recognised container kind."""
+ """Raised when a stack frame's anchor element is not a recognised container kind.
+
+ Args:
+ element_type_name: The class name held by the offending stack frame.
+ """
def __init__(self, element_type_name: str) -> None:
- message = f"Stack frame anchored at unexpected element type {element_type_name}"
+ message = compose_annotated_message(
+ summary=f"stack frame anchored at unexpected element {element_type_name!r}",
+ trigger_hint="pattern touched here",
+ note=(
+ "builder frames must anchor at a container element such as CaptureElement, "
+ f"NamedCaptureElement, GroupElement, AnyOfElement, or a lookaround; "
+ f"{element_type_name} is not one of the accepted container kinds."
+ ),
+ help_line=(
+ "help: this is an internal edify bug; please file it at "
+ "https://github.com/luciferreeves/edify/issues with the pattern that "
+ "triggered the error."
+ ),
+ )
super().__init__(message)
class FailedToCompileRegexError(EdifySyntaxError):
- """Raised when the underlying ``re`` engine rejects the emitted pattern."""
+ """Raised when the underlying ``re`` engine rejects the emitted pattern.
+
+ Args:
+ underlying_error_message: The message string reported by ``re.compile``.
+ """
def __init__(self, underlying_error_message: str) -> None:
- message = f"Cannot compile regex: {underlying_error_message}"
+ message = compose_annotated_message(
+ summary="the emitted regex was rejected by the re engine",
+ trigger_hint=".to_regex() / .to_regex_string() called here",
+ note=(f"re.compile refused the pattern: {underlying_error_message}"),
+ help_line=(
+ "help: inspect the raw pattern with .to_regex_string() and cross-check "
+ "quantifier / group placement; if the pattern looks correct, please file "
+ "the issue at https://github.com/luciferreeves/edify/issues."
+ ),
+ )
super().__init__(message)
diff --git a/edify/errors/introspect.py b/edify/errors/introspect.py
new file mode 100644
index 0000000..0401924
--- /dev/null
+++ b/edify/errors/introspect.py
@@ -0,0 +1,89 @@
+"""Exceptions raised by the :class:`edify.result.regex.Regex` introspection API."""
+
+from __future__ import annotations
+
+from edify.errors.context import capture_caller_context
+from edify.errors.formatting import format_error, format_note_line, format_pointer_block
+from edify.errors.syntax import EdifySyntaxError
+
+_SUPPORTED_FORMATS = ("ascii", "svg")
+_SUPPORTED_ENGINES_BY_FORMAT: dict[str, tuple[str, ...]] = {
+ "ascii": ("ascii",),
+ "svg": ("graphviz",),
+}
+
+
+class UnsupportedVisualizationFormatError(EdifySyntaxError):
+ """Raised when ``Regex.visualize(format=...)`` receives an unknown format.
+
+ Args:
+ received_format: The format string the caller passed.
+ """
+
+ def __init__(self, received_format: str) -> None:
+ caller_context = capture_caller_context()
+ trigger_block = ""
+ if caller_context is not None:
+ trigger_block = format_pointer_block(caller_context, "unknown format here")
+ supported = ", ".join(f"'{fmt}'" for fmt in _SUPPORTED_FORMATS)
+ note_line = format_note_line(
+ f"Regex.visualize accepts one of {supported}; received {received_format!r}."
+ )
+ message = format_error(
+ f"unsupported visualization format {received_format!r}",
+ trigger_block + "\n" + note_line if trigger_block else note_line,
+ "help: pass format='ascii' for a text railroad diagram, "
+ "or format='svg' for a Graphviz-rendered SVG.",
+ )
+ super().__init__(message)
+
+
+class UnsupportedVisualizationEngineError(EdifySyntaxError):
+ """Raised when ``Regex.visualize(engine=...)`` does not pair with the chosen format.
+
+ Args:
+ received_format: The format string the caller passed.
+ received_engine: The engine string the caller passed.
+ """
+
+ def __init__(self, received_format: str, received_engine: str) -> None:
+ caller_context = capture_caller_context()
+ trigger_block = ""
+ if caller_context is not None:
+ trigger_block = format_pointer_block(caller_context, "engine does not match format")
+ supported = _SUPPORTED_ENGINES_BY_FORMAT.get(received_format, ())
+ supported_list = ", ".join(f"'{engine}'" for engine in supported) or "(none)"
+ note_line = format_note_line(
+ f"format={received_format!r} pairs with engine in {supported_list}; "
+ f"received engine={received_engine!r}."
+ )
+ preferred_engine = supported_list.split(",")[0].strip() if supported else "ascii"
+ message = format_error(
+ f"engine {received_engine!r} does not pair with format {received_format!r}",
+ trigger_block + "\n" + note_line if trigger_block else note_line,
+ f"help: pass engine={preferred_engine!r}.",
+ )
+ super().__init__(message)
+
+
+class MissingGraphvizDependencyError(EdifySyntaxError):
+ """Raised when ``Regex.visualize(format='svg')`` is called without graphviz installed."""
+
+ def __init__(self) -> None:
+ caller_context = capture_caller_context()
+ trigger_block = ""
+ if caller_context is not None:
+ trigger_block = format_pointer_block(
+ caller_context, "graphviz dependency required here"
+ )
+ note_line = format_note_line(
+ "the graphviz Python package is required to render SVG diagrams; "
+ "it is not installed in this environment."
+ )
+ message = format_error(
+ "graphviz optional dependency is not installed",
+ trigger_block + "\n" + note_line if trigger_block else note_line,
+ "help: install with `pip install 'edify[graphviz]'` "
+ "or `uv add edify --extra graphviz`.",
+ )
+ super().__init__(message)
diff --git a/edify/errors/naming.py b/edify/errors/naming.py
index 6897552..7ac6f09 100644
--- a/edify/errors/naming.py
+++ b/edify/errors/naming.py
@@ -1,37 +1,74 @@
-"""Exception classes raised when named-group operations are misused.
-
-Named groups (``named_capture``, ``named_back_reference``) require a unique,
-valid identifier for each name. These classes surface the three failure
-modes: duplicate name, invalid name shape, and reference to a name that
-was never declared.
-"""
+"""Exception classes raised when named-group operations are misused."""
from __future__ import annotations
+from edify.errors.formatting import compose_annotated_message
from edify.errors.syntax import EdifySyntaxError
class NameNotValidError(EdifySyntaxError):
- """Raised when a named-group name fails the identifier shape check."""
+ """Raised when a named-group name fails the identifier shape check.
+
+ Args:
+ name: The rejected name string.
+ """
def __init__(self, name: str) -> None:
- message = (
- f"Name {name} is not valid. (only alphanumeric characters and underscores are allowed)"
+ message = compose_annotated_message(
+ summary=f"named-group name {name!r} is not a valid identifier",
+ trigger_hint="invalid name passed here",
+ note=(
+ "named-group names must be non-empty and contain only letters, digits, "
+ "and underscores (no spaces, hyphens, or punctuation)."
+ ),
+ help_line=(
+ f"help: rename {name!r} to a bare identifier (e.g. 'year', 'user_id', 'scheme')."
+ ),
)
super().__init__(message)
class CannotCreateDuplicateNamedGroupError(EdifySyntaxError):
- """Raised when ``named_capture`` is called with a name already registered on the builder."""
+ """Raised when ``named_capture`` is called with a name already registered on the builder.
+
+ Args:
+ name: The duplicate name.
+ """
def __init__(self, name: str) -> None:
- message = f'Can not create duplicate named group "{name}".'
+ message = compose_annotated_message(
+ summary=f"named group {name!r} already exists in this pattern",
+ trigger_hint="duplicate name declared here",
+ note=(
+ "every named capture group must have a unique name; the earlier "
+ f".named_capture({name!r}) call already registered this one."
+ ),
+ help_line=(
+ f"help: pick a different name for the second group, "
+ f"or use .named_back_reference({name!r}) to reuse the existing group's match."
+ ),
+ )
super().__init__(message)
class NamedGroupDoesNotExistError(EdifySyntaxError):
- """Raised when ``named_back_reference`` references a name that was never declared."""
+ """Raised when ``named_back_reference`` references a name that was never declared.
+
+ Args:
+ name: The name the caller tried to reference.
+ """
def __init__(self, name: str) -> None:
- message = f'Named group "{name}" does not exist (create one with .named_capture()).'
+ message = compose_annotated_message(
+ summary=f"named group {name!r} does not exist in this pattern",
+ trigger_hint="unknown name referenced here",
+ note=(
+ f".named_back_reference({name!r}) can only refer to a group that was "
+ "declared earlier in the chain; no such group has been declared yet."
+ ),
+ help_line=(
+ f"help: add a .named_capture({name!r})...end() before this call, "
+ "or reference an already-declared name."
+ ),
+ )
super().__init__(message)
diff --git a/edify/errors/quantifier.py b/edify/errors/quantifier.py
index 23e435e..2acc47b 100644
--- a/edify/errors/quantifier.py
+++ b/edify/errors/quantifier.py
@@ -1,15 +1,8 @@
-"""Exception classes raised for quantifier misuse in a builder chain.
-
-* :class:`DanglingQuantifierError` — a terminal was called with a pending
- quantifier that never received an operand. Emitting silently would
- drop the quantifier from the output.
-* :class:`StackedQuantifierError` — a quantifier chain method was called
- while another quantifier was already pending. Emitting silently would
- drop the outer quantifier.
-"""
+"""Exception classes raised for quantifier misuse in a builder chain."""
from __future__ import annotations
+from edify.errors.formatting import compose_annotated_message
from edify.errors.syntax import EdifySyntaxError
@@ -17,9 +10,18 @@ class DanglingQuantifierError(EdifySyntaxError):
"""Raised when a terminal is called while a quantifier is still pending."""
def __init__(self) -> None:
- message = (
- "Dangling quantifier with no operand. "
- "Append an element (e.g. .digit()) before compiling."
+ message = compose_annotated_message(
+ summary="dangling quantifier with no operand to apply to",
+ trigger_hint="terminal called here",
+ note=(
+ "a quantifier such as .exactly(n), .one_or_more(), .at_least(n), or "
+ ".between(a, b) attaches to the next element in the chain; the chain "
+ "ended before an element was supplied."
+ ),
+ help_line=(
+ "help: append the element the quantifier should apply to "
+ "(e.g. .digit(), .word(), .string('...')) before the terminal call."
+ ),
)
super().__init__(message)
@@ -28,8 +30,17 @@ class StackedQuantifierError(EdifySyntaxError):
"""Raised when a quantifier chain method is called with another quantifier already pending."""
def __init__(self) -> None:
- message = (
- "Cannot stack a quantifier on top of another pending quantifier. "
- "Add an operand between the two quantifiers or drop one."
+ message = compose_annotated_message(
+ summary="cannot stack a quantifier on top of another pending quantifier",
+ trigger_hint="second quantifier queued here",
+ note=(
+ "a quantifier waits for the next element in the chain; adding another "
+ "quantifier before that element would leave the earlier one with nothing "
+ "to attach to."
+ ),
+ help_line=(
+ "help: add an operand (e.g. .digit(), .word()) between the two "
+ "quantifier calls, or drop one of the two quantifiers."
+ ),
)
super().__init__(message)
diff --git a/edify/errors/structure.py b/edify/errors/structure.py
index 20689b7..e55bf3e 100644
--- a/edify/errors/structure.py
+++ b/edify/errors/structure.py
@@ -1,11 +1,8 @@
-"""Exception classes raised for builder-frame structural errors.
-
-These cover frame-stack misuse: closing the root frame, or calling
-``subexpression`` on a builder whose own chain still has an open frame.
-"""
+"""Exception classes raised for builder-frame structural errors."""
from __future__ import annotations
+from edify.errors.formatting import compose_annotated_message
from edify.errors.syntax import EdifySyntaxError
@@ -13,16 +10,39 @@ class CannotEndWhileBuildingRootExpressionError(EdifySyntaxError):
"""Raised when ``.end()`` is called on a builder whose only open frame is the root."""
def __init__(self) -> None:
- message = "Can not end while building the root expression."
+ message = compose_annotated_message(
+ summary="cannot .end() while building the root expression",
+ trigger_hint=".end() called here",
+ note=(
+ ".end() closes the innermost open frame, but the root expression has no "
+ "matching opener; there is no frame to close."
+ ),
+ help_line=(
+ "help: remove this .end() call, or open a frame first with "
+ ".capture(), .named_capture(name), .group(), .any_of(), or a lookaround."
+ ),
+ )
super().__init__(message)
class CannotCallSubexpressionError(EdifySyntaxError):
- """Raised when ``.subexpression(expression)`` is given an expression with open frames."""
+ """Raised when ``.subexpression(expression)`` is given an expression with open frames.
+
+ Args:
+ current_frame_type: Name of the frame kind that is still open on the subexpression.
+ """
def __init__(self, current_frame_type: str) -> None:
- message = (
- "Can not call subexpression on a not yet fully specified regex object. "
- f"(Try adding a .end() call to match the {current_frame_type} on the subexpression)"
+ message = compose_annotated_message(
+ summary="cannot merge a subexpression that has an unclosed frame",
+ trigger_hint="subexpression merged here",
+ note=(
+ f"the subexpression still has an open {current_frame_type} frame; "
+ "only fully-closed expressions can be merged into another builder."
+ ),
+ help_line=(
+ f"help: add a matching .end() call to close the {current_frame_type} frame "
+ "on the subexpression before passing it to .subexpression(...)."
+ ),
)
super().__init__(message)
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..adda379
--- /dev/null
+++ b/edify/introspect/ascii.py
@@ -0,0 +1,458 @@
+"""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")
+ element_diagrams = [_element_diagram(element) for element in elements]
+ parts: list[Diagram] = [start_box, *element_diagrams, 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=(*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`` between."""
+ 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..3440301
--- /dev/null
+++ b/edify/introspect/graphviz.py
@@ -0,0 +1,271 @@
+"""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/pattern/anchors.py b/edify/pattern/anchors.py
index f4fe21b..6fda3de 100644
--- a/edify/pattern/anchors.py
+++ b/edify/pattern/anchors.py
@@ -1,15 +1,9 @@
"""Pre-built :class:`Pattern` instances for the ``^`` and ``$`` anchors.
-These constants exist so the operator algebra reads naturally:
-
.. code-block:: python
from edify import END, START, Pattern
quad_digit = START + Pattern().exactly(4).digit() + END
-
-They are immutable — the underlying ``BuilderState`` never mutates and the
-operator overloads always return fresh instances — so importers can share
-them freely without worrying about interference between call sites.
"""
from __future__ import annotations
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/pyproject.toml b/pyproject.toml
index df02ef5..9243650 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -33,6 +33,9 @@ classifiers = [
dependencies = []
dynamic = ["version"]
+[project.optional-dependencies]
+graphviz = ["graphviz>=0.20"]
+
[project.urls]
Documentation = "https://edify.readthedocs.io/"
Changelog = "https://edify.readthedocs.io/en/latest/changelog.html"
@@ -40,6 +43,7 @@ Changelog = "https://edify.readthedocs.io/en/latest/changelog.html"
[dependency-groups]
dev = [
+ "graphviz>=0.20",
"hypothesis>=6.100",
"pytest>=8.0",
"pytest-cov>=5.0",
diff --git a/tests/builder/cache.test.py b/tests/builder/cache.test.py
new file mode 100644
index 0000000..80862d3
--- /dev/null
+++ b/tests/builder/cache.test.py
@@ -0,0 +1,69 @@
+"""Tests for the lazy :class:`Regex` cache on :class:`BuilderCore`."""
+
+from edify import Pattern, RegexBuilder
+
+
+def test_repeated_lazy_regex_calls_return_the_same_instance():
+ builder = RegexBuilder().digit()
+ first = builder._lazy_regex()
+ second = builder._lazy_regex()
+ third = builder._lazy_regex()
+ assert first is second
+ assert second is third
+
+
+def test_repeated_matcher_calls_reuse_the_cached_regex():
+ builder = RegexBuilder().digit()
+ builder.match("1")
+ cached = builder._cached_regex
+ builder.search("2")
+ builder.findall("3")
+ builder.test("4")
+ assert builder._cached_regex is cached
+
+
+def test_pattern_lazy_regex_is_memoised_too():
+ pattern = Pattern().word()
+ first = pattern._lazy_regex()
+ second = pattern._lazy_regex()
+ assert first is second
+
+
+def test_a_forked_builder_gets_a_fresh_cache_slot():
+ original = RegexBuilder().digit()
+ original.match("1")
+ assert original._cached_regex is not None
+ forked = original.fork()
+ assert forked._cached_regex is None
+
+
+def test_a_copied_builder_gets_a_fresh_cache_slot():
+ original = RegexBuilder().digit()
+ original.match("1")
+ copied = original.copy()
+ assert copied._cached_regex is None
+
+
+def test_a_chain_extension_gets_a_fresh_cache_slot():
+ original = RegexBuilder().digit()
+ original.match("1")
+ extended = original.word()
+ assert extended._cached_regex is None
+
+
+def test_a_fresh_builder_starts_without_a_cached_regex():
+ builder = RegexBuilder()
+ assert builder._cached_regex is None
+
+
+def test_calling_to_regex_directly_does_not_populate_the_cache():
+ builder = RegexBuilder().digit()
+ _ = builder.to_regex()
+ assert builder._cached_regex is None
+
+
+def test_lazy_regex_produces_a_regex_that_matches_the_pattern():
+ builder = RegexBuilder().digit()
+ result = builder._lazy_regex()
+ assert result.source == "\\d"
+ assert result.match("7") is not None
diff --git a/tests/builder/diagnose.test.py b/tests/builder/diagnose.test.py
new file mode 100644
index 0000000..c61b3a9
--- /dev/null
+++ b/tests/builder/diagnose.test.py
@@ -0,0 +1,105 @@
+"""Tests for the diagnostic helpers in :mod:`edify.builder.diagnose`."""
+
+from types import SimpleNamespace
+
+import pytest
+
+from edify import Pattern, RegexBuilder
+from edify.builder.diagnose import _fallback, _frame_display_name, diagnose_unfinished
+from edify.errors.comparison import CannotCompareUnfinishedBuilderError
+
+
+def _first_pointer_hint(text: str) -> str | None:
+ for line in text.splitlines():
+ if "->" in line and ".py:" in line:
+ return line
+ return None
+
+
+def test_diagnose_returns_none_when_state_is_fully_specified():
+ finished = RegexBuilder().digit()
+ problem = diagnose_unfinished(finished._state, "left operand")
+ assert problem is None
+
+
+def test_open_group_frame_names_group_in_the_problem_description():
+ unfinished = RegexBuilder().group().digit()
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = unfinished == RegexBuilder()
+ text = str(excinfo.value)
+ assert "open `group()`" in text
+
+
+def test_open_capture_frame_names_capture_in_the_problem_description():
+ unfinished = RegexBuilder().capture().digit()
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = unfinished == RegexBuilder()
+ text = str(excinfo.value)
+ assert "open `capture()`" in text
+
+
+def test_open_named_capture_frame_names_the_name_in_the_problem_description():
+ unfinished = RegexBuilder().named_capture("year").digit()
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = unfinished == RegexBuilder()
+ text = str(excinfo.value)
+ assert 'named_capture("year")' in text
+
+
+def test_open_assert_ahead_frame_names_lookahead():
+ unfinished = RegexBuilder().assert_ahead().digit()
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = unfinished == RegexBuilder()
+ text = str(excinfo.value)
+ assert "assert_ahead()" in text
+
+
+def test_open_assert_not_ahead_frame_names_negative_lookahead():
+ unfinished = RegexBuilder().assert_not_ahead().digit()
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = unfinished == RegexBuilder()
+ text = str(excinfo.value)
+ assert "assert_not_ahead()" in text
+
+
+def test_open_assert_behind_frame_names_lookbehind():
+ unfinished = RegexBuilder().assert_behind().digit()
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = unfinished == RegexBuilder()
+ text = str(excinfo.value)
+ assert "assert_behind()" in text
+
+
+def test_open_assert_not_behind_frame_names_negative_lookbehind():
+ unfinished = RegexBuilder().assert_not_behind().digit()
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = unfinished == RegexBuilder()
+ text = str(excinfo.value)
+ assert "assert_not_behind()" in text
+
+
+def test_frame_display_name_falls_back_to_class_name_for_unknown_container():
+ mystery_class = type("MysteryContainer", (), {})
+ mystery_element = mystery_class()
+ fake_frame = SimpleNamespace(type_node=mystery_element)
+ assert _frame_display_name(fake_frame) == "MysteryContainer"
+
+
+def test_fallback_returns_context_when_provided():
+ dummy_context = "sentinel"
+ assert _fallback(dummy_context) == "sentinel"
+
+
+def test_fallback_returns_placeholder_when_context_is_none():
+ placeholder = _fallback(None)
+ assert placeholder.filename == "<unknown>"
+ assert placeholder.lineno == 0
+ assert placeholder.source_line == ""
+
+
+def test_dangling_quantifier_on_pattern_reports_quantifier_name():
+ unfinished_pattern = Pattern().one_or_more()
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = unfinished_pattern == Pattern()
+ text = str(excinfo.value)
+ assert "pending `.one_or_more()`" in text
diff --git a/tests/builder/equality.test.py b/tests/builder/equality.test.py
index 717d1ae..8482b51 100644
--- a/tests/builder/equality.test.py
+++ b/tests/builder/equality.test.py
@@ -1,6 +1,12 @@
"""Tests for value-based ``__eq__`` and ``__hash__`` on :class:`BuilderCore`."""
+import pytest
+
from edify import Pattern, RegexBuilder
+from edify.errors.comparison import (
+ CannotCompareUnfinishedBuilderError,
+ CannotHashUnfinishedBuilderError,
+)
def test_two_builders_with_the_same_chain_are_equal():
@@ -48,3 +54,84 @@ def test_hash_of_two_flags_that_differ_are_distinct():
b = RegexBuilder().digit()
assert a != b
assert hash(a) != hash(b)
+
+
+def test_equality_uses_emitted_pattern_not_underlying_state():
+ from_chain = RegexBuilder().string("hi")
+ from_string_kwarg = RegexBuilder().string("hi")
+ assert from_chain.to_regex_string() == from_string_kwarg.to_regex_string()
+ assert from_chain == from_string_kwarg
+
+
+def test_hash_uses_emitted_pattern_and_flags_tuple():
+ a = RegexBuilder().string("hi")
+ b = RegexBuilder().string("hi")
+ assert hash(a) == hash((a.to_regex_string(), a._state.flags))
+ assert hash(a) == hash(b)
+
+
+def test_equality_raises_when_left_operand_has_open_frames():
+ left = RegexBuilder().any_of()
+ right = RegexBuilder().digit()
+ with pytest.raises(CannotCompareUnfinishedBuilderError):
+ _ = left == right
+
+
+def test_equality_raises_when_right_operand_has_open_frames():
+ left = RegexBuilder().digit()
+ right = RegexBuilder().any_of()
+ with pytest.raises(CannotCompareUnfinishedBuilderError):
+ _ = left == right
+
+
+def test_equality_raises_when_both_operands_are_unfinished():
+ left = RegexBuilder().any_of()
+ right = RegexBuilder().exactly(3)
+ with pytest.raises(CannotCompareUnfinishedBuilderError):
+ _ = left == right
+
+
+def test_hash_raises_when_frames_are_open():
+ with pytest.raises(CannotHashUnfinishedBuilderError):
+ hash(RegexBuilder().any_of())
+
+
+def test_equality_raises_when_a_quantifier_is_dangling():
+ left = RegexBuilder().exactly(3)
+ right = RegexBuilder().digit()
+ with pytest.raises(CannotCompareUnfinishedBuilderError):
+ _ = left == right
+
+
+def test_hash_raises_when_a_quantifier_is_dangling():
+ with pytest.raises(CannotHashUnfinishedBuilderError):
+ hash(RegexBuilder().exactly(3))
+
+
+def test_compare_error_message_names_the_open_frame():
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = RegexBuilder().named_capture("domain") == RegexBuilder().digit()
+ assert 'named_capture("domain")' in str(excinfo.value)
+ assert "frame opened here" in str(excinfo.value)
+ assert ".end()" in str(excinfo.value)
+
+
+def test_compare_error_message_names_the_dangling_quantifier():
+ with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo:
+ _ = RegexBuilder().digit() == RegexBuilder().exactly(4)
+ assert "exactly(4)" in str(excinfo.value)
+ assert "no element follows" in str(excinfo.value)
+ assert ".digit()" in str(excinfo.value)
+
+
+def test_hash_error_message_names_the_specific_problem():
+ with pytest.raises(CannotHashUnfinishedBuilderError) as excinfo:
+ hash(RegexBuilder().at_least(2))
+ assert "at_least(2)" in str(excinfo.value)
+
+
+def test_error_message_shows_a_source_pointer_when_called_from_user_code():
+ with pytest.raises(CannotHashUnfinishedBuilderError) as excinfo:
+ hash(RegexBuilder().any_of())
+ assert "-->" in str(excinfo.value)
+ assert __file__ in str(excinfo.value)
diff --git a/tests/builder/properties.test.py b/tests/builder/properties.test.py
index 24f5d01..87770fa 100644
--- a/tests/builder/properties.test.py
+++ b/tests/builder/properties.test.py
@@ -1,15 +1,57 @@
-"""Property assertion — no chain ever silently drops a quantifier.
+"""Property assertions — no chain ever silently drops or duplicates an element.
-For any list of ``(quantifier method, args)`` calls, each immediately
-followed by a leaf-element call, the emitted regex is the concatenation
-of ``<element><suffix>`` fragments in the same order — no quantifier is
-lost, none appears twice.
+Every quantifier, group, capture, named capture, and subexpression in a
+randomly-generated composition emits its own fragment exactly once and in
+the correct place in the output regex string.
"""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
from hypothesis import given
from hypothesis import strategies as st
-from edify import RegexBuilder
+from edify import Pattern, RegexBuilder
+
+
+@dataclass(frozen=True)
+class LeafNode:
+ """A single element with an optional quantifier attached before it."""
+
+ element_name: str
+ element_args: tuple[int, ...]
+ element_regex: str
+ quantifier: tuple[str, tuple[int, ...], str] | None
+
+
+@dataclass(frozen=True)
+class GroupNode:
+ """A non-capturing group wrapping ``children``."""
+
+ children: tuple[object, ...]
+
+
+@dataclass(frozen=True)
+class CaptureNode:
+ """An unnamed capture group wrapping ``children``."""
+
+ children: tuple[object, ...]
+
+
+@dataclass(frozen=True)
+class NamedCaptureNode:
+ """A named-capture group wrapping ``children``; the name is assigned deterministically."""
+
+ children: tuple[object, ...]
+
+
+@dataclass(frozen=True)
+class SubexpressionNode:
+ """A subexpression built as a separate ``Pattern`` and merged into the parent."""
+
+ children: tuple[object, ...]
+
_QUANTIFIER_STRATEGIES: list[st.SearchStrategy[tuple[str, tuple[int, ...], str]]] = [
st.just(("optional", (), "?")),
@@ -44,30 +86,145 @@ _ELEMENT_STRATEGIES = [
st.just(("alphanumeric", (), "[a-zA-Z0-9]")),
]
-_quantifier_element_pair = st.tuples(
- st.one_of(*_QUANTIFIER_STRATEGIES),
- st.one_of(*_ELEMENT_STRATEGIES),
-)
+_element_pick = st.one_of(*_ELEMENT_STRATEGIES)
+_quantifier_pick_or_none = st.one_of(st.none(), *_QUANTIFIER_STRATEGIES)
+
+
+_ElementCall = tuple[str, tuple[int, ...], str]
+_QuantifierCall = tuple[str, tuple[int, ...], str]
+
+
+def _build_leaf(pair: tuple[_ElementCall, _QuantifierCall | None]) -> LeafNode:
+ element, quantifier = pair
+ element_name, element_args, element_regex = element
+ return LeafNode(
+ element_name=element_name,
+ element_args=element_args,
+ element_regex=element_regex,
+ quantifier=quantifier,
+ )
+
+
+_leaf_node_strategy = st.tuples(_element_pick, _quantifier_pick_or_none).map(_build_leaf)
+
+def _extend(children_strategy: st.SearchStrategy) -> st.SearchStrategy:
+ children_list = st.lists(children_strategy, min_size=1, max_size=4)
+ group_nodes = children_list.map(lambda children: GroupNode(tuple(children)))
+ capture_nodes = children_list.map(lambda children: CaptureNode(tuple(children)))
+ named_capture_nodes = children_list.map(lambda children: NamedCaptureNode(tuple(children)))
+ subexpression_nodes = children_list.map(lambda children: SubexpressionNode(tuple(children)))
+ return st.one_of(group_nodes, capture_nodes, named_capture_nodes, subexpression_nodes)
-@given(st.lists(_quantifier_element_pair, min_size=1, max_size=8))
-def test_every_quantifier_chain_call_produces_exactly_one_output_quantifier(pairs):
+
+_node_strategy = st.recursive(_leaf_node_strategy, _extend, max_leaves=6)
+
+
+def _apply_sequence(
+ builder,
+ nodes: list[object],
+ name_counter: int,
+) -> tuple[object, str, int]:
+ """Apply ``nodes`` to ``builder`` in order; return the updated triple."""
+ fragments: list[str] = []
+ for node in nodes:
+ builder, fragment, name_counter = _apply_node(builder, node, name_counter)
+ fragments.append(fragment)
+ combined_regex = "".join(fragments)
+ return builder, combined_regex, name_counter
+
+
+def _apply_node(builder, node, name_counter: int) -> tuple[object, str, int]:
+ """Dispatch on ``node`` type, applying it to ``builder`` and returning the fragment."""
+ if isinstance(node, LeafNode):
+ return _apply_leaf(builder, node, name_counter)
+ if isinstance(node, GroupNode):
+ return _apply_group(builder, node, name_counter)
+ if isinstance(node, CaptureNode):
+ return _apply_capture(builder, node, name_counter)
+ if isinstance(node, NamedCaptureNode):
+ return _apply_named_capture(builder, node, name_counter)
+ return _apply_subexpression(builder, node, name_counter)
+
+
+def _apply_leaf(builder, node: LeafNode, name_counter: int) -> tuple[object, str, int]:
+ """Apply a single leaf element, optionally preceded by a quantifier."""
+ if node.quantifier is None:
+ builder_after_element = getattr(builder, node.element_name)(*node.element_args)
+ return builder_after_element, node.element_regex, name_counter
+ quantifier_name, quantifier_args, quantifier_suffix = node.quantifier
+ builder_after_quantifier = getattr(builder, quantifier_name)(*quantifier_args)
+ builder_after_element = getattr(builder_after_quantifier, node.element_name)(*node.element_args)
+ fragment = f"{node.element_regex}{quantifier_suffix}"
+ return builder_after_element, fragment, name_counter
+
+
+def _apply_group(builder, node: GroupNode, name_counter: int) -> tuple[object, str, int]:
+ """Apply a non-capturing group around ``node.children``."""
+ builder_opened = builder.group()
+ builder_inner, inner_regex, name_counter = _apply_sequence(
+ builder_opened, list(node.children), name_counter
+ )
+ builder_closed = builder_inner.end()
+ return builder_closed, f"(?:{inner_regex})", name_counter
+
+
+def _apply_capture(builder, node: CaptureNode, name_counter: int) -> tuple[object, str, int]:
+ """Apply an unnamed capture group around ``node.children``."""
+ builder_opened = builder.capture()
+ builder_inner, inner_regex, name_counter = _apply_sequence(
+ builder_opened, list(node.children), name_counter
+ )
+ builder_closed = builder_inner.end()
+ return builder_closed, f"({inner_regex})", name_counter
+
+
+def _apply_named_capture(
+ builder,
+ node: NamedCaptureNode,
+ name_counter: int,
+) -> tuple[object, str, int]:
+ """Apply a named-capture group with a deterministically-assigned name."""
+ assigned_name = f"n{name_counter}"
+ next_counter = name_counter + 1
+ builder_opened = builder.named_capture(assigned_name)
+ builder_inner, inner_regex, next_counter = _apply_sequence(
+ builder_opened, list(node.children), next_counter
+ )
+ builder_closed = builder_inner.end()
+ return builder_closed, f"(?P<{assigned_name}>{inner_regex})", next_counter
+
+
+def _apply_subexpression(
+ builder,
+ node: SubexpressionNode,
+ name_counter: int,
+) -> tuple[object, str, int]:
+ """Apply a subexpression built as an independent ``Pattern`` and merged in."""
+ sub_pattern = Pattern()
+ sub_pattern_finished, sub_regex, next_counter = _apply_sequence(
+ sub_pattern, list(node.children), name_counter
+ )
+ builder_after_merge = builder.subexpression(sub_pattern_finished)
+ return builder_after_merge, sub_regex, next_counter
+
+
+@given(st.lists(_leaf_node_strategy, min_size=1, max_size=8))
+def test_bare_element_chain_emits_the_concatenation_of_element_fragments(nodes):
+ builder = RegexBuilder()
+ builder_after, expected_regex, _ = _apply_sequence(builder, list(nodes), 0)
+ assert builder_after.to_regex_string() == expected_regex
+
+
+@given(st.lists(_leaf_node_strategy, min_size=1, max_size=8))
+def test_every_quantifier_chain_call_produces_exactly_one_output_quantifier(nodes):
builder = RegexBuilder()
- expected_fragments: list[str] = []
- for quantifier_call, element_call in pairs:
- quantifier_name, quantifier_args, quantifier_suffix = quantifier_call
- element_name, element_args, element_regex = element_call
- builder = getattr(builder, quantifier_name)(*quantifier_args)
- builder = getattr(builder, element_name)(*element_args)
- expected_fragments.append(f"{element_regex}{quantifier_suffix}")
- assert builder.to_regex_string() == "".join(expected_fragments)
-
-
-@given(st.lists(st.one_of(*_ELEMENT_STRATEGIES), min_size=1, max_size=8))
-def test_bare_element_chain_emits_the_concatenation_of_element_fragments(elements):
+ builder_after, expected_regex, _ = _apply_sequence(builder, list(nodes), 0)
+ assert builder_after.to_regex_string() == expected_regex
+
+
+@given(st.lists(_node_strategy, min_size=1, max_size=6))
+def test_composition_over_groups_captures_and_subexpressions_is_faithful(nodes):
builder = RegexBuilder()
- expected_fragments: list[str] = []
- for element_name, element_args, element_regex in elements:
- builder = getattr(builder, element_name)(*element_args)
- expected_fragments.append(element_regex)
- assert builder.to_regex_string() == "".join(expected_fragments)
+ builder_after, expected_regex, _ = _apply_sequence(builder, list(nodes), 0)
+ assert builder_after.to_regex_string() == expected_regex
diff --git a/tests/errors/context.test.py b/tests/errors/context.test.py
new file mode 100644
index 0000000..fff9e09
--- /dev/null
+++ b/tests/errors/context.test.py
@@ -0,0 +1,107 @@
+"""Tests for the caller-source-location capture helpers in :mod:`edify.errors.context`."""
+
+from unittest import mock
+
+from edify.errors.context import (
+ CallerContext,
+ _context_for_frame,
+ capture_caller_context,
+)
+
+
+class _FakeCode:
+ """A stand-in for ``types.CodeType`` used to force specific ``co_positions`` behaviour."""
+
+ def __init__(self, filename: str, positions: list[tuple]) -> None:
+ self.co_filename = filename
+ self._positions = list(positions)
+
+ def co_positions(self):
+ return iter(self._positions)
+
+
+class _FakeFrame:
+ """A stand-in for ``types.FrameType`` shaped for :func:`_context_for_frame`."""
+
+ def __init__(
+ self,
+ filename: str,
+ f_lineno: int,
+ f_lasti: int,
+ positions: list[tuple],
+ ) -> None:
+ self.f_code = _FakeCode(filename, positions)
+ self.f_lineno = f_lineno
+ self.f_lasti = f_lasti
+
+
+def test_capture_caller_context_returns_none_when_every_frame_is_inside_edify():
+ with mock.patch("edify.errors.context.sys._getframe", return_value=None):
+ assert capture_caller_context() is None
+
+
+def test_context_for_frame_uses_lineno_fallback_when_instruction_index_out_of_range():
+ frame = _FakeFrame(
+ filename="/tmp/fake.py",
+ f_lineno=42,
+ f_lasti=999,
+ positions=[],
+ )
+ context = _context_for_frame(frame)
+ assert context.lineno == 42
+ assert context.colno == 1
+ assert context.end_colno == 1
+
+
+def test_context_for_frame_defaults_start_line_when_position_start_line_is_none():
+ frame = _FakeFrame(
+ filename="/tmp/fake.py",
+ f_lineno=99,
+ f_lasti=0,
+ positions=[(None, None, None, None)],
+ )
+ context = _context_for_frame(frame)
+ assert context.lineno == 99
+
+
+def test_context_for_frame_defaults_end_line_when_position_end_line_is_none():
+ frame = _FakeFrame(
+ filename="/tmp/fake.py",
+ f_lineno=7,
+ f_lasti=0,
+ positions=[(5, None, 2, 4)],
+ )
+ context = _context_for_frame(frame)
+ assert context.lineno == 5
+ assert context.colno == 3
+ assert context.end_colno == 5
+
+
+def test_context_for_frame_defaults_start_col_when_position_start_col_is_none():
+ frame = _FakeFrame(
+ filename="/tmp/fake.py",
+ f_lineno=1,
+ f_lasti=0,
+ positions=[(1, 1, None, 5)],
+ )
+ context = _context_for_frame(frame)
+ assert context.colno == 1
+ assert context.end_colno == 6
+
+
+def test_context_for_frame_defaults_end_col_when_position_end_col_is_none():
+ frame = _FakeFrame(
+ filename="/tmp/fake.py",
+ f_lineno=1,
+ f_lasti=0,
+ positions=[(1, 1, 3, None)],
+ )
+ context = _context_for_frame(frame)
+ assert context.colno == 4
+ assert context.end_colno == 4
+
+
+def test_caller_context_dataclass_is_frozen_and_holds_all_five_fields():
+ context = CallerContext(filename="a.py", lineno=1, colno=1, end_colno=5, source_line="x = 1")
+ assert context.filename == "a.py"
+ assert context.source_line == "x = 1"
diff --git a/tests/errors/errors.test.py b/tests/errors/errors.test.py
index 6472469..74acaa4 100644
--- a/tests/errors/errors.test.py
+++ b/tests/errors/errors.test.py
@@ -35,131 +35,212 @@ from edify.errors.structure import (
def test_start_input_already_defined_outside_subexpression():
error = StartInputAlreadyDefinedError()
- assert "already has a start" in str(error)
- assert "ignore_start_and_end" not in str(error)
+ text = str(error)
+ assert "start_of_input has already been added" in text
+ assert "help: remove the duplicate .start_of_input()" in text
+ assert "ignore_start_and_end" not in text
def test_start_input_already_defined_in_subexpression():
error = StartInputAlreadyDefinedError(in_subexpression=True)
- assert "ignore_start_and_end" in str(error)
+ text = str(error)
+ assert "start_of_input has already been added" in text
+ assert "ignore_start_and_end=True" in text
def test_end_input_already_defined_outside_subexpression():
error = EndInputAlreadyDefinedError()
- assert "already has an end" in str(error)
- assert "ignore_start_and_end" not in str(error)
+ text = str(error)
+ assert "end_of_input has already been added" in text
+ assert "help: remove the duplicate .end_of_input()" in text
+ assert "ignore_start_and_end" not in text
def test_end_input_already_defined_in_subexpression():
error = EndInputAlreadyDefinedError(in_subexpression=True)
- assert "ignore_start_and_end" in str(error)
+ text = str(error)
+ assert "end_of_input has already been added" in text
+ assert "ignore_start_and_end=True" in text
def test_cannot_define_start_after_end():
error = CannotDefineStartAfterEndError()
- assert "start of input after defining an end" in str(error)
+ text = str(error)
+ assert "start_of_input cannot follow end_of_input" in text
+ assert "move .start_of_input() to before" in text
-def test_invalid_total_capture_groups_index():
+def test_invalid_total_capture_groups_index_reports_out_of_range():
error = InvalidTotalCaptureGroupsIndexError(5, 3)
- assert "Invalid index #5" in str(error)
- assert "only 3 capture groups" in str(error)
+ text = str(error)
+ assert "back_reference index #5 is out of range" in text
+ assert "3 capture groups" in text
+ assert "valid indices are 1 to 3" in text
+
+
+def test_invalid_total_capture_groups_index_with_no_capture_groups_prompts_add_one():
+ error = InvalidTotalCaptureGroupsIndexError(1, 0)
+ text = str(error)
+ assert "no capture groups yet" in text
+ assert "add a .capture()" in text
def test_must_be_a_string():
error = MustBeAStringError("Name", "int")
- assert "Name must be a string" in str(error)
- assert "int" in str(error)
+ text = str(error)
+ assert "Name must be a string" in text
+ assert "int" in text
+ assert "convert the value with str(...)" in text
def test_must_be_one_character():
error = MustBeOneCharacterError("Value")
- assert "Value must be one character long" in str(error)
+ text = str(error)
+ assert "Value must be one character long" in text
def test_must_be_single_character():
error = MustBeSingleCharacterError("Value", "str")
- assert "Value must be a single character" in str(error)
- assert "str" in str(error)
+ text = str(error)
+ assert "Value must be a single character" in text
+ assert "str" in text
def test_must_be_positive_integer():
error = MustBePositiveIntegerError("count")
- assert "count must be a positive integer" in str(error)
+ text = str(error)
+ assert "count must be a positive integer" in text
def test_must_be_integer_greater_than_zero():
error = MustBeIntegerGreaterThanZeroError("x")
- assert "x must be an integer greater than zero" in str(error)
+ text = str(error)
+ assert "x must be an integer greater than zero" in text
def test_must_be_instance():
error = MustBeInstanceError("Expression", "str", "RegexBuilder")
- assert "Expression must be an instance of RegexBuilder" in str(error)
- assert "str" in str(error)
+ text = str(error)
+ assert "Expression must be an instance of RegexBuilder" in text
+ assert "str" in text
-def test_must_have_a_smaller_value():
+def test_must_have_a_smaller_value_reports_the_codepoints():
error = MustHaveASmallerValueError("z", "a")
- assert "z must have a smaller character value than a" in str(error)
+ text = str(error)
+ assert "range bounds are inverted" in text
+ assert "'z'" in text
+ assert "'a'" in text
+ assert "= 122" in text
+ assert "= 97" in text
def test_must_be_less_than():
error = MustBeLessThanError("X", "Y")
- assert "X must be less than Y" in str(error)
+ text = str(error)
+ assert "X must be less than Y" in text
def test_must_be_at_least_two_operands():
error = MustBeAtLeastTwoOperandsError("any_of")
- assert "any_of requires at least two operands" in str(error)
+ text = str(error)
+ assert "any_of requires at least two operands" in text
def test_must_be_at_least_one_literal():
error = MustBeAtLeastOneLiteralError("one_of")
- assert "one_of requires at least one literal" in str(error)
+ text = str(error)
+ assert "one_of requires at least one literal" in text
def test_name_not_valid():
error = NameNotValidError("bad name")
- assert "Name bad name is not valid" in str(error)
+ text = str(error)
+ assert "'bad name' is not a valid identifier" in text
+ assert "letters, digits, and underscores" in text
def test_cannot_create_duplicate_named_group():
error = CannotCreateDuplicateNamedGroupError("dup")
- assert 'Can not create duplicate named group "dup"' in str(error)
+ text = str(error)
+ assert "named group 'dup' already exists" in text
+ assert ".named_back_reference('dup')" in text
def test_named_group_does_not_exist():
error = NamedGroupDoesNotExistError("missing")
- assert 'Named group "missing" does not exist' in str(error)
+ text = str(error)
+ assert "named group 'missing' does not exist" in text
+ assert ".named_capture('missing')" in text
def test_cannot_end_while_building_root_expression():
error = CannotEndWhileBuildingRootExpressionError()
- assert "Can not end while building the root expression" in str(error)
+ text = str(error)
+ assert "cannot .end() while building the root expression" in text
+ assert "no matching opener" in text or "no frame to close" in text
def test_cannot_call_subexpression():
error = CannotCallSubexpressionError("capture")
- assert "Can not call subexpression" in str(error)
- assert "capture" in str(error)
+ text = str(error)
+ assert "cannot merge a subexpression that has an unclosed frame" in text
+ assert "capture" in text
def test_unknown_element_type():
error = UnknownElementTypeError("WeirdElement")
- assert "WeirdElement" in str(error)
+ text = str(error)
+ assert "unknown element type 'WeirdElement'" in text
def test_non_fusable_element():
error = NonFusableElementError("DigitElement")
- assert "Cannot fuse element of type DigitElement" in str(error)
+ text = str(error)
+ assert "cannot fuse element 'DigitElement' into a character class" in text
def test_unexpected_frame_type():
error = UnexpectedFrameTypeError("DigitElement")
- assert "Stack frame anchored at unexpected element type DigitElement" in str(error)
+ text = str(error)
+ assert "stack frame anchored at unexpected element 'DigitElement'" in text
def test_failed_to_compile_regex():
error = FailedToCompileRegexError("missing )")
- assert "Cannot compile regex: missing )" in str(error)
+ text = str(error)
+ assert "rejected by the re engine" in text
+ assert "missing )" in text
+
+
+def test_every_annotated_error_message_starts_with_error_prefix():
+ errors = [
+ StartInputAlreadyDefinedError(),
+ CannotDefineStartAfterEndError(),
+ EndInputAlreadyDefinedError(),
+ InvalidTotalCaptureGroupsIndexError(1, 0),
+ MustBeAStringError("X", "int"),
+ MustBeOneCharacterError("X"),
+ MustBeSingleCharacterError("X", "int"),
+ MustBePositiveIntegerError("X"),
+ MustBeIntegerGreaterThanZeroError("X"),
+ MustBeInstanceError("X", "int", "Y"),
+ MustHaveASmallerValueError("z", "a"),
+ MustBeLessThanError("A", "B"),
+ MustBeAtLeastTwoOperandsError("f"),
+ MustBeAtLeastOneLiteralError("f"),
+ NameNotValidError("x"),
+ CannotCreateDuplicateNamedGroupError("x"),
+ NamedGroupDoesNotExistError("x"),
+ CannotEndWhileBuildingRootExpressionError(),
+ CannotCallSubexpressionError("capture"),
+ UnknownElementTypeError("X"),
+ NonFusableElementError("X"),
+ UnexpectedFrameTypeError("X"),
+ FailedToCompileRegexError("boom"),
+ ]
+ for error in errors:
+ text = str(error)
+ assert text.startswith("error:")
+ assert "help:" in text
+ assert "= note:" in text
diff --git a/tests/errors/formatting.test.py b/tests/errors/formatting.test.py
new file mode 100644
index 0000000..314aff1
--- /dev/null
+++ b/tests/errors/formatting.test.py
@@ -0,0 +1,127 @@
+"""Tests for the message formatter helpers in :mod:`edify.errors.formatting`."""
+
+from unittest import mock
+
+from edify.errors.context import CallerContext
+from edify.errors.formatting import (
+ FixInsertion,
+ Problem,
+ compose_annotated_message,
+ format_error,
+ format_fix_block,
+ format_help_header,
+ format_note_line,
+ format_pointer_block,
+ format_problem,
+ format_problem_header,
+)
+
+
+def _context(source_line: str = " x = do_the_thing(1, 2, 3)", colno: int = 9) -> CallerContext:
+ return CallerContext(
+ filename="/tmp/user_code.py",
+ lineno=42,
+ colno=colno,
+ end_colno=colno + 15,
+ source_line=source_line,
+ )
+
+
+def test_format_error_with_only_summary_returns_bare_header():
+ assert format_error("boom") == "error: boom"
+
+
+def test_format_error_with_summary_and_blocks_joins_them_with_blank_line():
+ output = format_error("boom", "first block", "second block")
+ assert output == "error: boom\n\nfirst block\n\nsecond block"
+
+
+def test_format_error_skips_empty_blocks():
+ output = format_error("boom", "", "only real block", "")
+ assert output == "error: boom\n\nonly real block"
+
+
+def test_format_pointer_block_draws_caret_at_caller_column():
+ context = _context()
+ block = format_pointer_block(context, "here")
+ lines = block.splitlines()
+ assert lines[0] == " --> /tmp/user_code.py:42:9"
+ assert "^^^^^^^^^^^^^^^ here" in block
+
+
+def test_format_pointer_block_has_at_least_one_caret_when_span_is_empty():
+ context = CallerContext(
+ filename="/tmp/x.py",
+ lineno=1,
+ colno=5,
+ end_colno=5,
+ source_line="abcde",
+ )
+ block = format_pointer_block(context, "point")
+ assert "^ point" in block
+
+
+def test_format_note_line_prepends_the_note_prefix():
+ assert format_note_line("something is off") == " = note: something is off"
+
+
+def test_format_problem_header_prepends_problem_prefix():
+ assert format_problem_header("stuff") == "problem: stuff"
+
+
+def test_format_help_header_prepends_help_prefix():
+ assert format_help_header("do X") == "help: do X"
+
+
+def test_format_fix_block_inserts_text_at_the_target_column():
+ context = _context(source_line="value = compute()", colno=1)
+ insertion = FixInsertion(column=9, text="new_")
+ block = format_fix_block(context, insertion)
+ assert "value = new_compute()" in block
+ assert "++++" in block
+
+
+def test_format_problem_composes_header_pointer_help_and_fix():
+ problem_context = _context(source_line="pattern = build()", colno=11)
+ fix_context = _context(source_line="pattern = build()", colno=11)
+ fix_insertion = FixInsertion(column=11, text=".digit()")
+ problem = Problem(
+ description="missing operand",
+ problem_context=problem_context,
+ problem_hint="no operand added",
+ help_summary="add .digit() before .build()",
+ fix_context=fix_context,
+ fix_insertion=fix_insertion,
+ )
+ output = format_problem(problem)
+ assert "problem: missing operand" in output
+ assert "no operand added" in output
+ assert "help: add .digit() before .build()" in output
+ assert "++++++++" in output
+
+
+def test_compose_annotated_message_when_caller_context_is_available():
+ output = compose_annotated_message(
+ summary="bad thing happened",
+ trigger_hint="right here",
+ note="because reasons",
+ help_line="help: do X",
+ )
+ assert output.startswith("error: bad thing happened")
+ assert "-->" in output
+ assert "= note: because reasons" in output
+ assert output.rstrip().endswith("help: do X")
+
+
+def test_compose_annotated_message_falls_back_when_caller_context_is_none():
+ with mock.patch("edify.errors.formatting.capture_caller_context", return_value=None):
+ output = compose_annotated_message(
+ summary="bad thing happened",
+ trigger_hint="here",
+ note="because reasons",
+ help_line="help: do X",
+ )
+ assert output.startswith("error: bad thing happened")
+ assert "-->" not in output
+ assert "= note: because reasons" in output
+ assert output.rstrip().endswith("help: do X")
diff --git a/tests/errors/quantifier.test.py b/tests/errors/quantifier.test.py
index 27b5ddf..1256a68 100644
--- a/tests/errors/quantifier.test.py
+++ b/tests/errors/quantifier.test.py
@@ -42,14 +42,15 @@ def test_to_regex_raises_when_a_bare_quantifier_has_no_operand():
def test_dangling_message_hints_at_appending_an_operand():
- with pytest.raises(DanglingQuantifierError, match="Append an element"):
+ with pytest.raises(DanglingQuantifierError, match="append the element"):
RegexBuilder().exactly(3).to_regex_string()
def test_dangling_quantifier_error_message_contains_expected_text():
error = DanglingQuantifierError()
- assert "Dangling quantifier" in str(error)
- assert "no operand" in str(error)
+ text = str(error)
+ assert "dangling quantifier" in text
+ assert "no operand" in text
def test_stacking_one_or_more_over_exactly_raises():
@@ -84,5 +85,6 @@ def test_a_valid_quantifier_element_quantifier_element_chain_works():
def test_stacked_quantifier_error_message_contains_expected_text():
error = StackedQuantifierError()
- assert "stack" in str(error)
- assert "pending" in str(error)
+ text = str(error)
+ assert "stack a quantifier" in text
+ assert "pending quantifier" in text
diff --git a/tests/introspect/ascii.test.py b/tests/introspect/ascii.test.py
new file mode 100644
index 0000000..c2e3d68
--- /dev/null
+++ b/tests/introspect/ascii.test.py
@@ -0,0 +1,531 @@
+"""Tests for the ASCII railroad-diagram renderer in :mod:`edify.introspect.ascii`."""
+
+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.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..6444fb2
--- /dev/null
+++ b/tests/introspect/explain.test.py
@@ -0,0 +1,801 @@
+"""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..aad8378
--- /dev/null
+++ b/tests/introspect/graphviz.test.py
@@ -0,0 +1,355 @@
+"""Tests for the Graphviz DOT / SVG renderer in :mod:`edify.introspect.graphviz`."""
+
+import builtins
+import importlib
+import sys
+
+import pytest
+
+from edify import 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..7af8386
--- /dev/null
+++ b/tests/introspect/verbose.test.py
@@ -0,0 +1,453 @@
+"""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_capital_s():
+ output = _verbose(NonWhitespaceCharElement())
+ assert output.startswith("\\S")
+
+
+def test_non_digit_reads_as_backslash_capital_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_capital_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_capital_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_alpha_class():
+ output = _verbose(LetterElement())
+ assert output.startswith("[a-zA-Z]")
+
+
+def test_uppercase_reads_as_uppercase_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_alphanum_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
diff --git a/uv.lock b/uv.lock
index 9e67009..ccd1eb1 100644
--- a/uv.lock
+++ b/uv.lock
@@ -233,8 +233,14 @@ wheels = [
name = "edify"
source = { editable = "." }
+[package.optional-dependencies]
+graphviz = [
+ { name = "graphviz" },
+]
+
[package.dev-dependencies]
dev = [
+ { name = "graphviz" },
{ name = "hypothesis" },
{ name = "pytest" },
{ name = "pytest-cov" },
@@ -247,9 +253,12 @@ docs = [
]
[package.metadata]
+requires-dist = [{ name = "graphviz", marker = "extra == 'graphviz'", specifier = ">=0.20" }]
+provides-extras = ["graphviz"]
[package.metadata.requires-dev]
dev = [
+ { name = "graphviz", specifier = ">=0.20" },
{ name = "hypothesis", specifier = ">=6.100" },
{ name = "pytest", specifier = ">=8.0" },
{ name = "pytest-cov", specifier = ">=5.0" },
@@ -261,6 +270,15 @@ docs = [
]
[[package]]
+name = "graphviz"
+version = "0.21"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/b3/3ac91e9be6b761a4b30d66ff165e54439dcd48b83f4e20d644867215f6ca/graphviz-0.21.tar.gz", hash = "sha256:20743e7183be82aaaa8ad6c93f8893c923bd6658a04c32ee115edb3c8a835f78", size = 200434, upload-time = "2025-06-15T09:35:05.824Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl", hash = "sha256:54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42", size = 47300, upload-time = "2025-06-15T09:35:04.433Z" },
+]
+
+[[package]]
name = "hypothesis"
version = "6.155.7"
source = { registry = "https://pypi.org/simple" }