diff options
| author | Bobby <[email protected]> | 2026-07-10 16:59:00 +0530 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-07-10 16:59:00 +0530 |
| commit | 189bcdabe3865a0d52aea580d3fdbcc1cd48e896 (patch) | |
| tree | 8db00421de426b98a86d4b65665c71be7adcf0bf /tests/errors/errors.test.py | |
| parent | c757ea1fea447df3ed69e74b407ca9710a9f85e0 (diff) | |
| parent | 174eb521850550830d97994719ca3d09c467cb5a (diff) | |
| download | edify-189bcdabe3865a0d52aea580d3fdbcc1cd48e896.tar.xz edify-189bcdabe3865a0d52aea580d3fdbcc1cd48e896.zip | |
feat: regex introspection API — explain, verbose export, ASCII + SVG visualize + annotated errors (#272)
Ships the regex introspection API on the `Regex` wrapper — three new
methods that turn a compiled pattern into something a human can read,
plus a full retrofit of every error message to a shape that shows the
caller *where* the failure happened and *how* to fix it.
## `Regex.explain()`
Plain-English bullet list of what the pattern accepts, followed by a
small set of concrete accepted strings.
```
- The text must start with either "http" or "https".
- Then the text must have "://".
- Then the text must have one or more letters, digits, or underscores.
Text this pattern accepts:
http://ab1
https://b1_c
http://1_c2d
```
## `Regex.to_verbose_string()`
An `re.VERBOSE`-compatible export where every fragment is annotated
inline. Copy-pasting the output into `re.compile(..., re.VERBOSE)`
compiles to the same pattern.
```
(?P<year> # begin group named "year"
\d{4} # exactly 4
) # end group named "year"
```
## `Regex.visualize(format='ascii')`
ASCII railroad diagram — `START` and `END` boxes flanking the pattern,
arrows between elements, fork/merge junctions for alternation,
dashed-caption blocks for captures and lookarounds.
```
+--------+
+--->| "cat" |----+
| +--------+ |
| |
+-------+ | +--------+ | +-----+
| START |------>+--->| "dog" |----+-->| END |
+-------+ | +--------+ | +-----+
| |
| +--------+ |
+--->| "fish" |----+
+--------+
```
## `Regex.visualize(format='svg', engine='graphviz')`
SVG rendered by Graphviz. Junction-point fork/merge for alternation,
dashed rounded clusters for captures/lookarounds, folded two-line node
labels for quantifiers (e.g. `digit\n(one or more)`). Requires the
optional `graphviz` extra; the missing-dependency error names the
install command.
## Annotated error messages
Every exception across `edify.errors` — anchors, captures, input,
internal, naming, quantifier, structure — now emits a message that names
the offending call in the caller's source, explains the invariant that
was violated, and prescribes the fix:
```
error: start_of_input has already been added to this pattern
--> user_code.py:14:26
|
14 | pattern = RegexBuilder().start_of_input().digit().start_of_input()
| ^^^^^^^^^^^^^^^^ second start_of_input added here
|
= note: a pattern can carry at most one start_of_input anchor; the earlier .start_of_input() call already set it.
help: remove the duplicate .start_of_input() call from the chain.
```
Caller source location is captured via `sys._getframe()` walking to the
first non-edify frame and `co_positions()` for precise column spans.
## Other landings
- `Regex` retains the AST elements the builder produced; every
introspection method operates on the AST directly rather than parsing
the emitted regex string.
- Lazy compile cache on `BuilderCore` — a builder's `to_regex()` result
is memoised so `.match()` / `.search()` / `.explain()` share one
compiled pattern.
- `Regex.__getattr__` delegates any attribute not on the wrapper to the
underlying `re.Pattern`, so `.pattern`, `.flags`, `.groups`,
`.groupindex` continue to work.
- `Regex.__eq__` / `Regex.__hash__` raise when called on an unfinished
builder, pointing at both the compare/hash call site and the still-open
frame or dangling quantifier.
- Property test extended: every recursively-built composition of
quantifiers, groups, captures, named captures, and subexpressions emits
its expected regex exactly, no fragment dropped or duplicated.
Diffstat (limited to 'tests/errors/errors.test.py')
| -rw-r--r-- | tests/errors/errors.test.py | 149 |
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 |
