aboutsummaryrefslogtreecommitdiff
path: root/tests/errors/errors.test.py
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-07-10 16:59:00 +0530
committerGitHub <[email protected]>2026-07-10 16:59:00 +0530
commit189bcdabe3865a0d52aea580d3fdbcc1cd48e896 (patch)
tree8db00421de426b98a86d4b65665c71be7adcf0bf /tests/errors/errors.test.py
parentc757ea1fea447df3ed69e74b407ca9710a9f85e0 (diff)
parent174eb521850550830d97994719ca3d09c467cb5a (diff)
downloadedify-189bcdabe3865a0d52aea580d3fdbcc1cd48e896.tar.xz
edify-189bcdabe3865a0d52aea580d3fdbcc1cd48e896.zip
feat: regex introspection API — explain, verbose export, ASCII + SVG visualize + annotated errors (#272)
Ships the regex introspection API on the `Regex` wrapper — three new methods that turn a compiled pattern into something a human can read, plus a full retrofit of every error message to a shape that shows the caller *where* the failure happened and *how* to fix it. ## `Regex.explain()` Plain-English bullet list of what the pattern accepts, followed by a small set of concrete accepted strings. ``` - The text must start with either "http" or "https". - Then the text must have "://". - Then the text must have one or more letters, digits, or underscores. Text this pattern accepts: http://ab1 https://b1_c http://1_c2d ``` ## `Regex.to_verbose_string()` An `re.VERBOSE`-compatible export where every fragment is annotated inline. Copy-pasting the output into `re.compile(..., re.VERBOSE)` compiles to the same pattern. ``` (?P<year> # begin group named "year" \d{4} # exactly 4 ) # end group named "year" ``` ## `Regex.visualize(format='ascii')` ASCII railroad diagram — `START` and `END` boxes flanking the pattern, arrows between elements, fork/merge junctions for alternation, dashed-caption blocks for captures and lookarounds. ``` +--------+ +--->| "cat" |----+ | +--------+ | | | +-------+ | +--------+ | +-----+ | START |------>+--->| "dog" |----+-->| END | +-------+ | +--------+ | +-----+ | | | +--------+ | +--->| "fish" |----+ +--------+ ``` ## `Regex.visualize(format='svg', engine='graphviz')` SVG rendered by Graphviz. Junction-point fork/merge for alternation, dashed rounded clusters for captures/lookarounds, folded two-line node labels for quantifiers (e.g. `digit\n(one or more)`). Requires the optional `graphviz` extra; the missing-dependency error names the install command. ## Annotated error messages Every exception across `edify.errors` — anchors, captures, input, internal, naming, quantifier, structure — now emits a message that names the offending call in the caller's source, explains the invariant that was violated, and prescribes the fix: ``` error: start_of_input has already been added to this pattern --> user_code.py:14:26 | 14 | pattern = RegexBuilder().start_of_input().digit().start_of_input() | ^^^^^^^^^^^^^^^^ second start_of_input added here | = note: a pattern can carry at most one start_of_input anchor; the earlier .start_of_input() call already set it. help: remove the duplicate .start_of_input() call from the chain. ``` Caller source location is captured via `sys._getframe()` walking to the first non-edify frame and `co_positions()` for precise column spans. ## Other landings - `Regex` retains the AST elements the builder produced; every introspection method operates on the AST directly rather than parsing the emitted regex string. - Lazy compile cache on `BuilderCore` — a builder's `to_regex()` result is memoised so `.match()` / `.search()` / `.explain()` share one compiled pattern. - `Regex.__getattr__` delegates any attribute not on the wrapper to the underlying `re.Pattern`, so `.pattern`, `.flags`, `.groups`, `.groupindex` continue to work. - `Regex.__eq__` / `Regex.__hash__` raise when called on an unfinished builder, pointing at both the compare/hash call site and the still-open frame or dangling quantifier. - Property test extended: every recursively-built composition of quantifiers, groups, captures, named captures, and subexpressions emits its expected regex exactly, no fragment dropped or duplicated.
Diffstat (limited to 'tests/errors/errors.test.py')
-rw-r--r--tests/errors/errors.test.py149
1 files changed, 115 insertions, 34 deletions
diff --git a/tests/errors/errors.test.py b/tests/errors/errors.test.py
index 6472469..74acaa4 100644
--- a/tests/errors/errors.test.py
+++ b/tests/errors/errors.test.py
@@ -35,131 +35,212 @@ from edify.errors.structure import (
def test_start_input_already_defined_outside_subexpression():
error = StartInputAlreadyDefinedError()
- assert "already has a start" in str(error)
- assert "ignore_start_and_end" not in str(error)
+ text = str(error)
+ assert "start_of_input has already been added" in text
+ assert "help: remove the duplicate .start_of_input()" in text
+ assert "ignore_start_and_end" not in text
def test_start_input_already_defined_in_subexpression():
error = StartInputAlreadyDefinedError(in_subexpression=True)
- assert "ignore_start_and_end" in str(error)
+ text = str(error)
+ assert "start_of_input has already been added" in text
+ assert "ignore_start_and_end=True" in text
def test_end_input_already_defined_outside_subexpression():
error = EndInputAlreadyDefinedError()
- assert "already has an end" in str(error)
- assert "ignore_start_and_end" not in str(error)
+ text = str(error)
+ assert "end_of_input has already been added" in text
+ assert "help: remove the duplicate .end_of_input()" in text
+ assert "ignore_start_and_end" not in text
def test_end_input_already_defined_in_subexpression():
error = EndInputAlreadyDefinedError(in_subexpression=True)
- assert "ignore_start_and_end" in str(error)
+ text = str(error)
+ assert "end_of_input has already been added" in text
+ assert "ignore_start_and_end=True" in text
def test_cannot_define_start_after_end():
error = CannotDefineStartAfterEndError()
- assert "start of input after defining an end" in str(error)
+ text = str(error)
+ assert "start_of_input cannot follow end_of_input" in text
+ assert "move .start_of_input() to before" in text
-def test_invalid_total_capture_groups_index():
+def test_invalid_total_capture_groups_index_reports_out_of_range():
error = InvalidTotalCaptureGroupsIndexError(5, 3)
- assert "Invalid index #5" in str(error)
- assert "only 3 capture groups" in str(error)
+ text = str(error)
+ assert "back_reference index #5 is out of range" in text
+ assert "3 capture groups" in text
+ assert "valid indices are 1 to 3" in text
+
+
+def test_invalid_total_capture_groups_index_with_no_capture_groups_prompts_add_one():
+ error = InvalidTotalCaptureGroupsIndexError(1, 0)
+ text = str(error)
+ assert "no capture groups yet" in text
+ assert "add a .capture()" in text
def test_must_be_a_string():
error = MustBeAStringError("Name", "int")
- assert "Name must be a string" in str(error)
- assert "int" in str(error)
+ text = str(error)
+ assert "Name must be a string" in text
+ assert "int" in text
+ assert "convert the value with str(...)" in text
def test_must_be_one_character():
error = MustBeOneCharacterError("Value")
- assert "Value must be one character long" in str(error)
+ text = str(error)
+ assert "Value must be one character long" in text
def test_must_be_single_character():
error = MustBeSingleCharacterError("Value", "str")
- assert "Value must be a single character" in str(error)
- assert "str" in str(error)
+ text = str(error)
+ assert "Value must be a single character" in text
+ assert "str" in text
def test_must_be_positive_integer():
error = MustBePositiveIntegerError("count")
- assert "count must be a positive integer" in str(error)
+ text = str(error)
+ assert "count must be a positive integer" in text
def test_must_be_integer_greater_than_zero():
error = MustBeIntegerGreaterThanZeroError("x")
- assert "x must be an integer greater than zero" in str(error)
+ text = str(error)
+ assert "x must be an integer greater than zero" in text
def test_must_be_instance():
error = MustBeInstanceError("Expression", "str", "RegexBuilder")
- assert "Expression must be an instance of RegexBuilder" in str(error)
- assert "str" in str(error)
+ text = str(error)
+ assert "Expression must be an instance of RegexBuilder" in text
+ assert "str" in text
-def test_must_have_a_smaller_value():
+def test_must_have_a_smaller_value_reports_the_codepoints():
error = MustHaveASmallerValueError("z", "a")
- assert "z must have a smaller character value than a" in str(error)
+ text = str(error)
+ assert "range bounds are inverted" in text
+ assert "'z'" in text
+ assert "'a'" in text
+ assert "= 122" in text
+ assert "= 97" in text
def test_must_be_less_than():
error = MustBeLessThanError("X", "Y")
- assert "X must be less than Y" in str(error)
+ text = str(error)
+ assert "X must be less than Y" in text
def test_must_be_at_least_two_operands():
error = MustBeAtLeastTwoOperandsError("any_of")
- assert "any_of requires at least two operands" in str(error)
+ text = str(error)
+ assert "any_of requires at least two operands" in text
def test_must_be_at_least_one_literal():
error = MustBeAtLeastOneLiteralError("one_of")
- assert "one_of requires at least one literal" in str(error)
+ text = str(error)
+ assert "one_of requires at least one literal" in text
def test_name_not_valid():
error = NameNotValidError("bad name")
- assert "Name bad name is not valid" in str(error)
+ text = str(error)
+ assert "'bad name' is not a valid identifier" in text
+ assert "letters, digits, and underscores" in text
def test_cannot_create_duplicate_named_group():
error = CannotCreateDuplicateNamedGroupError("dup")
- assert 'Can not create duplicate named group "dup"' in str(error)
+ text = str(error)
+ assert "named group 'dup' already exists" in text
+ assert ".named_back_reference('dup')" in text
def test_named_group_does_not_exist():
error = NamedGroupDoesNotExistError("missing")
- assert 'Named group "missing" does not exist' in str(error)
+ text = str(error)
+ assert "named group 'missing' does not exist" in text
+ assert ".named_capture('missing')" in text
def test_cannot_end_while_building_root_expression():
error = CannotEndWhileBuildingRootExpressionError()
- assert "Can not end while building the root expression" in str(error)
+ text = str(error)
+ assert "cannot .end() while building the root expression" in text
+ assert "no matching opener" in text or "no frame to close" in text
def test_cannot_call_subexpression():
error = CannotCallSubexpressionError("capture")
- assert "Can not call subexpression" in str(error)
- assert "capture" in str(error)
+ text = str(error)
+ assert "cannot merge a subexpression that has an unclosed frame" in text
+ assert "capture" in text
def test_unknown_element_type():
error = UnknownElementTypeError("WeirdElement")
- assert "WeirdElement" in str(error)
+ text = str(error)
+ assert "unknown element type 'WeirdElement'" in text
def test_non_fusable_element():
error = NonFusableElementError("DigitElement")
- assert "Cannot fuse element of type DigitElement" in str(error)
+ text = str(error)
+ assert "cannot fuse element 'DigitElement' into a character class" in text
def test_unexpected_frame_type():
error = UnexpectedFrameTypeError("DigitElement")
- assert "Stack frame anchored at unexpected element type DigitElement" in str(error)
+ text = str(error)
+ assert "stack frame anchored at unexpected element 'DigitElement'" in text
def test_failed_to_compile_regex():
error = FailedToCompileRegexError("missing )")
- assert "Cannot compile regex: missing )" in str(error)
+ text = str(error)
+ assert "rejected by the re engine" in text
+ assert "missing )" in text
+
+
+def test_every_annotated_error_message_starts_with_error_prefix():
+ errors = [
+ StartInputAlreadyDefinedError(),
+ CannotDefineStartAfterEndError(),
+ EndInputAlreadyDefinedError(),
+ InvalidTotalCaptureGroupsIndexError(1, 0),
+ MustBeAStringError("X", "int"),
+ MustBeOneCharacterError("X"),
+ MustBeSingleCharacterError("X", "int"),
+ MustBePositiveIntegerError("X"),
+ MustBeIntegerGreaterThanZeroError("X"),
+ MustBeInstanceError("X", "int", "Y"),
+ MustHaveASmallerValueError("z", "a"),
+ MustBeLessThanError("A", "B"),
+ MustBeAtLeastTwoOperandsError("f"),
+ MustBeAtLeastOneLiteralError("f"),
+ NameNotValidError("x"),
+ CannotCreateDuplicateNamedGroupError("x"),
+ NamedGroupDoesNotExistError("x"),
+ CannotEndWhileBuildingRootExpressionError(),
+ CannotCallSubexpressionError("capture"),
+ UnknownElementTypeError("X"),
+ NonFusableElementError("X"),
+ UnexpectedFrameTypeError("X"),
+ FailedToCompileRegexError("boom"),
+ ]
+ for error in errors:
+ text = str(error)
+ assert text.startswith("error:")
+ assert "help:" in text
+ assert "= note:" in text