feat: 公开精确物理行范围接口

This commit is contained in:
2026-08-28 15:00:56 +08:00
parent 8b689eebe1
commit 61b7ea7534
9 changed files with 466 additions and 82 deletions
@@ -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="",
),
),
),
)
```
项目仍负责判断标题、列表、引用、代码、表格和业务语义。公共编辑执行器仍负责验证快照哈希、范围、预期原文与冲突。