aboutsummaryrefslogtreecommitdiff
path: root/tests/errors
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-07-15 15:37:46 +0530
committerGitHub <[email protected]>2026-07-15 15:37:46 +0530
commit119475cb439ea3cbad7c75bdddb2f35033e17202 (patch)
tree20d7bf2b8241465ae7eb93ef8eaa90c83ea41338 /tests/errors
parent3dcca5d124fb0c5e92995bf5116f34c8794235d8 (diff)
parentbe086a5933d29acb63572c666deb4d5fa23e0f0e (diff)
downloadedify-119475cb439ea3cbad7c75bdddb2f35033e17202.tar.xz
edify-119475cb439ea3cbad7c75bdddb2f35033e17202.zip
feat: snapshot suite, char-class normalization, ReDoS detector, and structured open-frame errors (#281)
Ships the compile-path correctness epic + the build-time validation epic end to end. ## Snapshot suite - **Snapshot helper.** `edify/testing/snapshots.py` — an ~80-line difflib-based helper (`assert_snapshot(actual, path)` + `SnapshotMismatchError` / `SnapshotMissingError`) with `EDIFY_UPDATE_SNAPSHOTS=1` regenerate mode. Publicly re-exported through `edify.testing`. - **Library snapshots.** Every one of the 223 public library validators emits a committed `.regex` snapshot under `tests/snapshots/library/`. Any compile-path change that touches the emitted form surfaces here first. - **Doc snapshots.** Every `.. code-block:: python` block across `docs/` is exec-ed in a fresh edify-populated namespace and its resulting `Regex` / `Pattern` bodies are snapshotted under `tests/snapshots/docs/<file-stem>/<line>.regex`. Broken blocks pinned to planned validators (`email_rfc_5322`, `ipv4`, `ipv6`, `iso_date`, `zip`) are explicitly deferred to the docs rewrite (epic #81). - **Edge-case fixtures.** Deeply-nested alternation, combined lookaround, item-4 named-backref, item-6 ReDoS-flagged constructs, hex/email/composition — 8 fixtures under `tests/snapshots/fixtures/`. A parallel test asserts the ReDoS warning fires for the nested-quantifier fixture. - **Orphan detection.** A discipline test walks all three snapshot roots and fails CI if any snapshot has no live source (library Pattern / registered fixture / discovered code-block). The reverse direction (source without snapshot) is caught by `SnapshotMissingError` in the per-source test. ## Compile-path invariants - **Insertion-order determinism** pinned: `any_of_chars("zyxabc321")` emits `[zyxabc321]` deterministically across runs; a discipline test defends against future set/dict-backed rewrites. - **`to_regex_string() == to_regex().pattern`** pinned per registered library Pattern (223 parametrized checks). Item 5's regex-module backend was the change most likely to silently split these — the invariant now holds them together. ## Char-class escaping normalization (breaking) - Introduces `edify.compile.escape.escape_for_char_class(value)` — the minimal correct form. Inside `[...]`, only `\`, `]`, `^` (first position), and `-` (interior position) are escaped; everything else is a literal. - `any_of_chars` and `anything_but_chars` now use the minimal escaper; `string`, `char`, `anything_but_string` keep the full `re.escape` since they live outside a class. - **Before:** `any_of_chars('#?!@$%^&*-')` → `[\#\?!@$%\^\&\*\-]`. **After:** `[#?!@$%^&*-]`. Match behavior is identical (equivalence corpus test asserts this). ## AST walker substrate + ReDoS detector - **`edify.elements.walk.walk_elements`** — the shared depth-first walker that yields every `BaseElement` in an emission-order tree. Serialize already had one; extracted, generalized, and reused by ReDoS. - **Build-time ReDoS detector** (`edify.compile.redos.warn_on_redos_constructs`) — walks the element tree at `to_regex()` time and emits `ReDoSWarning` when it finds the classic `(x+)+` shape (an unbounded quantifier wrapping a single-child group whose one child is itself an unbounded quantifier). The check is intentionally conservative — it requires the group's children tuple to have length one, so composite patterns like `(?:X)*(?:Y*)*` aren't false-positived. ## Structured open-frame stack in error messages - `CannotCallSubexpressionError.__init__(open_frames: tuple[OpenFrameInfo, ...])` — lists every open frame innermost-first, one line per frame, with its opener call site (`filename:line:col`). Named captures reproduce the declared name (`.named_capture("username")`). - `DanglingQuantifierError.__init__(pending_quantifier_name, pending_quantifier_call_site)` — names the specific queued quantifier (`.exactly(3)`) and points at its call site. - `edify.builder.diagnose.describe_open_frames(state)` — the shared helper both terminals and subexpression use. ## Breaking changes - `any_of_chars` and `anything_but_chars` emit the minimal-correct form, not the over-escaped form. Match behavior is unchanged (equivalence corpus test verifies), but any caller comparing the emitted string against a hard-coded expected value with `\.`/`\-` etc. needs to drop the redundant backslashes. - `CannotCallSubexpressionError.__init__` now takes `tuple[OpenFrameInfo, ...]` instead of a bare string. Direct constructors need updating. - `DanglingQuantifierError.__init__` accepts optional `pending_quantifier_name` / `pending_quantifier_call_site` kwargs; the bare `DanglingQuantifierError()` call still works. Closes #72, closes #98, closes #99, closes #100, closes #101, closes #102, closes #103, closes #104, closes #105, closes #106, closes #73, closes #107, closes #114, closes #192.
Diffstat (limited to 'tests/errors')
-rw-r--r--tests/errors/errors.test.py23
-rw-r--r--tests/errors/frames.test.py69
2 files changed, 89 insertions, 3 deletions
diff --git a/tests/errors/errors.test.py b/tests/errors/errors.test.py
index 632045d..82eb8fe 100644
--- a/tests/errors/errors.test.py
+++ b/tests/errors/errors.test.py
@@ -24,6 +24,7 @@ from edify.errors.naming import (
from edify.errors.structure import (
CannotCallSubexpressionError,
CannotEndWhileBuildingRootExpressionError,
+ OpenFrameInfo,
)
@@ -176,10 +177,26 @@ def test_cannot_end_while_building_root_expression():
def test_cannot_call_subexpression():
- error = CannotCallSubexpressionError("capture")
+ frames = (OpenFrameInfo(kind="capture", opened_at=None),)
+ error = CannotCallSubexpressionError(frames)
text = str(error)
assert "cannot merge a subexpression that has an unclosed frame" in text
- assert "capture" in text
+ assert "1 open frame" in text
+ assert ".capture()" in text
+
+
+def test_cannot_call_subexpression_lists_every_open_frame_in_the_stack():
+ frames = (
+ OpenFrameInfo(kind="capture", opened_at=None),
+ OpenFrameInfo(kind="assert_ahead", opened_at=None),
+ OpenFrameInfo(kind="group", opened_at=None),
+ )
+ error = CannotCallSubexpressionError(frames)
+ text = str(error)
+ assert "3 open frame" in text
+ assert "1. .capture()" in text
+ assert "2. .assert_ahead()" in text
+ assert "3. .group()" in text
def test_every_annotated_error_message_starts_with_error_prefix():
@@ -202,7 +219,7 @@ def test_every_annotated_error_message_starts_with_error_prefix():
CannotCreateDuplicateNamedGroupError("x"),
NamedGroupDoesNotExistError("x"),
CannotEndWhileBuildingRootExpressionError(),
- CannotCallSubexpressionError("capture"),
+ CannotCallSubexpressionError((OpenFrameInfo(kind="capture", opened_at=None),)),
]
for error in errors:
text = str(error)
diff --git a/tests/errors/frames.test.py b/tests/errors/frames.test.py
new file mode 100644
index 0000000..b4077ba
--- /dev/null
+++ b/tests/errors/frames.test.py
@@ -0,0 +1,69 @@
+"""Tests for the structured open-frame stack surfaced by CannotCallSubexpressionError."""
+
+import pytest
+
+from edify import RegexBuilder
+from edify.errors.structure import CannotCallSubexpressionError
+
+
+def test_single_open_capture_frame_is_named_in_the_error_message():
+ unfinished = RegexBuilder().capture().digit()
+ with pytest.raises(CannotCallSubexpressionError) as excinfo:
+ unfinished.to_regex_string()
+ text = str(excinfo.value)
+ assert "1 open frame" in text
+ assert "1. .capture()" in text
+
+
+def test_nested_open_frames_are_listed_innermost_first():
+ unfinished = RegexBuilder().capture().group().assert_ahead().digit()
+ with pytest.raises(CannotCallSubexpressionError) as excinfo:
+ unfinished.to_regex_string()
+ text = str(excinfo.value)
+ assert "3 open frame" in text
+ innermost_index = text.index("1. .assert_ahead()")
+ middle_index = text.index("2. .group()")
+ outermost_index = text.index("3. .capture()")
+ assert innermost_index < middle_index < outermost_index
+
+
+def test_named_capture_frame_reports_the_declared_group_name():
+ unfinished = RegexBuilder().named_capture("username").letter()
+ with pytest.raises(CannotCallSubexpressionError) as excinfo:
+ unfinished.to_regex_string()
+ text = str(excinfo.value)
+ assert '.named_capture("username")' in text
+
+
+def test_open_frames_carry_a_file_line_column_pointer_when_the_context_is_captured():
+ unfinished = RegexBuilder().assert_behind().digit()
+ with pytest.raises(CannotCallSubexpressionError) as excinfo:
+ unfinished.to_regex_string()
+ text = str(excinfo.value)
+ assert "opened at" in text
+ assert __file__ in text or "frames.test.py" in text
+
+
+def test_subexpression_lists_open_frames_from_the_argument_not_the_receiver():
+ unfinished = RegexBuilder().named_capture("group").digit()
+ parent = RegexBuilder()
+ with pytest.raises(CannotCallSubexpressionError) as excinfo:
+ parent.subexpression(unfinished)
+ assert 'named_capture("group")' in str(excinfo.value)
+
+
+def test_dangling_quantifier_reports_the_specific_pending_quantifier_name():
+ from edify.errors.quantifier import DanglingQuantifierError
+
+ with pytest.raises(DanglingQuantifierError) as excinfo:
+ RegexBuilder().exactly(3).to_regex_string()
+ text = str(excinfo.value)
+ assert ".exactly(3)" in text
+
+
+def test_dangling_quantifier_falls_back_to_generic_summary_when_name_unknown():
+ from edify.errors.quantifier import DanglingQuantifierError
+
+ error = DanglingQuantifierError()
+ text = str(error)
+ assert "dangling quantifier" in text