From 9d80c99432c0c7c5f184cbe7bb25513aace01a7e Mon Sep 17 00:00:00 2001 From: natsuoto <279971144+natsuoto@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:18:09 +0530 Subject: feat(errors): annotated error messages with caller-context pointers --- tests/errors/errors.test.py | 147 ++++++++++++++++++++++++++++++---------- tests/errors/formatting.test.py | 129 +++++++++++++++++++++++++++++++++++ tests/errors/quantifier.test.py | 12 ++-- 3 files changed, 249 insertions(+), 39 deletions(-) create mode 100644 tests/errors/formatting.test.py (limited to 'tests/errors') 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 -- cgit v1.2.3 From 329116d9ad0c64db3800ed17ea0530ad68f95d4b Mon Sep 17 00:00:00 2001 From: natsuoto <279971144+natsuoto@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:27:09 +0530 Subject: chore: apply ruff lint and format fixes --- tests/errors/errors.test.py | 6 ++++-- tests/errors/formatting.test.py | 4 +--- 2 files changed, 5 insertions(+), 5 deletions(-) (limited to 'tests/errors') diff --git a/tests/errors/errors.test.py b/tests/errors/errors.test.py index 5b48165..74acaa4 100644 --- a/tests/errors/errors.test.py +++ b/tests/errors/errors.test.py @@ -129,8 +129,10 @@ def test_must_have_a_smaller_value_reports_the_codepoints(): error = MustHaveASmallerValueError("z", "a") 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 + assert "'z'" in text + assert "'a'" in text + assert "= 122" in text + assert "= 97" in text def test_must_be_less_than(): diff --git a/tests/errors/formatting.test.py b/tests/errors/formatting.test.py index 8cdf0ab..314aff1 100644 --- a/tests/errors/formatting.test.py +++ b/tests/errors/formatting.test.py @@ -114,9 +114,7 @@ def test_compose_annotated_message_when_caller_context_is_available(): def test_compose_annotated_message_falls_back_when_caller_context_is_none(): - with mock.patch( - "edify.errors.formatting.capture_caller_context", return_value=None - ): + with mock.patch("edify.errors.formatting.capture_caller_context", return_value=None): output = compose_annotated_message( summary="bad thing happened", trigger_hint="here", -- cgit v1.2.3 From 6f27b88358e5760cdd034a77a61de7a32e77c425 Mon Sep 17 00:00:00 2001 From: natsuoto <279971144+natsuoto@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:55:33 +0530 Subject: test: cover diagnose fallback branches and caller-context position fallbacks --- tests/errors/context.test.py | 112 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 tests/errors/context.test.py (limited to 'tests/errors') diff --git a/tests/errors/context.test.py b/tests/errors/context.test.py new file mode 100644 index 0000000..3b54605 --- /dev/null +++ b/tests/errors/context.test.py @@ -0,0 +1,112 @@ +"""Tests for the caller-source-location capture helpers in :mod:`edify.errors.context`.""" + +from types import SimpleNamespace +from unittest import mock + +from edify.errors.context import ( + CallerContext, + _context_for_frame, + capture_caller_context, +) + + +class _FakeCode: + """A stand-in for ``types.CodeType`` used to force specific ``co_positions`` behaviour.""" + + def __init__(self, filename: str, positions: list[tuple]) -> None: + self.co_filename = filename + self._positions = list(positions) + + def co_positions(self): + return iter(self._positions) + + +class _FakeFrame: + """A stand-in for ``types.FrameType`` shaped for :func:`_context_for_frame`.""" + + def __init__( + self, + filename: str, + f_lineno: int, + f_lasti: int, + positions: list[tuple], + ) -> None: + self.f_code = _FakeCode(filename, positions) + self.f_lineno = f_lineno + self.f_lasti = f_lasti + + +def test_capture_caller_context_returns_none_when_every_frame_is_inside_edify(): + with mock.patch( + "edify.errors.context.sys._getframe", return_value=None + ): + assert capture_caller_context() is None + + +def test_context_for_frame_uses_lineno_fallback_when_instruction_index_out_of_range(): + frame = _FakeFrame( + filename="/tmp/fake.py", + f_lineno=42, + f_lasti=999, + positions=[], + ) + context = _context_for_frame(frame) + assert context.lineno == 42 + assert context.colno == 1 + assert context.end_colno == 1 + + +def test_context_for_frame_defaults_start_line_when_position_start_line_is_none(): + frame = _FakeFrame( + filename="/tmp/fake.py", + f_lineno=99, + f_lasti=0, + positions=[(None, None, None, None)], + ) + context = _context_for_frame(frame) + assert context.lineno == 99 + + +def test_context_for_frame_defaults_end_line_when_position_end_line_is_none(): + frame = _FakeFrame( + filename="/tmp/fake.py", + f_lineno=7, + f_lasti=0, + positions=[(5, None, 2, 4)], + ) + context = _context_for_frame(frame) + assert context.lineno == 5 + assert context.colno == 3 + assert context.end_colno == 5 + + +def test_context_for_frame_defaults_start_col_when_position_start_col_is_none(): + frame = _FakeFrame( + filename="/tmp/fake.py", + f_lineno=1, + f_lasti=0, + positions=[(1, 1, None, 5)], + ) + context = _context_for_frame(frame) + assert context.colno == 1 + assert context.end_colno == 6 + + +def test_context_for_frame_defaults_end_col_when_position_end_col_is_none(): + frame = _FakeFrame( + filename="/tmp/fake.py", + f_lineno=1, + f_lasti=0, + positions=[(1, 1, 3, None)], + ) + context = _context_for_frame(frame) + assert context.colno == 4 + assert context.end_colno == 4 + + +def test_caller_context_dataclass_is_frozen_and_holds_all_five_fields(): + context = CallerContext( + filename="a.py", lineno=1, colno=1, end_colno=5, source_line="x = 1" + ) + assert context.filename == "a.py" + assert context.source_line == "x = 1" -- cgit v1.2.3 From 174eb521850550830d97994719ca3d09c467cb5a Mon Sep 17 00:00:00 2001 From: natsuoto <279971144+natsuoto@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:56:03 +0530 Subject: chore: apply ruff format and drop unused import in new context tests --- tests/errors/context.test.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) (limited to 'tests/errors') diff --git a/tests/errors/context.test.py b/tests/errors/context.test.py index 3b54605..fff9e09 100644 --- a/tests/errors/context.test.py +++ b/tests/errors/context.test.py @@ -1,6 +1,5 @@ """Tests for the caller-source-location capture helpers in :mod:`edify.errors.context`.""" -from types import SimpleNamespace from unittest import mock from edify.errors.context import ( @@ -37,9 +36,7 @@ class _FakeFrame: def test_capture_caller_context_returns_none_when_every_frame_is_inside_edify(): - with mock.patch( - "edify.errors.context.sys._getframe", return_value=None - ): + with mock.patch("edify.errors.context.sys._getframe", return_value=None): assert capture_caller_context() is None @@ -105,8 +102,6 @@ def test_context_for_frame_defaults_end_col_when_position_end_col_is_none(): def test_caller_context_dataclass_is_frozen_and_holds_all_five_fields(): - context = CallerContext( - filename="a.py", lineno=1, colno=1, end_colno=5, source_line="x = 1" - ) + context = CallerContext(filename="a.py", lineno=1, colno=1, end_colno=5, source_line="x = 1") assert context.filename == "a.py" assert context.source_line == "x = 1" -- cgit v1.2.3