From 61b7ea75344108658ea50673cb4ad4c08fa2ce5f Mon Sep 17 00:00:00 2001 From: Bepr4 <63661977@qq.com> Date: Fri, 28 Aug 2026 15:00:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=AC=E5=BC=80=E7=B2=BE=E7=A1=AE?= =?UTF-8?q?=E7=89=A9=E7=90=86=E8=A1=8C=E8=8C=83=E5=9B=B4=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 54 ++++-- pyproject.toml | 2 +- .../reference/physical-line-ranges.md | 137 ++++++++++++++ src/mdpolish/_text_ranges.py | 60 ------ src/mdpolish/modifiers/html_table_layout.py | 2 +- src/mdpolish/modifiers/mapped_line_join.py | 6 +- src/mdpolish/text_ranges.py | 111 +++++++++++ tests/test_mapped_line_join.py | 2 +- tests/test_text_ranges.py | 174 ++++++++++++++++++ 9 files changed, 466 insertions(+), 82 deletions(-) create mode 100644 research-wiki/reference/physical-line-ranges.md delete mode 100644 src/mdpolish/_text_ranges.py create mode 100644 src/mdpolish/text_ranges.py create mode 100644 tests/test_text_ranges.py diff --git a/README.md b/README.md index 4b1b076..e0e7bdf 100644 --- a/README.md +++ b/README.md @@ -3,8 +3,8 @@ `mdpolish` 是实验室共用的、项目无关的 Python Markdown 修改库。它提供函数式 `Modifier`、精确文本编辑执行器、 有序 `Pipeline`、正则修改器工厂,以及少量可以用合成样例完整说明的通用修改器。 -当前发布版本是 [`v0.4.0`](https://github.com/Bepr4/mdpolish/releases/tag/v0.4.0),当前源码树的下一候选版本是 -`0.5.0`。库只处理内存中的 Markdown 字符串,不读取或写入文件,不提供默认流水线,也不包含任何项目的规则集合、 +当前发布版本是 [`v0.6.0`](https://github.com/Bepr4/mdpolish/releases/tag/v0.6.0)。库只处理内存中的 Markdown 字符串, +不读取或写入文件,不提供默认流水线,也不包含任何项目的规则集合、 数据清单、实验脚本或评审界面。 ## 当前能力 @@ -18,6 +18,7 @@ | `render_markdown_report()` | 把评审视图编排成完整 Markdown 源码报告字符串 | 只返回内存字符串,不创建文件或业务页面 | | `review_document_to_dict()` | 按 schema `1.0` 把评审视图投影成普通 JSON 基本值 | 单向投影,不反序列化或重新应用修改 | | `render_json_report()` | 复用正式 dict 投影生成确定的内存 JSON 字符串 | 不创建文件;默认摘要不等于公开安全日志 | +| `mdpolish.text_ranges` | 返回 CR/LF/CRLF 物理行的精确不可变原文范围 | 不解析 Markdown 块,不自动执行或修改文本 | | `regex_replace()` | 把非空正则匹配转换为精确编辑 | 不提供规则注册表、配置加载或默认模式 | | `mapped_line_join()` | 用精确、正则或可选本地词典规则合并跨行片段 | 无默认规则;代码、表格、未知结构和歧义失败关闭 | | HTML 表格修改器 | 处理严格表格子集的实体和单行布局 | 不是完整 HTML parser,也不是 HTML→GFM 转换器 | @@ -36,20 +37,19 @@ 不可移动的 tag: ```bash -python -m pip install 'mdpolish @ git+https://github.com/Bepr4/mdpolish.git@v0.4.0' -python -m pip install 'mdpolish[lexical] @ git+https://github.com/Bepr4/mdpolish.git@v0.4.0' -python -m pip install 'mdpolish[frequency] @ git+https://github.com/Bepr4/mdpolish.git@v0.4.0' +python -m pip install 'mdpolish @ git+https://github.com/Bepr4/mdpolish.git@v0.6.0' +python -m pip install 'mdpolish[lexical] @ git+https://github.com/Bepr4/mdpolish.git@v0.6.0' +python -m pip install 'mdpolish[frequency] @ git+https://github.com/Bepr4/mdpolish.git@v0.6.0' ``` 也可以安装同一 GitHub Release 附带的 wheel: ```bash -python -m pip install 'mdpolish[lexical] @ https://github.com/Bepr4/mdpolish/releases/download/v0.4.0/mdpolish-0.4.0-py3-none-any.whl' +python -m pip install 'mdpolish[lexical] @ https://github.com/Bepr4/mdpolish/releases/download/v0.6.0/mdpolish-0.6.0-py3-none-any.whl' ``` Release 页面同时提供 wheel 的 SHA-256 校验值。仓库或 Release 如果是私有的,调用方需要自行配置 GitHub 访问权限; -库不会保存凭据。以上命令当前安装的是已发布的 `v0.4.0`,不包含本源码树尚未发布的 `0.5.0` 机器投影。开发环境仍从 -本地工作树安装: +库不会保存凭据。开发环境仍从本地工作树安装: ```bash python -m venv .venv @@ -212,6 +212,26 @@ Markdown reporter 会包含完整输入、当前全文、统一 diff 以及实 dict 和 JSON 都是单向派生视图,不用于恢复 `ReviewDocument` 或重新应用修改。完整 schema、坐标、哈希和兼容口径见 [`review-projection-schema-v1.md`](research-wiki/reference/review-projection-schema-v1.md)。 +## 扫描精确物理行 + +项目 Modifier 如果需要识别独占行、检查相邻行或连同行尾删除一行,可以按原文 code point 范围扫描: + +```python +from mdpolish.text_ranges import physical_lines + +source = "first\r\n\r\nlast" +lines = physical_lines(source) + +assert lines[0].content(source) == "first" +assert lines[0].line_ending(source) == "\r\n" +assert lines[1].is_empty +assert source[lines[2].content_start : lines[2].full_end] == "last" +``` + +扫描只识别 LF、CR 和 CRLF,不规范化原文,也不判断段落、标题、列表、引用、代码或表格。它不会成为 Pipeline 的全局预处理; +只有显式调用它的 Modifier 才会扫描当前阶段快照。精确空文档、尾换行、offset 和失败关闭口径见 +[`physical-line-ranges.md`](research-wiki/reference/physical-line-ranges.md)。 + ## 编写项目自己的修改器 复杂规则使用普通函数返回精确候选修改,不需要继承库基类: @@ -288,6 +308,7 @@ src/mdpolish/ ├── pipeline.py # 有序执行与最终稳定性复查 ├── review.py # 可信评审视图、机器投影及内存 JSON/Markdown reporter ├── regex.py # 正则修改器工厂 +├── text_ranges.py # 公共精确物理行范围 └── modifiers/ # 少量项目无关的通用修改器 tests/ # 只使用虚构文本的核心与通用修改器测试 research-wiki/ @@ -312,8 +333,9 @@ research-wiki/ [`0011-generic-review-projection-and-reporting.md`](research-wiki/design/0011-generic-review-projection-and-reporting.md),当前机制见 [`review-projection.md`](research-wiki/explanation/review-projection.md)。正式机器投影的批准边界见 [`0012-review-document-machine-projection.md`](research-wiki/design/0012-review-document-machine-projection.md),schema `1.0` 的稳定 -查询口径见 [`review-projection-schema-v1.md`](research-wiki/reference/review-projection-schema-v1.md)。旧 design 只保存历史 -决策,不代表当前交付能力。 +查询口径见 [`review-projection-schema-v1.md`](research-wiki/reference/review-projection-schema-v1.md)。精确物理行公共接口的边界见 +[`0013-public-physical-line-ranges.md`](research-wiki/design/0013-public-physical-line-ranges.md),稳定查询口径见 +[`physical-line-ranges.md`](research-wiki/reference/physical-line-ranges.md)。旧 design 只保存历史决策,不代表当前交付能力。 ## 当前可用检查 @@ -344,10 +366,10 @@ Python 标准库。Release wheel 的 SHA-256 是 上述结果证明当前版本可安装并按合成契约运行,不代表任意词典阈值已经在真实业务语料上达到生产准确率。 -`0.5.0` 候选于 2026-08-28 在 Python 3.13.11 开发环境中实际得到:mypy 通过,pytest 为 -`228 passed, 3 skipped`;三个 skip 仍是没有安装的 optional backend。除工作区已有的 `src/mdpolish/regex.py` 中文注释 -改动外,Ruff 全部通过;未排除该文件的全仓 Ruff 因其中 30 个 `RUF002` / `RUF003` 失败,本轮没有擅自修改该用户改动。 +`v0.6.0` 于 2026-08-28 在 Python 3.13.11 开发环境中实际得到:Ruff 和 mypy 通过,pytest 为 +`290 passed, 3 skipped`;三个 skip 仍是没有安装的 optional backend。 -候选 `mdpolish-0.5.0-py3-none-any.whl` 构建成功,共 17 个文件,包含更新后的 `review.py` 和 `py.typed`,不包含 tests、Wiki、 -报告或真实数据;在仓库外全新虚拟环境中无依赖安装后,dict 投影与 JSON reporter smoke test 通过。该临时 wheel 不是 Release -资产,其哈希不构成发布身份;完成全仓 Ruff、提交、合并、tag 和 Release 仍需要分别确认。 +Release wheel `mdpolish-0.6.0-py3-none-any.whl` 共 17 个文件,包含 `text_ranges.py`、`review.py` 和 `py.typed`,不包含 +`_text_ranges.py`、tests、Wiki、报告或真实数据;在仓库外全新虚拟环境中无依赖安装后,版本、公共导入、精确混合行尾范围、 +行尾集合、空行判断和 wheel 清单 smoke test 通过。Release wheel 的 SHA-256 是 +`695502b1a443d4e98dbf63e8bdcee59452baea2185cb7e1e13160127f70c920f`。 diff --git a/pyproject.toml b/pyproject.toml index cfb2026..f4ad777 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "mdpolish" -version = "0.5.0" +version = "0.6.0" description = "Deterministic functional core for composing exact Markdown modifiers" requires-python = ">=3.11" dependencies = [] diff --git a/research-wiki/reference/physical-line-ranges.md b/research-wiki/reference/physical-line-ranges.md new file mode 100644 index 0000000..44e8e67 --- /dev/null +++ b/research-wiki/reference/physical-line-ranges.md @@ -0,0 +1,137 @@ +# 精确物理行范围 + +本文记录 `mdpolish.text_ranges` 当前稳定的公共查询口径。实现和运行校验以 +`src/mdpolish/text_ranges.py` 与测试为准;设计理由、方案取舍和施工边界见 +[`0013-public-physical-line-ranges.md`](../design/0013-public-physical-line-ranges.md)。 + +## 1. 公共入口 + +```python +from mdpolish.text_ranges import ( + PhysicalLine, + iter_physical_lines, + line_ending_styles, + physical_lines, +) +``` + +这些名称只从 `mdpolish.text_ranges` 导出,不在包根重复导出。`mdpolish._text_ranges` 不是兼容路径。 + +模块只接收内存中的 `str`。它不读取文件、不改写文本、不建立 Markdown AST,也不会在 `Pipeline` 运行前自动执行。 + +## 2. 什么是物理行 + +物理行由以下任一边界结束: + +| 原文边界 | 行尾字符串 | code point 长度 | +| --- | --- | ---: | +| LF | `"\n"` | 1 | +| CR | `"\r"` | 1 | +| CRLF | `"\r\n"` | 2 | +| 文档末尾 | `""` | 0 | + +CRLF 是一个物理行尾。U+2028、U+2029、vertical tab、form feed、NUL 以及其他字符都属于行内容,不会被当成物理行尾。 +扫描不做 Unicode 或换行规范化。 + +空文档产生零条物理行。原文以行尾结束时,不会在末尾虚构额外空行;连续行尾之间确实存在的零长度行仍会返回。 + +## 3. `PhysicalLine` + +`PhysicalLine` 是 `dataclass(frozen=True, slots=True)`: + +```python +PhysicalLine( + content_start=0, + content_end=4, + full_end=6, +) +``` + +三个 offset 都是相对于同一个 Python `str` 的 0-based Unicode code point index: + +- 内容范围:`[content_start, content_end)`; +- 行尾范围:`[content_end, full_end)`; +- 完整物理行范围:`[content_start, full_end)`。 + +它们不是 UTF-8 byte、UTF-16 code unit、终端显示列或人类使用的 1-based 行列。 + +公共成员: + +| 成员 | 结果 | +| --- | --- | +| `content(source)` | 返回内容范围的精确切片 | +| `line_ending(source)` | 返回 `""`、LF、CR 或 CRLF | +| `is_empty` | 内容范围长度是否为零 | + +`is_empty` 不等于 Markdown blank line。`" \n"` 和 `"\t\n"` 的内容不是零长度,因此均为 `False`。项目若要把 space、Tab +或其他 Unicode whitespace 当作空白,必须在自己的 Modifier 中显式定义。 + +手工构造时,offset 必须是非负的严格 `int`,不接受 `bool`,并满足: + +```text +content_start <= content_end <= full_end +full_end - content_end <= 2 +``` + +访问器还会拒绝非字符串来源、超出来源长度的范围,以及不是 `""`、LF、CR、CRLF 的行尾切片。错误信息不包含原文。 + +## 4. 扫描函数 + +`iter_physical_lines(source)` 返回惰性迭代器,但会在函数调用当下检查 `source` 类型。它单次从左到右扫描,不预先复制原文。 + +`physical_lines(source)` 返回同一扫描结果的不可变 tuple,适合需要查看前后行或重复遍历的 Modifier。 + +`line_ending_styles(source)` 返回原文实际出现过的非空行尾集合,只可能包含 `"\n"`、`"\r"`、`"\r\n"`。空文档或 +没有换行的文档返回空 `frozenset`。 + +## 5. 精确示例 + +下表的结果写作 `(content_start, content_end, full_end)`: + +| source | 结果 | +| --- | --- | +| `""` | `()` | +| `"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),)` | + +按顺序拼接所有 `source[line.content_start:line.full_end]` 必须逐 code point 还原原文。 + +## 6. 在项目 Modifier 中使用 + +物理行范围只是定位工具,不会自动成为修改: + +```python +from mdpolish import DocumentSnapshot, ProposedChange, TextEdit, TextSpan +from mdpolish.text_ranges import physical_lines + + +def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]: + lines = physical_lines(snapshot.markdown) + if not lines or lines[0].content(snapshot.markdown) != "[REMOVE-ME]": + return () + + line = lines[0] + expected = snapshot.markdown[line.content_start : line.full_end] + return ( + ProposedChange( + snapshot_sha256=snapshot.sha256, + reason="删除项目确认的独占行标记", + edits=( + TextEdit( + snapshot_sha256=snapshot.sha256, + span=TextSpan(line.content_start, line.full_end), + expected_text=expected, + replacement="", + ), + ), + ), + ) +``` + +项目仍负责判断标题、列表、引用、代码、表格和业务语义。公共编辑执行器仍负责验证快照哈希、范围、预期原文与冲突。 diff --git a/src/mdpolish/_text_ranges.py b/src/mdpolish/_text_ranges.py deleted file mode 100644 index d617c46..0000000 --- a/src/mdpolish/_text_ranges.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Exact physical-line ranges shared by text cleaning modifiers.""" - -from __future__ import annotations - -from collections.abc import Iterator -from dataclasses import dataclass - - -@dataclass(frozen=True, slots=True) -class PhysicalLine: - """A content range and its optional line ending in one source string.""" - - content_start: int - content_end: int - full_end: int - - def content(self, markdown: str) -> str: - return markdown[self.content_start : self.content_end] - - def line_ending(self, markdown: str) -> str: - return markdown[self.content_end : self.full_end] - - def is_blank(self, markdown: str) -> bool: - return self.content_start == self.content_end - - -def iter_physical_lines(markdown: str) -> Iterator[PhysicalLine]: - """Yield exact physical lines without normalizing CR or LF endings.""" - line_start = 0 - position = 0 - while position < len(markdown): - character = markdown[position] - if character == "\n": - yield PhysicalLine(line_start, position, position + 1) - position += 1 - line_start = position - elif character == "\r": - line_end = position + 2 if position + 1 < len(markdown) and markdown[position + 1] == "\n" else position + 1 - yield PhysicalLine(line_start, position, line_end) - position = line_end - line_start = position - else: - position += 1 - - if line_start < len(markdown): - yield PhysicalLine(line_start, len(markdown), len(markdown)) - - -def physical_lines(markdown: str) -> tuple[PhysicalLine, ...]: - """Return all physical-line ranges as an immutable sequence.""" - return tuple(iter_physical_lines(markdown)) - - -def line_ending_styles(markdown: str) -> frozenset[str]: - """Return the distinct non-empty physical line endings in a string.""" - return frozenset( - line.line_ending(markdown) - for line in iter_physical_lines(markdown) - if line.full_end > line.content_end - ) diff --git a/src/mdpolish/modifiers/html_table_layout.py b/src/mdpolish/modifiers/html_table_layout.py index cee81f4..a590284 100644 --- a/src/mdpolish/modifiers/html_table_layout.py +++ b/src/mdpolish/modifiers/html_table_layout.py @@ -3,9 +3,9 @@ from __future__ import annotations from mdpolish._html_table import strict_html_tables -from mdpolish._text_ranges import line_ending_styles from mdpolish.models import DocumentSnapshot, ProposedChange, TextEdit, TextSpan from mdpolish.modifier import Modifier +from mdpolish.text_ranges import line_ending_styles _REASON = "展开严格单行 HTML 表格的行布局" _APPLICABILITY = ( diff --git a/src/mdpolish/modifiers/mapped_line_join.py b/src/mdpolish/modifiers/mapped_line_join.py index f7bce71..1a74d5e 100644 --- a/src/mdpolish/modifiers/mapped_line_join.py +++ b/src/mdpolish/modifiers/mapped_line_join.py @@ -48,9 +48,9 @@ from importlib.metadata import version as package_version from itertools import pairwise from typing import Protocol, TypeAlias -from mdpolish._text_ranges import PhysicalLine, physical_lines from mdpolish.models import DocumentSnapshot, ProposedChange, TextEdit, TextSpan from mdpolish.modifier import Modifier, ModifierContractError +from mdpolish.text_ranges import PhysicalLine, physical_lines try: from spellchecker import SpellChecker as _SpellChecker # type: ignore[import-not-found] @@ -543,7 +543,7 @@ def _scan_lines(markdown: str) -> tuple[_ScannedLine, ...]: blank_run = 0 for index, line in enumerate(lines): raw = line.content(markdown) - if line.is_blank(markdown): + if line.is_empty: blank_run += 1 active_heading = None if blank_run > 1: @@ -1143,7 +1143,7 @@ def _boundary_between( if right.physical_index <= left.physical_index: raise ModifierContractError("line join scanner produced non-monotonic physical lines") intervening = lines[left.physical_index + 1 : right.physical_index] - if len(intervening) > max_blank_lines or any(not line.is_blank(markdown) for line in intervening): + if len(intervening) > max_blank_lines or any(not line.is_empty for line in intervening): return None ending = left.physical.line_ending(markdown) if not ending or any(line.line_ending(markdown) != ending for line in intervening): diff --git a/src/mdpolish/text_ranges.py b/src/mdpolish/text_ranges.py new file mode 100644 index 0000000..8ccec71 --- /dev/null +++ b/src/mdpolish/text_ranges.py @@ -0,0 +1,111 @@ +"""Exact physical-line ranges for project and library modifiers. + +This module scans only CR, LF, and CRLF boundaries. It preserves the source +string and reports Python Unicode code point offsets; it does not parse +Markdown blocks, normalize line endings, read files, or propose edits. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass + +__all__ = [ + "PhysicalLine", + "iter_physical_lines", + "line_ending_styles", + "physical_lines", +] + +_LINE_ENDINGS = frozenset(("", "\n", "\r", "\r\n")) + + +def _require_source(source: object) -> str: + if not isinstance(source, str): + raise TypeError("source must be a string") + return source + + +@dataclass(frozen=True, slots=True) +class PhysicalLine: + """A source string's exact content range and optional line-ending range.""" + + content_start: int + content_end: int + full_end: int + + def __post_init__(self) -> None: + for field_name in ("content_start", "content_end", "full_end"): + if type(getattr(self, field_name)) is not int: + raise TypeError(f"{field_name} must be an integer") + if self.content_start < 0 or self.content_end < 0 or self.full_end < 0: + raise ValueError("physical line offsets must be non-negative") + if not self.content_start <= self.content_end <= self.full_end: + raise ValueError("physical line offsets must be ordered") + if self.full_end - self.content_end > 2: + raise ValueError("physical line ending range must contain at most two code points") + + def _validated_source(self, source: object) -> str: + validated = _require_source(source) + if self.full_end > len(validated): + raise ValueError("physical line range exceeds source length") + return validated + + def content(self, source: str) -> str: + """Return this line's content without its physical line ending.""" + validated = self._validated_source(source) + return validated[self.content_start : self.content_end] + + def line_ending(self, source: str) -> str: + """Return this line's exact CR, LF, CRLF, or absent line ending.""" + validated = self._validated_source(source) + ending = validated[self.content_end : self.full_end] + if ending not in _LINE_ENDINGS: + raise ValueError("physical line ending must be empty, LF, CR, or CRLF") + return ending + + @property + def is_empty(self) -> bool: + """Return whether the content range has zero length.""" + return self.content_start == self.content_end + + +def _iter_physical_lines(source: str) -> Iterator[PhysicalLine]: + line_start = 0 + position = 0 + while position < len(source): + character = source[position] + if character == "\n": + yield PhysicalLine(line_start, position, position + 1) + position += 1 + line_start = position + elif character == "\r": + line_end = position + 2 if position + 1 < len(source) and source[position + 1] == "\n" else position + 1 + yield PhysicalLine(line_start, position, line_end) + position = line_end + line_start = position + else: + position += 1 + + if line_start < len(source): + yield PhysicalLine(line_start, len(source), len(source)) + + +def iter_physical_lines(source: str) -> Iterator[PhysicalLine]: + """Return a lazy iterator over exact physical lines in ``source``.""" + return _iter_physical_lines(_require_source(source)) + + +def physical_lines(source: str) -> tuple[PhysicalLine, ...]: + """Return all exact physical-line ranges as an immutable sequence.""" + return tuple(iter_physical_lines(source)) + + +def line_ending_styles(source: str) -> frozenset[str]: + """Return the distinct non-empty physical line endings in ``source``.""" + validated = _require_source(source) + return frozenset( + line.line_ending(validated) + for line in iter_physical_lines(validated) + if line.full_end > line.content_end + ) diff --git a/tests/test_mapped_line_join.py b/tests/test_mapped_line_join.py index 037db9d..acc6fb0 100644 --- a/tests/test_mapped_line_join.py +++ b/tests/test_mapped_line_join.py @@ -362,7 +362,7 @@ def test_empty_document_and_empty_rule_set_are_no_ops() -> None: def test_installed_package_version_matches_delivery_candidate() -> None: - assert distribution_version("mdpolish") == "0.4.0" + assert distribution_version("mdpolish") == "0.6.0" def test_parameters_preserve_rule_order_and_record_all_options() -> None: diff --git a/tests/test_text_ranges.py b/tests/test_text_ranges.py new file mode 100644 index 0000000..a39e8c1 --- /dev/null +++ b/tests/test_text_ranges.py @@ -0,0 +1,174 @@ +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"}