aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authornatsuoto <[email protected]>2026-07-10 16:18:09 +0530
committernatsuoto <[email protected]>2026-07-10 16:18:09 +0530
commit9d80c99432c0c7c5f184cbe7bb25513aace01a7e (patch)
treeec79c1768bf35c10ce9f708d0ee77a18eb746d80
parentc757ea1fea447df3ed69e74b407ca9710a9f85e0 (diff)
downloadedify-9d80c99432c0c7c5f184cbe7bb25513aace01a7e.tar.xz
edify-9d80c99432c0c7c5f184cbe7bb25513aace01a7e.zip
feat(errors): annotated error messages with caller-context pointers
-rw-r--r--edify/errors/anchors.py73
-rw-r--r--edify/errors/captures.py37
-rw-r--r--edify/errors/comparison.py66
-rw-r--r--edify/errors/context.py82
-rw-r--r--edify/errors/formatting.py133
-rw-r--r--edify/errors/input.py217
-rw-r--r--edify/errors/internal.py88
-rw-r--r--edify/errors/introspect.py89
-rw-r--r--edify/errors/naming.py66
-rw-r--r--edify/errors/quantifier.py41
-rw-r--r--edify/errors/structure.py40
-rw-r--r--tests/errors/errors.test.py147
-rw-r--r--tests/errors/formatting.test.py129
-rw-r--r--tests/errors/quantifier.test.py12
14 files changed, 1071 insertions, 149 deletions
diff --git a/edify/errors/anchors.py b/edify/errors/anchors.py
index ce3507f..1f3114a 100644
--- a/edify/errors/anchors.py
+++ b/edify/errors/anchors.py
@@ -1,29 +1,36 @@
-"""Exception classes raised when start/end-of-input anchors are misused.
-
-Each anchor (``start_of_input``, ``end_of_input``) may be declared at most
-once per pattern, and ``start_of_input`` may not be added after
-``end_of_input``. When a subexpression carries its own anchors and merges
-into a parent that already has the corresponding anchor, the raised error
-also points at the ``ignore_start_and_end`` opt-out.
-"""
+"""Exception classes raised when start/end-of-input anchors are misused."""
from __future__ import annotations
+from edify.errors.formatting import compose_annotated_message
from edify.errors.syntax import EdifySyntaxError
-_IGNORE_START_AND_END_HINT = (
- " You can ignore a subexpression's start_of_input/end_of_input markers "
- "with the ignore_start_and_end option"
-)
-
class StartInputAlreadyDefinedError(EdifySyntaxError):
- """Raised when ``start_of_input`` is added to a pattern that already has one."""
+ """Raised when ``start_of_input`` is added to a pattern that already has one.
+
+ Args:
+ in_subexpression: True when the duplicate came from a subexpression being merged
+ into a parent that already carries the anchor.
+ """
def __init__(self, in_subexpression: bool = False) -> None:
- message = "Regex already has a start of input."
if in_subexpression:
- message = message + _IGNORE_START_AND_END_HINT
+ help_line = (
+ "help: pass ignore_start_and_end=True when merging the subexpression "
+ "so its own start_of_input is dropped."
+ )
+ else:
+ help_line = "help: remove the duplicate .start_of_input() call from the chain."
+ message = compose_annotated_message(
+ summary="start_of_input has already been added to this pattern",
+ trigger_hint="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_line=help_line,
+ )
super().__init__(message)
@@ -31,15 +38,41 @@ class CannotDefineStartAfterEndError(EdifySyntaxError):
"""Raised when ``start_of_input`` is added after ``end_of_input``."""
def __init__(self) -> None:
- message = "Can not define a start of input after defining an end of input."
+ message = compose_annotated_message(
+ summary="start_of_input cannot follow end_of_input in the chain",
+ trigger_hint="start_of_input added here",
+ note=(
+ "start_of_input must come before any content; the pattern already declared "
+ "end_of_input, so the anchor order is inverted."
+ ),
+ help_line="help: move .start_of_input() to before the .end_of_input() call.",
+ )
super().__init__(message)
class EndInputAlreadyDefinedError(EdifySyntaxError):
- """Raised when ``end_of_input`` is added to a pattern that already has one."""
+ """Raised when ``end_of_input`` is added to a pattern that already has one.
+
+ Args:
+ in_subexpression: True when the duplicate came from a subexpression being merged
+ into a parent that already carries the anchor.
+ """
def __init__(self, in_subexpression: bool = False) -> None:
- message = "Regex already has an end of input."
if in_subexpression:
- message = message + _IGNORE_START_AND_END_HINT
+ help_line = (
+ "help: pass ignore_start_and_end=True when merging the subexpression "
+ "so its own end_of_input is dropped."
+ )
+ else:
+ help_line = "help: remove the duplicate .end_of_input() call from the chain."
+ message = compose_annotated_message(
+ summary="end_of_input has already been added to this pattern",
+ trigger_hint="second end_of_input added here",
+ note=(
+ "a pattern can carry at most one end_of_input anchor; "
+ "the earlier .end_of_input() call already set it."
+ ),
+ help_line=help_line,
+ )
super().__init__(message)
diff --git a/edify/errors/captures.py b/edify/errors/captures.py
index a18674d..9f052cf 100644
--- a/edify/errors/captures.py
+++ b/edify/errors/captures.py
@@ -1,18 +1,39 @@
-"""Exception class raised for invalid capture-group references.
-
-``back_reference(index)`` accepts a 1-based index that must already exist on
-the builder. Passing an out-of-range index raises this error rather than
-silently emitting a broken regex.
-"""
+"""Exception class raised for invalid capture-group references."""
from __future__ import annotations
+from edify.errors.formatting import compose_annotated_message
from edify.errors.syntax import EdifySyntaxError
class InvalidTotalCaptureGroupsIndexError(EdifySyntaxError):
- """Raised when ``back_reference(index)`` is given an index out of range."""
+ """Raised when ``back_reference(index)`` is given an index out of range.
+
+ Args:
+ index: The 1-based index the caller requested.
+ total_capture_groups: How many capture groups exist on the builder so far.
+ """
def __init__(self, index: int, total_capture_groups: int) -> None:
- message = f"Invalid index #{index}. There are only {total_capture_groups} capture groups."
+ if total_capture_groups == 0:
+ note = (
+ f"the pattern has no capture groups yet; index {index} refers to nothing."
+ )
+ help_line = "help: add a .capture() call before this back_reference."
+ else:
+ plural = "s" if total_capture_groups != 1 else ""
+ note = (
+ f"the pattern currently has {total_capture_groups} capture group{plural} "
+ f"(valid indices are 1 to {total_capture_groups}); index {index} is out of range."
+ )
+ help_line = (
+ f"help: use an index between 1 and {total_capture_groups}, "
+ "or add another .capture() first."
+ )
+ message = compose_annotated_message(
+ summary=f"back_reference index #{index} is out of range",
+ trigger_hint=f"index {index} referenced here",
+ note=note,
+ help_line=help_line,
+ )
super().__init__(message)
diff --git a/edify/errors/comparison.py b/edify/errors/comparison.py
new file mode 100644
index 0000000..c4a7587
--- /dev/null
+++ b/edify/errors/comparison.py
@@ -0,0 +1,66 @@
+"""Exceptions raised for equality / hash operations on unfinished builders."""
+
+from __future__ import annotations
+
+from edify.errors.context import capture_caller_context
+from edify.errors.formatting import (
+ Problem,
+ format_error,
+ format_note_line,
+ format_pointer_block,
+ format_problem,
+)
+from edify.errors.syntax import EdifySyntaxError
+
+
+class CannotCompareUnfinishedBuilderError(EdifySyntaxError):
+ """Raised by ``==`` when either operand has open frames or a pending quantifier.
+
+ Args:
+ problems: One :class:`Problem` per unfinished operand.
+ """
+
+ def __init__(self, problems: list[Problem]) -> None:
+ caller_context = capture_caller_context()
+ trigger_block = ""
+ if caller_context is not None:
+ trigger_block = format_pointer_block(caller_context, "comparison rejected here")
+ note_line = format_note_line(
+ "value equality on RegexBuilder is (emitted_pattern, flags); "
+ "both operands must render their regex, but edify sees "
+ f"{len(problems)} problem{'s' if len(problems) != 1 else ''} below."
+ )
+ trigger_with_note = trigger_block + "\n" + note_line if trigger_block else note_line
+ problem_blocks = [format_problem(problem) for problem in problems]
+ message = format_error(
+ "cannot compare an unfinished builder",
+ trigger_with_note,
+ *problem_blocks,
+ )
+ super().__init__(message)
+
+
+class CannotHashUnfinishedBuilderError(EdifySyntaxError):
+ """Raised by ``hash(...)`` when the builder has open frames or a pending quantifier.
+
+ Args:
+ problem: The single :class:`Problem` describing what is unfinished.
+ """
+
+ def __init__(self, problem: Problem) -> None:
+ caller_context = capture_caller_context()
+ trigger_block = ""
+ if caller_context is not None:
+ trigger_block = format_pointer_block(caller_context, "hash rejected here")
+ note_line = format_note_line(
+ "the value hash on RegexBuilder is derived from (emitted_pattern, flags); "
+ "the builder must be renderable, but edify sees the problem below."
+ )
+ trigger_with_note = trigger_block + "\n" + note_line if trigger_block else note_line
+ problem_block = format_problem(problem)
+ message = format_error(
+ "cannot hash an unfinished builder",
+ trigger_with_note,
+ problem_block,
+ )
+ super().__init__(message)
diff --git a/edify/errors/context.py b/edify/errors/context.py
new file mode 100644
index 0000000..1109e7f
--- /dev/null
+++ b/edify/errors/context.py
@@ -0,0 +1,82 @@
+"""Caller-source-location capture for :mod:`edify.errors`."""
+
+from __future__ import annotations
+
+import linecache
+import os
+import sys
+from dataclasses import dataclass
+
+_EDIFY_PACKAGE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_EDIFY_PACKAGE_PREFIX = _EDIFY_PACKAGE_ROOT + os.sep
+
+
+@dataclass(frozen=True)
+class CallerContext:
+ """A source-location snapshot of a specific call in the caller's code.
+
+ Attributes:
+ filename: Absolute path to the file that contains the offending call.
+ lineno: 1-indexed line number of the offending call.
+ colno: 1-indexed column at which the offending call starts.
+ end_colno: 1-indexed column one past where the offending call ends.
+ source_line: The verbatim source line, trailing whitespace stripped.
+ """
+
+ filename: str
+ lineno: int
+ colno: int
+ end_colno: int
+ source_line: str
+
+
+def capture_caller_context() -> CallerContext | None:
+ """Return a :class:`CallerContext` for the first non-``edify`` frame on the stack.
+
+ Returns ``None`` when every frame on the stack lives inside the ``edify/``
+ package tree.
+ """
+ current_frame = sys._getframe(1)
+ while current_frame is not None:
+ filename = current_frame.f_code.co_filename
+ absolute_filename = (
+ os.path.abspath(filename) if os.path.isabs(filename) is False else filename
+ )
+ if not absolute_filename.startswith(_EDIFY_PACKAGE_PREFIX):
+ return _context_for_frame(current_frame)
+ current_frame = current_frame.f_back
+ return None
+
+
+def _context_for_frame(frame) -> CallerContext:
+ """Return a :class:`CallerContext` describing ``frame``'s current instruction."""
+ filename = frame.f_code.co_filename
+ positions = list(frame.f_code.co_positions())
+ instruction_index = frame.f_lasti // 2
+ if 0 <= instruction_index < len(positions):
+ raw_position = positions[instruction_index]
+ else:
+ raw_position = (frame.f_lineno, frame.f_lineno, None, None)
+ start_line, end_line, start_col, end_col = raw_position
+ if start_line is None:
+ start_line = frame.f_lineno
+ if end_line is None:
+ end_line = start_line
+ if start_col is None:
+ start_col = 0
+ if end_col is None:
+ end_col = start_col
+ source_line = _read_source_line(filename, start_line)
+ return CallerContext(
+ filename=filename,
+ lineno=start_line,
+ colno=start_col + 1,
+ end_colno=end_col + 1,
+ source_line=source_line,
+ )
+
+
+def _read_source_line(filename: str, lineno: int) -> str:
+ """Return the verbatim source line at ``lineno``, or an empty string when unreadable."""
+ raw_line = linecache.getline(filename, lineno)
+ return raw_line.rstrip()
diff --git a/edify/errors/formatting.py b/edify/errors/formatting.py
new file mode 100644
index 0000000..f5bc044
--- /dev/null
+++ b/edify/errors/formatting.py
@@ -0,0 +1,133 @@
+"""Message formatter helpers for the edify error classes."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from edify.errors.context import CallerContext, capture_caller_context
+
+
+@dataclass(frozen=True)
+class FixInsertion:
+ """A single insertion into a source line.
+
+ Attributes:
+ column: 1-indexed column where the insertion begins.
+ text: The text to insert; also the width used to draw the ``+`` marks.
+ """
+
+ column: int
+ text: str
+
+
+@dataclass(frozen=True)
+class Problem:
+ """A single named problem with a pointer at its creation site and a suggested fix.
+
+ Attributes:
+ description: Full-sentence text that follows the ``problem:`` label.
+ problem_context: Source-location snapshot of the offending chain call.
+ problem_hint: Inline label rendered next to the problem's caret.
+ help_summary: One-line text that follows the ``help:`` label.
+ fix_context: Source-location snapshot of the line that should be patched.
+ fix_insertion: The text to insert into the source line and where.
+ """
+
+ description: str
+ problem_context: CallerContext
+ problem_hint: str
+ help_summary: str
+ fix_context: CallerContext
+ fix_insertion: FixInsertion
+
+
+def format_error(summary: str, *blocks: str) -> str:
+ """Return the full message: ``error: <summary>`` followed by ``blocks``."""
+ non_empty_blocks = [block for block in blocks if block]
+ body = "\n\n".join(non_empty_blocks)
+ if body:
+ return f"error: {summary}\n\n{body}"
+ return f"error: {summary}"
+
+
+def format_pointer_block(context: CallerContext, hint: str) -> str:
+ """Return a ``-->`` pointer block underlining the offending call in the source line."""
+ lineno_prefix = str(context.lineno)
+ left_pad = " " * len(lineno_prefix)
+ caret_width = max(1, context.end_colno - context.colno)
+ caret_indent = " " * (context.colno - 1)
+ caret_line = f"{left_pad} | {caret_indent}{'^' * caret_width} {hint}".rstrip()
+ header_line = f"{left_pad}--> {context.filename}:{context.lineno}:{context.colno}"
+ bar_line = f"{left_pad} |"
+ source_line = f"{lineno_prefix} | {context.source_line}"
+ lines = [header_line, bar_line, source_line, caret_line, bar_line]
+ return "\n".join(lines)
+
+
+def format_fix_block(context: CallerContext, insertion: FixInsertion) -> str:
+ """Return a pointer-style block that shows the corrected line with ``+`` marks."""
+ lineno_prefix = str(context.lineno)
+ left_pad = " " * len(lineno_prefix)
+ original_line = context.source_line
+ insertion_zero_col = insertion.column - 1
+ corrected_line = (
+ original_line[:insertion_zero_col] + insertion.text + original_line[insertion_zero_col:]
+ )
+ plus_indent = " " * insertion_zero_col
+ plus_marks = "+" * len(insertion.text)
+ bar_line = f"{left_pad} |"
+ source_line = f"{lineno_prefix} | {corrected_line}"
+ marker_line = f"{left_pad} | {plus_indent}{plus_marks}"
+ return "\n".join([bar_line, source_line, marker_line, bar_line])
+
+
+def format_note_line(note: str) -> str:
+ """Return an aligned ``= note:`` continuation line."""
+ return f" = note: {note}"
+
+
+def format_problem_header(description: str) -> str:
+ """Return the ``problem: <description>`` header line."""
+ return f"problem: {description}"
+
+
+def format_help_header(help: str) -> str:
+ """Return the ``help: <help>`` header line."""
+ return f"help: {help}"
+
+
+def format_problem(problem: Problem) -> str:
+ """Return a full problem block: header, pointer, help header, fix pointer."""
+ header = format_problem_header(problem.description)
+ problem_pointer = format_pointer_block(problem.problem_context, problem.problem_hint)
+ help_header = format_help_header(problem.help_summary)
+ fix_pointer = format_fix_block(problem.fix_context, problem.fix_insertion)
+ return "\n\n".join([header, problem_pointer, help_header, fix_pointer])
+
+
+def compose_annotated_message(
+ summary: str,
+ trigger_hint: str,
+ note: str,
+ help_line: str,
+) -> str:
+ """Return a full annotated error message.
+
+ Captures the caller context, draws a pointer block with ``trigger_hint`` at the
+ caret, adds a ``= note:`` line explaining ``note``, and appends the pre-formatted
+ ``help_line`` (which must start with ``help:``).
+
+ Args:
+ summary: One-line summary printed after ``error:``.
+ trigger_hint: Inline label placed next to the caret in the pointer block.
+ note: Sentence explaining why the invariant was violated.
+ help_line: Pre-formatted help line, starting with ``help:``.
+ """
+ caller_context = capture_caller_context()
+ note_line = format_note_line(note)
+ if caller_context is None:
+ body = note_line
+ else:
+ trigger_block = format_pointer_block(caller_context, trigger_hint)
+ body = trigger_block + "\n" + note_line
+ return format_error(summary, body, help_line)
diff --git a/edify/errors/input.py b/edify/errors/input.py
index f560ba6..a709772 100644
--- a/edify/errors/input.py
+++ b/edify/errors/input.py
@@ -1,96 +1,255 @@
-"""Exception classes raised when a builder method receives invalid input.
-
-Each class is a thin :class:`EdifySyntaxError` subclass whose ``__init__``
-formats the message internally. Classes that report what was passed take
-the already-extracted type name as a string (``type(value).__name__``) so
-the exception surface stays strongly typed end-to-end.
-"""
+"""Exception classes raised when a builder method receives invalid input."""
from __future__ import annotations
+from edify.errors.formatting import compose_annotated_message
from edify.errors.syntax import EdifySyntaxError
class MustBeAStringError(EdifySyntaxError):
- """Raised when a builder argument is required to be a string but is not."""
+ """Raised when a builder argument is required to be a string but is not.
+
+ Args:
+ label: What the argument is called in the API (e.g. ``"Name"``).
+ actual_type_name: The class name of the value the caller passed.
+ """
def __init__(self, label: str, actual_type_name: str) -> None:
- message = f"{label} must be a string. (got {actual_type_name})"
+ lowered = label.lower()
+ message = compose_annotated_message(
+ summary=f"{label} must be a string (got {actual_type_name})",
+ trigger_hint=f"{lowered} passed here",
+ note=(
+ f"this builder method expects {lowered} to be a str, but the caller "
+ f"passed a value of type {actual_type_name}."
+ ),
+ help_line=(
+ f"help: convert the value with str(...) or pass a string literal for {lowered}."
+ ),
+ )
super().__init__(message)
class MustBeOneCharacterError(EdifySyntaxError):
- """Raised when a builder argument must have a length of at least one character."""
+ """Raised when a builder argument must have a length of at least one character.
+
+ Args:
+ label: What the argument is called in the API.
+ """
def __init__(self, label: str) -> None:
- message = f"{label} must be one character long."
+ lowered = label.lower()
+ message = compose_annotated_message(
+ summary=f"{label} must be one character long",
+ trigger_hint=f"empty {lowered} passed here",
+ note=(
+ f"this builder method needs {lowered} to be a non-empty string; an "
+ "empty string has no character to match."
+ ),
+ help_line=(
+ f"help: pass a single-character string for {lowered} (e.g. \"a\")."
+ ),
+ )
super().__init__(message)
class MustBeSingleCharacterError(EdifySyntaxError):
- """Raised when a builder argument must be exactly one character long."""
+ """Raised when a builder argument must be exactly one character long.
+
+ Args:
+ label: What the argument is called in the API.
+ actual_type_name: The class name of the value the caller passed.
+ """
def __init__(self, label: str, actual_type_name: str) -> None:
- message = f"{label} must be a single character. (got {actual_type_name})"
+ lowered = label.lower()
+ message = compose_annotated_message(
+ summary=f"{label} must be a single character (got {actual_type_name})",
+ trigger_hint=f"{lowered} passed here",
+ note=(
+ f"this builder method expects {lowered} to be a one-character string; "
+ f"a value of type {actual_type_name} does not qualify."
+ ),
+ help_line=(
+ f"help: pass a single-character string for {lowered} "
+ "(e.g. \"a\" — not \"ab\" or a non-string value)."
+ ),
+ )
super().__init__(message)
class MustBePositiveIntegerError(EdifySyntaxError):
- """Raised when a builder argument must be a positive integer (``> 0``)."""
+ """Raised when a builder argument must be a positive integer (``> 0``).
+
+ Args:
+ label: What the argument is called in the API.
+ """
def __init__(self, label: str) -> None:
- message = f"{label} must be a positive integer."
+ lowered = label.lower()
+ message = compose_annotated_message(
+ summary=f"{label} must be a positive integer",
+ trigger_hint=f"{lowered} passed here",
+ note=(
+ f"this quantifier bound needs {lowered} to be an int greater than or "
+ "equal to 1; zero and negative counts have no matching semantics."
+ ),
+ help_line=(
+ f"help: pass an int >= 1 for {lowered}; use .optional() if you meant "
+ "zero-or-one."
+ ),
+ )
super().__init__(message)
class MustBeIntegerGreaterThanZeroError(EdifySyntaxError):
- """Raised when a builder argument must be an integer strictly greater than zero."""
+ """Raised when a builder argument must be an integer strictly greater than zero.
+
+ Args:
+ label: What the argument is called in the API.
+ """
def __init__(self, label: str) -> None:
- message = f"{label} must be an integer greater than zero."
+ lowered = label.lower()
+ message = compose_annotated_message(
+ summary=f"{label} must be an integer greater than zero",
+ trigger_hint=f"{lowered} passed here",
+ note=(
+ f"this quantifier bound needs {lowered} to be an int strictly greater "
+ "than 0; zero and negative counts have no matching semantics."
+ ),
+ help_line=(
+ f"help: pass an int > 0 for {lowered}; use .optional() if you meant "
+ "zero-or-one."
+ ),
+ )
super().__init__(message)
class MustBeInstanceError(EdifySyntaxError):
- """Raised when a builder argument must be an instance of a specific class."""
+ """Raised when a builder argument must be an instance of a specific class.
+
+ Args:
+ label: What the argument is called in the API.
+ actual_type_name: The class name of the value the caller passed.
+ expected_class_name: The class name the caller was expected to pass.
+ """
def __init__(self, label: str, actual_type_name: str, expected_class_name: str) -> None:
- message = f"{label} must be an instance of {expected_class_name}. (got {actual_type_name})"
+ lowered = label.lower()
+ message = compose_annotated_message(
+ summary=(
+ f"{label} must be an instance of {expected_class_name} "
+ f"(got {actual_type_name})"
+ ),
+ trigger_hint=f"{lowered} passed here",
+ note=(
+ f"this method requires {lowered} to be an instance of {expected_class_name}; "
+ f"a value of type {actual_type_name} cannot participate in the composition."
+ ),
+ help_line=(
+ f"help: construct {lowered} via {expected_class_name}(...) or one of the "
+ "top-level factory functions before passing it here."
+ ),
+ )
super().__init__(message)
class MustHaveASmallerValueError(EdifySyntaxError):
- """Raised when the first character argument must order before the second."""
+ """Raised when the first character argument must order before the second.
+
+ Args:
+ first: The lower-bound character supplied.
+ second: The upper-bound character supplied.
+ """
def __init__(self, first: str, second: str) -> None:
first_codepoint = ord(first)
second_codepoint = ord(second)
- message = (
- f"{first} must have a smaller character value than {second}. "
- f"(a = {first_codepoint}, b = {second_codepoint})"
+ message = compose_annotated_message(
+ summary=(
+ f"range bounds are inverted: {first!r} must come before {second!r} "
+ "in codepoint order"
+ ),
+ trigger_hint="inverted range declared here",
+ note=(
+ f"a character range needs the first bound to have a strictly smaller "
+ f"codepoint than the second; {first!r} = {first_codepoint} and "
+ f"{second!r} = {second_codepoint}."
+ ),
+ help_line=(
+ f"help: swap the bounds so the smaller character comes first "
+ f"(e.g. range_of({second!r}, {first!r}))."
+ ),
)
super().__init__(message)
class MustBeLessThanError(EdifySyntaxError):
- """Raised when a numeric argument must order strictly before another."""
+ """Raised when a numeric argument must order strictly before another.
+
+ Args:
+ first_label: What the smaller-bound argument is called in the API.
+ second_label: What the larger-bound argument is called in the API.
+ """
def __init__(self, first_label: str, second_label: str) -> None:
- message = f"{first_label} must be less than {second_label}."
+ message = compose_annotated_message(
+ summary=f"{first_label} must be less than {second_label}",
+ trigger_hint="inverted bounds declared here",
+ note=(
+ f"the pair ({first_label}, {second_label}) forms a range; "
+ f"{first_label} must be strictly less than {second_label} for the range "
+ "to contain any matches."
+ ),
+ help_line=(
+ f"help: swap the two arguments so {first_label} is the smaller value."
+ ),
+ )
super().__init__(message)
class MustBeAtLeastTwoOperandsError(EdifySyntaxError):
- """Raised when a variadic factory needs at least two operands but got fewer."""
+ """Raised when a variadic factory needs at least two operands but got fewer.
+
+ Args:
+ label: The name of the factory function (e.g. ``"any_of"``).
+ """
def __init__(self, label: str) -> None:
- message = f"{label} requires at least two operands."
+ message = compose_annotated_message(
+ summary=f"{label} requires at least two operands",
+ trigger_hint=f"{label} called here",
+ note=(
+ f"{label} builds an alternation between two-or-more branches; passing "
+ "fewer than two operands leaves nothing to alternate between."
+ ),
+ help_line=(
+ f"help: pass at least two Pattern operands to {label}(...); "
+ "if you have only one alternative, use it directly without an alternation."
+ ),
+ )
super().__init__(message)
class MustBeAtLeastOneLiteralError(EdifySyntaxError):
- """Raised when a variadic literal-alternation chain method got zero literals."""
+ """Raised when a variadic literal-alternation chain method got zero literals.
+
+ Args:
+ label: The name of the chain method (e.g. ``"one_of"``).
+ """
def __init__(self, label: str) -> None:
- message = f"{label} requires at least one literal."
+ message = compose_annotated_message(
+ summary=f"{label} requires at least one literal",
+ trigger_hint=f"{label} called here",
+ note=(
+ f"{label} builds a literal alternation; without any string literal, "
+ "the chain method has no branch to add."
+ ),
+ help_line=(
+ f"help: pass one or more string literals to {label}(...) "
+ "(e.g. .one_of('cat', 'dog'))."
+ ),
+ )
super().__init__(message)
diff --git a/edify/errors/internal.py b/edify/errors/internal.py
index 7484e47..4181d83 100644
--- a/edify/errors/internal.py
+++ b/edify/errors/internal.py
@@ -1,42 +1,102 @@
-"""Exception classes for internal-consistency violations inside edify.
-
-These errors should never fire under correct builder use; they exist so that
-unrecognised AST shapes raise loudly with a specific class instead of slipping
-through with a generic message.
-"""
+"""Exception classes for internal-consistency violations inside edify."""
from __future__ import annotations
+from edify.errors.formatting import compose_annotated_message
from edify.errors.syntax import EdifySyntaxError
class UnknownElementTypeError(EdifySyntaxError):
- """Raised when the compile dispatcher receives an element class it does not recognise."""
+ """Raised when the compile dispatcher receives an element class it does not recognise.
+
+ Args:
+ element_type_name: The class name that was not registered on the dispatch table.
+ """
def __init__(self, element_type_name: str) -> None:
- message = f"Cannot render unknown element type {element_type_name}"
+ message = compose_annotated_message(
+ summary=f"unknown element type {element_type_name!r} reached the compiler",
+ trigger_hint="pattern compiled here",
+ note=(
+ f"the compile dispatcher has no rule for {element_type_name}; either the "
+ "element is misconstructed or a compile handler is missing for this class."
+ ),
+ help_line=(
+ "help: this is an internal edify bug; please file it at "
+ "https://github.com/luciferreeves/edify/issues with the pattern that "
+ "triggered the error."
+ ),
+ )
super().__init__(message)
class NonFusableElementError(EdifySyntaxError):
- """Raised when the char-class fuser is handed an element it cannot fuse."""
+ """Raised when the char-class fuser is handed an element it cannot fuse.
+
+ Args:
+ element_type_name: The class name that the fuser refused.
+ """
def __init__(self, element_type_name: str) -> None:
- message = f"Cannot fuse element of type {element_type_name} into a char class"
+ message = compose_annotated_message(
+ summary=f"cannot fuse element {element_type_name!r} into a character class",
+ trigger_hint="pattern compiled here",
+ note=(
+ "the character-class fuser accepts only character-shaped elements "
+ f"(CharElement, RangeElement, AnyOfCharsElement); {element_type_name} is not one of them."
+ ),
+ help_line=(
+ "help: this is an internal edify bug; please file it at "
+ "https://github.com/luciferreeves/edify/issues with the pattern that "
+ "triggered the error."
+ ),
+ )
super().__init__(message)
class UnexpectedFrameTypeError(EdifySyntaxError):
- """Raised when a stack frame's anchor element is not a recognised container kind."""
+ """Raised when a stack frame's anchor element is not a recognised container kind.
+
+ Args:
+ element_type_name: The class name held by the offending stack frame.
+ """
def __init__(self, element_type_name: str) -> None:
- message = f"Stack frame anchored at unexpected element type {element_type_name}"
+ message = compose_annotated_message(
+ summary=f"stack frame anchored at unexpected element {element_type_name!r}",
+ trigger_hint="pattern touched here",
+ note=(
+ "builder frames must anchor at a container element such as CaptureElement, "
+ f"NamedCaptureElement, GroupElement, AnyOfElement, or a lookaround; "
+ f"{element_type_name} is not one of the accepted container kinds."
+ ),
+ help_line=(
+ "help: this is an internal edify bug; please file it at "
+ "https://github.com/luciferreeves/edify/issues with the pattern that "
+ "triggered the error."
+ ),
+ )
super().__init__(message)
class FailedToCompileRegexError(EdifySyntaxError):
- """Raised when the underlying ``re`` engine rejects the emitted pattern."""
+ """Raised when the underlying ``re`` engine rejects the emitted pattern.
+
+ Args:
+ underlying_error_message: The message string reported by ``re.compile``.
+ """
def __init__(self, underlying_error_message: str) -> None:
- message = f"Cannot compile regex: {underlying_error_message}"
+ message = compose_annotated_message(
+ summary="the emitted regex was rejected by the re engine",
+ trigger_hint=".to_regex() / .to_regex_string() called here",
+ note=(
+ f"re.compile refused the pattern: {underlying_error_message}"
+ ),
+ help_line=(
+ "help: inspect the raw pattern with .to_regex_string() and cross-check "
+ "quantifier / group placement; if the pattern looks correct, please file "
+ "the issue at https://github.com/luciferreeves/edify/issues."
+ ),
+ )
super().__init__(message)
diff --git a/edify/errors/introspect.py b/edify/errors/introspect.py
new file mode 100644
index 0000000..0401924
--- /dev/null
+++ b/edify/errors/introspect.py
@@ -0,0 +1,89 @@
+"""Exceptions raised by the :class:`edify.result.regex.Regex` introspection API."""
+
+from __future__ import annotations
+
+from edify.errors.context import capture_caller_context
+from edify.errors.formatting import format_error, format_note_line, format_pointer_block
+from edify.errors.syntax import EdifySyntaxError
+
+_SUPPORTED_FORMATS = ("ascii", "svg")
+_SUPPORTED_ENGINES_BY_FORMAT: dict[str, tuple[str, ...]] = {
+ "ascii": ("ascii",),
+ "svg": ("graphviz",),
+}
+
+
+class UnsupportedVisualizationFormatError(EdifySyntaxError):
+ """Raised when ``Regex.visualize(format=...)`` receives an unknown format.
+
+ Args:
+ received_format: The format string the caller passed.
+ """
+
+ def __init__(self, received_format: str) -> None:
+ caller_context = capture_caller_context()
+ trigger_block = ""
+ if caller_context is not None:
+ trigger_block = format_pointer_block(caller_context, "unknown format here")
+ supported = ", ".join(f"'{fmt}'" for fmt in _SUPPORTED_FORMATS)
+ note_line = format_note_line(
+ f"Regex.visualize accepts one of {supported}; received {received_format!r}."
+ )
+ message = format_error(
+ f"unsupported visualization format {received_format!r}",
+ trigger_block + "\n" + note_line if trigger_block else note_line,
+ "help: pass format='ascii' for a text railroad diagram, "
+ "or format='svg' for a Graphviz-rendered SVG.",
+ )
+ super().__init__(message)
+
+
+class UnsupportedVisualizationEngineError(EdifySyntaxError):
+ """Raised when ``Regex.visualize(engine=...)`` does not pair with the chosen format.
+
+ Args:
+ received_format: The format string the caller passed.
+ received_engine: The engine string the caller passed.
+ """
+
+ def __init__(self, received_format: str, received_engine: str) -> None:
+ caller_context = capture_caller_context()
+ trigger_block = ""
+ if caller_context is not None:
+ trigger_block = format_pointer_block(caller_context, "engine does not match format")
+ supported = _SUPPORTED_ENGINES_BY_FORMAT.get(received_format, ())
+ supported_list = ", ".join(f"'{engine}'" for engine in supported) or "(none)"
+ note_line = format_note_line(
+ f"format={received_format!r} pairs with engine in {supported_list}; "
+ f"received engine={received_engine!r}."
+ )
+ preferred_engine = supported_list.split(",")[0].strip() if supported else "ascii"
+ message = format_error(
+ f"engine {received_engine!r} does not pair with format {received_format!r}",
+ trigger_block + "\n" + note_line if trigger_block else note_line,
+ f"help: pass engine={preferred_engine!r}.",
+ )
+ super().__init__(message)
+
+
+class MissingGraphvizDependencyError(EdifySyntaxError):
+ """Raised when ``Regex.visualize(format='svg')`` is called without graphviz installed."""
+
+ def __init__(self) -> None:
+ caller_context = capture_caller_context()
+ trigger_block = ""
+ if caller_context is not None:
+ trigger_block = format_pointer_block(
+ caller_context, "graphviz dependency required here"
+ )
+ note_line = format_note_line(
+ "the graphviz Python package is required to render SVG diagrams; "
+ "it is not installed in this environment."
+ )
+ message = format_error(
+ "graphviz optional dependency is not installed",
+ trigger_block + "\n" + note_line if trigger_block else note_line,
+ "help: install with `pip install 'edify[graphviz]'` "
+ "or `uv add edify --extra graphviz`.",
+ )
+ super().__init__(message)
diff --git a/edify/errors/naming.py b/edify/errors/naming.py
index 6897552..835c9d5 100644
--- a/edify/errors/naming.py
+++ b/edify/errors/naming.py
@@ -1,37 +1,75 @@
-"""Exception classes raised when named-group operations are misused.
-
-Named groups (``named_capture``, ``named_back_reference``) require a unique,
-valid identifier for each name. These classes surface the three failure
-modes: duplicate name, invalid name shape, and reference to a name that
-was never declared.
-"""
+"""Exception classes raised when named-group operations are misused."""
from __future__ import annotations
+from edify.errors.formatting import compose_annotated_message
from edify.errors.syntax import EdifySyntaxError
class NameNotValidError(EdifySyntaxError):
- """Raised when a named-group name fails the identifier shape check."""
+ """Raised when a named-group name fails the identifier shape check.
+
+ Args:
+ name: The rejected name string.
+ """
def __init__(self, name: str) -> None:
- message = (
- f"Name {name} is not valid. (only alphanumeric characters and underscores are allowed)"
+ message = compose_annotated_message(
+ summary=f"named-group name {name!r} is not a valid identifier",
+ trigger_hint="invalid name passed here",
+ note=(
+ "named-group names must be non-empty and contain only letters, digits, "
+ "and underscores (no spaces, hyphens, or punctuation)."
+ ),
+ help_line=(
+ f"help: rename {name!r} to a bare identifier "
+ "(e.g. 'year', 'user_id', 'scheme')."
+ ),
)
super().__init__(message)
class CannotCreateDuplicateNamedGroupError(EdifySyntaxError):
- """Raised when ``named_capture`` is called with a name already registered on the builder."""
+ """Raised when ``named_capture`` is called with a name already registered on the builder.
+
+ Args:
+ name: The duplicate name.
+ """
def __init__(self, name: str) -> None:
- message = f'Can not create duplicate named group "{name}".'
+ message = compose_annotated_message(
+ summary=f"named group {name!r} already exists in this pattern",
+ trigger_hint="duplicate name declared here",
+ note=(
+ "every named capture group must have a unique name; the earlier "
+ f".named_capture({name!r}) call already registered this one."
+ ),
+ help_line=(
+ f"help: pick a different name for the second group, "
+ f"or use .named_back_reference({name!r}) to reuse the existing group's match."
+ ),
+ )
super().__init__(message)
class NamedGroupDoesNotExistError(EdifySyntaxError):
- """Raised when ``named_back_reference`` references a name that was never declared."""
+ """Raised when ``named_back_reference`` references a name that was never declared.
+
+ Args:
+ name: The name the caller tried to reference.
+ """
def __init__(self, name: str) -> None:
- message = f'Named group "{name}" does not exist (create one with .named_capture()).'
+ message = compose_annotated_message(
+ summary=f"named group {name!r} does not exist in this pattern",
+ trigger_hint="unknown name referenced here",
+ note=(
+ f".named_back_reference({name!r}) can only refer to a group that was "
+ "declared earlier in the chain; no such group has been declared yet."
+ ),
+ help_line=(
+ f"help: add a .named_capture({name!r})...end() before this call, "
+ "or reference an already-declared name."
+ ),
+ )
super().__init__(message)
diff --git a/edify/errors/quantifier.py b/edify/errors/quantifier.py
index 23e435e..2acc47b 100644
--- a/edify/errors/quantifier.py
+++ b/edify/errors/quantifier.py
@@ -1,15 +1,8 @@
-"""Exception classes raised for quantifier misuse in a builder chain.
-
-* :class:`DanglingQuantifierError` — a terminal was called with a pending
- quantifier that never received an operand. Emitting silently would
- drop the quantifier from the output.
-* :class:`StackedQuantifierError` — a quantifier chain method was called
- while another quantifier was already pending. Emitting silently would
- drop the outer quantifier.
-"""
+"""Exception classes raised for quantifier misuse in a builder chain."""
from __future__ import annotations
+from edify.errors.formatting import compose_annotated_message
from edify.errors.syntax import EdifySyntaxError
@@ -17,9 +10,18 @@ class DanglingQuantifierError(EdifySyntaxError):
"""Raised when a terminal is called while a quantifier is still pending."""
def __init__(self) -> None:
- message = (
- "Dangling quantifier with no operand. "
- "Append an element (e.g. .digit()) before compiling."
+ message = compose_annotated_message(
+ summary="dangling quantifier with no operand to apply to",
+ 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."
+ ),
+ help_line=(
+ "help: append the element the quantifier should apply to "
+ "(e.g. .digit(), .word(), .string('...')) before the terminal call."
+ ),
)
super().__init__(message)
@@ -28,8 +30,17 @@ class StackedQuantifierError(EdifySyntaxError):
"""Raised when a quantifier chain method is called with another quantifier already pending."""
def __init__(self) -> None:
- message = (
- "Cannot stack a quantifier on top of another pending quantifier. "
- "Add an operand between the two quantifiers or drop one."
+ message = compose_annotated_message(
+ summary="cannot stack a quantifier on top of another pending quantifier",
+ trigger_hint="second quantifier queued here",
+ note=(
+ "a quantifier waits for the next element in the chain; adding another "
+ "quantifier before that element would leave the earlier one with nothing "
+ "to attach to."
+ ),
+ help_line=(
+ "help: add an operand (e.g. .digit(), .word()) between the two "
+ "quantifier calls, or drop one of the two quantifiers."
+ ),
)
super().__init__(message)
diff --git a/edify/errors/structure.py b/edify/errors/structure.py
index 20689b7..e55bf3e 100644
--- a/edify/errors/structure.py
+++ b/edify/errors/structure.py
@@ -1,11 +1,8 @@
-"""Exception classes raised for builder-frame structural errors.
-
-These cover frame-stack misuse: closing the root frame, or calling
-``subexpression`` on a builder whose own chain still has an open frame.
-"""
+"""Exception classes raised for builder-frame structural errors."""
from __future__ import annotations
+from edify.errors.formatting import compose_annotated_message
from edify.errors.syntax import EdifySyntaxError
@@ -13,16 +10,39 @@ class CannotEndWhileBuildingRootExpressionError(EdifySyntaxError):
"""Raised when ``.end()`` is called on a builder whose only open frame is the root."""
def __init__(self) -> None:
- message = "Can not end while building the root expression."
+ message = compose_annotated_message(
+ summary="cannot .end() while building the root expression",
+ trigger_hint=".end() called here",
+ note=(
+ ".end() closes the innermost open frame, but the root expression has no "
+ "matching opener; there is no frame to close."
+ ),
+ help_line=(
+ "help: remove this .end() call, or open a frame first with "
+ ".capture(), .named_capture(name), .group(), .any_of(), or a lookaround."
+ ),
+ )
super().__init__(message)
class CannotCallSubexpressionError(EdifySyntaxError):
- """Raised when ``.subexpression(expression)`` is given an expression with open frames."""
+ """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.
+ """
def __init__(self, current_frame_type: str) -> None:
- message = (
- "Can not call subexpression on a not yet fully specified regex object. "
- f"(Try adding a .end() call to match the {current_frame_type} on the subexpression)"
+ 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."
+ ),
+ help_line=(
+ f"help: add a matching .end() call to close the {current_frame_type} frame "
+ "on the subexpression before passing it to .subexpression(...)."
+ ),
)
super().__init__(message)
diff --git a/tests/errors/errors.test.py b/tests/errors/errors.test.py
index 6472469..5b48165 100644
--- a/tests/errors/errors.test.py
+++ b/tests/errors/errors.test.py
@@ -35,131 +35,210 @@ from edify.errors.structure import (
def test_start_input_already_defined_outside_subexpression():
error = StartInputAlreadyDefinedError()
- assert "already has a start" in str(error)
- assert "ignore_start_and_end" not in str(error)
+ text = str(error)
+ assert "start_of_input has already been added" in text
+ assert "help: remove the duplicate .start_of_input()" in text
+ assert "ignore_start_and_end" not in text
def test_start_input_already_defined_in_subexpression():
error = StartInputAlreadyDefinedError(in_subexpression=True)
- assert "ignore_start_and_end" in str(error)
+ text = str(error)
+ assert "start_of_input has already been added" in text
+ assert "ignore_start_and_end=True" in text
def test_end_input_already_defined_outside_subexpression():
error = EndInputAlreadyDefinedError()
- assert "already has an end" in str(error)
- assert "ignore_start_and_end" not in str(error)
+ text = str(error)
+ assert "end_of_input has already been added" in text
+ assert "help: remove the duplicate .end_of_input()" in text
+ assert "ignore_start_and_end" not in text
def test_end_input_already_defined_in_subexpression():
error = EndInputAlreadyDefinedError(in_subexpression=True)
- assert "ignore_start_and_end" in str(error)
+ text = str(error)
+ assert "end_of_input has already been added" in text
+ assert "ignore_start_and_end=True" in text
def test_cannot_define_start_after_end():
error = CannotDefineStartAfterEndError()
- assert "start of input after defining an end" in str(error)
+ text = str(error)
+ assert "start_of_input cannot follow end_of_input" in text
+ assert "move .start_of_input() to before" in text
-def test_invalid_total_capture_groups_index():
+def test_invalid_total_capture_groups_index_reports_out_of_range():
error = InvalidTotalCaptureGroupsIndexError(5, 3)
- assert "Invalid index #5" in str(error)
- assert "only 3 capture groups" in str(error)
+ text = str(error)
+ assert "back_reference index #5 is out of range" in text
+ assert "3 capture groups" in text
+ assert "valid indices are 1 to 3" in text
+
+
+def test_invalid_total_capture_groups_index_with_no_capture_groups_prompts_add_one():
+ error = InvalidTotalCaptureGroupsIndexError(1, 0)
+ text = str(error)
+ assert "no capture groups yet" in text
+ assert "add a .capture()" in text
def test_must_be_a_string():
error = MustBeAStringError("Name", "int")
- assert "Name must be a string" in str(error)
- assert "int" in str(error)
+ text = str(error)
+ assert "Name must be a string" in text
+ assert "int" in text
+ assert "convert the value with str(...)" in text
def test_must_be_one_character():
error = MustBeOneCharacterError("Value")
- assert "Value must be one character long" in str(error)
+ text = str(error)
+ assert "Value must be one character long" in text
def test_must_be_single_character():
error = MustBeSingleCharacterError("Value", "str")
- assert "Value must be a single character" in str(error)
- assert "str" in str(error)
+ text = str(error)
+ assert "Value must be a single character" in text
+ assert "str" in text
def test_must_be_positive_integer():
error = MustBePositiveIntegerError("count")
- assert "count must be a positive integer" in str(error)
+ text = str(error)
+ assert "count must be a positive integer" in text
def test_must_be_integer_greater_than_zero():
error = MustBeIntegerGreaterThanZeroError("x")
- assert "x must be an integer greater than zero" in str(error)
+ text = str(error)
+ assert "x must be an integer greater than zero" in text
def test_must_be_instance():
error = MustBeInstanceError("Expression", "str", "RegexBuilder")
- assert "Expression must be an instance of RegexBuilder" in str(error)
- assert "str" in str(error)
+ text = str(error)
+ assert "Expression must be an instance of RegexBuilder" in text
+ assert "str" in text
-def test_must_have_a_smaller_value():
+def test_must_have_a_smaller_value_reports_the_codepoints():
error = MustHaveASmallerValueError("z", "a")
- assert "z must have a smaller character value than a" in str(error)
+ text = str(error)
+ assert "range bounds are inverted" in text
+ assert "'z'" in text and "'a'" in text
+ assert "= 122" in text and "= 97" in text
def test_must_be_less_than():
error = MustBeLessThanError("X", "Y")
- assert "X must be less than Y" in str(error)
+ text = str(error)
+ assert "X must be less than Y" in text
def test_must_be_at_least_two_operands():
error = MustBeAtLeastTwoOperandsError("any_of")
- assert "any_of requires at least two operands" in str(error)
+ text = str(error)
+ assert "any_of requires at least two operands" in text
def test_must_be_at_least_one_literal():
error = MustBeAtLeastOneLiteralError("one_of")
- assert "one_of requires at least one literal" in str(error)
+ text = str(error)
+ assert "one_of requires at least one literal" in text
def test_name_not_valid():
error = NameNotValidError("bad name")
- assert "Name bad name is not valid" in str(error)
+ text = str(error)
+ assert "'bad name' is not a valid identifier" in text
+ assert "letters, digits, and underscores" in text
def test_cannot_create_duplicate_named_group():
error = CannotCreateDuplicateNamedGroupError("dup")
- assert 'Can not create duplicate named group "dup"' in str(error)
+ text = str(error)
+ assert "named group 'dup' already exists" in text
+ assert ".named_back_reference('dup')" in text
def test_named_group_does_not_exist():
error = NamedGroupDoesNotExistError("missing")
- assert 'Named group "missing" does not exist' in str(error)
+ text = str(error)
+ assert "named group 'missing' does not exist" in text
+ assert ".named_capture('missing')" in text
def test_cannot_end_while_building_root_expression():
error = CannotEndWhileBuildingRootExpressionError()
- assert "Can not end while building the root expression" in str(error)
+ text = str(error)
+ assert "cannot .end() while building the root expression" in text
+ assert "no matching opener" in text or "no frame to close" in text
def test_cannot_call_subexpression():
error = CannotCallSubexpressionError("capture")
- assert "Can not call subexpression" in str(error)
- assert "capture" in str(error)
+ text = str(error)
+ assert "cannot merge a subexpression that has an unclosed frame" in text
+ assert "capture" in text
def test_unknown_element_type():
error = UnknownElementTypeError("WeirdElement")
- assert "WeirdElement" in str(error)
+ text = str(error)
+ assert "unknown element type 'WeirdElement'" in text
def test_non_fusable_element():
error = NonFusableElementError("DigitElement")
- assert "Cannot fuse element of type DigitElement" in str(error)
+ text = str(error)
+ assert "cannot fuse element 'DigitElement' into a character class" in text
def test_unexpected_frame_type():
error = UnexpectedFrameTypeError("DigitElement")
- assert "Stack frame anchored at unexpected element type DigitElement" in str(error)
+ text = str(error)
+ assert "stack frame anchored at unexpected element 'DigitElement'" in text
def test_failed_to_compile_regex():
error = FailedToCompileRegexError("missing )")
- assert "Cannot compile regex: missing )" in str(error)
+ text = str(error)
+ assert "rejected by the re engine" in text
+ assert "missing )" in text
+
+
+def test_every_annotated_error_message_starts_with_error_prefix():
+ errors = [
+ StartInputAlreadyDefinedError(),
+ CannotDefineStartAfterEndError(),
+ EndInputAlreadyDefinedError(),
+ InvalidTotalCaptureGroupsIndexError(1, 0),
+ MustBeAStringError("X", "int"),
+ MustBeOneCharacterError("X"),
+ MustBeSingleCharacterError("X", "int"),
+ MustBePositiveIntegerError("X"),
+ MustBeIntegerGreaterThanZeroError("X"),
+ MustBeInstanceError("X", "int", "Y"),
+ MustHaveASmallerValueError("z", "a"),
+ MustBeLessThanError("A", "B"),
+ MustBeAtLeastTwoOperandsError("f"),
+ MustBeAtLeastOneLiteralError("f"),
+ NameNotValidError("x"),
+ CannotCreateDuplicateNamedGroupError("x"),
+ NamedGroupDoesNotExistError("x"),
+ CannotEndWhileBuildingRootExpressionError(),
+ CannotCallSubexpressionError("capture"),
+ UnknownElementTypeError("X"),
+ NonFusableElementError("X"),
+ UnexpectedFrameTypeError("X"),
+ FailedToCompileRegexError("boom"),
+ ]
+ for error in errors:
+ text = str(error)
+ assert text.startswith("error:")
+ assert "help:" in text
+ assert "= note:" in text
diff --git a/tests/errors/formatting.test.py b/tests/errors/formatting.test.py
new file mode 100644
index 0000000..8cdf0ab
--- /dev/null
+++ b/tests/errors/formatting.test.py
@@ -0,0 +1,129 @@
+"""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")
diff --git a/tests/errors/quantifier.test.py b/tests/errors/quantifier.test.py
index 27b5ddf..1256a68 100644
--- a/tests/errors/quantifier.test.py
+++ b/tests/errors/quantifier.test.py
@@ -42,14 +42,15 @@ def test_to_regex_raises_when_a_bare_quantifier_has_no_operand():
def test_dangling_message_hints_at_appending_an_operand():
- with pytest.raises(DanglingQuantifierError, match="Append an element"):
+ with pytest.raises(DanglingQuantifierError, match="append the element"):
RegexBuilder().exactly(3).to_regex_string()
def test_dangling_quantifier_error_message_contains_expected_text():
error = DanglingQuantifierError()
- assert "Dangling quantifier" in str(error)
- assert "no operand" in str(error)
+ text = str(error)
+ assert "dangling quantifier" in text
+ assert "no operand" in text
def test_stacking_one_or_more_over_exactly_raises():
@@ -84,5 +85,6 @@ def test_a_valid_quantifier_element_quantifier_element_chain_works():
def test_stacked_quantifier_error_message_contains_expected_text():
error = StackedQuantifierError()
- assert "stack" in str(error)
- assert "pending" in str(error)
+ text = str(error)
+ assert "stack a quantifier" in text
+ assert "pending quantifier" in text