From 9d80c99432c0c7c5f184cbe7bb25513aace01a7e Mon Sep 17 00:00:00 2001 From: natsuoto <279971144+natsuoto@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:18:09 +0530 Subject: feat(errors): annotated error messages with caller-context pointers --- edify/errors/anchors.py | 73 ++++++++++---- edify/errors/captures.py | 37 +++++-- edify/errors/comparison.py | 66 ++++++++++++ edify/errors/context.py | 82 +++++++++++++++ edify/errors/formatting.py | 133 ++++++++++++++++++++++++ edify/errors/input.py | 217 ++++++++++++++++++++++++++++++++++------ edify/errors/internal.py | 88 +++++++++++++--- edify/errors/introspect.py | 89 ++++++++++++++++ edify/errors/naming.py | 66 +++++++++--- edify/errors/quantifier.py | 41 +++++--- edify/errors/structure.py | 40 ++++++-- tests/errors/errors.test.py | 147 ++++++++++++++++++++------- tests/errors/formatting.test.py | 129 ++++++++++++++++++++++++ tests/errors/quantifier.test.py | 12 ++- 14 files changed, 1071 insertions(+), 149 deletions(-) create mode 100644 edify/errors/comparison.py create mode 100644 edify/errors/context.py create mode 100644 edify/errors/formatting.py create mode 100644 edify/errors/introspect.py create mode 100644 tests/errors/formatting.test.py 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..9f052cf 100644 --- a/edify/errors/captures.py +++ b/edify/errors/captures.py @@ -1,18 +1,39 @@ -"""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: `` 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: `` header line.""" + return f"problem: {description}" + + +def format_help_header(help: str) -> str: + """Return the ``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..a709772 100644 --- a/edify/errors/input.py +++ b/edify/errors/input.py @@ -1,96 +1,255 @@ -"""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} " + f"(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..4181d83 100644 --- a/edify/errors/internal.py +++ b/edify/errors/internal.py @@ -1,42 +1,102 @@ -"""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 " + f"(CharElement, RangeElement, AnyOfCharsElement); {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..835c9d5 100644 --- a/edify/errors/naming.py +++ b/edify/errors/naming.py @@ -1,37 +1,75 @@ -"""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/tests/errors/errors.test.py b/tests/errors/errors.test.py index 6472469..5b48165 100644 --- a/tests/errors/errors.test.py +++ b/tests/errors/errors.test.py @@ -35,131 +35,210 @@ 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 and "'a'" in text + assert "= 122" in text and "= 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..8cdf0ab --- /dev/null +++ b/tests/errors/formatting.test.py @@ -0,0 +1,129 @@ +"""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 -- cgit v1.2.3 From 74d6b084993feb9c9dc1a72abeb3cf2497ac9171 Mon Sep 17 00:00:00 2001 From: natsuoto <279971144+natsuoto@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:18:36 +0530 Subject: feat(builder): capture caller source location on chain methods --- edify/builder/mixins/quantifiers.py | 68 ++++++++++++++++++++++--------------- edify/builder/types/frame.py | 63 ++++++++++++++++++++++++---------- edify/builder/types/protocol.py | 13 +++++++ edify/builder/types/state.py | 43 +++++++++++++++-------- 4 files changed, 128 insertions(+), 59 deletions(-) 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/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) -- cgit v1.2.3 From 1ca22160898b7c9bcc945ac8d59d71b72f8a7116 Mon Sep 17 00:00:00 2001 From: natsuoto <279971144+natsuoto@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:19:01 +0530 Subject: feat(builder): lazy Regex compile cache and __eq__/__hash__ diagnose unfinished builders (#127, #137) --- edify/builder/core.py | 61 +++++++++++++++-------- edify/builder/diagnose.py | 106 ++++++++++++++++++++++++++++++++++++++++ edify/builder/mixins/matcher.py | 25 +++++----- tests/builder/cache.test.py | 69 ++++++++++++++++++++++++++ tests/builder/equality.test.py | 87 +++++++++++++++++++++++++++++++++ 5 files changed, 316 insertions(+), 32 deletions(-) create mode 100644 edify/builder/diagnose.py create mode 100644 tests/builder/cache.test.py 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 = "" @@ -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="", + lineno=0, + colno=1, + end_colno=1, + source_line="", + ) 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/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/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) -- cgit v1.2.3 From 127471e2c2ed56cc7d862d0b960bd0333e2179fc Mon Sep 17 00:00:00 2001 From: natsuoto <279971144+natsuoto@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:19:08 +0530 Subject: feat(result): Regex wrapper with AST, delegation, and introspection API (#133, #145, #146, #147, #148) --- edify/builder/mixins/terminals.py | 6 +- edify/introspect/__init__.py | 5 + edify/introspect/ascii.py | 466 +++++++++++++++++++++ edify/introspect/explain.py | 571 +++++++++++++++++++++++++ edify/introspect/graphviz.py | 275 +++++++++++++ edify/introspect/types.py | 35 ++ edify/introspect/verbose.py | 363 ++++++++++++++++ edify/introspect/visualize.py | 33 ++ edify/result/regex.py | 85 +++- tests/introspect/ascii.test.py | 561 +++++++++++++++++++++++++ tests/introspect/explain.test.py | 823 +++++++++++++++++++++++++++++++++++++ tests/introspect/graphviz.test.py | 359 ++++++++++++++++ tests/introspect/verbose.test.py | 459 +++++++++++++++++++++ tests/introspect/visualize.test.py | 92 +++++ tests/result/regex.test.py | 29 ++ 15 files changed, 4145 insertions(+), 17 deletions(-) create mode 100644 edify/introspect/__init__.py create mode 100644 edify/introspect/ascii.py create mode 100644 edify/introspect/explain.py create mode 100644 edify/introspect/graphviz.py create mode 100644 edify/introspect/types.py create mode 100644 edify/introspect/verbose.py create mode 100644 edify/introspect/visualize.py create mode 100644 tests/introspect/ascii.test.py create mode 100644 tests/introspect/explain.test.py create mode 100644 tests/introspect/graphviz.test.py create mode 100644 tests/introspect/verbose.test.py create mode 100644 tests/introspect/visualize.test.py diff --git a/edify/builder/mixins/terminals.py b/edify/builder/mixins/terminals.py index 0887afa..decf204 100644 --- a/edify/builder/mixins/terminals.py +++ b/edify/builder/mixins/terminals.py @@ -79,7 +79,11 @@ class TerminalsMixin(BuilderProtocol): compiled_pattern = re.compile(pattern_string, flags=flag_bitmask) except re.error as compile_error: raise FailedToCompileRegexError(str(compile_error)) from compile_error - return Regex(source=pattern_string, compiled=compiled_pattern) + return Regex( + source=pattern_string, + compiled=compiled_pattern, + elements=tuple(self._state.top_frame.children), + ) def _ensure_fully_specified(builder: BuilderProtocol) -> None: diff --git a/edify/introspect/__init__.py b/edify/introspect/__init__.py new file mode 100644 index 0000000..061282e --- /dev/null +++ b/edify/introspect/__init__.py @@ -0,0 +1,5 @@ +from edify.introspect.explain import explain_elements +from edify.introspect.verbose import verbose_elements +from edify.introspect.visualize import visualize_elements + +__all__ = ["explain_elements", "verbose_elements", "visualize_elements"] diff --git a/edify/introspect/ascii.py b/edify/introspect/ascii.py new file mode 100644 index 0000000..e9b2b67 --- /dev/null +++ b/edify/introspect/ascii.py @@ -0,0 +1,466 @@ +"""ASCII railroad-diagram renderer for edify AST elements.""" + +from __future__ import annotations + +import re + +from edify.elements.types.base import BaseElement +from edify.elements.types.captures import ( + BackReferenceElement, + CaptureElement, + NamedBackReferenceElement, + NamedCaptureElement, +) +from edify.elements.types.chars import ( + AnyOfCharsElement, + AnythingButCharsElement, + AnythingButRangeElement, + AnythingButStringElement, + CharElement, + RangeElement, + StringElement, +) +from edify.elements.types.groups import ( + AnyOfElement, + AssertAheadElement, + AssertBehindElement, + AssertNotAheadElement, + AssertNotBehindElement, + GroupElement, + SubexpressionElement, +) +from edify.elements.types.leaves import ( + AlphanumericElement, + AnyCharElement, + CarriageReturnElement, + DigitElement, + EndOfInputElement, + LetterElement, + LowercaseElement, + NewLineElement, + NonDigitElement, + NonWhitespaceCharElement, + NonWordBoundaryElement, + NonWordElement, + NoopElement, + NullByteElement, + StartOfInputElement, + TabElement, + UppercaseElement, + WhitespaceCharElement, + WordBoundaryElement, + WordElement, +) +from edify.elements.types.quantifiers import ( + AtLeastElement, + AtMostElement, + BetweenElement, + BetweenLazyElement, + ExactlyElement, + OneOrMoreElement, + OneOrMoreLazyElement, + OptionalElement, + ZeroOrMoreElement, + ZeroOrMoreLazyElement, +) +from edify.introspect.types import Diagram + +_EDGE = "-->" +_EDGE_WIDTH = len(_EDGE) +_INDENT = 3 + + +def render_ascii(elements: tuple[BaseElement, ...]) -> str: + """Return an ASCII railroad diagram for ``elements`` as a multi-line string.""" + start_box = _boxed("START") + end_box = _boxed("END") + parts: list[Diagram] = [start_box] + for element in elements: + parts.append(_element_diagram(element)) + parts.append(end_box) + diagram = _join_horizontal(parts) + indented = _indent_left(diagram, _INDENT) + return "\n".join(indented.rows) + + +def _boxed(label: str) -> Diagram: + """Return a three-row boxed diagram with ``label`` centered in a one-space margin.""" + content = f" {label} " + border = "+" + "-" * len(content) + "+" + middle = "|" + content + "|" + return Diagram(rows=(border, middle, border), entry_row=1, width=len(border)) + + +def _element_diagram(element: BaseElement) -> Diagram: + """Return the diagram for a single AST ``element``.""" + inline = _inline_label(element) + if inline is not None: + return _boxed(inline) + quantifier = _quantifier_diagram(element) + if quantifier is not None: + return quantifier + if isinstance(element, AnyOfElement): + return _alternation_diagram(element.children) + if isinstance(element, SubexpressionElement): + return _sequence_diagram(element.children) + if isinstance(element, GroupElement): + return _annotated_sequence(element.children, "grouped") + if isinstance(element, CaptureElement): + return _annotated_sequence(element.children, "captured") + if isinstance(element, NamedCaptureElement): + return _annotated_sequence(element.children, f'saved as "{element.name}"') + if isinstance(element, BackReferenceElement): + return _boxed(f"match same text as group {element.index}") + if isinstance(element, NamedBackReferenceElement): + return _boxed(f'match same text as "{element.name}"') + if isinstance(element, AssertAheadElement): + return _annotated_sequence(element.children, "must be followed by") + if isinstance(element, AssertNotAheadElement): + return _annotated_sequence(element.children, "must NOT be followed by") + if isinstance(element, AssertBehindElement): + return _annotated_sequence(element.children, "must be preceded by") + if isinstance(element, AssertNotBehindElement): + return _annotated_sequence(element.children, "must NOT be preceded by") + return _boxed(f"?{type(element).__name__}") + + +def _inline_label(element: BaseElement) -> str | None: + """Return a short plain-English label for a leaf or character element, or ``None``.""" + leaf = _leaf_label(element) + if leaf is not None: + return leaf + return _char_label(element) + + +def _leaf_label(element: BaseElement) -> str | None: + """Return the plain-English label for a leaf element, or ``None``.""" + if isinstance(element, StartOfInputElement): + return "text starts here" + if isinstance(element, EndOfInputElement): + return "text ends here" + if isinstance(element, AnyCharElement): + return "any character" + if isinstance(element, WhitespaceCharElement): + return "whitespace" + if isinstance(element, NonWhitespaceCharElement): + return "non-whitespace" + if isinstance(element, DigitElement): + return "digit" + if isinstance(element, NonDigitElement): + return "non-digit character" + if isinstance(element, WordElement): + return "word character" + if isinstance(element, NonWordElement): + return "non-word character" + if isinstance(element, WordBoundaryElement): + return "word boundary" + if isinstance(element, NonWordBoundaryElement): + return "non-word boundary" + if isinstance(element, NewLineElement): + return "newline" + if isinstance(element, CarriageReturnElement): + return "carriage return" + if isinstance(element, TabElement): + return "tab" + if isinstance(element, NullByteElement): + return "null byte" + if isinstance(element, LetterElement): + return "letter" + if isinstance(element, UppercaseElement): + return "uppercase letter" + if isinstance(element, LowercaseElement): + return "lowercase letter" + if isinstance(element, AlphanumericElement): + return "letter or digit" + if isinstance(element, NoopElement): + return "no-op" + return None + + +def _char_label(element: BaseElement) -> str | None: + """Return the plain-English label for a character-shaped element, or ``None``.""" + if isinstance(element, CharElement): + return f'"{_display_string(element.value)}"' + if isinstance(element, StringElement): + return f'"{_display_string(element.value)}"' + if isinstance(element, RangeElement): + return f'any character from "{element.start}" to "{element.end}"' + if isinstance(element, AnyOfCharsElement): + return f'any of "{_display_string(element.value)}"' + if isinstance(element, AnythingButCharsElement): + return f'anything except "{_display_string(element.value)}"' + if isinstance(element, AnythingButRangeElement): + return f'anything outside "{element.start}"-"{element.end}"' + if isinstance(element, AnythingButStringElement): + return f'anything except the string "{_display_string(element.value)}"' + return None + + +def _display_string(value: str) -> str: + """Return ``value`` with regex-escape backslashes stripped for human display.""" + return re.sub(r"\\(.)", r"\1", value) + + +def _quantifier_diagram(element: BaseElement) -> Diagram | None: + """Return the diagram for a quantifier wrapping a child, or ``None`` when not a quantifier.""" + label = _quantifier_label(element) + if label is None: + return None + return _boxed(label) + + +def _quantifier_label(element: BaseElement) -> str | None: + """Return a single-line plain-English label for a quantifier, or ``None``.""" + if isinstance(element, ExactlyElement): + return _phrase_count(element.times, element.child) + if isinstance(element, OneOrMoreElement): + return f"one or more {_child_plural(element.child)}" + if isinstance(element, OneOrMoreLazyElement): + return f"one or more {_child_plural(element.child)} (lazy)" + if isinstance(element, ZeroOrMoreElement): + return f"zero or more {_child_plural(element.child)}" + if isinstance(element, ZeroOrMoreLazyElement): + return f"zero or more {_child_plural(element.child)} (lazy)" + if isinstance(element, OptionalElement): + return f"optional {_child_singular(element.child)}" + if isinstance(element, AtLeastElement): + return f"at least {element.times} {_child_plural(element.child)}" + if isinstance(element, AtMostElement): + return f"at most {element.times} {_child_plural(element.child)}" + if isinstance(element, BetweenElement): + return ( + f"{element.lower} to {element.upper} {_child_plural(element.child)}" + ) + if isinstance(element, BetweenLazyElement): + return ( + f"{element.lower} to {element.upper} {_child_plural(element.child)} (lazy)" + ) + return None + + +def _child_singular(child: BaseElement) -> str: + """Return the singular plain-English label for a quantifier's child.""" + label = _inline_label(child) + if label is not None: + return label + return "group" + + +def _child_plural(child: BaseElement) -> str: + """Return the plural plain-English label for a quantifier's child.""" + return _pluralize(_child_singular(child)) + + +def _phrase_count(times: int, child: BaseElement) -> str: + """Return a ``"{times} {label}"`` phrase, pluralized when ``times`` is not one.""" + label = _child_singular(child) + if times == 1: + return f"1 {label}" + return f"{times} {_pluralize(label)}" + + +def _pluralize(label: str) -> str: + """Return an English plural of ``label`` (respecting literal-string quotes).""" + if label.startswith('"') and label.endswith('"'): + return label + if label.endswith(("s", "x", "z", "ch", "sh")): + return label + "es" + if len(label) >= 2 and label.endswith("y") and label[-2] not in "aeiou": + return label[:-1] + "ies" + return label + "s" + + +def _sequence_diagram(children: tuple[BaseElement, ...]) -> Diagram: + """Return the horizontal join of ``children`` diagrams with edge connectors.""" + if not children: + return _boxed("nothing") + diagrams = [_element_diagram(child) for child in children] + return _join_horizontal(diagrams) + + +def _annotated_sequence(children: tuple[BaseElement, ...], annotation: str) -> Diagram: + """Return the diagram for ``children`` with ``annotation`` displayed under the middle.""" + inner = _sequence_diagram(children) + caption = f"({annotation})" + return _caption_below(inner, caption) + + +def _caption_below(diagram: Diagram, caption: str) -> Diagram: + """Return ``diagram`` with a caption row appended below and centered under it. + + When ``caption`` is longer than ``diagram.width``, the diagram is widened; the + entry row is padded with dashes so the horizontal flow line continues to the + new right edge, and the caption is centered under the widened diagram. + """ + new_width = max(diagram.width, len(caption)) + extra = new_width - diagram.width + padded_rows: list[str] = [] + for row_index, row in enumerate(diagram.rows): + if extra == 0: + padded_rows.append(row) + elif row_index == diagram.entry_row: + padded_rows.append(row + "-" * extra) + else: + padded_rows.append(row + " " * extra) + left_pad = (new_width - len(caption)) // 2 + right_pad = new_width - left_pad - len(caption) + caption_row = " " * left_pad + caption + " " * right_pad + return Diagram( + rows=tuple(padded_rows) + (caption_row,), + entry_row=diagram.entry_row, + width=new_width, + ) + + +def _alternation_diagram(children: tuple[BaseElement, ...]) -> Diagram: + """Return a vertical fork/merge diagram over ``children`` alternatives.""" + if not children: + return _boxed("nothing") + branches = [_element_diagram(child) for child in children] + branch_widths = [branch.width for branch in branches] + branch_width = max(branch_widths) + padded = [_widen(branch, branch_width) for branch in branches] + branch_entry_rows: list[int] = [] + body_rows: list[str] = [] + for index, branch in enumerate(padded): + base_row = len(body_rows) + for row_index, row in enumerate(branch.rows): + if row_index == branch.entry_row: + body_rows.append(f"+--->{row}----+") + else: + body_rows.append(f"| {row} |") + branch_entry_rows.append(base_row + branch.entry_row) + if index < len(padded) - 1: + body_rows.append(f"| {' ' * branch_width} |") + body_rows = _clip_trunk_above(body_rows, branch_entry_rows[0]) + body_rows = _clip_trunk_below(body_rows, branch_entry_rows[-1]) + entry_row = branch_entry_rows[len(branch_entry_rows) // 2] + return Diagram( + rows=tuple(body_rows), + entry_row=entry_row, + width=branch_width + 10, + ) + + +def _clip_trunk_above(rows: list[str], first_entry: int) -> list[str]: + """Replace trunk ``|`` characters in rows above ``first_entry`` with spaces.""" + result = list(rows) + for index in range(first_entry): + row = result[index] + result[index] = " " + row[1:-1] + " " + return result + + +def _clip_trunk_below(rows: list[str], last_entry: int) -> list[str]: + """Replace trunk ``|`` characters in rows below ``last_entry`` with spaces.""" + result = list(rows) + for index in range(last_entry + 1, len(result)): + row = result[index] + result[index] = " " + row[1:-1] + " " + return result + + +def _pad_right(diagram: Diagram, target_width: int) -> Diagram: + """Return ``diagram`` right-padded with spaces so every row reaches ``target_width``.""" + if diagram.width >= target_width: + return diagram + extra = target_width - diagram.width + padded_rows_list = [row + " " * extra for row in diagram.rows] + new_rows = tuple(padded_rows_list) + return Diagram(rows=new_rows, entry_row=diagram.entry_row, width=target_width) + + +def _widen(diagram: Diagram, target_width: int) -> Diagram: + """Return ``diagram`` widened to ``target_width``, extending single-box borders in place.""" + if diagram.width >= target_width: + return diagram + if _looks_like_single_box(diagram): + return _widen_box(diagram, target_width) + return _pad_right(diagram, target_width) + + +def _looks_like_single_box(diagram: Diagram) -> bool: + """Return ``True`` when ``diagram`` is a plain three-row ``+---+ | X | +---+`` box.""" + if len(diagram.rows) != 3 or diagram.entry_row != 1: + return False + top, middle, bottom = diagram.rows + if top != bottom: + return False + if not (top.startswith("+") and top.endswith("+")): + return False + if not (middle.startswith("|") and middle.endswith("|")): + return False + return set(top[1:-1]) == {"-"} + + +def _widen_box(diagram: Diagram, target_width: int) -> Diagram: + """Return a simple box ``diagram`` widened to ``target_width`` by extending its borders.""" + extra = target_width - diagram.width + top, middle, _bottom = diagram.rows + new_border = "+" + "-" * (target_width - 2) + "+" + interior = middle[1:-1] + new_middle = "|" + interior + " " * extra + "|" + return Diagram( + rows=(new_border, new_middle, new_border), + entry_row=1, + width=target_width, + ) + + +def _join_horizontal(diagrams: list[Diagram]) -> Diagram: + """Return the horizontal concatenation of ``diagrams`` with edge connectors between.""" + result = diagrams[0] + for next_diagram in diagrams[1:]: + result = _join_two(result, next_diagram) + return result + + +def _join_two(left: Diagram, right: Diagram) -> Diagram: + """Return ``left`` and ``right`` joined on their shared entry row with an ``_EDGE`` connector.""" + target_entry = max(left.entry_row, right.entry_row) + left_shifted = _align_entry_row(left, target_entry) + right_shifted = _align_entry_row(right, target_entry) + height = max(len(left_shifted.rows), len(right_shifted.rows)) + left_expanded = _pad_below(left_shifted, height) + right_expanded = _pad_below(right_shifted, height) + new_rows: list[str] = [] + for row_index in range(height): + connector = _EDGE if row_index == target_entry else " " * _EDGE_WIDTH + new_rows.append( + left_expanded.rows[row_index] + connector + right_expanded.rows[row_index] + ) + combined_width = left_expanded.width + _EDGE_WIDTH + right_expanded.width + return Diagram(rows=tuple(new_rows), entry_row=target_entry, width=combined_width) + + +def _align_entry_row(diagram: Diagram, target_entry_row: int) -> Diagram: + """Return ``diagram`` with blank rows above so its entry row lands at ``target_entry_row``.""" + if diagram.entry_row >= target_entry_row: + return diagram + extra_rows_above = target_entry_row - diagram.entry_row + blank = " " * diagram.width + new_rows = (blank,) * extra_rows_above + diagram.rows + return Diagram(rows=new_rows, entry_row=target_entry_row, width=diagram.width) + + +def _pad_below(diagram: Diagram, target_height: int) -> Diagram: + """Return ``diagram`` with blank rows appended so it reaches ``target_height`` rows total.""" + current_height = len(diagram.rows) + if current_height >= target_height: + return diagram + extra_rows_below = target_height - current_height + blank = " " * diagram.width + new_rows = diagram.rows + (blank,) * extra_rows_below + return Diagram(rows=new_rows, entry_row=diagram.entry_row, width=diagram.width) + + +def _indent_left(diagram: Diagram, spaces: int) -> Diagram: + """Return ``diagram`` with ``spaces`` blank columns prepended to every row.""" + prefix = " " * spaces + indented_rows_list = [prefix + row for row in diagram.rows] + new_rows = tuple(indented_rows_list) + return Diagram( + rows=new_rows, + entry_row=diagram.entry_row, + width=diagram.width + spaces, + ) diff --git a/edify/introspect/explain.py b/edify/introspect/explain.py new file mode 100644 index 0000000..c4a935b --- /dev/null +++ b/edify/introspect/explain.py @@ -0,0 +1,571 @@ +"""Plain-English explanation renderer for edify AST elements.""" + +from __future__ import annotations + +from edify.elements.types.base import BaseElement +from edify.elements.types.captures import ( + BackReferenceElement, + CaptureElement, + NamedBackReferenceElement, + NamedCaptureElement, +) +from edify.elements.types.chars import ( + AnyOfCharsElement, + AnythingButCharsElement, + AnythingButRangeElement, + AnythingButStringElement, + CharElement, + RangeElement, + StringElement, +) +from edify.elements.types.groups import ( + AnyOfElement, + AssertAheadElement, + AssertBehindElement, + AssertNotAheadElement, + AssertNotBehindElement, + GroupElement, + SubexpressionElement, +) +from edify.elements.types.leaves import ( + AlphanumericElement, + AnyCharElement, + CarriageReturnElement, + DigitElement, + EndOfInputElement, + LetterElement, + LowercaseElement, + NewLineElement, + NonDigitElement, + NonWhitespaceCharElement, + NonWordBoundaryElement, + NonWordElement, + NoopElement, + NullByteElement, + StartOfInputElement, + TabElement, + UppercaseElement, + WhitespaceCharElement, + WordBoundaryElement, + WordElement, +) +from edify.elements.types.quantifiers import ( + AtLeastElement, + AtMostElement, + BetweenElement, + BetweenLazyElement, + ExactlyElement, + OneOrMoreElement, + OneOrMoreLazyElement, + OptionalElement, + ZeroOrMoreElement, + ZeroOrMoreLazyElement, +) + + +def explain_elements(elements: tuple[BaseElement, ...]) -> str: + """Return a bullet-list explanation plus accept examples for ``elements``.""" + if not elements: + return "This pattern is empty and matches an empty string." + flat_elements = _flatten(elements) + build_lines = _build_lines(flat_elements) + all_match_examples = _generate_match_examples(flat_elements) + non_empty_match_examples = [example for example in all_match_examples if example] + lines: list[str] = [f"- {description}" for description in build_lines] + if non_empty_match_examples: + lines.append("") + lines.append("Text this pattern accepts:") + lines.extend(f" {example}" for example in non_empty_match_examples) + return "\n".join(lines) + + +def _flatten(elements: tuple[BaseElement, ...]) -> tuple[BaseElement, ...]: + """Return ``elements`` with every :class:`SubexpressionElement` recursively inlined.""" + result: list[BaseElement] = [] + for element in elements: + if isinstance(element, SubexpressionElement): + result.extend(_flatten(element.children)) + else: + result.append(element) + return tuple(result) + + +def _build_lines(elements: tuple[BaseElement, ...]) -> list[str]: + """Return one bullet description per top-level element after skipping the anchor pair.""" + working = list(elements) + starts_with_start_anchor = bool(working) and isinstance(working[0], StartOfInputElement) + if starts_with_start_anchor: + working = working[1:] + if ( + len(working) >= 2 + and isinstance(working[-1], EndOfInputElement) + and isinstance(working[-2], AssertAheadElement) + ): + trailing_lookahead = working[-2] + working = working[:-2] + trailing_line = ( + f"The text must end at {_describe_inline_children(trailing_lookahead.children)}." + ) + elif working and isinstance(working[-1], EndOfInputElement): + working = working[:-1] + trailing_line = None + else: + trailing_line = None + lines: list[str] = [] + for index, element in enumerate(working): + is_first = index == 0 + lines.append( + _describe_step( + element, + is_first_step=is_first, + anchored_at_start=starts_with_start_anchor, + ) + ) + if trailing_line is not None: + lines.append(trailing_line) + return lines + + +def _wrap_step(step_number: int, description: str) -> str: + """Return the description prefixed with the step number and 3-space continuation indent.""" + prefix = f" {step_number}. " + continuation_prefix = " " * len(prefix) + words = description.split() + if not words: + return prefix.rstrip() + lines: list[str] = [] + current_line = prefix + words[0] + max_width = 76 + for word in words[1:]: + candidate = current_line + " " + word + if len(candidate) > max_width: + lines.append(current_line) + current_line = continuation_prefix + word + else: + current_line = candidate + lines.append(current_line) + return "\n".join(lines) + + +def _describe_step( + element: BaseElement, + *, + is_first_step: bool, + anchored_at_start: bool, +) -> str: + """Return a one-sentence plain-English description of a top-level ``element``.""" + if isinstance(element, WordBoundaryElement): + return ( + "This point must fall on a word boundary — where a letter, digit, " + "or underscore meets something else." + ) + if isinstance(element, NonWordBoundaryElement): + return ( + "This point must NOT fall on a word boundary — it must be inside a " + "run of letters, digits, or underscores, or between two other " + "characters." + ) + starting_form = "The text must start with" if anchored_at_start else "The text must contain" + connector = starting_form if is_first_step else "Then the text must have" + if isinstance(element, OptionalElement): + return f"Optional: {_describe_optional_inner(element.child)}." + if isinstance(element, ZeroOrMoreElement): + return f"{connector} zero or more {_describe_plural(element.child)}." + if isinstance(element, ZeroOrMoreLazyElement): + return f"{connector} zero or more {_describe_plural(element.child)} (as few as possible)." + if isinstance(element, OneOrMoreElement): + return f"{connector} one or more {_describe_plural(element.child)}." + if isinstance(element, OneOrMoreLazyElement): + return f"{connector} one or more {_describe_plural(element.child)} (as few as possible)." + if isinstance(element, ExactlyElement): + return f"{connector} exactly {element.times} {_describe_plural(element.child)}." + if isinstance(element, AtLeastElement): + return f"{connector} at least {element.times} {_describe_plural(element.child)}." + if isinstance(element, AtMostElement): + return f"{connector} at most {element.times} {_describe_plural(element.child)}." + if isinstance(element, BetweenElement): + return ( + f"{connector} between {element.lower} and {element.upper} " + f"{_describe_plural(element.child)}." + ) + if isinstance(element, BetweenLazyElement): + return ( + f"{connector} between {element.lower} and {element.upper} " + f"{_describe_plural(element.child)} (as few as possible)." + ) + if isinstance(element, CaptureElement): + return f"{connector} {_describe_inline_children(element.children)}." + if isinstance(element, NamedCaptureElement): + return f"{connector} {_describe_inline_children(element.children)}." + if isinstance(element, BackReferenceElement): + return f"{connector} the exact same text that appeared earlier in group #{element.index}." + if isinstance(element, NamedBackReferenceElement): + return ( + f"{connector} the exact same text that appeared earlier in the " + f'group labeled "{element.name}".' + ) + if isinstance(element, AssertAheadElement): + return f"Then the text must be followed by {_describe_inline_children(element.children)}." + if isinstance(element, AssertNotAheadElement): + return ( + f"Then the text must NOT be followed by {_describe_inline_children(element.children)}." + ) + if isinstance(element, AssertBehindElement): + return ( + f"Just before this point, {_describe_inline_children(element.children)} " + "must have appeared." + ) + if isinstance(element, AssertNotBehindElement): + return ( + f"Just before this point, {_describe_inline_children(element.children)} " + "must NOT have appeared." + ) + if isinstance(element, GroupElement): + return f"{connector} {_describe_inline_children(element.children)}." + if isinstance(element, AnyOfElement): + return f"{connector} {_describe_alternatives(element.children)}." + inline = _describe_inline(element) + return f"{connector} {inline}." + + +def _describe_optional_inner(element: BaseElement) -> str: + """Return the phrase used after "may optionally continue with".""" + if isinstance(element, SubexpressionElement | GroupElement): + return _describe_inline_children(element.children) + if isinstance(element, CharElement): + return f'"{_unescape_literal(element.value)}"' + if isinstance(element, StringElement): + return f'"{_unescape_literal(element.value)}"' + return _describe_inline(element) + + +def _describe_plural(element: BaseElement) -> str: + """Return the plural-stem phrase for ``element`` used after a count word like "one or more".""" + if isinstance(element, AnyCharElement): + return "characters (any character)" + if isinstance(element, WhitespaceCharElement): + return "whitespace characters (space, tab, newline, etc.)" + if isinstance(element, NonWhitespaceCharElement): + return "non-whitespace characters" + if isinstance(element, DigitElement): + return "digits (0-9)" + if isinstance(element, NonDigitElement): + return "non-digit characters" + if isinstance(element, WordElement): + return "letters, digits, or underscores" + if isinstance(element, NonWordElement): + return "characters that are not letters, digits, or underscores" + if isinstance(element, NewLineElement): + return 'line-feed characters ("\\n")' + if isinstance(element, CarriageReturnElement): + return 'carriage-return characters ("\\r")' + if isinstance(element, TabElement): + return 'tab characters ("\\t")' + if isinstance(element, NullByteElement): + return 'null bytes ("\\0")' + if isinstance(element, LetterElement): + return "letters (a-z or A-Z)" + if isinstance(element, UppercaseElement): + return "uppercase letters (A-Z)" + if isinstance(element, LowercaseElement): + return "lowercase letters (a-z)" + if isinstance(element, AlphanumericElement): + return "letters or digits (a-z, A-Z, or 0-9)" + if isinstance(element, CharElement): + return f'copies of the character "{_unescape_literal(element.value)}"' + if isinstance(element, StringElement): + return f'copies of the text "{_unescape_literal(element.value)}"' + if isinstance(element, RangeElement): + return f'characters from "{element.start}" through "{element.end}"' + if isinstance(element, AnyOfCharsElement): + return f'characters from the set "{element.value}"' + if isinstance(element, AnythingButCharsElement): + return f'characters NOT from the set "{element.value}"' + if isinstance(element, AnythingButRangeElement): + return f'characters outside "{element.start}" through "{element.end}"' + return f"of {_describe_inline(element)}" + + +def _describe_inline(element: BaseElement) -> str: + """Return an inline phrase for ``element`` suitable for embedding in a sentence.""" + if isinstance(element, StartOfInputElement): + return "the very beginning of the text" + if isinstance(element, EndOfInputElement): + return "the very end of the text" + if isinstance(element, AnyCharElement): + return "any single character" + if isinstance(element, WhitespaceCharElement): + return "one whitespace character (space, tab, newline, etc.)" + if isinstance(element, NonWhitespaceCharElement): + return "one non-whitespace character" + if isinstance(element, DigitElement): + return "one digit (0-9)" + if isinstance(element, NonDigitElement): + return "one non-digit character" + if isinstance(element, WordElement): + return "one letter, digit, or underscore" + if isinstance(element, NonWordElement): + return "one character that is not a letter, digit, or underscore" + if isinstance(element, WordBoundaryElement): + return "a word boundary" + if isinstance(element, NonWordBoundaryElement): + return "a non-word-boundary position" + if isinstance(element, NewLineElement): + return 'a line-feed character (the newline "\\n")' + if isinstance(element, CarriageReturnElement): + return 'a carriage-return character ("\\r")' + if isinstance(element, TabElement): + return 'a tab character ("\\t")' + if isinstance(element, NullByteElement): + return 'the null byte ("\\0")' + if isinstance(element, LetterElement): + return "one letter (a-z or A-Z)" + if isinstance(element, UppercaseElement): + return "one uppercase letter (A-Z)" + if isinstance(element, LowercaseElement): + return "one lowercase letter (a-z)" + if isinstance(element, AlphanumericElement): + return "one letter or digit (a-z, A-Z, or 0-9)" + if isinstance(element, NoopElement): + return "nothing" + if isinstance(element, CharElement): + return f'"{_unescape_literal(element.value)}"' + if isinstance(element, StringElement): + return f'"{_unescape_literal(element.value)}"' + if isinstance(element, RangeElement): + return f'one character from "{element.start}" through "{element.end}"' + if isinstance(element, AnyOfCharsElement): + return f'one character from the set "{element.value}"' + if isinstance(element, AnythingButCharsElement): + return f'one character NOT from the set "{element.value}"' + if isinstance(element, AnythingButRangeElement): + return f'one character outside "{element.start}" through "{element.end}"' + if isinstance(element, AnythingButStringElement): + return ( + f"a run of {len(element.value)} characters " + f'not matching "{element.value}" position-for-position' + ) + if isinstance(element, OptionalElement): + return f"an optional {_describe_optional_inner(element.child)}" + if isinstance(element, ZeroOrMoreElement): + return f"zero or more {_describe_plural(element.child)}" + if isinstance(element, ZeroOrMoreLazyElement): + return f"zero or more {_describe_plural(element.child)} (as few as possible)" + if isinstance(element, OneOrMoreElement): + return f"one or more {_describe_plural(element.child)}" + if isinstance(element, OneOrMoreLazyElement): + return f"one or more {_describe_plural(element.child)} (as few as possible)" + if isinstance(element, ExactlyElement): + return f"exactly {element.times} {_describe_plural(element.child)}" + if isinstance(element, AtLeastElement): + return f"at least {element.times} {_describe_plural(element.child)}" + if isinstance(element, AtMostElement): + return f"at most {element.times} {_describe_plural(element.child)}" + if isinstance(element, BetweenElement): + return f"between {element.lower} and {element.upper} {_describe_plural(element.child)}" + if isinstance(element, BetweenLazyElement): + return ( + f"between {element.lower} and {element.upper} " + f"{_describe_plural(element.child)} (as few as possible)" + ) + if isinstance(element, CaptureElement): + return f"{_describe_inline_children(element.children)} (captured)" + if isinstance(element, NamedCaptureElement): + return ( + f"{_describe_inline_children(element.children)} " + f'(captured under the label "{element.name}")' + ) + if isinstance(element, GroupElement): + return _describe_inline_children(element.children) + if isinstance(element, AnyOfElement): + return _describe_alternatives(element.children) + if isinstance(element, SubexpressionElement): + return _describe_inline_children(element.children) + if isinstance(element, BackReferenceElement): + return f"the same text that group #{element.index} captured" + if isinstance(element, NamedBackReferenceElement): + return f'the same text that the "{element.name}" group captured' + return type(element).__name__ + + +def _describe_inline_children(children: tuple[BaseElement, ...]) -> str: + """Return the "A, then B, then C" phrase describing ``children`` in sequence.""" + flat = _flatten(children) + if not flat: + return "nothing" + phrases = [_describe_inline(child) for child in flat] + if len(phrases) == 1: + return phrases[0] + return ", then ".join(phrases) + + +def _describe_alternatives(children: tuple[BaseElement, ...]) -> str: + """Return the "either A or B or C" phrase describing an alternation.""" + if not children: + return "nothing" + phrases = [_describe_inline(child) for child in children] + if len(phrases) == 1: + return phrases[0] + if len(phrases) == 2: + return f"either {phrases[0]} or {phrases[1]}" + joined = ", ".join(phrases[:-1]) + return f"either {joined}, or {phrases[-1]}" + + +def _generate_match_examples(elements: tuple[BaseElement, ...]) -> list[str]: + """Return a small set of concrete strings that the pattern matches.""" + seeds = [_build_example(elements, alternative_index) for alternative_index in range(3)] + unique_seeds: list[str] = [] + for seed in seeds: + if seed not in unique_seeds: + unique_seeds.append(seed) + return unique_seeds + + +def _build_example(elements: tuple[BaseElement, ...], alternative_index: int) -> str: + """Return one matching example string using ``alternative_index`` where alternation applies.""" + parts = [_example_for(element, alternative_index) for element in elements] + return "".join(parts) + + +_LETTER_ROTATION = ("e", "o", "a", "i", "u") +_DIGIT_ROTATION = ("1", "2", "3", "4", "5", "6", "7", "8", "9", "0") +_UPPERCASE_ROTATION = ("A", "B", "C", "D", "E") +_ALPHANUMERIC_ROTATION = ("a", "1", "b", "2", "c", "3") +_WORD_ROTATION = ("a", "b", "1", "_", "c", "2", "d") + + +def _example_for(element: BaseElement, alternative_index: int) -> str: + """Return a piece of text ``element`` accepts, using ``alternative_index`` where relevant.""" + if isinstance( + element, + StartOfInputElement | EndOfInputElement | WordBoundaryElement | NonWordBoundaryElement, + ): + return "" + if isinstance(element, AnyCharElement): + return _LETTER_ROTATION[alternative_index % len(_LETTER_ROTATION)] + if isinstance(element, WhitespaceCharElement): + return " " + if isinstance(element, NonWhitespaceCharElement): + return _LETTER_ROTATION[alternative_index % len(_LETTER_ROTATION)] + if isinstance(element, DigitElement): + return _DIGIT_ROTATION[alternative_index % len(_DIGIT_ROTATION)] + if isinstance(element, NonDigitElement): + return _LETTER_ROTATION[alternative_index % len(_LETTER_ROTATION)] + if isinstance(element, WordElement): + return _WORD_ROTATION[alternative_index % len(_WORD_ROTATION)] + if isinstance(element, NonWordElement): + return "-" + if isinstance(element, NewLineElement): + return "\n" + if isinstance(element, CarriageReturnElement): + return "\r" + if isinstance(element, TabElement): + return "\t" + if isinstance(element, NullByteElement): + return "\0" + if isinstance(element, LetterElement): + return _LETTER_ROTATION[alternative_index % len(_LETTER_ROTATION)] + if isinstance(element, UppercaseElement): + return _UPPERCASE_ROTATION[alternative_index % len(_UPPERCASE_ROTATION)] + if isinstance(element, LowercaseElement): + return _LETTER_ROTATION[alternative_index % len(_LETTER_ROTATION)] + if isinstance(element, AlphanumericElement): + return _ALPHANUMERIC_ROTATION[alternative_index % len(_ALPHANUMERIC_ROTATION)] + if isinstance(element, NoopElement): + return "" + if isinstance(element, CharElement): + return _unescape_literal(element.value) + if isinstance(element, StringElement): + return _unescape_literal(element.value) + if isinstance(element, RangeElement): + return element.start + if isinstance(element, AnyOfCharsElement): + if not element.value: + return "a" + raw = _unescape_literal(element.value) + return raw[alternative_index % len(raw)] + if isinstance(element, AnythingButCharsElement): + return _pick_character_not_in(element.value) + if isinstance(element, AnythingButRangeElement): + return _pick_character_outside_range(element.start, element.end) + if isinstance(element, AnythingButStringElement): + return "x" * len(element.value) if element.value else "" + if isinstance(element, OptionalElement): + if alternative_index % 2 == 0: + return _example_for(element.child, alternative_index) + return "" + if isinstance(element, ZeroOrMoreElement | ZeroOrMoreLazyElement): + return _expand_variable(element.child, alternative_index, minimum=1) + if isinstance(element, OneOrMoreElement | OneOrMoreLazyElement): + return _expand_variable(element.child, alternative_index, minimum=1) + if isinstance(element, ExactlyElement): + return _expand_child_n_times(element.child, alternative_index, element.times) + if isinstance(element, AtLeastElement): + return _expand_child_n_times(element.child, alternative_index, element.times + 1) + if isinstance(element, AtMostElement): + take = max(1, min(element.times, 2 + alternative_index % max(1, element.times))) + return _expand_child_n_times(element.child, alternative_index, take) + if isinstance(element, BetweenElement | BetweenLazyElement): + take = element.lower + alternative_index % max(1, element.upper - element.lower + 1) + take = max(element.lower, min(element.upper, take)) + return _expand_child_n_times(element.child, alternative_index, take) + if isinstance( + element, CaptureElement | NamedCaptureElement | GroupElement | SubexpressionElement + ): + return "".join(_example_for(child, alternative_index) for child in element.children) + if isinstance(element, AnyOfElement): + if not element.children: + return "" + chosen = element.children[alternative_index % len(element.children)] + return _example_for(chosen, alternative_index) + if isinstance(element, AssertAheadElement | AssertBehindElement): + return "".join(_example_for(child, alternative_index) for child in element.children) + if isinstance(element, AssertNotAheadElement | AssertNotBehindElement): + return "" + if isinstance(element, BackReferenceElement | NamedBackReferenceElement): + return "a" + return "" + + +def _expand_variable(child: BaseElement, alternative_index: int, minimum: int) -> str: + """Return a run of ``child`` examples between 3 and 6 characters long depending on rotation.""" + run_length = max(minimum, 3 + alternative_index % 3) + return _expand_child_n_times(child, alternative_index, run_length) + + +def _expand_child_n_times(child: BaseElement, alternative_index: int, count: int) -> str: + """Return ``count`` example pieces of ``child`` back-to-back, varying content per position.""" + pieces = [_example_for(child, alternative_index + position) for position in range(count)] + return "".join(pieces) + + +def _unescape_literal(escaped_value: str) -> str: + """Return ``escaped_value`` stripped of the leading backslashes edify adds around metachars.""" + result: list[str] = [] + index = 0 + while index < len(escaped_value): + character = escaped_value[index] + if character == "\\" and index + 1 < len(escaped_value): + result.append(escaped_value[index + 1]) + index = index + 2 + else: + result.append(character) + index = index + 1 + return "".join(result) + + +def _pick_character_not_in(disallowed: str) -> str: + """Return a single character that is not in ``disallowed``.""" + for candidate in "abcdefghijklmnopqrstuvwxyz0123456789": + if candidate not in disallowed: + return candidate + return "!" + + +def _pick_character_outside_range(start: str, end: str) -> str: + """Return a single character outside the ``start``-through-``end`` range.""" + for candidate in "abcdefghijklmnopqrstuvwxyz0123456789": + if not (start <= candidate <= end): + return candidate + return "!" diff --git a/edify/introspect/graphviz.py b/edify/introspect/graphviz.py new file mode 100644 index 0000000..3e4b236 --- /dev/null +++ b/edify/introspect/graphviz.py @@ -0,0 +1,275 @@ +"""Graphviz DOT / SVG renderer for edify AST elements.""" + +from __future__ import annotations + +from edify.elements.types.base import BaseElement +from edify.elements.types.captures import ( + BackReferenceElement, + CaptureElement, + NamedBackReferenceElement, + NamedCaptureElement, +) +from edify.elements.types.groups import ( + AnyOfElement, + AssertAheadElement, + AssertBehindElement, + AssertNotAheadElement, + AssertNotBehindElement, + GroupElement, + SubexpressionElement, +) +from edify.elements.types.quantifiers import ( + AtLeastElement, + AtMostElement, + BetweenElement, + BetweenLazyElement, + ExactlyElement, + OneOrMoreElement, + OneOrMoreLazyElement, + OptionalElement, + ZeroOrMoreElement, + ZeroOrMoreLazyElement, +) +from edify.errors.introspect import MissingGraphvizDependencyError +from edify.introspect.ascii import _char_label, _leaf_label +from edify.introspect.types import Emission + +try: + import graphviz as _graphviz_module +except ImportError: + _graphviz_module = None + + +def render_graphviz_svg(elements: tuple[BaseElement, ...]) -> str: + """Return an SVG rendering of ``elements`` produced by Graphviz.""" + if _graphviz_module is None: + raise MissingGraphvizDependencyError() + dot_source = render_dot(elements) + source = _graphviz_module.Source(dot_source, format="svg") + piped_bytes = source.pipe(format="svg") + return piped_bytes.decode("utf-8") + + +def render_dot(elements: tuple[BaseElement, ...]) -> str: + """Return the DOT source describing ``elements``.""" + counter = _Counter() + entry_id, exit_id, sequence_lines = _emit_sequence(elements, counter) + body: list[str] = list(sequence_lines) + if entry_id is None: + body.append(" start -> end;") + else: + body.append(f" start -> {entry_id};") + body.append(f" {exit_id} -> end;") + header = [ + "digraph edify_pattern {", + " rankdir=LR;", + ' node [shape=box, style=rounded, fontname="Menlo"];', + ' edge [fontname="Menlo"];', + ' start [label="START", shape=circle];', + ' end [label="END", shape=circle];', + ] + return "\n".join(header + body + ["}"]) + + +def _emit_sequence( + elements: tuple[BaseElement, ...], counter: _Counter +) -> tuple[str | None, str | None, list[str]]: + """Return ``(entry_id, exit_id, lines)`` for a left-to-right sequence of ``elements``.""" + if not elements: + return None, None, [] + lines: list[str] = [] + first_entry: str | None = None + prev_exit: str | None = None + for element in elements: + emission = _emit_element(element, counter) + lines.extend(emission.lines) + if first_entry is None: + first_entry = emission.entry_id + if prev_exit is not None: + lines.append(f" {prev_exit} -> {emission.entry_id};") + prev_exit = emission.exit_id + return first_entry, prev_exit, lines + + +def _emit_element(element: BaseElement, counter: _Counter) -> Emission: + """Return the DOT emission for a single AST ``element``.""" + inline_label = _inline_label(element) + if inline_label is not None: + return _emit_leaf(inline_label, counter) + if isinstance(element, AnyOfElement): + return _emit_alternation(element.children, counter) + if isinstance(element, SubexpressionElement): + return _emit_subexpression(element.children, counter) + if isinstance(element, GroupElement): + return _emit_cluster(element.children, "grouped", counter) + if isinstance(element, CaptureElement): + return _emit_cluster(element.children, "captured", counter) + if isinstance(element, NamedCaptureElement): + return _emit_cluster(element.children, f'saved as "{element.name}"', counter) + if isinstance(element, AssertAheadElement): + return _emit_cluster(element.children, "must be followed by", counter) + if isinstance(element, AssertNotAheadElement): + return _emit_cluster(element.children, "must NOT be followed by", counter) + if isinstance(element, AssertBehindElement): + return _emit_cluster(element.children, "must be preceded by", counter) + if isinstance(element, AssertNotBehindElement): + return _emit_cluster(element.children, "must NOT be preceded by", counter) + if isinstance(element, BackReferenceElement): + return _emit_leaf(f"match same text as group {element.index}", counter) + if isinstance(element, NamedBackReferenceElement): + return _emit_leaf(f'match same text as "{element.name}"', counter) + complex_quantifier = _emit_quantifier_cluster(element, counter) + if complex_quantifier is not None: + return complex_quantifier + return _emit_leaf(f"?{type(element).__name__}", counter) + + +def _emit_leaf(label: str, counter: _Counter) -> Emission: + """Emit a single box node labelled with ``label`` and return its emission.""" + node_id = counter.next("n") + lines = (f' {node_id} [label="{_escape_dot(label)}"];',) + return Emission(entry_id=node_id, exit_id=node_id, lines=lines) + + +def _emit_alternation( + children: tuple[BaseElement, ...], counter: _Counter +) -> Emission: + """Emit an alternation as fork/merge over ``children`` using junction points.""" + if not children: + return _emit_leaf("nothing", counter) + fork_id = counter.next("fork") + merge_id = counter.next("merge") + lines: list[str] = [ + f' {fork_id} [label="", shape=point, width=0.08];', + f' {merge_id} [label="", shape=point, width=0.08];', + ] + for child in children: + emission = _emit_element(child, counter) + lines.extend(emission.lines) + lines.append(f" {fork_id} -> {emission.entry_id};") + lines.append(f" {emission.exit_id} -> {merge_id};") + lines_tuple = tuple(lines) + return Emission(entry_id=fork_id, exit_id=merge_id, lines=lines_tuple) + + +def _emit_subexpression( + children: tuple[BaseElement, ...], counter: _Counter +) -> Emission: + """Emit a transparent subexpression as a plain sequence of ``children``.""" + if not children: + return _emit_leaf("empty subexpression", counter) + entry_id, exit_id, lines = _emit_sequence(children, counter) + lines_tuple = tuple(lines) + return Emission(entry_id=entry_id or "", exit_id=exit_id or "", lines=lines_tuple) + + +def _emit_cluster( + children: tuple[BaseElement, ...], cluster_label: str, counter: _Counter +) -> Emission: + """Emit ``children`` inside a dashed rounded cluster labelled ``cluster_label``.""" + if not children: + return _emit_leaf(f"{cluster_label} (empty)", counter) + cluster_id = counter.next("cluster") + entry_id, exit_id, inner_lines = _emit_sequence(children, counter) + lines: list[str] = [ + f" subgraph cluster_{cluster_id} {{", + f' label="{_escape_dot(cluster_label)}";', + ' style="dashed,rounded";', + ' fontname="Menlo";', + ] + indented_inner_lines = [f" {line}" for line in inner_lines] + lines.extend(indented_inner_lines) + lines.append(" }") + lines_tuple = tuple(lines) + return Emission(entry_id=entry_id or "", exit_id=exit_id or "", lines=lines_tuple) + + +def _emit_quantifier_cluster(element: BaseElement, counter: _Counter) -> Emission | None: + """Emit a quantifier wrapping a complex child as a dashed cluster; ``None`` if not one.""" + quantifier_phrase = _quantifier_phrase(element) + if quantifier_phrase is None: + return None + child_emission = _emit_element(element.child, counter) + cluster_id = counter.next("cluster") + lines: list[str] = [ + f" subgraph cluster_{cluster_id} {{", + f' label="{_escape_dot(quantifier_phrase)}";', + ' style="dashed,rounded";', + ' fontname="Menlo";', + ] + indented_child_lines = [f" {line}" for line in child_emission.lines] + lines.extend(indented_child_lines) + lines.append(" }") + lines_tuple = tuple(lines) + return Emission( + entry_id=child_emission.entry_id, + exit_id=child_emission.exit_id, + lines=lines_tuple, + ) + + +def _inline_label(element: BaseElement) -> str | None: + """Return a single-line label for a leaf / char / simple-quantifier element, else ``None``.""" + leaf = _leaf_label(element) + if leaf is not None: + return leaf + char = _char_label(element) + if char is not None: + return char + return _simple_quantifier_label(element) + + +def _simple_quantifier_label(element: BaseElement) -> str | None: + """Return a folded two-line label when a quantifier wraps a leaf / char element.""" + quantifier_phrase = _quantifier_phrase(element) + if quantifier_phrase is None: + return None + child = element.child + child_label = _leaf_label(child) or _char_label(child) + if child_label is None: + return None + return f"{child_label}\\n({quantifier_phrase})" + + +def _quantifier_phrase(element: BaseElement) -> str | None: + """Return the plain-English quantifier phrase (e.g. ``"one or more"``) or ``None``.""" + if isinstance(element, ExactlyElement): + return f"exactly {element.times}" + if isinstance(element, OneOrMoreElement): + return "one or more" + if isinstance(element, OneOrMoreLazyElement): + return "one or more (lazy)" + if isinstance(element, ZeroOrMoreElement): + return "zero or more" + if isinstance(element, ZeroOrMoreLazyElement): + return "zero or more (lazy)" + if isinstance(element, OptionalElement): + return "optional" + if isinstance(element, AtLeastElement): + return f"at least {element.times}" + if isinstance(element, AtMostElement): + return f"at most {element.times}" + if isinstance(element, BetweenElement): + return f"{element.lower} to {element.upper}" + if isinstance(element, BetweenLazyElement): + return f"{element.lower} to {element.upper} (lazy)" + return None + + +def _escape_dot(label: str) -> str: + """Return ``label`` with characters that break DOT double-quoted strings escaped.""" + backslash_escaped = label.replace("\\", "\\\\") + quote_escaped = backslash_escaped.replace('"', '\\"') + return quote_escaped.replace("\\\\n", "\\n") + + +class _Counter: + """Monotonically increasing counter used to mint unique DOT node identifiers.""" + + def __init__(self) -> None: + self._value = 0 + + def next(self, prefix: str) -> str: + """Return the next ``prefix_`` 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, "(? 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: `` # ``.""" + left = f"{prefix}{fragment}" + if not comment: + return left.rstrip() + padding_needed = _COMMENT_COLUMN - len(left) + if padding_needed < 2: + padding_needed = 2 + padding = " " * padding_needed + return f"{left}{padding}# {comment}" + + +def _extract_source(rendered_line: str) -> str: + """Return the raw regex fragment from an already-rendered ``_render_line`` output.""" + without_comment = rendered_line.split("#", 1)[0] + return without_comment.rstrip().lstrip() + + +def _needs_group(child: BaseElement) -> bool: + """Return True when ``child``'s regex fragment needs grouping to apply a quantifier.""" + if isinstance(child, StringElement): + return len(child.value) > 1 + return False diff --git a/edify/introspect/visualize.py b/edify/introspect/visualize.py new file mode 100644 index 0000000..d777ce6 --- /dev/null +++ b/edify/introspect/visualize.py @@ -0,0 +1,33 @@ +"""Dispatch entry point for the :meth:`Regex.visualize` rendering formats.""" + +from __future__ import annotations + +from edify.elements.types.base import BaseElement +from edify.errors.introspect import ( + UnsupportedVisualizationEngineError, + UnsupportedVisualizationFormatError, +) +from edify.introspect.ascii import render_ascii +from edify.introspect.graphviz import render_graphviz_svg + +_FORMAT_ASCII = "ascii" +_FORMAT_SVG = "svg" +_ENGINE_ASCII = "ascii" +_ENGINE_GRAPHVIZ = "graphviz" + + +def visualize_elements( + elements: tuple[BaseElement, ...], + format: str = _FORMAT_ASCII, + engine: str = _ENGINE_ASCII, +) -> str: + """Render ``elements`` in the requested ``format`` / ``engine`` combination.""" + if format == _FORMAT_ASCII: + if engine != _ENGINE_ASCII: + raise UnsupportedVisualizationEngineError(format, engine) + return render_ascii(elements) + if format == _FORMAT_SVG: + if engine != _ENGINE_GRAPHVIZ: + raise UnsupportedVisualizationEngineError(format, engine) + return render_graphviz_svg(elements) + raise UnsupportedVisualizationFormatError(format) diff --git a/edify/result/regex.py b/edify/result/regex.py index 9dbcd63..b52e3ee 100644 --- a/edify/result/regex.py +++ b/edify/result/regex.py @@ -1,40 +1,57 @@ -""":class:`Regex` — composition wrapper over :class:`re.Pattern`. - -:class:`re.Pattern` is a CPython C type and cannot be subclassed, so we -compose: :class:`Regex` holds both the source string and the compiled -:class:`re.Pattern`, exposing them as ``.source`` and ``.compiled``, and -delegates the eight canonical query methods (``match``, ``search``, -``fullmatch``, ``findall``, ``finditer``, ``sub``, ``subn``, ``split``) -to the underlying compiled pattern. - -This wrapper is what :meth:`edify.builder.mixins.terminals.TerminalsMixin.to_regex` -returns; the raw :class:`re.Pattern` is still available via ``.compiled``. -""" +"""The :class:`Regex` composition wrapper over :class:`re.Pattern`.""" from __future__ import annotations import re import sys -from collections.abc import Callable, Iterator +from collections.abc import Callable, Iterator, Mapping + +from edify.elements.types.base import BaseElement +from edify.introspect.explain import explain_elements +from edify.introspect.verbose import verbose_elements +from edify.introspect.visualize import visualize_elements + +_RePatternMethodReturn = ( + re.Match[str] + | list[str] + | list[tuple[str, ...]] + | Iterator[re.Match[str]] + | str + | tuple[str, int] + | None +) + +_RePatternAttribute = int | str | Mapping[str, int] | Callable[..., _RePatternMethodReturn] class Regex: """A compiled edify pattern; wraps :class:`re.Pattern` by composition.""" - def __init__(self, source: str, compiled: re.Pattern[str]) -> None: + def __init__( + self, + source: str, + compiled: re.Pattern[str], + elements: tuple[BaseElement, ...] = (), + ) -> None: self._source = source self._compiled = compiled + self._elements = elements @property def source(self) -> str: - """The regex string that produced this wrapper (identical to :meth:`re.Pattern.pattern`).""" + """The regex string this wrapper was built from.""" return self._source @property def compiled(self) -> re.Pattern[str]: - """The underlying :class:`re.Pattern`; callers that need identity checks read this.""" + """The underlying :class:`re.Pattern` for direct interop with :mod:`re`.""" return self._compiled + @property + def elements(self) -> tuple[BaseElement, ...]: + """The AST elements produced by the builder, in emission order.""" + return self._elements + def match(self, string: str, pos: int = 0, endpos: int = sys.maxsize) -> re.Match[str] | None: """Delegate to :meth:`re.Pattern.match`.""" return self._compiled.match(string, pos, endpos) @@ -83,6 +100,42 @@ class Regex: """Delegate to :meth:`re.Pattern.split`.""" return self._compiled.split(string, maxsplit=maxsplit) + def explain(self) -> str: + """Return a human-readable narrative describing what the pattern matches.""" + return explain_elements(self._elements) + + def to_verbose_string(self) -> str: + """Return the pattern as an annotated ``re.VERBOSE``-compatible string.""" + return verbose_elements(self._elements) + + def visualize(self, format: str = "ascii", engine: str = "ascii") -> str: + """Return a visual rendering of the pattern in ``format``. + + Args: + format: ``"ascii"`` (default) for an ASCII railroad diagram, or + ``"svg"`` for a Graphviz-rendered SVG string. + engine: Rendering engine identifier; ``"ascii"`` for the built-in + ASCII layout, ``"graphviz"`` for the SVG renderer. Must match + ``format``. + + Returns: + The rendered diagram as a string. + + Raises: + edify.errors.introspect.UnsupportedVisualizationFormatError: when + ``format`` is not one of ``"ascii"`` or ``"svg"``. + edify.errors.introspect.UnsupportedVisualizationEngineError: when + ``engine`` does not match the requested ``format``. + edify.errors.introspect.MissingGraphvizDependencyError: when + ``format="svg"`` is requested but the ``graphviz`` extra is + not installed. + """ + return visualize_elements(self._elements, format=format, engine=engine) + + def __getattr__(self, name: str) -> _RePatternAttribute: + """Delegate any attribute access not explicitly defined here to the compiled pattern.""" + return getattr(self._compiled, name) + def __repr__(self) -> str: """Return ````.""" return f"" diff --git a/tests/introspect/ascii.test.py b/tests/introspect/ascii.test.py new file mode 100644 index 0000000..0ebcc8f --- /dev/null +++ b/tests/introspect/ascii.test.py @@ -0,0 +1,561 @@ +"""Tests for the ASCII railroad-diagram renderer in :mod:`edify.introspect.ascii`.""" + +from edify import Pattern, RegexBuilder +from edify.elements.types.captures import ( + BackReferenceElement, + CaptureElement, + NamedBackReferenceElement, + NamedCaptureElement, +) +from edify.elements.types.chars import ( + AnyOfCharsElement, + AnythingButCharsElement, + AnythingButRangeElement, + AnythingButStringElement, + CharElement, + RangeElement, + StringElement, +) +from edify.elements.types.groups import ( + AnyOfElement, + AssertAheadElement, + AssertBehindElement, + AssertNotAheadElement, + AssertNotBehindElement, + GroupElement, + SubexpressionElement, +) +from edify.elements.types.leaves import ( + AlphanumericElement, + AnyCharElement, + CarriageReturnElement, + DigitElement, + EndOfInputElement, + LetterElement, + LowercaseElement, + NewLineElement, + NonDigitElement, + NonWhitespaceCharElement, + NonWordBoundaryElement, + NonWordElement, + NoopElement, + NullByteElement, + StartOfInputElement, + TabElement, + UppercaseElement, + WhitespaceCharElement, + WordBoundaryElement, + WordElement, +) +from edify.elements.types.quantifiers import ( + AtLeastElement, + AtMostElement, + BetweenElement, + BetweenLazyElement, + ExactlyElement, + OneOrMoreElement, + OneOrMoreLazyElement, + OptionalElement, + ZeroOrMoreElement, + ZeroOrMoreLazyElement, +) +from edify.introspect.ascii import ( + _looks_like_single_box, + _pad_right, + _pluralize, + _widen, + render_ascii, +) +from edify.introspect.types import Diagram + + +def _render(*elements) -> str: + return render_ascii(tuple(elements)) + + +def test_empty_pattern_renders_start_arrow_end(): + assert _render() == ( + " +-------+ +-----+\n" + " | START |-->| END |\n" + " +-------+ +-----+" + ) + + +def test_single_digit_pattern(): + output = _render(DigitElement()) + assert output == ( + " +-------+ +-------+ +-----+\n" + " | START |-->| digit |-->| END |\n" + " +-------+ +-------+ +-----+" + ) + + +def test_one_or_more_digit_reads_as_plural_english(): + output = _render(OneOrMoreElement(child=DigitElement())) + assert "one or more digits" in output + assert "START" in output + assert "END" in output + + +def test_exactly_four_digits_reads_as_count_plural(): + output = _render(ExactlyElement(times=4, child=DigitElement())) + assert "4 digits" in output + + +def test_exactly_one_digit_stays_singular(): + output = _render(ExactlyElement(times=1, child=DigitElement())) + assert "1 digit" in output + assert "1 digits" not in output + + +def test_start_of_input_reads_as_text_starts_here(): + output = _render(StartOfInputElement()) + assert "text starts here" in output + + +def test_end_of_input_reads_as_text_ends_here(): + output = _render(EndOfInputElement()) + assert "text ends here" in output + + +def test_character_literal_strips_regex_escape_for_display(): + output = _render(CharElement(value="\\-")) + assert '"-"' in output + assert '"\\-"' not in output + + +def test_string_literal_renders_in_quotes(): + output = _render(StringElement(value="hello")) + assert '"hello"' in output + + +def test_alternation_lays_out_as_fork_and_merge(): + output = _render( + AnyOfElement( + children=( + StringElement(value="cat"), + StringElement(value="dog"), + StringElement(value="fish"), + ) + ) + ) + lines = output.splitlines() + assert any("+--->" in line for line in lines) + assert any("----+" in line for line in lines) + assert any('"cat"' in line for line in lines) + assert any('"dog"' in line for line in lines) + assert any('"fish"' in line for line in lines) + + +def test_alternation_widens_smaller_boxes_to_match_widest(): + output = _render( + AnyOfElement( + children=( + StringElement(value="cat"), + StringElement(value="fish"), + ) + ) + ) + assert output.count("+--------+") == 4 + assert '| "cat" |' in output + assert '| "fish" |' in output + + +def test_alternation_with_single_branch_still_renders(): + output = _render(AnyOfElement(children=(StringElement(value="only"),))) + assert '"only"' in output + + +def test_named_capture_places_caption_below_child(): + output = _render( + NamedCaptureElement( + name="year", + children=(ExactlyElement(times=4, child=DigitElement()),), + ) + ) + lines = output.splitlines() + assert any('(saved as "year")' in line for line in lines) + assert any("4 digits" in line for line in lines) + + +def test_unnamed_capture_places_captured_caption_below_child(): + output = _render( + CaptureElement(children=(DigitElement(),)) + ) + assert "(captured)" in output + + +def test_group_places_grouped_caption_below_child(): + output = _render(GroupElement(children=(DigitElement(),))) + assert "(grouped)" in output + + +def test_subexpression_flattens_into_the_sequence(): + output = _render( + SubexpressionElement(children=(DigitElement(), DigitElement())) + ) + assert output.count("digit") == 2 + + +def test_backreference_reads_as_match_same_text_as_group_n(): + output = _render(BackReferenceElement(index=1)) + assert "match same text as group 1" in output + + +def test_named_backreference_reads_as_match_same_text_as_name(): + output = _render(NamedBackReferenceElement(name="year")) + assert 'match same text as "year"' in output + + +def test_assert_ahead_caption(): + output = _render(AssertAheadElement(children=(DigitElement(),))) + assert "(must be followed by)" in output + + +def test_assert_not_ahead_caption(): + output = _render(AssertNotAheadElement(children=(DigitElement(),))) + assert "(must NOT be followed by)" in output + + +def test_assert_behind_caption(): + output = _render(AssertBehindElement(children=(DigitElement(),))) + assert "(must be preceded by)" in output + + +def test_assert_not_behind_caption(): + output = _render(AssertNotBehindElement(children=(DigitElement(),))) + assert "(must NOT be preceded by)" in output + + +def test_range_element_reads_as_from_x_to_y(): + output = _render(RangeElement(start="a", end="z")) + assert 'from "a" to "z"' in output + + +def test_any_of_chars_reads_as_any_of_value(): + output = _render(AnyOfCharsElement(value="abc")) + assert 'any of "abc"' in output + + +def test_anything_but_chars_reads_as_except_value(): + output = _render(AnythingButCharsElement(value="abc")) + assert 'except "abc"' in output + + +def test_anything_but_range_reads_as_outside_range(): + output = _render(AnythingButRangeElement(start="a", end="z")) + assert 'outside "a"-"z"' in output + + +def test_anything_but_string_reads_as_except_string(): + output = _render(AnythingButStringElement(value="stop")) + assert 'except the string "stop"' in output + + +def test_optional_digit_reads_as_optional_singular(): + output = _render(OptionalElement(child=DigitElement())) + assert "optional digit" in output + + +def test_zero_or_more_digits_reads_as_plural(): + output = _render(ZeroOrMoreElement(child=DigitElement())) + assert "zero or more digits" in output + + +def test_zero_or_more_lazy_marks_lazy(): + output = _render(ZeroOrMoreLazyElement(child=DigitElement())) + assert "zero or more digits (lazy)" in output + + +def test_one_or_more_lazy_marks_lazy(): + output = _render(OneOrMoreLazyElement(child=DigitElement())) + assert "one or more digits (lazy)" in output + + +def test_at_least_reads_as_at_least_n_plural(): + output = _render(AtLeastElement(times=3, child=DigitElement())) + assert "at least 3 digits" in output + + +def test_at_most_reads_as_at_most_n_plural(): + output = _render(AtMostElement(times=2, child=DigitElement())) + assert "at most 2 digits" in output + + +def test_between_reads_as_lower_to_upper_plural(): + output = _render(BetweenElement(lower=2, upper=5, child=DigitElement())) + assert "2 to 5 digits" in output + + +def test_between_lazy_marks_lazy(): + output = _render(BetweenLazyElement(lower=2, upper=5, child=DigitElement())) + assert "2 to 5 digits (lazy)" in output + + +def test_letter_labeled_letter(): + output = _render(LetterElement()) + assert "letter" in output + + +def test_uppercase_labeled_uppercase_letter(): + output = _render(UppercaseElement()) + assert "uppercase letter" in output + + +def test_lowercase_labeled_lowercase_letter(): + output = _render(LowercaseElement()) + assert "lowercase letter" in output + + +def test_alphanumeric_labeled_letter_or_digit(): + output = _render(AlphanumericElement()) + assert "letter or digit" in output + + +def test_any_char_labeled_any_character(): + output = _render(AnyCharElement()) + assert "any character" in output + + +def test_whitespace_labeled_whitespace(): + output = _render(WhitespaceCharElement()) + assert "| whitespace |" in output + + +def test_non_whitespace_labeled_non_whitespace(): + output = _render(NonWhitespaceCharElement()) + assert "non-whitespace" in output + + +def test_non_digit_labeled_non_digit_character(): + output = _render(NonDigitElement()) + assert "non-digit character" in output + + +def test_word_char_labeled_word_character(): + output = _render(WordElement()) + assert "word character" in output + + +def test_non_word_char_labeled_non_word_character(): + output = _render(NonWordElement()) + assert "non-word character" in output + + +def test_word_boundary_labeled_word_boundary(): + output = _render(WordBoundaryElement()) + assert "word boundary" in output + + +def test_non_word_boundary_labeled_non_word_boundary(): + output = _render(NonWordBoundaryElement()) + assert "non-word boundary" in output + + +def test_newline_labeled_newline(): + output = _render(NewLineElement()) + assert "| newline |" in output + + +def test_carriage_return_labeled(): + output = _render(CarriageReturnElement()) + assert "carriage return" in output + + +def test_tab_labeled_tab(): + output = _render(TabElement()) + assert "| tab |" in output + + +def test_null_byte_labeled(): + output = _render(NullByteElement()) + assert "null byte" in output + + +def test_noop_labeled_noop(): + output = _render(NoopElement()) + assert "no-op" in output + + +def test_alternation_with_no_children_shows_nothing_placeholder(): + output = _render(AnyOfElement(children=())) + assert "nothing" in output + + +def test_regex_visualize_end_to_end_delegates_to_render_ascii(): + regex = RegexBuilder().digit().to_regex() + output = regex.visualize() + assert "| digit |" in output + assert "| START |" in output + assert "| END |" in output + + +def test_visualize_with_alternation_end_to_end(): + regex = RegexBuilder().any_of("cat", "dog", "fish").to_regex() + output = regex.visualize() + assert '"cat"' in output + assert '"dog"' in output + assert '"fish"' in output + assert "+--->" in output + + +def test_visualize_named_capture_end_to_end(): + regex = ( + RegexBuilder() + .named_capture("year") + .exactly(4) + .digit() + .end() + .to_regex() + ) + output = regex.visualize() + assert '(saved as "year")' in output + assert "4 digits" in output + + +def test_visualize_anchored_pattern_end_to_end(): + regex = ( + RegexBuilder() + .start_of_input() + .one_or_more() + .digit() + .end_of_input() + .to_regex() + ) + output = regex.visualize() + assert "text starts here" in output + assert "text ends here" in output + assert "one or more digits" in output + + +def test_singular_stays_singular_for_literal_labels(): + output = _render(ExactlyElement(times=3, child=StringElement(value="ab"))) + assert '3 "ab"' in output + + +def test_pluralize_default_adds_s(): + assert _pluralize("digit") == "digits" + + +def test_pluralize_adds_es_after_ch_ending(): + assert _pluralize("match") == "matches" + + +def test_pluralize_adds_es_after_sh_ending(): + assert _pluralize("fish") == "fishes" + + +def test_pluralize_adds_es_after_x_ending(): + assert _pluralize("box") == "boxes" + + +def test_pluralize_adds_es_after_s_ending(): + assert _pluralize("miss") == "misses" + + +def test_pluralize_adds_es_after_z_ending(): + assert _pluralize("buzz") == "buzzes" + + +def test_pluralize_converts_y_to_ies_after_consonant(): + assert _pluralize("cherry") == "cherries" + + +def test_pluralize_adds_s_after_y_when_preceded_by_vowel(): + assert _pluralize("boy") == "boys" + + +def test_pluralize_leaves_quoted_literal_string_untouched(): + assert _pluralize('"cat"') == '"cat"' + + +def test_optional_of_capture_falls_back_to_group_label(): + inner_capture = CaptureElement(children=(DigitElement(),)) + output = _render(OptionalElement(child=inner_capture)) + assert "optional group" in output + + +def test_quantifier_around_alternation_falls_back_to_group_label(): + inner_alt = AnyOfElement(children=(StringElement(value="a"), StringElement(value="b"))) + output = _render(ExactlyElement(times=2, child=inner_alt)) + assert "2 groups" in output + + +def test_unknown_element_type_renders_placeholder_label(): + class MysteryElement(DigitElement.__mro__[1]): + pass + + output = _render(MysteryElement()) + assert "?MysteryElement" in output + + +def test_empty_group_shows_nothing_placeholder(): + output = _render(GroupElement(children=())) + assert "nothing" in output + + +def test_nested_alternation_pads_wider_branch_with_spaces(): + outer = AnyOfElement( + children=( + AnyOfElement( + children=( + StringElement(value="a"), + StringElement(value="b"), + ) + ), + StringElement(value="verylongword"), + ) + ) + output = _render(outer) + assert '"a"' in output + assert '"b"' in output + assert '"verylongword"' in output + + +def test_single_branch_alternation_looks_like_single_branch(): + output = _render( + AnyOfElement(children=(StringElement(value="only-one"),)) + ) + assert '"only-one"' in output + assert "+--->" in output + + +def test_looks_like_single_box_rejects_multi_row_diagram(): + multi = Diagram(rows=("+---+", "|abc|", "+---+", "extra"), entry_row=1, width=5) + assert _looks_like_single_box(multi) is False + + +def test_looks_like_single_box_rejects_asymmetric_borders(): + asymmetric = Diagram( + rows=("+---+", "| a |", "+xxx+"), entry_row=1, width=5 + ) + assert _looks_like_single_box(asymmetric) is False + + +def test_looks_like_single_box_rejects_non_box_borders(): + non_box = Diagram(rows=(" ", "| a |", " "), entry_row=1, width=5) + assert _looks_like_single_box(non_box) is False + + +def test_looks_like_single_box_rejects_middle_without_pipes(): + bad_middle = Diagram( + rows=("+---+", " a ", "+---+"), entry_row=1, width=5 + ) + assert _looks_like_single_box(bad_middle) is False + + +def test_looks_like_single_box_rejects_border_with_non_dash_interior(): + striped = Diagram( + rows=("+-x-+", "| a |", "+-x-+"), entry_row=1, width=5 + ) + assert _looks_like_single_box(striped) is False + + +def test_pad_right_returns_diagram_untouched_when_already_wide_enough(): + original = Diagram(rows=("abcde",), entry_row=0, width=5) + assert _pad_right(original, 3) is original + + +def test_widen_returns_diagram_untouched_when_already_wide_enough(): + original = Diagram(rows=("abcde",), entry_row=0, width=5) + assert _widen(original, 3) is original diff --git a/tests/introspect/explain.test.py b/tests/introspect/explain.test.py new file mode 100644 index 0000000..c782dfb --- /dev/null +++ b/tests/introspect/explain.test.py @@ -0,0 +1,823 @@ +"""Tests for the plain-English explanation renderer in :mod:`edify.introspect.explain`.""" + +from edify import RegexBuilder +from edify.elements.types.captures import ( + BackReferenceElement, + CaptureElement, + NamedBackReferenceElement, + NamedCaptureElement, +) +from edify.elements.types.chars import ( + AnyOfCharsElement, + AnythingButCharsElement, + AnythingButRangeElement, + AnythingButStringElement, + CharElement, + RangeElement, + StringElement, +) +from edify.elements.types.groups import ( + AnyOfElement, + AssertAheadElement, + AssertBehindElement, + AssertNotAheadElement, + AssertNotBehindElement, + GroupElement, + SubexpressionElement, +) +from edify.elements.types.leaves import ( + AlphanumericElement, + AnyCharElement, + CarriageReturnElement, + DigitElement, + EndOfInputElement, + LetterElement, + LowercaseElement, + NewLineElement, + NonDigitElement, + NonWhitespaceCharElement, + NonWordBoundaryElement, + NonWordElement, + NoopElement, + NullByteElement, + StartOfInputElement, + TabElement, + UppercaseElement, + WhitespaceCharElement, + WordBoundaryElement, + WordElement, +) +from edify.elements.types.quantifiers import ( + AtLeastElement, + AtMostElement, + BetweenElement, + BetweenLazyElement, + ExactlyElement, + OneOrMoreElement, + OneOrMoreLazyElement, + OptionalElement, + ZeroOrMoreElement, + ZeroOrMoreLazyElement, +) +from edify.introspect.explain import ( + _describe_inline, + _describe_inline_children, + _describe_optional_inner, + _describe_plural, + _example_for, + _pick_character_not_in, + _pick_character_outside_range, + _wrap_step, + explain_elements, +) + + +def _explain(*elements) -> str: + return explain_elements(tuple(elements)) + + +def _accepted_examples(output: str) -> list[str]: + if "Text this pattern accepts:" not in output: + return [] + accept_block = output.split("Text this pattern accepts:")[1] + stripped_block = accept_block.strip() + lines = stripped_block.splitlines() + return [line.strip() for line in lines if line.strip()] + + +def test_empty_elements_produces_empty_string_notice(): + assert _explain() == "This pattern is empty and matches an empty string." + + +def test_single_digit_reads_as_must_contain_one_digit(): + output = _explain(DigitElement()) + assert output.startswith("- The text must contain one digit (0-9).") + assert "Text this pattern accepts:" in output + + +def test_start_of_input_switches_lead_in_to_must_start_with(): + output = _explain(StartOfInputElement(), DigitElement()) + assert "The text must start with" in output + + +def test_trailing_end_of_input_alone_is_absorbed_silently(): + output = _explain(DigitElement(), EndOfInputElement()) + assert "The text must contain one digit" in output + assert "very end" not in output + + +def test_trailing_assert_ahead_then_end_becomes_must_end_at_line(): + output = _explain( + DigitElement(), + AssertAheadElement(children=(CharElement(value="/"),)), + EndOfInputElement(), + ) + assert 'The text must end at "/".' in output + + +def test_second_element_uses_then_the_text_must_have(): + output = _explain(DigitElement(), CharElement(value="-")) + assert "Then the text must have" in output + + +def test_optional_element_gets_dedicated_optional_prefix(): + output = _explain(OptionalElement(child=DigitElement())) + assert "Optional:" in output + + +def test_zero_or_more_reads_as_zero_or_more_phrase(): + output = _explain(ZeroOrMoreElement(child=DigitElement())) + assert "zero or more digits" in output + + +def test_zero_or_more_lazy_reads_as_as_few_as_possible(): + output = _explain(ZeroOrMoreLazyElement(child=DigitElement())) + assert "as few as possible" in output + + +def test_one_or_more_reads_as_one_or_more_phrase(): + output = _explain(OneOrMoreElement(child=DigitElement())) + assert "one or more digits" in output + + +def test_one_or_more_lazy_reads_as_as_few_as_possible(): + output = _explain(OneOrMoreLazyElement(child=DigitElement())) + assert "as few as possible" in output + + +def test_exactly_reads_as_exactly_n_phrase(): + output = _explain(ExactlyElement(times=4, child=DigitElement())) + assert "exactly 4 digits" in output + + +def test_at_least_reads_as_at_least_n_phrase(): + output = _explain(AtLeastElement(times=3, child=DigitElement())) + assert "at least 3 digits" in output + + +def test_at_most_reads_as_at_most_n_phrase(): + output = _explain(AtMostElement(times=5, child=DigitElement())) + assert "at most 5 digits" in output + + +def test_between_reads_as_between_lower_and_upper_phrase(): + output = _explain(BetweenElement(lower=2, upper=5, child=DigitElement())) + assert "between 2 and 5 digits" in output + + +def test_between_lazy_reads_as_as_few_as_possible(): + output = _explain(BetweenLazyElement(lower=2, upper=5, child=DigitElement())) + assert "as few as possible" in output + + +def test_word_boundary_gets_a_dedicated_bullet(): + output = _explain(WordBoundaryElement()) + assert "word boundary" in output + + +def test_non_word_boundary_gets_a_dedicated_bullet(): + output = _explain(NonWordBoundaryElement()) + assert "must NOT fall on a word boundary" in output + + +def test_capture_step_describes_children_inline(): + output = _explain(CaptureElement(children=(DigitElement(),))) + assert "one digit" in output + + +def test_named_capture_step_describes_children_inline(): + output = _explain( + NamedCaptureElement(name="year", children=(DigitElement(),)) + ) + assert "one digit" in output + + +def test_backreference_step_names_the_group_index(): + output = _explain(BackReferenceElement(index=2)) + assert "group #2" in output + + +def test_named_backreference_step_names_the_group_label(): + output = _explain(NamedBackReferenceElement(name="year")) + assert 'group labeled "year"' in output + + +def test_assert_ahead_reads_as_must_be_followed_by(): + output = _explain( + DigitElement(), AssertAheadElement(children=(CharElement(value="/"),)) + ) + assert "must be followed by" in output + + +def test_assert_not_ahead_reads_as_must_not_be_followed_by(): + output = _explain( + DigitElement(), + AssertNotAheadElement(children=(CharElement(value="/"),)), + ) + assert "must NOT be followed by" in output + + +def test_assert_behind_reads_as_just_before_this_point(): + output = _explain( + DigitElement(), AssertBehindElement(children=(CharElement(value="/"),)) + ) + assert "Just before this point" in output + + +def test_assert_not_behind_reads_as_just_before_must_not_have_appeared(): + output = _explain( + DigitElement(), + AssertNotBehindElement(children=(CharElement(value="/"),)), + ) + assert "must NOT have appeared" in output + + +def test_group_step_describes_children_inline(): + output = _explain(GroupElement(children=(DigitElement(),))) + assert "one digit" in output + + +def test_alternation_uses_either_or_for_two_alternatives(): + output = _explain( + AnyOfElement( + children=(StringElement(value="cat"), StringElement(value="dog")) + ) + ) + assert 'either "cat" or "dog"' in output + + +def test_alternation_uses_either_a_b_or_c_for_three_alternatives(): + output = _explain( + AnyOfElement( + children=( + StringElement(value="cat"), + StringElement(value="dog"), + StringElement(value="fish"), + ) + ) + ) + assert 'either "cat", "dog", or "fish"' in output + + +def test_alternation_with_single_child_falls_through_to_single_phrase(): + output = _explain(AnyOfElement(children=(StringElement(value="only"),))) + assert '"only"' in output + + +def test_alternation_with_no_children_reads_as_nothing(): + output = _explain(AnyOfElement(children=())) + assert "nothing" in output + + +def test_examples_section_lists_up_to_three_accepted_strings(): + output = _explain(DigitElement()) + strings = _accepted_examples(output) + assert 1 <= len(strings) <= 3 + + +def test_examples_are_unique_across_the_three_seeds(): + output = _explain(DigitElement()) + strings = _accepted_examples(output) + assert len(set(strings)) == len(strings) + + +def test_examples_section_omitted_when_only_empty_strings_are_generated(): + output = _explain(StartOfInputElement(), EndOfInputElement()) + assert "Text this pattern accepts:" not in output + + +def test_subexpression_is_flattened_into_the_step_list(): + output = _explain( + SubexpressionElement(children=(DigitElement(), CharElement(value="-"))) + ) + assert output.count("- ") >= 2 + + +def test_char_element_reads_as_quoted_literal(): + output = _explain(CharElement(value="\\-")) + assert '"-"' in output + + +def test_string_element_reads_as_quoted_literal(): + output = _explain(StringElement(value="hi")) + assert '"hi"' in output + + +def test_range_element_describes_from_start_to_end(): + output = _explain(RangeElement(start="a", end="z")) + assert 'from "a" through "z"' in output + + +def test_any_of_chars_describes_from_the_set(): + output = _explain(AnyOfCharsElement(value="abc")) + assert 'from the set "abc"' in output + + +def test_anything_but_chars_describes_not_from_the_set(): + output = _explain(AnythingButCharsElement(value="abc")) + assert 'NOT from the set "abc"' in output + + +def test_anything_but_range_describes_outside_range(): + output = _explain(AnythingButRangeElement(start="a", end="z")) + assert 'outside "a" through "z"' in output + + +def test_anything_but_string_describes_position_for_position_mismatch(): + output = _explain(AnythingButStringElement(value="stop")) + assert "position-for-position" in output + + +def test_any_char_reads_as_any_single_character(): + output = _explain(AnyCharElement()) + assert "any single character" in output + + +def test_whitespace_char_mentions_space_tab_newline(): + output = _explain(WhitespaceCharElement()) + assert "whitespace character" in output + + +def test_non_whitespace_char_reads_as_non_whitespace(): + output = _explain(NonWhitespaceCharElement()) + assert "non-whitespace character" in output + + +def test_non_digit_char_reads_as_non_digit_character(): + output = _explain(NonDigitElement()) + assert "non-digit character" in output + + +def test_word_element_reads_as_letter_digit_or_underscore(): + output = _explain(WordElement()) + assert "one letter, digit, or underscore" in output + + +def test_non_word_element_reads_as_not_letter_digit_or_underscore(): + output = _explain(NonWordElement()) + assert "not a letter, digit, or underscore" in output + + +def test_new_line_element_labeled_as_line_feed(): + output = _explain(NewLineElement()) + assert "line-feed character" in output + + +def test_carriage_return_element_labeled_as_carriage_return(): + output = _explain(CarriageReturnElement()) + assert "carriage-return character" in output + + +def test_tab_element_labeled_as_tab_character(): + output = _explain(TabElement()) + assert "tab character" in output + + +def test_null_byte_element_labeled_as_null_byte(): + output = _explain(NullByteElement()) + assert "null byte" in output + + +def test_letter_element_labeled_a_to_z_case_insensitive(): + output = _explain(LetterElement()) + assert "one letter" in output + + +def test_uppercase_element_labeled_uppercase_range(): + output = _explain(UppercaseElement()) + assert "uppercase letter" in output + + +def test_lowercase_element_labeled_lowercase_range(): + output = _explain(LowercaseElement()) + assert "lowercase letter" in output + + +def test_alphanumeric_element_labeled_letter_or_digit(): + output = _explain(AlphanumericElement()) + assert "letter or digit" in output + + +def test_noop_element_labeled_nothing_inline(): + output = _explain(GroupElement(children=(NoopElement(),))) + assert "nothing" in output + + +def test_regex_explain_end_to_end_via_builder(): + output = RegexBuilder().digit().to_regex().explain() + assert "one digit" in output + + +def test_examples_use_digit_rotation_for_a_pure_digit_pattern(): + output = _explain(DigitElement()) + strings = _accepted_examples(output) + assert all(text.isdigit() for text in strings) + + +def test_examples_for_alternation_pick_a_different_branch_per_seed(): + output = _explain( + AnyOfElement( + children=( + StringElement(value="cat"), + StringElement(value="dog"), + StringElement(value="fish"), + ) + ) + ) + strings = set(_accepted_examples(output)) + assert strings == {"cat", "dog", "fish"} + + +def test_examples_for_optional_include_both_present_and_absent_forms(): + output = _explain( + DigitElement(), OptionalElement(child=CharElement(value="x")), DigitElement() + ) + strings = _accepted_examples(output) + assert any("x" in text for text in strings) + + +def test_examples_for_exactly_produce_that_length_string(): + output = _explain(ExactlyElement(times=4, child=DigitElement())) + strings = _accepted_examples(output) + assert all(len(text) == 4 for text in strings) + + +def test_examples_for_at_least_produce_more_than_lower_bound_length(): + output = _explain(AtLeastElement(times=2, child=DigitElement())) + strings = _accepted_examples(output) + assert all(len(text) >= 2 for text in strings) + + +def test_examples_for_at_most_produce_bounded_length(): + output = _explain(AtMostElement(times=3, child=DigitElement())) + strings = _accepted_examples(output) + assert all(1 <= len(text) <= 3 for text in strings) + + +def test_examples_for_between_stay_within_range(): + output = _explain(BetweenElement(lower=2, upper=4, child=DigitElement())) + strings = _accepted_examples(output) + assert all(2 <= len(text) <= 4 for text in strings) + + +def test_examples_for_between_lazy_stay_within_range(): + output = _explain(BetweenLazyElement(lower=2, upper=4, child=DigitElement())) + strings = _accepted_examples(output) + assert all(2 <= len(text) <= 4 for text in strings) + + +def test_examples_for_backreference_use_a_placeholder(): + output = _explain( + CaptureElement(children=(DigitElement(),)), + BackReferenceElement(index=1), + ) + strings = _accepted_examples(output) + assert all("a" in text for text in strings) + + +def test_examples_for_named_backreference_use_a_placeholder(): + output = _explain( + NamedCaptureElement(name="a", children=(DigitElement(),)), + NamedBackReferenceElement(name="a"), + ) + strings = _accepted_examples(output) + assert all("a" in text for text in strings) + + +def test_examples_for_anything_but_chars_pick_a_char_not_in_set(): + output = _explain(AnythingButCharsElement(value="abc")) + strings = _accepted_examples(output) + for text in strings: + for character in text: + assert character not in "abc" + + +def test_examples_for_anything_but_range_pick_a_char_outside_range(): + output = _explain(AnythingButRangeElement(start="a", end="z")) + strings = _accepted_examples(output) + for text in strings: + for character in text: + assert not ("a" <= character <= "z") + + +def test_examples_for_anything_but_string_have_matching_length(): + output = _explain(AnythingButStringElement(value="stop")) + strings = _accepted_examples(output) + assert all(len(text) == 4 for text in strings) + + +def test_examples_for_anything_but_string_empty_produces_empty_seed(): + output = _explain(AnythingButStringElement(value="")) + assert "Text this pattern accepts:" not in output + + +def test_examples_for_any_of_chars_empty_uses_default_letter(): + output = _explain(AnyOfCharsElement(value="")) + strings = _accepted_examples(output) + assert all(text == "a" for text in strings) + + +def test_examples_for_negative_lookaround_contribute_nothing(): + output = _explain( + DigitElement(), + AssertNotAheadElement(children=(CharElement(value="/"),)), + ) + strings = _accepted_examples(output) + assert all(text.isdigit() for text in strings) + + +def test_pick_character_not_in_uses_bang_when_set_exhausts_alphanumerics(): + everything = "abcdefghijklmnopqrstuvwxyz0123456789" + assert _pick_character_not_in(everything) == "!" + + +def test_pick_character_outside_range_uses_bang_when_range_covers_alphanumerics(): + assert _pick_character_outside_range("0", "z") == "!" + + +def test_step_wrap_returns_empty_prefix_for_empty_description(): + wrapped = _wrap_step(1, "") + assert wrapped.strip() == "1." + + +def test_step_wrap_breaks_long_descriptions_across_continuation_lines(): + long_description = " ".join(["word"] * 40) + wrapped = _wrap_step(1, long_description) + assert "\n" in wrapped + + +def test_optional_inner_wraps_char_literal(): + assert _describe_optional_inner(CharElement(value="\\-")) == '"-"' + + +def test_optional_inner_wraps_string_literal(): + assert _describe_optional_inner(StringElement(value="hi")) == '"hi"' + + +def test_optional_inner_delegates_to_inline_for_leaf_elements(): + assert "one digit" in _describe_optional_inner(DigitElement()) + + +def test_describe_inline_falls_back_to_class_name_for_unknown_type(): + class MysteryElement(DigitElement.__mro__[1]): + pass + + mystery = MysteryElement() + description = _describe_inline(mystery) + assert description == "MysteryElement" + + +def test_describe_plural_falls_back_to_of_inline_for_unknown_type(): + class MysteryElement(DigitElement.__mro__[1]): + pass + + mystery = MysteryElement() + plural = _describe_plural(mystery) + assert plural.startswith("of ") + + +def test_step_wrapper_directly_wraps_first_line_at_width_boundary(): + words = " ".join(["w"] * 100) + wrapped = _wrap_step(1, words) + for line in wrapped.splitlines(): + stripped_line = line.strip() + assert len(line) <= 76 or stripped_line.startswith("w") + + +def _plural(element) -> str: + return _describe_plural(element) + + +def _inline(element) -> str: + return _describe_inline(element) + + +def test_plural_any_char_reads_as_generic_characters(): + assert _plural(AnyCharElement()) == "characters (any character)" + + +def test_plural_whitespace_reads_as_whitespace_characters(): + assert "whitespace characters" in _plural(WhitespaceCharElement()) + + +def test_plural_non_whitespace_reads_as_non_whitespace_characters(): + assert _plural(NonWhitespaceCharElement()) == "non-whitespace characters" + + +def test_plural_non_digit_reads_as_non_digit_characters(): + assert _plural(NonDigitElement()) == "non-digit characters" + + +def test_plural_word_reads_as_letters_digits_underscores(): + assert _plural(WordElement()) == "letters, digits, or underscores" + + +def test_plural_non_word_reads_as_not_letters_digits_or_underscores(): + assert "not letters, digits, or underscores" in _plural(NonWordElement()) + + +def test_plural_new_line_reads_as_line_feed_characters(): + assert "line-feed characters" in _plural(NewLineElement()) + + +def test_plural_carriage_return_reads_as_carriage_return_characters(): + assert "carriage-return characters" in _plural(CarriageReturnElement()) + + +def test_plural_tab_reads_as_tab_characters(): + assert "tab characters" in _plural(TabElement()) + + +def test_plural_null_byte_reads_as_null_bytes(): + assert "null bytes" in _plural(NullByteElement()) + + +def test_plural_letter_reads_as_letters_a_to_z(): + assert _plural(LetterElement()) == "letters (a-z or A-Z)" + + +def test_plural_uppercase_reads_as_uppercase_letters(): + assert "uppercase letters" in _plural(UppercaseElement()) + + +def test_plural_lowercase_reads_as_lowercase_letters(): + assert "lowercase letters" in _plural(LowercaseElement()) + + +def test_plural_alphanumeric_reads_as_letters_or_digits(): + assert "letters or digits" in _plural(AlphanumericElement()) + + +def test_plural_char_literal_reads_as_copies_of_character(): + assert "copies of the character" in _plural(CharElement(value="\\-")) + + +def test_plural_string_literal_reads_as_copies_of_text(): + assert "copies of the text" in _plural(StringElement(value="hi")) + + +def test_plural_range_reads_as_from_through(): + assert 'from "a" through "z"' in _plural(RangeElement(start="a", end="z")) + + +def test_plural_any_of_chars_reads_as_from_the_set(): + assert 'from the set "abc"' in _plural(AnyOfCharsElement(value="abc")) + + +def test_plural_anything_but_chars_reads_as_not_from_the_set(): + assert 'NOT from the set "abc"' in _plural(AnythingButCharsElement(value="abc")) + + +def test_plural_anything_but_range_reads_as_outside_range(): + assert 'outside "a" through "z"' in _plural( + AnythingButRangeElement(start="a", end="z") + ) + + +def test_plural_unknown_element_falls_back_to_of_inline_form(): + class MysteryElement(DigitElement.__mro__[1]): + pass + + mystery = MysteryElement() + plural = _plural(mystery) + assert plural.startswith("of ") + + +def test_inline_start_of_input_reads_as_very_beginning_of_text(): + assert _inline(StartOfInputElement()) == "the very beginning of the text" + + +def test_inline_end_of_input_reads_as_very_end_of_text(): + assert _inline(EndOfInputElement()) == "the very end of the text" + + +def test_inline_word_boundary_reads_as_word_boundary(): + assert _inline(WordBoundaryElement()) == "a word boundary" + + +def test_inline_non_word_boundary_reads_as_non_word_boundary_position(): + assert _inline(NonWordBoundaryElement()) == "a non-word-boundary position" + + +def test_inline_optional_reads_as_an_optional_x(): + element = OptionalElement(child=DigitElement()) + + phrase = _inline(element) + + assert phrase.startswith("an optional ") + + +def test_inline_zero_or_more_reads_as_zero_or_more_x(): + element = ZeroOrMoreElement(child=DigitElement()) + + phrase = _inline(element) + + assert phrase.startswith("zero or more") + + +def test_inline_zero_or_more_lazy_notes_as_few_as_possible(): + assert "as few as possible" in _inline(ZeroOrMoreLazyElement(child=DigitElement())) + + +def test_inline_one_or_more_reads_as_one_or_more_x(): + element = OneOrMoreElement(child=DigitElement()) + + phrase = _inline(element) + + assert phrase.startswith("one or more") + + +def test_inline_one_or_more_lazy_notes_as_few_as_possible(): + assert "as few as possible" in _inline(OneOrMoreLazyElement(child=DigitElement())) + + +def test_inline_exactly_reads_as_exactly_n_x(): + element = ExactlyElement(times=4, child=DigitElement()) + + phrase = _inline(element) + + assert phrase.startswith("exactly 4") + + +def test_inline_at_least_reads_as_at_least_n_x(): + element = AtLeastElement(times=3, child=DigitElement()) + + phrase = _inline(element) + + assert phrase.startswith("at least 3") + + +def test_inline_at_most_reads_as_at_most_n_x(): + element = AtMostElement(times=2, child=DigitElement()) + + phrase = _inline(element) + + assert phrase.startswith("at most 2") + + +def test_inline_between_reads_as_between_lower_and_upper(): + assert "between 2 and 5" in _inline( + BetweenElement(lower=2, upper=5, child=DigitElement()) + ) + + +def test_inline_between_lazy_notes_as_few_as_possible(): + assert "as few as possible" in _inline( + BetweenLazyElement(lower=2, upper=5, child=DigitElement()) + ) + + +def test_inline_capture_reads_as_child_captured_marker(): + assert "(captured)" in _inline(CaptureElement(children=(DigitElement(),))) + + +def test_inline_named_capture_includes_the_label(): + assert 'label "year"' in _inline( + NamedCaptureElement(name="year", children=(DigitElement(),)) + ) + + +def test_inline_group_reads_as_children_inline(): + assert "one digit" in _inline(GroupElement(children=(DigitElement(),))) + + +def test_inline_subexpression_reads_as_children_inline(): + assert "one digit" in _inline(SubexpressionElement(children=(DigitElement(),))) + + +def test_inline_alternation_reads_as_either_or_phrase(): + assert "either" in _inline( + AnyOfElement( + children=(StringElement(value="a"), StringElement(value="b")) + ) + ) + + +def test_inline_backreference_reads_as_same_text_that_group_captured(): + assert "group #1 captured" in _inline(BackReferenceElement(index=1)) + + +def test_inline_named_backreference_reads_as_that_the_name_group_captured(): + assert 'the "year" group captured' in _inline(NamedBackReferenceElement(name="year")) + + +def test_optional_inner_reads_group_children_inline(): + assert "one digit" in _describe_optional_inner( + GroupElement(children=(DigitElement(),)) + ) + + +def test_describe_inline_children_reads_as_nothing_when_empty(): + assert _describe_inline_children(()) == "nothing" + + +def test_describe_inline_children_joins_multiple_phrases_with_then(): + joined = _describe_inline_children((DigitElement(), StringElement(value="X"))) + assert ", then " in joined + + +def test_example_for_unknown_element_returns_empty_string(): + class MysteryElement(DigitElement.__mro__[1]): + pass + + assert _example_for(MysteryElement(), 0) == "" diff --git a/tests/introspect/graphviz.test.py b/tests/introspect/graphviz.test.py new file mode 100644 index 0000000..516d91b --- /dev/null +++ b/tests/introspect/graphviz.test.py @@ -0,0 +1,359 @@ +"""Tests for the Graphviz DOT / SVG renderer in :mod:`edify.introspect.graphviz`.""" + +import builtins +import importlib +import sys + +import pytest + +from edify import Pattern, RegexBuilder +from edify.elements.types.captures import ( + BackReferenceElement, + CaptureElement, + NamedBackReferenceElement, + NamedCaptureElement, +) +from edify.elements.types.chars import ( + CharElement, + StringElement, +) +from edify.elements.types.groups import ( + AnyOfElement, + AssertAheadElement, + AssertBehindElement, + AssertNotAheadElement, + AssertNotBehindElement, + GroupElement, + SubexpressionElement, +) +from edify.elements.types.leaves import ( + DigitElement, + EndOfInputElement, + StartOfInputElement, + WordElement, +) +from edify.elements.types.quantifiers import ( + AtLeastElement, + AtMostElement, + BetweenElement, + BetweenLazyElement, + ExactlyElement, + OneOrMoreElement, + OneOrMoreLazyElement, + OptionalElement, + ZeroOrMoreElement, + ZeroOrMoreLazyElement, +) +from edify.errors.introspect import MissingGraphvizDependencyError +from edify.introspect import graphviz as graphviz_module +from edify.introspect.graphviz import ( + _Counter, + _escape_dot, + render_dot, + render_graphviz_svg, +) + + +def _dot(*elements) -> str: + return render_dot(tuple(elements)) + + +def test_empty_pattern_produces_direct_start_to_end_edge(): + output = _dot() + assert "start -> end;" in output + assert "digraph edify_pattern" in output + + +def test_single_digit_pattern_creates_one_labeled_node(): + output = _dot(DigitElement()) + assert 'label="digit"' in output + assert "start -> n_1" in output + assert "n_1 -> end" in output + + +def test_header_configures_left_to_right_layout_and_menlo_font(): + output = _dot(DigitElement()) + assert "rankdir=LR" in output + assert 'fontname="Menlo"' in output + + +def test_start_and_end_terminals_are_circles(): + output = _dot(DigitElement()) + assert 'start [label="START", shape=circle]' in output + assert 'end [label="END", shape=circle]' in output + + +def test_one_or_more_digit_folds_quantifier_as_second_line(): + output = _dot(OneOrMoreElement(child=DigitElement())) + assert 'label="digit\\n(one or more)"' in output + + +def test_one_or_more_lazy_appends_lazy_marker_to_phrase(): + output = _dot(OneOrMoreLazyElement(child=DigitElement())) + assert "one or more (lazy)" in output + + +def test_zero_or_more_reads_as_zero_or_more_phrase(): + output = _dot(ZeroOrMoreElement(child=DigitElement())) + assert "zero or more" in output + + +def test_zero_or_more_lazy_appends_lazy_marker(): + output = _dot(ZeroOrMoreLazyElement(child=DigitElement())) + assert "zero or more (lazy)" in output + + +def test_optional_reads_as_optional_phrase(): + output = _dot(OptionalElement(child=DigitElement())) + assert "optional" in output + + +def test_exactly_reads_as_exactly_n_phrase(): + output = _dot(ExactlyElement(times=4, child=DigitElement())) + assert "exactly 4" in output + + +def test_at_least_reads_as_at_least_n_phrase(): + output = _dot(AtLeastElement(times=3, child=DigitElement())) + assert "at least 3" in output + + +def test_at_most_reads_as_at_most_n_phrase(): + output = _dot(AtMostElement(times=5, child=DigitElement())) + assert "at most 5" in output + + +def test_between_reads_as_lower_to_upper_phrase(): + output = _dot(BetweenElement(lower=2, upper=5, child=DigitElement())) + assert "2 to 5" in output + + +def test_between_lazy_appends_lazy_marker(): + output = _dot(BetweenLazyElement(lower=2, upper=5, child=DigitElement())) + assert "2 to 5 (lazy)" in output + + +def test_alternation_creates_fork_and_merge_junction_points(): + output = _dot( + AnyOfElement( + children=( + StringElement(value="cat"), + StringElement(value="dog"), + StringElement(value="fish"), + ) + ) + ) + assert 'shape=point, width=0.08' in output + assert output.count("shape=point") == 2 + assert output.count("fork_") >= 1 + assert output.count("merge_") >= 1 + + +def test_alternation_emits_edge_from_fork_and_to_merge_for_each_branch(): + output = _dot( + AnyOfElement( + children=( + StringElement(value="cat"), + StringElement(value="dog"), + StringElement(value="fish"), + ) + ) + ) + assert output.count("fork_1 ->") == 3 + assert output.count("-> merge_2") == 3 + + +def test_empty_alternation_becomes_nothing_placeholder(): + output = _dot(AnyOfElement(children=())) + assert 'label="nothing"' in output + + +def test_named_capture_wraps_children_in_dashed_cluster(): + output = _dot( + NamedCaptureElement( + name="year", + children=(ExactlyElement(times=4, child=DigitElement()),), + ) + ) + assert 'label="saved as \\"year\\""' in output + assert 'style="dashed,rounded"' in output + assert "subgraph cluster_" in output + + +def test_unnamed_capture_wraps_children_in_captured_cluster(): + output = _dot(CaptureElement(children=(DigitElement(),))) + assert 'label="captured"' in output + + +def test_empty_capture_becomes_captured_empty_placeholder(): + output = _dot(CaptureElement(children=())) + assert "captured (empty)" in output + + +def test_group_wraps_children_in_grouped_cluster(): + output = _dot(GroupElement(children=(DigitElement(),))) + assert 'label="grouped"' in output + + +def test_subexpression_flattens_into_a_plain_sequence(): + output = _dot( + SubexpressionElement(children=(DigitElement(), WordElement())) + ) + assert "cluster_" not in output + assert 'label="digit"' in output + assert 'label="word character"' in output + + +def test_empty_subexpression_becomes_empty_placeholder(): + output = _dot(SubexpressionElement(children=())) + assert "empty subexpression" in output + + +def test_start_of_input_reads_as_text_starts_here(): + output = _dot(StartOfInputElement()) + assert 'label="text starts here"' in output + + +def test_end_of_input_reads_as_text_ends_here(): + output = _dot(EndOfInputElement()) + assert 'label="text ends here"' in output + + +def test_backreference_reads_as_match_same_text_as_group_n(): + output = _dot(BackReferenceElement(index=2)) + assert "match same text as group 2" in output + + +def test_named_backreference_reads_as_match_same_text_as_name(): + output = _dot(NamedBackReferenceElement(name="year")) + assert 'match same text as \\"year\\"' in output + + +def test_assert_ahead_wraps_children_in_must_be_followed_by_cluster(): + output = _dot(AssertAheadElement(children=(DigitElement(),))) + assert '"must be followed by"' in output + + +def test_assert_not_ahead_wraps_children_in_must_not_be_followed_by_cluster(): + output = _dot(AssertNotAheadElement(children=(DigitElement(),))) + assert "must NOT be followed by" in output + + +def test_assert_behind_wraps_children_in_must_be_preceded_by_cluster(): + output = _dot(AssertBehindElement(children=(DigitElement(),))) + assert "must be preceded by" in output + + +def test_assert_not_behind_wraps_children_in_must_not_be_preceded_by_cluster(): + output = _dot(AssertNotBehindElement(children=(DigitElement(),))) + assert "must NOT be preceded by" in output + + +def test_character_literal_display_strips_regex_escaping(): + output = _dot(CharElement(value="\\-")) + assert '"\\"-\\""' in output + + +def test_quantifier_wrapping_complex_child_uses_cluster_not_inline_label(): + inner = AnyOfElement( + children=(StringElement(value="a"), StringElement(value="b")) + ) + output = _dot(OneOrMoreElement(child=inner)) + assert '"one or more"' in output + assert "subgraph cluster_" in output + + +def test_unknown_element_type_produces_question_mark_label(): + class MysteryElement(DigitElement.__mro__[1]): + pass + + output = _dot(MysteryElement()) + assert "?MysteryElement" in output + + +def test_counter_advances_and_produces_unique_ids(): + counter = _Counter() + first = counter.next("n") + second = counter.next("n") + third = counter.next("fork") + assert first == "n_1" + assert second == "n_2" + assert third == "fork_3" + + +def test_escape_dot_escapes_backslashes_and_quotes(): + assert _escape_dot('a"b') == 'a\\"b' + assert _escape_dot("a\\b") == "a\\\\b" + + +def test_escape_dot_preserves_newline_escape_for_two_line_labels(): + assert _escape_dot("digit\\n(one or more)") == "digit\\n(one or more)" + + +def test_render_dot_end_to_end_via_builder(): + dot = RegexBuilder().any_of("cat", "dog", "fish").to_regex() + output = render_dot(dot.elements) + assert 'label="\\"cat\\""' in output + assert 'label="\\"dog\\""' in output + assert 'label="\\"fish\\""' in output + + +def test_render_graphviz_svg_returns_svg_string_when_graphviz_available(): + regex = RegexBuilder().digit().to_regex() + output = render_graphviz_svg(regex.elements) + assert output.startswith("" 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 "" in svg diff --git a/tests/introspect/verbose.test.py b/tests/introspect/verbose.test.py new file mode 100644 index 0000000..1f339a2 --- /dev/null +++ b/tests/introspect/verbose.test.py @@ -0,0 +1,459 @@ +"""Tests for the re.VERBOSE-compatible renderer in :mod:`edify.introspect.verbose`.""" + +import re + +from edify import RegexBuilder +from edify.elements.types.captures import ( + BackReferenceElement, + CaptureElement, + NamedBackReferenceElement, + NamedCaptureElement, +) +from edify.elements.types.chars import ( + AnyOfCharsElement, + AnythingButCharsElement, + AnythingButRangeElement, + AnythingButStringElement, + CharElement, + RangeElement, + StringElement, +) +from edify.elements.types.groups import ( + AnyOfElement, + AssertAheadElement, + AssertBehindElement, + AssertNotAheadElement, + AssertNotBehindElement, + GroupElement, + SubexpressionElement, +) +from edify.elements.types.leaves import ( + AlphanumericElement, + AnyCharElement, + CarriageReturnElement, + DigitElement, + EndOfInputElement, + LetterElement, + LowercaseElement, + NewLineElement, + NonDigitElement, + NonWhitespaceCharElement, + NonWordBoundaryElement, + NonWordElement, + NoopElement, + NullByteElement, + StartOfInputElement, + TabElement, + UppercaseElement, + WhitespaceCharElement, + WordBoundaryElement, + WordElement, +) +from edify.elements.types.quantifiers import ( + AtLeastElement, + AtMostElement, + BetweenElement, + BetweenLazyElement, + ExactlyElement, + OneOrMoreElement, + OneOrMoreLazyElement, + OptionalElement, + ZeroOrMoreElement, + ZeroOrMoreLazyElement, +) +from edify.introspect import verbose as verbose_module +from edify.introspect.verbose import _render_line, verbose_elements + + +def _verbose(*elements) -> str: + return verbose_elements(tuple(elements)) + + +def _strip_pattern(output: str) -> str: + fragments = [] + for line in output.splitlines(): + without_comment = line.split("#", 1)[0] + code = without_comment.strip() + if code: + fragments.append(code) + return "".join(fragments) + + +def test_empty_elements_produces_empty_string(): + assert _verbose() == "" + + +def test_single_digit_reads_as_backslash_d_with_comment(): + output = _verbose(DigitElement()) + assert output.startswith("\\d") + assert "any digit (0-9)" in output + + +def test_start_of_input_reads_as_caret_with_comment(): + output = _verbose(StartOfInputElement()) + assert output.startswith("^") + assert "start of input" in output + + +def test_end_of_input_reads_as_dollar_with_comment(): + output = _verbose(EndOfInputElement()) + assert output.startswith("$") + assert "end of input" in output + + +def test_any_char_reads_as_dot_with_comment(): + output = _verbose(AnyCharElement()) + assert output.startswith(".") + assert "any single character" in output + + +def test_whitespace_reads_as_backslash_s(): + output = _verbose(WhitespaceCharElement()) + assert output.startswith("\\s") + + +def test_non_whitespace_reads_as_backslash_S(): + output = _verbose(NonWhitespaceCharElement()) + assert output.startswith("\\S") + + +def test_non_digit_reads_as_backslash_D(): + output = _verbose(NonDigitElement()) + assert output.startswith("\\D") + + +def test_word_reads_as_backslash_w(): + output = _verbose(WordElement()) + assert output.startswith("\\w") + + +def test_non_word_reads_as_backslash_W(): + output = _verbose(NonWordElement()) + assert output.startswith("\\W") + + +def test_word_boundary_reads_as_backslash_b(): + output = _verbose(WordBoundaryElement()) + assert output.startswith("\\b") + + +def test_non_word_boundary_reads_as_backslash_B(): + output = _verbose(NonWordBoundaryElement()) + assert output.startswith("\\B") + + +def test_new_line_reads_as_backslash_n(): + output = _verbose(NewLineElement()) + assert output.startswith("\\n") + + +def test_carriage_return_reads_as_backslash_r(): + output = _verbose(CarriageReturnElement()) + assert output.startswith("\\r") + + +def test_tab_reads_as_backslash_t(): + output = _verbose(TabElement()) + assert output.startswith("\\t") + + +def test_null_byte_reads_as_backslash_zero(): + output = _verbose(NullByteElement()) + assert output.startswith("\\0") + + +def test_letter_reads_as_a_z_A_Z_class(): + output = _verbose(LetterElement()) + assert output.startswith("[a-zA-Z]") + + +def test_uppercase_reads_as_A_Z_class(): + output = _verbose(UppercaseElement()) + assert output.startswith("[A-Z]") + + +def test_lowercase_reads_as_a_z_class(): + output = _verbose(LowercaseElement()) + assert output.startswith("[a-z]") + + +def test_alphanumeric_reads_as_a_z_A_Z_0_9_class(): + output = _verbose(AlphanumericElement()) + assert output.startswith("[a-zA-Z0-9]") + + +def test_noop_reads_as_no_op_comment_and_empty_pattern(): + output = _verbose(NoopElement()) + assert "no-op" in output + assert _strip_pattern(output) == "" + + +def test_char_literal_shows_raw_value_and_literal_comment(): + output = _verbose(CharElement(value="a")) + assert output.startswith("a") + assert 'literal "a"' in output + + +def test_string_literal_shows_raw_value_and_literal_string_comment(): + output = _verbose(StringElement(value="hello")) + assert output.startswith("hello") + assert 'literal string "hello"' in output + + +def test_range_reads_as_bracketed_range_class(): + output = _verbose(RangeElement(start="a", end="z")) + assert output.startswith("[a-z]") + assert "range a-z" in output + + +def test_any_of_chars_reads_as_bracketed_class(): + output = _verbose(AnyOfCharsElement(value="abc")) + assert output.startswith("[abc]") + + +def test_anything_but_chars_reads_as_negated_bracketed_class(): + output = _verbose(AnythingButCharsElement(value="abc")) + assert output.startswith("[^abc]") + + +def test_anything_but_range_reads_as_negated_range_class(): + output = _verbose(AnythingButRangeElement(start="a", end="z")) + assert output.startswith("[^a-z]") + + +def test_anything_but_string_produces_per_position_negation(): + output = _verbose(AnythingButStringElement(value="ab")) + assert "per-position negation" in output + + +def test_optional_digit_reads_as_question_mark_with_comment(): + output = _verbose(OptionalElement(child=DigitElement())) + assert output.startswith("\\d?") + assert "optional (zero or one)" in output + + +def test_zero_or_more_digit_reads_as_star_greedy(): + output = _verbose(ZeroOrMoreElement(child=DigitElement())) + assert output.startswith("\\d*") + assert "zero or more (greedy)" in output + + +def test_zero_or_more_lazy_reads_as_star_lazy(): + output = _verbose(ZeroOrMoreLazyElement(child=DigitElement())) + assert output.startswith("\\d*?") + assert "zero or more (lazy)" in output + + +def test_one_or_more_digit_reads_as_plus_greedy(): + output = _verbose(OneOrMoreElement(child=DigitElement())) + assert output.startswith("\\d+") + assert "one or more (greedy)" in output + + +def test_one_or_more_lazy_reads_as_plus_lazy(): + output = _verbose(OneOrMoreLazyElement(child=DigitElement())) + assert output.startswith("\\d+?") + + +def test_exactly_reads_as_curly_braces_with_count(): + output = _verbose(ExactlyElement(times=4, child=DigitElement())) + assert output.startswith("\\d{4}") + assert "exactly 4" in output + + +def test_at_least_reads_as_curly_braces_with_open_upper(): + output = _verbose(AtLeastElement(times=3, child=DigitElement())) + assert output.startswith("\\d{3,}") + assert "at least 3" in output + + +def test_at_most_reads_as_curly_braces_with_zero_lower(): + output = _verbose(AtMostElement(times=5, child=DigitElement())) + assert output.startswith("\\d{0,5}") + assert "at most 5" in output + + +def test_between_reads_as_curly_braces_with_lower_upper(): + output = _verbose(BetweenElement(lower=2, upper=5, child=DigitElement())) + assert output.startswith("\\d{2,5}") + assert "between 2 and 5 (greedy)" in output + + +def test_between_lazy_reads_as_curly_braces_with_question_mark(): + output = _verbose(BetweenLazyElement(lower=2, upper=5, child=DigitElement())) + assert output.startswith("\\d{2,5}?") + assert "between 2 and 5 (lazy)" in output + + +def test_quantifier_around_multi_char_string_wraps_child_in_non_capturing_group(): + output = _verbose(OneOrMoreElement(child=StringElement(value="ab"))) + assert output.startswith("(?:ab)+") + + +def test_quantifier_around_single_char_string_does_not_add_grouping(): + output = _verbose(OneOrMoreElement(child=StringElement(value="a"))) + assert output.startswith("a+") + + +def test_quantifier_around_group_uses_multi_line_wrap(): + inner = GroupElement(children=(DigitElement(), WordElement())) + output = _verbose(OneOrMoreElement(child=inner)) + assert "begin group for one or more (greedy)" in output + assert "end group; apply one or more (greedy)" in output + + +def test_non_capturing_group_reads_with_begin_and_end_comments(): + output = _verbose(GroupElement(children=(DigitElement(),))) + assert "begin non-capturing group" in output + assert "end non-capturing group" in output + + +def test_alternation_with_multiple_alternatives_lists_each_with_pipe(): + output = _verbose( + AnyOfElement( + children=( + StringElement(value="cat"), + StringElement(value="dog"), + StringElement(value="fish"), + ) + ) + ) + assert "begin alternation" in output + assert "end alternation" in output + assert "alternative 1" in output + assert "alternative 2" in output + assert "alternative 3" in output + assert output.count(" or") >= 2 + + +def test_alternation_with_no_children_reads_as_empty_alternation(): + output = _verbose(AnyOfElement(children=())) + assert "empty alternation" in output + assert "(?:)" in output + + +def test_subexpression_inlines_its_children_without_extra_scoping(): + output = _verbose( + SubexpressionElement(children=(DigitElement(), CharElement(value="-"))) + ) + assert "begin non-capturing group" not in output + lines = [line for line in output.splitlines() if line.strip()] + assert len(lines) == 2 + + +def test_capture_reads_with_begin_captured_group_comment(): + output = _verbose(CaptureElement(children=(DigitElement(),))) + assert "begin captured group" in output + assert "end captured group" in output + + +def test_named_capture_includes_the_name_in_both_comments(): + output = _verbose( + NamedCaptureElement(name="year", children=(DigitElement(),)) + ) + assert 'begin group named "year"' in output + assert 'end group named "year"' in output + assert "(?P" 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_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 "" 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\\d)(?P\\w)", + re.compile("(?P\\d)(?P\\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 -- cgit v1.2.3 From 4cc194a3b00ca38b46c496ad47e611afa2038303 Mon Sep 17 00:00:00 2001 From: natsuoto <279971144+natsuoto@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:19:13 +0530 Subject: test(builder): extend Hypothesis property test to cover groups, captures, subexpressions (#111) --- tests/builder/properties.test.py | 211 +++++++++++++++++++++++++++++++++------ 1 file changed, 182 insertions(+), 29 deletions(-) diff --git a/tests/builder/properties.test.py b/tests/builder/properties.test.py index 24f5d01..99605aa 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 ```` 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,141 @@ _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) + + +def _build_leaf(pair: tuple[tuple[str, tuple[int, ...], str], tuple[str, tuple[int, ...], str] | 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 -- cgit v1.2.3 From 0d029b2fe8426270b232e81778d2d9eff768ae13 Mon Sep 17 00:00:00 2001 From: natsuoto <279971144+natsuoto@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:24:40 +0530 Subject: docs: strip design narrative from module docstrings, keep API contract --- edify/builder/builder.py | 12 +----------- edify/builder/mixins/flags.py | 3 +-- edify/builder/mixins/operators.py | 11 ++--------- edify/compile/escape.py | 6 +----- edify/compile/leaves.py | 7 +------ edify/elements/types/base.py | 14 ++++---------- edify/elements/types/captures.py | 4 ---- edify/elements/types/chars.py | 7 ------- edify/elements/types/groups.py | 3 --- edify/elements/types/leaves.py | 5 ----- edify/elements/types/quantifiers.py | 4 ---- edify/elements/types/union.py | 9 +-------- edify/pattern/anchors.py | 6 ------ 13 files changed, 11 insertions(+), 80 deletions(-) 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/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/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/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...)`` named capture. * :class:`BackReferenceElement` — ``\\`` 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` — ``(? Date: Fri, 10 Jul 2026 16:27:09 +0530 Subject: chore: apply ruff lint and format fixes --- edify/errors/captures.py | 4 +--- edify/errors/input.py | 19 +++++---------- edify/errors/internal.py | 7 +++--- edify/errors/naming.py | 3 +-- edify/introspect/ascii.py | 24 +++++++------------ edify/introspect/graphviz.py | 8 ++----- tests/builder/properties.test.py | 6 ++++- tests/errors/errors.test.py | 6 +++-- tests/errors/formatting.test.py | 4 +--- tests/introspect/ascii.test.py | 50 ++++++++------------------------------- tests/introspect/explain.test.py | 44 +++++++++------------------------- tests/introspect/graphviz.test.py | 12 ++++------ tests/introspect/verbose.test.py | 26 ++++++++------------ 13 files changed, 66 insertions(+), 147 deletions(-) diff --git a/edify/errors/captures.py b/edify/errors/captures.py index 9f052cf..409885c 100644 --- a/edify/errors/captures.py +++ b/edify/errors/captures.py @@ -16,9 +16,7 @@ class InvalidTotalCaptureGroupsIndexError(EdifySyntaxError): def __init__(self, index: int, total_capture_groups: int) -> None: if total_capture_groups == 0: - note = ( - f"the pattern has no capture groups yet; index {index} refers to nothing." - ) + 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 "" diff --git a/edify/errors/input.py b/edify/errors/input.py index a709772..31a6333 100644 --- a/edify/errors/input.py +++ b/edify/errors/input.py @@ -46,9 +46,7 @@ class MustBeOneCharacterError(EdifySyntaxError): 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\")." - ), + help_line=(f'help: pass a single-character string for {lowered} (e.g. "a").'), ) super().__init__(message) @@ -72,7 +70,7 @@ class MustBeSingleCharacterError(EdifySyntaxError): ), help_line=( f"help: pass a single-character string for {lowered} " - "(e.g. \"a\" — not \"ab\" or a non-string value)." + '(e.g. "a" — not "ab" or a non-string value).' ), ) super().__init__(message) @@ -95,8 +93,7 @@ class MustBePositiveIntegerError(EdifySyntaxError): "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." + f"help: pass an int >= 1 for {lowered}; use .optional() if you meant zero-or-one." ), ) super().__init__(message) @@ -119,8 +116,7 @@ class MustBeIntegerGreaterThanZeroError(EdifySyntaxError): "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." + f"help: pass an int > 0 for {lowered}; use .optional() if you meant zero-or-one." ), ) super().__init__(message) @@ -139,8 +135,7 @@ class MustBeInstanceError(EdifySyntaxError): lowered = label.lower() message = compose_annotated_message( summary=( - f"{label} must be an instance of {expected_class_name} " - f"(got {actual_type_name})" + f"{label} must be an instance of {expected_class_name} (got {actual_type_name})" ), trigger_hint=f"{lowered} passed here", note=( @@ -202,9 +197,7 @@ class MustBeLessThanError(EdifySyntaxError): 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." - ), + help_line=(f"help: swap the two arguments so {first_label} is the smaller value."), ) super().__init__(message) diff --git a/edify/errors/internal.py b/edify/errors/internal.py index 4181d83..04881d5 100644 --- a/edify/errors/internal.py +++ b/edify/errors/internal.py @@ -43,7 +43,8 @@ class NonFusableElementError(EdifySyntaxError): trigger_hint="pattern compiled here", note=( "the character-class fuser accepts only character-shaped elements " - f"(CharElement, RangeElement, AnyOfCharsElement); {element_type_name} is not one of them." + "(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 " @@ -90,9 +91,7 @@ class FailedToCompileRegexError(EdifySyntaxError): 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}" - ), + 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 " diff --git a/edify/errors/naming.py b/edify/errors/naming.py index 835c9d5..7ac6f09 100644 --- a/edify/errors/naming.py +++ b/edify/errors/naming.py @@ -22,8 +22,7 @@ class NameNotValidError(EdifySyntaxError): "and underscores (no spaces, hyphens, or punctuation)." ), help_line=( - f"help: rename {name!r} to a bare identifier " - "(e.g. 'year', 'user_id', 'scheme')." + f"help: rename {name!r} to a bare identifier (e.g. 'year', 'user_id', 'scheme')." ), ) super().__init__(message) diff --git a/edify/introspect/ascii.py b/edify/introspect/ascii.py index e9b2b67..adda379 100644 --- a/edify/introspect/ascii.py +++ b/edify/introspect/ascii.py @@ -74,10 +74,8 @@ def render_ascii(elements: tuple[BaseElement, ...]) -> str: """Return an ASCII railroad diagram for ``elements`` as a multi-line string.""" start_box = _boxed("START") end_box = _boxed("END") - parts: list[Diagram] = [start_box] - for element in elements: - parts.append(_element_diagram(element)) - parts.append(end_box) + 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) @@ -228,13 +226,9 @@ def _quantifier_label(element: BaseElement) -> str | None: 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)}" - ) + 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 f"{element.lower} to {element.upper} {_child_plural(element.child)} (lazy)" return None @@ -306,7 +300,7 @@ def _caption_below(diagram: Diagram, caption: str) -> Diagram: right_pad = new_width - left_pad - len(caption) caption_row = " " * left_pad + caption + " " * right_pad return Diagram( - rows=tuple(padded_rows) + (caption_row,), + rows=(*padded_rows, caption_row), entry_row=diagram.entry_row, width=new_width, ) @@ -396,7 +390,7 @@ def _looks_like_single_box(diagram: Diagram) -> bool: 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 + _top, middle, _bottom = diagram.rows new_border = "+" + "-" * (target_width - 2) + "+" interior = middle[1:-1] new_middle = "|" + interior + " " * extra + "|" @@ -416,7 +410,7 @@ def _join_horizontal(diagrams: list[Diagram]) -> Diagram: def _join_two(left: Diagram, right: Diagram) -> Diagram: - """Return ``left`` and ``right`` joined on their shared entry row with an ``_EDGE`` connector.""" + """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) @@ -426,9 +420,7 @@ def _join_two(left: Diagram, right: Diagram) -> Diagram: 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] - ) + 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) diff --git a/edify/introspect/graphviz.py b/edify/introspect/graphviz.py index 3e4b236..3440301 100644 --- a/edify/introspect/graphviz.py +++ b/edify/introspect/graphviz.py @@ -131,9 +131,7 @@ def _emit_leaf(label: str, counter: _Counter) -> Emission: return Emission(entry_id=node_id, exit_id=node_id, lines=lines) -def _emit_alternation( - children: tuple[BaseElement, ...], counter: _Counter -) -> Emission: +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) @@ -152,9 +150,7 @@ def _emit_alternation( return Emission(entry_id=fork_id, exit_id=merge_id, lines=lines_tuple) -def _emit_subexpression( - children: tuple[BaseElement, ...], counter: _Counter -) -> Emission: +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) diff --git a/tests/builder/properties.test.py b/tests/builder/properties.test.py index 99605aa..87770fa 100644 --- a/tests/builder/properties.test.py +++ b/tests/builder/properties.test.py @@ -90,7 +90,11 @@ _element_pick = st.one_of(*_ELEMENT_STRATEGIES) _quantifier_pick_or_none = st.one_of(st.none(), *_QUANTIFIER_STRATEGIES) -def _build_leaf(pair: tuple[tuple[str, tuple[int, ...], str], tuple[str, tuple[int, ...], str] | None]) -> LeafNode: +_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( diff --git a/tests/errors/errors.test.py b/tests/errors/errors.test.py index 5b48165..74acaa4 100644 --- a/tests/errors/errors.test.py +++ b/tests/errors/errors.test.py @@ -129,8 +129,10 @@ def test_must_have_a_smaller_value_reports_the_codepoints(): error = MustHaveASmallerValueError("z", "a") text = str(error) assert "range bounds are inverted" in text - assert "'z'" in text and "'a'" in text - assert "= 122" in text and "= 97" in text + assert "'z'" in text + assert "'a'" in text + assert "= 122" in text + assert "= 97" in text def test_must_be_less_than(): diff --git a/tests/errors/formatting.test.py b/tests/errors/formatting.test.py index 8cdf0ab..314aff1 100644 --- a/tests/errors/formatting.test.py +++ b/tests/errors/formatting.test.py @@ -114,9 +114,7 @@ def test_compose_annotated_message_when_caller_context_is_available(): def test_compose_annotated_message_falls_back_when_caller_context_is_none(): - with mock.patch( - "edify.errors.formatting.capture_caller_context", return_value=None - ): + with mock.patch("edify.errors.formatting.capture_caller_context", return_value=None): output = compose_annotated_message( summary="bad thing happened", trigger_hint="here", diff --git a/tests/introspect/ascii.test.py b/tests/introspect/ascii.test.py index 0ebcc8f..c2e3d68 100644 --- a/tests/introspect/ascii.test.py +++ b/tests/introspect/ascii.test.py @@ -1,6 +1,6 @@ """Tests for the ASCII railroad-diagram renderer in :mod:`edify.introspect.ascii`.""" -from edify import Pattern, RegexBuilder +from edify import RegexBuilder from edify.elements.types.captures import ( BackReferenceElement, CaptureElement, @@ -74,11 +74,7 @@ def _render(*elements) -> str: def test_empty_pattern_renders_start_arrow_end(): - assert _render() == ( - " +-------+ +-----+\n" - " | START |-->| END |\n" - " +-------+ +-----+" - ) + assert _render() == (" +-------+ +-----+\n | START |-->| END |\n +-------+ +-----+") def test_single_digit_pattern(): @@ -179,9 +175,7 @@ def test_named_capture_places_caption_below_child(): def test_unnamed_capture_places_captured_caption_below_child(): - output = _render( - CaptureElement(children=(DigitElement(),)) - ) + output = _render(CaptureElement(children=(DigitElement(),))) assert "(captured)" in output @@ -191,9 +185,7 @@ def test_group_places_grouped_caption_below_child(): def test_subexpression_flattens_into_the_sequence(): - output = _render( - SubexpressionElement(children=(DigitElement(), DigitElement())) - ) + output = _render(SubexpressionElement(children=(DigitElement(), DigitElement()))) assert output.count("digit") == 2 @@ -400,28 +392,14 @@ def test_visualize_with_alternation_end_to_end(): def test_visualize_named_capture_end_to_end(): - regex = ( - RegexBuilder() - .named_capture("year") - .exactly(4) - .digit() - .end() - .to_regex() - ) + 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() - ) + 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 @@ -513,9 +491,7 @@ def test_nested_alternation_pads_wider_branch_with_spaces(): def test_single_branch_alternation_looks_like_single_branch(): - output = _render( - AnyOfElement(children=(StringElement(value="only-one"),)) - ) + output = _render(AnyOfElement(children=(StringElement(value="only-one"),))) assert '"only-one"' in output assert "+--->" in output @@ -526,9 +502,7 @@ def test_looks_like_single_box_rejects_multi_row_diagram(): def test_looks_like_single_box_rejects_asymmetric_borders(): - asymmetric = Diagram( - rows=("+---+", "| a |", "+xxx+"), entry_row=1, width=5 - ) + asymmetric = Diagram(rows=("+---+", "| a |", "+xxx+"), entry_row=1, width=5) assert _looks_like_single_box(asymmetric) is False @@ -538,16 +512,12 @@ def test_looks_like_single_box_rejects_non_box_borders(): def test_looks_like_single_box_rejects_middle_without_pipes(): - bad_middle = Diagram( - rows=("+---+", " a ", "+---+"), entry_row=1, width=5 - ) + 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 - ) + striped = Diagram(rows=("+-x-+", "| a |", "+-x-+"), entry_row=1, width=5) assert _looks_like_single_box(striped) is False diff --git a/tests/introspect/explain.test.py b/tests/introspect/explain.test.py index c782dfb..6444fb2 100644 --- a/tests/introspect/explain.test.py +++ b/tests/introspect/explain.test.py @@ -186,9 +186,7 @@ def test_capture_step_describes_children_inline(): def test_named_capture_step_describes_children_inline(): - output = _explain( - NamedCaptureElement(name="year", children=(DigitElement(),)) - ) + output = _explain(NamedCaptureElement(name="year", children=(DigitElement(),))) assert "one digit" in output @@ -203,9 +201,7 @@ def test_named_backreference_step_names_the_group_label(): def test_assert_ahead_reads_as_must_be_followed_by(): - output = _explain( - DigitElement(), AssertAheadElement(children=(CharElement(value="/"),)) - ) + output = _explain(DigitElement(), AssertAheadElement(children=(CharElement(value="/"),))) assert "must be followed by" in output @@ -218,9 +214,7 @@ def test_assert_not_ahead_reads_as_must_not_be_followed_by(): def test_assert_behind_reads_as_just_before_this_point(): - output = _explain( - DigitElement(), AssertBehindElement(children=(CharElement(value="/"),)) - ) + output = _explain(DigitElement(), AssertBehindElement(children=(CharElement(value="/"),))) assert "Just before this point" in output @@ -239,9 +233,7 @@ def test_group_step_describes_children_inline(): def test_alternation_uses_either_or_for_two_alternatives(): output = _explain( - AnyOfElement( - children=(StringElement(value="cat"), StringElement(value="dog")) - ) + AnyOfElement(children=(StringElement(value="cat"), StringElement(value="dog"))) ) assert 'either "cat" or "dog"' in output @@ -287,9 +279,7 @@ def test_examples_section_omitted_when_only_empty_strings_are_generated(): def test_subexpression_is_flattened_into_the_step_list(): - output = _explain( - SubexpressionElement(children=(DigitElement(), CharElement(value="-"))) - ) + output = _explain(SubexpressionElement(children=(DigitElement(), CharElement(value="-")))) assert output.count("- ") >= 2 @@ -429,9 +419,7 @@ def test_examples_for_alternation_pick_a_different_branch_per_seed(): def test_examples_for_optional_include_both_present_and_absent_forms(): - output = _explain( - DigitElement(), OptionalElement(child=CharElement(value="x")), DigitElement() - ) + output = _explain(DigitElement(), OptionalElement(child=CharElement(value="x")), DigitElement()) strings = _accepted_examples(output) assert any("x" in text for text in strings) @@ -669,9 +657,7 @@ def test_plural_anything_but_chars_reads_as_not_from_the_set(): def test_plural_anything_but_range_reads_as_outside_range(): - assert 'outside "a" through "z"' in _plural( - AnythingButRangeElement(start="a", end="z") - ) + assert 'outside "a" through "z"' in _plural(AnythingButRangeElement(start="a", end="z")) def test_plural_unknown_element_falls_back_to_of_inline_form(): @@ -756,9 +742,7 @@ def test_inline_at_most_reads_as_at_most_n_x(): def test_inline_between_reads_as_between_lower_and_upper(): - assert "between 2 and 5" in _inline( - BetweenElement(lower=2, upper=5, child=DigitElement()) - ) + assert "between 2 and 5" in _inline(BetweenElement(lower=2, upper=5, child=DigitElement())) def test_inline_between_lazy_notes_as_few_as_possible(): @@ -772,9 +756,7 @@ def test_inline_capture_reads_as_child_captured_marker(): def test_inline_named_capture_includes_the_label(): - assert 'label "year"' in _inline( - NamedCaptureElement(name="year", children=(DigitElement(),)) - ) + assert 'label "year"' in _inline(NamedCaptureElement(name="year", children=(DigitElement(),))) def test_inline_group_reads_as_children_inline(): @@ -787,9 +769,7 @@ def test_inline_subexpression_reads_as_children_inline(): def test_inline_alternation_reads_as_either_or_phrase(): assert "either" in _inline( - AnyOfElement( - children=(StringElement(value="a"), StringElement(value="b")) - ) + AnyOfElement(children=(StringElement(value="a"), StringElement(value="b"))) ) @@ -802,9 +782,7 @@ def test_inline_named_backreference_reads_as_that_the_name_group_captured(): def test_optional_inner_reads_group_children_inline(): - assert "one digit" in _describe_optional_inner( - GroupElement(children=(DigitElement(),)) - ) + assert "one digit" in _describe_optional_inner(GroupElement(children=(DigitElement(),))) def test_describe_inline_children_reads_as_nothing_when_empty(): diff --git a/tests/introspect/graphviz.test.py b/tests/introspect/graphviz.test.py index 516d91b..aad8378 100644 --- a/tests/introspect/graphviz.test.py +++ b/tests/introspect/graphviz.test.py @@ -6,7 +6,7 @@ import sys import pytest -from edify import Pattern, RegexBuilder +from edify import RegexBuilder from edify.elements.types.captures import ( BackReferenceElement, CaptureElement, @@ -143,7 +143,7 @@ def test_alternation_creates_fork_and_merge_junction_points(): ) ) ) - assert 'shape=point, width=0.08' in output + assert "shape=point, width=0.08" in output assert output.count("shape=point") == 2 assert output.count("fork_") >= 1 assert output.count("merge_") >= 1 @@ -196,9 +196,7 @@ def test_group_wraps_children_in_grouped_cluster(): def test_subexpression_flattens_into_a_plain_sequence(): - output = _dot( - SubexpressionElement(children=(DigitElement(), WordElement())) - ) + output = _dot(SubexpressionElement(children=(DigitElement(), WordElement()))) assert "cluster_" not in output assert 'label="digit"' in output assert 'label="word character"' in output @@ -255,9 +253,7 @@ def test_character_literal_display_strips_regex_escaping(): def test_quantifier_wrapping_complex_child_uses_cluster_not_inline_label(): - inner = AnyOfElement( - children=(StringElement(value="a"), StringElement(value="b")) - ) + 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 diff --git a/tests/introspect/verbose.test.py b/tests/introspect/verbose.test.py index 1f339a2..7af8386 100644 --- a/tests/introspect/verbose.test.py +++ b/tests/introspect/verbose.test.py @@ -112,12 +112,12 @@ def test_whitespace_reads_as_backslash_s(): assert output.startswith("\\s") -def test_non_whitespace_reads_as_backslash_S(): +def test_non_whitespace_reads_as_backslash_capital_s(): output = _verbose(NonWhitespaceCharElement()) assert output.startswith("\\S") -def test_non_digit_reads_as_backslash_D(): +def test_non_digit_reads_as_backslash_capital_d(): output = _verbose(NonDigitElement()) assert output.startswith("\\D") @@ -127,7 +127,7 @@ def test_word_reads_as_backslash_w(): assert output.startswith("\\w") -def test_non_word_reads_as_backslash_W(): +def test_non_word_reads_as_backslash_capital_w(): output = _verbose(NonWordElement()) assert output.startswith("\\W") @@ -137,7 +137,7 @@ def test_word_boundary_reads_as_backslash_b(): assert output.startswith("\\b") -def test_non_word_boundary_reads_as_backslash_B(): +def test_non_word_boundary_reads_as_backslash_capital_b(): output = _verbose(NonWordBoundaryElement()) assert output.startswith("\\B") @@ -162,12 +162,12 @@ def test_null_byte_reads_as_backslash_zero(): assert output.startswith("\\0") -def test_letter_reads_as_a_z_A_Z_class(): +def test_letter_reads_as_alpha_class(): output = _verbose(LetterElement()) assert output.startswith("[a-zA-Z]") -def test_uppercase_reads_as_A_Z_class(): +def test_uppercase_reads_as_uppercase_class(): output = _verbose(UppercaseElement()) assert output.startswith("[A-Z]") @@ -177,7 +177,7 @@ def test_lowercase_reads_as_a_z_class(): assert output.startswith("[a-z]") -def test_alphanumeric_reads_as_a_z_A_Z_0_9_class(): +def test_alphanumeric_reads_as_alphanum_class(): output = _verbose(AlphanumericElement()) assert output.startswith("[a-zA-Z0-9]") @@ -333,9 +333,7 @@ def test_alternation_with_no_children_reads_as_empty_alternation(): def test_subexpression_inlines_its_children_without_extra_scoping(): - output = _verbose( - SubexpressionElement(children=(DigitElement(), CharElement(value="-"))) - ) + 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 @@ -348,9 +346,7 @@ def test_capture_reads_with_begin_captured_group_comment(): def test_named_capture_includes_the_name_in_both_comments(): - output = _verbose( - NamedCaptureElement(name="year", children=(DigitElement(),)) - ) + output = _verbose(NamedCaptureElement(name="year", children=(DigitElement(),))) assert 'begin group named "year"' in output assert 'end group named "year"' in output assert "(?P" in output @@ -431,9 +427,7 @@ def test_output_compiles_and_matches_for_alternation_pattern(): def test_alignment_uses_common_comment_column_across_lines(): - output = _verbose( - DigitElement(), CharElement(value="-"), DigitElement() - ) + output = _verbose(DigitElement(), CharElement(value="-"), DigitElement()) hash_columns = [line.index("#") for line in output.splitlines() if "#" in line] assert len(set(hash_columns)) == 1 -- cgit v1.2.3 From 4b923b22547dccccca1670862f5cb90c2d8b5ea7 Mon Sep 17 00:00:00 2001 From: natsuoto <279971144+natsuoto@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:48:52 +0530 Subject: chore: declare graphviz optional dependency and add to dev group --- pyproject.toml | 4 ++++ uv.lock | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) 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/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" }, @@ -260,6 +269,15 @@ docs = [ { name = "sphinx-rtd-theme" }, ] +[[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" -- cgit v1.2.3 From 433a7af9e012f88f59da1ace228dd79245cfa4bd Mon Sep 17 00:00:00 2001 From: natsuoto <279971144+natsuoto@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:51:09 +0530 Subject: ci: install graphviz system package for SVG-rendering tests --- .github/workflows/coverage.yml | 2 ++ .github/workflows/github-actions.yml | 2 ++ 2 files changed, 4 insertions(+) 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 }} -- cgit v1.2.3 From 6f27b88358e5760cdd034a77a61de7a32e77c425 Mon Sep 17 00:00:00 2001 From: natsuoto <279971144+natsuoto@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:55:33 +0530 Subject: test: cover diagnose fallback branches and caller-context position fallbacks --- tests/builder/diagnose.test.py | 105 ++++++++++++++++++++++++++++++++++++++ tests/errors/context.test.py | 112 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 tests/builder/diagnose.test.py create mode 100644 tests/errors/context.test.py 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 == "" + 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/errors/context.test.py b/tests/errors/context.test.py new file mode 100644 index 0000000..3b54605 --- /dev/null +++ b/tests/errors/context.test.py @@ -0,0 +1,112 @@ +"""Tests for the caller-source-location capture helpers in :mod:`edify.errors.context`.""" + +from types import SimpleNamespace +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" -- cgit v1.2.3 From 174eb521850550830d97994719ca3d09c467cb5a Mon Sep 17 00:00:00 2001 From: natsuoto <279971144+natsuoto@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:56:03 +0530 Subject: chore: apply ruff format and drop unused import in new context tests --- tests/errors/context.test.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/tests/errors/context.test.py b/tests/errors/context.test.py index 3b54605..fff9e09 100644 --- a/tests/errors/context.test.py +++ b/tests/errors/context.test.py @@ -1,6 +1,5 @@ """Tests for the caller-source-location capture helpers in :mod:`edify.errors.context`.""" -from types import SimpleNamespace from unittest import mock from edify.errors.context import ( @@ -37,9 +36,7 @@ class _FakeFrame: def test_capture_caller_context_returns_none_when_every_frame_is_inside_edify(): - with mock.patch( - "edify.errors.context.sys._getframe", return_value=None - ): + with mock.patch("edify.errors.context.sys._getframe", return_value=None): assert capture_caller_context() is None @@ -105,8 +102,6 @@ def test_context_for_frame_defaults_end_col_when_position_end_col_is_none(): 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" - ) + 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" -- cgit v1.2.3