diff options
| author | Bobby <[email protected]> | 2026-07-15 15:37:46 +0530 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-07-15 15:37:46 +0530 |
| commit | 119475cb439ea3cbad7c75bdddb2f35033e17202 (patch) | |
| tree | 20d7bf2b8241465ae7eb93ef8eaa90c83ea41338 /tests/errors | |
| parent | 3dcca5d124fb0c5e92995bf5116f34c8794235d8 (diff) | |
| parent | be086a5933d29acb63572c666deb4d5fa23e0f0e (diff) | |
| download | edify-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.py | 23 | ||||
| -rw-r--r-- | tests/errors/frames.test.py | 69 |
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 |
