diff options
| author | natsuoto <[email protected]> | 2026-07-10 16:18:09 +0530 |
|---|---|---|
| committer | natsuoto <[email protected]> | 2026-07-10 16:18:09 +0530 |
| commit | 9d80c99432c0c7c5f184cbe7bb25513aace01a7e (patch) | |
| tree | ec79c1768bf35c10ce9f708d0ee77a18eb746d80 | |
| parent | c757ea1fea447df3ed69e74b407ca9710a9f85e0 (diff) | |
| download | edify-9d80c99432c0c7c5f184cbe7bb25513aace01a7e.tar.xz edify-9d80c99432c0c7c5f184cbe7bb25513aace01a7e.zip | |
feat(errors): annotated error messages with caller-context pointers
| -rw-r--r-- | edify/errors/anchors.py | 73 | ||||
| -rw-r--r-- | edify/errors/captures.py | 37 | ||||
| -rw-r--r-- | edify/errors/comparison.py | 66 | ||||
| -rw-r--r-- | edify/errors/context.py | 82 | ||||
| -rw-r--r-- | edify/errors/formatting.py | 133 | ||||
| -rw-r--r-- | edify/errors/input.py | 217 | ||||
| -rw-r--r-- | edify/errors/internal.py | 88 | ||||
| -rw-r--r-- | edify/errors/introspect.py | 89 | ||||
| -rw-r--r-- | edify/errors/naming.py | 66 | ||||
| -rw-r--r-- | edify/errors/quantifier.py | 41 | ||||
| -rw-r--r-- | edify/errors/structure.py | 40 | ||||
| -rw-r--r-- | tests/errors/errors.test.py | 147 | ||||
| -rw-r--r-- | tests/errors/formatting.test.py | 129 | ||||
| -rw-r--r-- | tests/errors/quantifier.test.py | 12 |
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 |
