Files
mdpolish/research-wiki/reference/physical-line-ranges.md
T

4.7 KiB
Raw Blame History

精确物理行范围

本文记录 mdpolish.text_ranges 当前稳定的公共查询口径。实现和运行校验以 src/mdpolish/text_ranges.py 与测试为准;设计理由、方案取舍和施工边界见 0013-public-physical-line-ranges.md

1. 公共入口

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

PhysicalLinedataclass(frozen=True, slots=True)

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,并满足:

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 中使用

物理行范围只是定位工具,不会自动成为修改:

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="",
                ),
            ),
        ),
    )

项目仍负责判断标题、列表、引用、代码、表格和业务语义。公共编辑执行器仍负责验证快照哈希、范围、预期原文与冲突。