aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authornatsuoto <[email protected]>2026-07-15 15:15:21 +0530
committernatsuoto <[email protected]>2026-07-15 15:15:21 +0530
commit91942559f53003d2ef7a524456aef84f93b7f319 (patch)
treed4064676c1188ff23541a1c50072af448ae74068
parentc343f05467045bd369aa8ecd6273924fe7c89f95 (diff)
downloadedify-91942559f53003d2ef7a524456aef84f93b7f319.tar.xz
edify-91942559f53003d2ef7a524456aef84f93b7f319.zip
feat(errors): structured open-frame stack with per-frame call sites; DanglingQuantifierError names the pending quantifier
-rw-r--r--edify/builder/diagnose.py36
-rw-r--r--edify/builder/mixins/subexpression.py5
-rw-r--r--edify/builder/mixins/terminals.py10
-rw-r--r--edify/errors/quantifier.py54
-rw-r--r--edify/errors/structure.py50
-rw-r--r--tests/errors/errors.test.py23
-rw-r--r--tests/errors/frames.test.py69
7 files changed, 225 insertions, 22 deletions
diff --git a/edify/builder/diagnose.py b/edify/builder/diagnose.py
index c96f8e4..6583747 100644
--- a/edify/builder/diagnose.py
+++ b/edify/builder/diagnose.py
@@ -15,6 +15,7 @@ from edify.elements.types.groups import (
)
from edify.errors.context import CallerContext
from edify.errors.formatting import FixInsertion, Problem
+from edify.errors.structure import OpenFrameInfo
_END_INSERTION_TEXT = ".end()"
_DANGLING_INSERTION_TEXT = ".digit()"
@@ -26,6 +27,41 @@ _UNKNOWN_CALL_SITE = CallerContext(
)
+def describe_open_frames(state: BuilderState) -> tuple[OpenFrameInfo, ...]:
+ """Return the innermost-first list of frames still open on ``state``.
+
+ The root frame is excluded; only user-opened frames appear.
+ """
+ stack_without_root = state.stack[1:]
+ infos = [
+ OpenFrameInfo(
+ kind=_frame_kind(frame),
+ opened_at=frame.call_site,
+ )
+ for frame in stack_without_root
+ ]
+ infos.reverse()
+ return tuple(infos)
+
+
+def _frame_kind(frame: StackFrame) -> str:
+ type_node = frame.type_node
+ if isinstance(type_node, NamedCaptureElement):
+ return f'named_capture("{type_node.name}")'
+ return _STATIC_FRAME_KIND_NAMES[type(type_node)]
+
+
+_STATIC_FRAME_KIND_NAMES: dict[type, str] = {
+ AnyOfElement: "any_of",
+ GroupElement: "group",
+ AssertAheadElement: "assert_ahead",
+ AssertNotAheadElement: "assert_not_ahead",
+ AssertBehindElement: "assert_behind",
+ AssertNotBehindElement: "assert_not_behind",
+ CaptureElement: "capture",
+}
+
+
def diagnose_unfinished(state: BuilderState, subject: str) -> Problem | None:
"""Return a :class:`Problem` for the first unfinished thing in ``state``, or ``None``."""
if len(state.stack) > 1:
diff --git a/edify/builder/mixins/subexpression.py b/edify/builder/mixins/subexpression.py
index f3bcf1c..0ba96f5 100644
--- a/edify/builder/mixins/subexpression.py
+++ b/edify/builder/mixins/subexpression.py
@@ -16,6 +16,7 @@ from __future__ import annotations
from typing import Self
+from edify.builder.diagnose import describe_open_frames
from edify.builder.merge import MergeContext, merge_element
from edify.builder.types.protocol import BuilderProtocol
from edify.elements.types.base import BaseElement
@@ -91,8 +92,8 @@ def _ensure_fully_specified(expression: BuilderProtocol) -> None:
"""Raise when ``expression`` still has nested frames open beyond the root."""
if len(expression._state.stack) == 1:
return
- top_frame_type_name = type(expression._state.top_frame.type_node).__name__
- raise CannotCallSubexpressionError(top_frame_type_name)
+ open_frames = describe_open_frames(expression._state)
+ raise CannotCallSubexpressionError(open_frames)
def _merge_expression_children(
diff --git a/edify/builder/mixins/terminals.py b/edify/builder/mixins/terminals.py
index b25873b..38f77af 100644
--- a/edify/builder/mixins/terminals.py
+++ b/edify/builder/mixins/terminals.py
@@ -10,6 +10,7 @@ an :class:`edify.result.Regex`.
from __future__ import annotations
+from edify.builder.diagnose import describe_open_frames
from edify.builder.types.engine import Engine
from edify.builder.types.flags import Flags
from edify.builder.types.protocol import BuilderProtocol
@@ -107,12 +108,15 @@ def _ensure_fully_specified(builder: BuilderProtocol) -> None:
"""Raise :class:`CannotCallSubexpressionError` when frames beyond the root remain open."""
if len(builder._state.stack) == 1:
return
- top_frame_type_name = type(builder._state.top_frame.type_node).__name__
- raise CannotCallSubexpressionError(top_frame_type_name)
+ open_frames = describe_open_frames(builder._state)
+ raise CannotCallSubexpressionError(open_frames)
def _ensure_no_dangling_quantifier(builder: BuilderProtocol) -> None:
"""Raise :class:`DanglingQuantifierError` when any frame carries an unconsumed quantifier."""
for frame in builder._state.stack:
if frame.quantifier is not None:
- raise DanglingQuantifierError()
+ raise DanglingQuantifierError(
+ pending_quantifier_name=frame.quantifier_name,
+ pending_quantifier_call_site=frame.quantifier_call_site,
+ )
diff --git a/edify/errors/quantifier.py b/edify/errors/quantifier.py
index 2acc47b..224196d 100644
--- a/edify/errors/quantifier.py
+++ b/edify/errors/quantifier.py
@@ -2,28 +2,66 @@
from __future__ import annotations
+from edify.errors.context import CallerContext
from edify.errors.formatting import compose_annotated_message
from edify.errors.syntax import EdifySyntaxError
class DanglingQuantifierError(EdifySyntaxError):
- """Raised when a terminal is called while a quantifier is still pending."""
+ """Raised when a terminal is called while a quantifier is still pending.
- def __init__(self) -> None:
+ Args:
+ pending_quantifier_name: Human-readable name of the pending quantifier,
+ e.g. ``"exactly(3)"`` or ``"one_or_more()"``. ``None`` when the name
+ could not be recovered.
+ pending_quantifier_call_site: Caller context for the chain call that queued
+ the pending quantifier; ``None`` when the location could not be captured.
+ """
+
+ def __init__(
+ self,
+ pending_quantifier_name: str | None = None,
+ pending_quantifier_call_site: CallerContext | None = None,
+ ) -> None:
+ summary = _format_dangling_summary(pending_quantifier_name)
+ note = _format_dangling_note(pending_quantifier_name, pending_quantifier_call_site)
message = compose_annotated_message(
- summary="dangling quantifier with no operand to apply to",
+ summary=summary,
trigger_hint="terminal called here",
- note=(
- "a quantifier such as .exactly(n), .one_or_more(), .at_least(n), or "
- ".between(a, b) attaches to the next element in the chain; the chain "
- "ended before an element was supplied."
- ),
+ note=note,
help_line=(
"help: append the element the quantifier should apply to "
"(e.g. .digit(), .word(), .string('...')) before the terminal call."
),
)
super().__init__(message)
+ self.pending_quantifier_name = pending_quantifier_name
+ self.pending_quantifier_call_site = pending_quantifier_call_site
+
+
+def _format_dangling_summary(pending_quantifier_name: str | None) -> str:
+ if pending_quantifier_name is None:
+ return "dangling quantifier with no operand to apply to"
+ return f"dangling .{pending_quantifier_name} with no operand to apply to"
+
+
+def _format_dangling_note(
+ pending_quantifier_name: str | None,
+ pending_quantifier_call_site: CallerContext | None,
+) -> str:
+ generic_prefix = (
+ "a quantifier such as .exactly(n), .one_or_more(), .at_least(n), or "
+ ".between(a, b) attaches to the next element in the chain; the chain "
+ "ended before an element was supplied."
+ )
+ if pending_quantifier_name is None or pending_quantifier_call_site is None:
+ return generic_prefix
+ location = pending_quantifier_call_site
+ return (
+ f"{generic_prefix}\n"
+ f"dangling quantifier: .{pending_quantifier_name} — queued at "
+ f"{location.filename}:{location.lineno}:{location.colno}"
+ )
class StackedQuantifierError(EdifySyntaxError):
diff --git a/edify/errors/structure.py b/edify/errors/structure.py
index e55bf3e..1806b13 100644
--- a/edify/errors/structure.py
+++ b/edify/errors/structure.py
@@ -2,10 +2,27 @@
from __future__ import annotations
+from dataclasses import dataclass
+
+from edify.errors.context import CallerContext
from edify.errors.formatting import compose_annotated_message
from edify.errors.syntax import EdifySyntaxError
+@dataclass(frozen=True)
+class OpenFrameInfo:
+ """One frame in the open-frame stack, named for error rendering.
+
+ Attributes:
+ kind: Human-readable frame type (``"capture"``, ``"assert_ahead"``, ...).
+ opened_at: Caller context for the chain call that opened the frame;
+ ``None`` when the location could not be captured.
+ """
+
+ kind: str
+ opened_at: CallerContext | None
+
+
class CannotEndWhileBuildingRootExpressionError(EdifySyntaxError):
"""Raised when ``.end()`` is called on a builder whose only open frame is the root."""
@@ -29,20 +46,41 @@ class CannotCallSubexpressionError(EdifySyntaxError):
"""Raised when ``.subexpression(expression)`` is given an expression with open frames.
Args:
- current_frame_type: Name of the frame kind that is still open on the subexpression.
+ open_frames: The stack of frames that remain open on the subexpression, in
+ innermost-first order.
"""
- def __init__(self, current_frame_type: str) -> None:
+ def __init__(self, open_frames: tuple[OpenFrameInfo, ...]) -> None:
+ rendered_stack = _format_open_frame_stack(open_frames)
+ innermost_kind = open_frames[0].kind
message = compose_annotated_message(
summary="cannot merge a subexpression that has an unclosed frame",
trigger_hint="subexpression merged here",
note=(
- f"the subexpression still has an open {current_frame_type} frame; "
- "only fully-closed expressions can be merged into another builder."
+ f"the subexpression still has {len(open_frames)} open frame(s); "
+ "only fully-closed expressions can be merged into another builder.\n"
+ f"open frames (innermost first):\n{rendered_stack}"
),
help_line=(
- f"help: add a matching .end() call to close the {current_frame_type} frame "
- "on the subexpression before passing it to .subexpression(...)."
+ f"help: add a matching .end() call for each open frame — start by closing "
+ f"the innermost .{innermost_kind}() frame — before passing the subexpression "
+ "to .subexpression(...)."
),
)
super().__init__(message)
+ self.open_frames = open_frames
+
+
+def _format_open_frame_stack(open_frames: tuple[OpenFrameInfo, ...]) -> str:
+ rendered_lines: list[str] = []
+ for depth, frame_info in enumerate(open_frames, start=1):
+ rendered_lines.append(_format_open_frame_line(depth, frame_info))
+ return "\n".join(rendered_lines)
+
+
+def _format_open_frame_line(depth: int, frame_info: OpenFrameInfo) -> str:
+ prefix = f" {depth}. .{frame_info.kind}()"
+ if frame_info.opened_at is None:
+ return f"{prefix} — opened at <unknown>"
+ context = frame_info.opened_at
+ return f"{prefix} — opened at {context.filename}:{context.lineno}:{context.colno}"
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