diff options
| author | natsuoto <[email protected]> | 2026-07-15 15:15:21 +0530 |
|---|---|---|
| committer | natsuoto <[email protected]> | 2026-07-15 15:15:21 +0530 |
| commit | 91942559f53003d2ef7a524456aef84f93b7f319 (patch) | |
| tree | d4064676c1188ff23541a1c50072af448ae74068 | |
| parent | c343f05467045bd369aa8ecd6273924fe7c89f95 (diff) | |
| download | edify-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.py | 36 | ||||
| -rw-r--r-- | edify/builder/mixins/subexpression.py | 5 | ||||
| -rw-r--r-- | edify/builder/mixins/terminals.py | 10 | ||||
| -rw-r--r-- | edify/errors/quantifier.py | 54 | ||||
| -rw-r--r-- | edify/errors/structure.py | 50 | ||||
| -rw-r--r-- | tests/errors/errors.test.py | 23 | ||||
| -rw-r--r-- | tests/errors/frames.test.py | 69 |
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 |
