aboutsummaryrefslogtreecommitdiff
path: root/tests/errors/formatting.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/formatting.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/formatting.test.py')
-rw-r--r--tests/errors/formatting.test.py127
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")