from __future__ import annotations from collections.abc import Callable from dataclasses import FrozenInstanceError from pathlib import Path from typing import Any, cast import pytest from mdpolish.text_ranges import PhysicalLine, iter_physical_lines, line_ending_styles, physical_lines def _offsets(lines: tuple[PhysicalLine, ...]) -> tuple[tuple[int, int, int], ...]: return tuple((line.content_start, line.content_end, line.full_end) for line in lines) @pytest.mark.parametrize( ("source", "expected"), [ ("", ()), ("a", ((0, 1, 1),)), ("a\n", ((0, 1, 2),)), ("\n", ((0, 0, 1),)), ("\r\n", ((0, 0, 2),)), ("a\n\n", ((0, 1, 2), (2, 2, 3))), ("\ntext", ((0, 0, 1), (1, 5, 5))), ("a\r\nb\rc\n", ((0, 1, 3), (3, 4, 5), (5, 6, 7))), ("a\u2028b", ((0, 3, 3),)), ], ) def test_scans_exact_physical_line_offsets( source: str, expected: tuple[tuple[int, int, int], ...], ) -> None: assert _offsets(physical_lines(source)) == expected @pytest.mark.parametrize( "source", [ "", "one line", "\nleading empty", "a\n\n", "a\r\nb\rc\n", "\ufeff中文😀e\u0301\u2028\x00\t\f", ], ) def test_ranges_reconstruct_source_without_normalization(source: str) -> None: lines = physical_lines(source) assert "".join(source[line.content_start : line.full_end] for line in lines) == source assert lines == tuple(iter_physical_lines(source)) assert lines == physical_lines(source) for line in lines: assert line.content(source) == source[line.content_start : line.content_end] assert line.line_ending(source) == source[line.content_end : line.full_end] def test_unicode_separators_and_control_characters_remain_content() -> None: source = "\ufeff中文😀e\u0301\u2028\u2029\x00\t\f" line = physical_lines(source)[0] assert (line.content_start, line.content_end, line.full_end) == (0, len(source), len(source)) assert line.content(source) == source assert line.line_ending(source) == "" @pytest.mark.parametrize( ("source", "expected"), [ ("", frozenset()), ("no ending", frozenset()), ("a\nb\n", frozenset(("\n",))), ("a\r\nb\r\n", frozenset(("\r\n",))), ("a\rb\r", frozenset(("\r",))), ("a\nb\r\nc\r", frozenset(("\n", "\r\n", "\r"))), ], ) def test_reports_only_line_endings_present_in_source(source: str, expected: frozenset[str]) -> None: assert line_ending_styles(source) == expected def test_empty_means_zero_length_content_not_whitespace() -> None: empty, space, tab = physical_lines("\n \n\t\n") assert empty.is_empty assert not space.is_empty assert not tab.is_empty def test_physical_line_is_frozen_and_slotted() -> None: line = PhysicalLine(0, 1, 1) mutable_view = cast(Any, line) with pytest.raises(FrozenInstanceError): mutable_view.content_start = 1 assert not hasattr(line, "__dict__") @pytest.mark.parametrize("field_index", [0, 1, 2]) @pytest.mark.parametrize("invalid", [True, 1.0, "1", None]) def test_rejects_non_integer_offsets(field_index: int, invalid: object) -> None: values = [0, 0, 0] values[field_index] = cast(int, invalid) with pytest.raises(TypeError): PhysicalLine(*values) @pytest.mark.parametrize( "offsets", [ (-1, 0, 0), (0, -1, 0), (0, 0, -1), (1, 0, 1), (0, 2, 1), (0, 0, 3), ], ) def test_rejects_invalid_offset_ranges(offsets: tuple[int, int, int]) -> None: with pytest.raises(ValueError): PhysicalLine(*offsets) @pytest.mark.parametrize( "scanner", [iter_physical_lines, physical_lines, line_ending_styles], ) @pytest.mark.parametrize("invalid", [b"text", Path("text.md"), None, 1]) def test_scanners_reject_non_string_sources_immediately( scanner: Callable[[str], object], invalid: object, ) -> None: with pytest.raises(TypeError, match="source must be a string"): scanner(cast(str, invalid)) def test_iter_scanner_validates_before_iteration_begins() -> None: with pytest.raises(TypeError, match="source must be a string"): iter_physical_lines(cast(str, b"deferred validation would be wrong")) def test_accessors_reject_non_string_or_short_sources() -> None: line = PhysicalLine(0, 1, 2) with pytest.raises(TypeError, match="source must be a string"): line.content(cast(str, b"a\n")) with pytest.raises(TypeError, match="source must be a string"): line.line_ending(cast(str, None)) with pytest.raises(ValueError, match="exceeds source length"): line.content("a") with pytest.raises(ValueError, match="exceeds source length"): line.line_ending("a") @pytest.mark.parametrize( ("line", "source"), [ (PhysicalLine(0, 1, 2), "ab"), (PhysicalLine(0, 0, 2), "\n\r"), ], ) def test_line_ending_rejects_non_physical_slices(line: PhysicalLine, source: str) -> None: with pytest.raises(ValueError, match="empty, LF, CR, or CRLF"): line.line_ending(source) @pytest.mark.parametrize("source", ["a", "a\n", "a\r", "a\r\n"]) def test_line_ending_accepts_every_supported_slice(source: str) -> None: line = physical_lines(source)[0] assert line.line_ending(source) in {"", "\n", "\r", "\r\n"}