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/formatting.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/formatting.test.py')
| -rw-r--r-- | tests/errors/formatting.test.py | 127 |
1 files changed, 127 insertions, 0 deletions
diff --git a/tests/errors/formatting.test.py b/tests/errors/formatting.test.py new file mode 100644 index 0000000..314aff1 --- /dev/null +++ b/tests/errors/formatting.test.py @@ -0,0 +1,127 @@ +"""Tests for the message formatter helpers in :mod:`edify.errors.formatting`.""" + +from unittest import mock + +from edify.errors.context import CallerContext +from edify.errors.formatting import ( + FixInsertion, + Problem, + compose_annotated_message, + format_error, + format_fix_block, + format_help_header, + format_note_line, + format_pointer_block, + format_problem, + format_problem_header, +) + + +def _context(source_line: str = " x = do_the_thing(1, 2, 3)", colno: int = 9) -> CallerContext: + return CallerContext( + filename="/tmp/user_code.py", + lineno=42, + colno=colno, + end_colno=colno + 15, + source_line=source_line, + ) + + +def test_format_error_with_only_summary_returns_bare_header(): + assert format_error("boom") == "error: boom" + + +def test_format_error_with_summary_and_blocks_joins_them_with_blank_line(): + output = format_error("boom", "first block", "second block") + assert output == "error: boom\n\nfirst block\n\nsecond block" + + +def test_format_error_skips_empty_blocks(): + output = format_error("boom", "", "only real block", "") + assert output == "error: boom\n\nonly real block" + + +def test_format_pointer_block_draws_caret_at_caller_column(): + context = _context() + block = format_pointer_block(context, "here") + lines = block.splitlines() + assert lines[0] == " --> /tmp/user_code.py:42:9" + assert "^^^^^^^^^^^^^^^ here" in block + + +def test_format_pointer_block_has_at_least_one_caret_when_span_is_empty(): + context = CallerContext( + filename="/tmp/x.py", + lineno=1, + colno=5, + end_colno=5, + source_line="abcde", + ) + block = format_pointer_block(context, "point") + assert "^ point" in block + + +def test_format_note_line_prepends_the_note_prefix(): + assert format_note_line("something is off") == " = note: something is off" + + +def test_format_problem_header_prepends_problem_prefix(): + assert format_problem_header("stuff") == "problem: stuff" + + +def test_format_help_header_prepends_help_prefix(): + assert format_help_header("do X") == "help: do X" + + +def test_format_fix_block_inserts_text_at_the_target_column(): + context = _context(source_line="value = compute()", colno=1) + insertion = FixInsertion(column=9, text="new_") + block = format_fix_block(context, insertion) + assert "value = new_compute()" in block + assert "++++" in block + + +def test_format_problem_composes_header_pointer_help_and_fix(): + problem_context = _context(source_line="pattern = build()", colno=11) + fix_context = _context(source_line="pattern = build()", colno=11) + fix_insertion = FixInsertion(column=11, text=".digit()") + problem = Problem( + description="missing operand", + problem_context=problem_context, + problem_hint="no operand added", + help_summary="add .digit() before .build()", + fix_context=fix_context, + fix_insertion=fix_insertion, + ) + output = format_problem(problem) + assert "problem: missing operand" in output + assert "no operand added" in output + assert "help: add .digit() before .build()" in output + assert "++++++++" in output + + +def test_compose_annotated_message_when_caller_context_is_available(): + output = compose_annotated_message( + summary="bad thing happened", + trigger_hint="right here", + note="because reasons", + help_line="help: do X", + ) + assert output.startswith("error: bad thing happened") + assert "-->" in output + assert "= note: because reasons" in output + assert output.rstrip().endswith("help: do X") + + +def test_compose_annotated_message_falls_back_when_caller_context_is_none(): + with mock.patch("edify.errors.formatting.capture_caller_context", return_value=None): + output = compose_annotated_message( + summary="bad thing happened", + trigger_hint="here", + note="because reasons", + help_line="help: do X", + ) + assert output.startswith("error: bad thing happened") + assert "-->" not in output + assert "= note: because reasons" in output + assert output.rstrip().endswith("help: do X") |
