Files
mdpolish/research-wiki/design/0013-public-physical-line-ranges.md
T

18 KiB
Raw Blame History

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

状态

已于 2026-08-28 获用户明确批准,按本文第 13 节实施。本文自批准起冻结;后续改变决策需新增 design 并使用 supersedes 指向本文。

extends: 0008:继续使用函数式 Modifier、不可变值、精确原文范围、无文件 I/O 和项目规则外置的公共库边界;本文只把 已经由多个通用 Modifier 共用的物理行范围工具变成受支持的扩展接口。

extends: 0009mapped_line_join() 的 Markdown 块分类、代码/表格排除和跨行合并语义保持不变;本文只公开它已经使用的 底层物理行扫描,不公开其保守块扫描器。

本文不改变 0012 的机器投影 schema、detail 或报告行为。

1. 问题与可观察现象

项目自定义 Modifier 最终要提交精确 TextEdit。只要规则依赖“完整独占一行”“删除自己的换行”“检查下一空行”或“聚合 重复行”,项目就必须先准确回答:

  • 这一行正文从哪里开始、在哪里结束;
  • 行尾是 LF、CR、CRLF,还是文档末尾没有换行;
  • 空行是否真的有零长度内容;
  • 删除范围是否包含行尾;
  • 下一条物理行在原始字符串中的准确位置是什么。

上游已经在私有模块 mdpolish._text_ranges 中实现:

PhysicalLine
iter_physical_lines()
physical_lines()
line_ending_styles()

mapped_line_join() 使用它建立跨行候选,html_table_layout() 使用它检查混合换行。独立 wheel-pilot 的论文清洗设计又需要 同样的 LF、CRLF、CR、空行、无末尾换行和原文范围语义,但项目治理正确地禁止导入带下划线的私有模块,只能计划复制一份 扫描器。

这已经不是“某个项目想少写几行代码”。同一个确定性边界算法出现在上游多个通用 Modifier,并被独立消费项目再次需要。 如果项目复制实现,以下细节很容易漂移:

  • 把 CRLF 错当成两个换行;
  • 在末尾换行之后制造一条不存在的空行;
  • 把只有空格的行和零长度空行混为一谈;
  • 删除内容却遗留 \r
  • 用累计字符串长度重新计算 offset 时产生偏移;
  • 上游修复边界后,项目副本继续使用旧语义。

因此,缺少的是“项目 Modifier 作者可以合法复用的精确物理行范围”,不是新的清洗规则或 Markdown parser。

2. 目标与非目标

2.1 目标

  • 提供受支持的公共模块 mdpolish.text_ranges
  • 公开不可变 PhysicalLine 和惰性/急切两种物理行扫描入口;
  • 精确保留 LF、CRLF、CR 和无末尾换行,不规范化源字符串;
  • 公开已存在的行尾样式集合查询;
  • 在首次公开前修正空行判断的误导命名,并明确零长度与空白字符的区别;
  • 给公共值对象和函数增加显式类型、范围和来源切片校验;
  • 让上游现有 Modifier 使用同一公共实现,不维护私有/公共两套算法;
  • 用合成文本直接固定空文档、空行、Unicode、混合换行和范围语义;
  • 保持核心安装零第三方运行依赖,无文件 I/O、网络或模型调用。

2.2 非目标

  • 不在 Pipeline 运行 Modifier 前自动扫描、分块或预处理 Markdown;
  • 不缓存或跨 Modifier 共享扫描结果;后一个 Modifier 仍读取前一阶段应用后的完整新快照;
  • 不解析段落、标题、列表、引用、代码块、表格、HTML 或 Markdown AST
  • 不公开 mapped_line_join() 内部的 _ScannedLine、scope、结构排除或块分类器;
  • 不提供 remove_matching_lines()、行号删除器、页眉删除器、批注删除器或其他 Modifier 工厂;
  • 不定义“只含空格或 Tab 的行是否算 blank”,也不使用 Unicode strip() 暗中替项目作决定;
  • 不读取文件、不接收路径、不返回文件行号或改写文本;
  • 不把 range 自动转成 TextEdit,不绕过快照哈希、expected text 和批次验证;
  • 不修改 wheel-pilot、真实论文、外部数据或项目 design;
  • 不承诺通用文本编辑器、LSP、UTF-16 或 byte offset 接口。

3. 方案比较

方案 优点 问题 选择
每个项目复制扫描器 上游不增加接口 CRLF、尾换行和空行语义会漂移;多个项目重复测试 不采用
项目直接导入 mdpolish._text_ranges 零上游改动 私有路径没有兼容承诺,wheel 消费方被迫依赖内部实现 不采用
把当前私有模块原样改成公共路径 改动最小 is_blank(markdown) 命名和参数误导;没有直接公共契约测试或范围校验 不直接采用
公开经过收窄和加固的精确物理行范围 一个算法来源;项目规则保持外置;接口小而可测试 增加需要兼容维护的公共模块 采用
引入统一 Markdown parser / AST 可共享高级结构 远超当前需求,并会改变所有 Modifier 的共同边界 不采用
Pipeline 预扫描并把行列表传给所有 Modifier 避免重复扫描 改变 ModifierFunction 签名;快照每阶段变化使缓存身份复杂 不采用

4. 职责边界

采用后的调用关系为:

完整 DocumentSnapshot
        │
        ▼
项目 Modifier.propose(snapshot)
        │
        │  只有该 Modifier 需要时调用
        ▼
physical_lines(snapshot.markdown)
        │
        ▼
PhysicalLine 原文范围
        │
        │  项目判断业务证据
        ▼
ProposedChange / TextEdit
        │
        ▼
公共执行器验证并原子应用
层次 负责 不负责
text_ranges 确定物理行内容与行尾范围 判断行的业务含义
项目 Modifier 根据项目证据选择范围和替换 重写 CR/LF 扫描算法
编辑执行器 验证快照、范围、原文、冲突并应用 判断为什么要删除一行
Pipeline 按顺序运行并稳定性复查 建立全局共享分块

公开接口不会在未调用时执行,也不会改变传给第一个 Modifier 的原始 Markdown。

5. 第一版公共模块

第一版受支持的导入路径是:

from mdpolish.text_ranges import (
    PhysicalLine,
    iter_physical_lines,
    line_ending_styles,
    physical_lines,
)

这些名称只从 mdpolish.text_ranges 导出。本轮不把它们再放进 mdpolish.__init__,避免包根继续堆积低层工具。

私有路径 mdpolish._text_ranges 从来不是公共契约。实现时把唯一算法移动到公共模块并更新上游内部导入,不保留另一份算法, 也不为未受支持的私有导入建立长期兼容别名。

6. PhysicalLine 公共值对象

6.1 字段

@dataclass(frozen=True, slots=True)
class PhysicalLine:
    """一个源字符串中物理行内容及可选行尾的精确范围。"""

    content_start: int
    content_end: int
    full_end: int

范围均使用 Python 字符串的 Unicode code point index

content_start             content_end       full_end
      │                         │               │
      ▼                         ▼               ▼
      one physical line content\r\n
      └──────── content ───────┘└ line ending ┘
      └──────────── complete physical line ────┘
  • 完整物理行范围是 [content_start, full_end)
  • 内容范围是 [content_start, content_end)
  • 行尾范围是 [content_end, full_end)
  • 最后一行没有行尾时 content_end == full_end
  • content_start 同时是完整物理行的开始,不另设重复字段;
  • offset 不是 UTF-8 byte、UTF-16 code unit、终端显示列或 1-based 人类行列。

6.2 构造校验

__post_init__() 必须显式拒绝:

  • 字段不是严格 int,包括 bool
  • 任一字段为负数;
  • 不满足 content_start <= content_end <= full_end
  • full_end - content_end > 2,因为受支持的物理行尾最长是 CRLF 两个 code point。

这些检查不能证明手工构造的范围一定来自某个 source;它们只保证值对象局部自洽。权威正常用法是消费扫描函数返回的对象。

6.3 内容和行尾访问

def content(self, source: str) -> str:
    ...

def line_ending(self, source: str) -> str:
    ...

两个方法都必须:

  • 要求 sourcestr
  • 确认 full_end <= len(source)
  • 根据保存的半开范围返回精确切片,不规范化或复制其他字符。

line_ending() 还要确认切片严格属于:

""
"\n"
"\r"
"\r\n"

否则抛出 ValueError,不把手工构造的错误范围静默当作合法行尾。正常扫描结果不会触发这个错误。

范围对象不保存 source 或快照哈希。这样保留单个 PhysicalLine 不会隐式持有整篇文档,也不会在每条行对象中重复相同哈希。 项目最终建立 TextEdit 时,现有 snapshot SHA-256 和 expected text 校验仍负责拒绝跨快照范围。

6.4 零长度行判断

当前私有方法:

line.is_blank(markdown)

完全没有读取 markdown,实际只判断 content_start == content_end。公开前改成只读属性:

@property
def is_empty(self) -> bool:
    return self.content_start == self.content_end

它的语义固定为“内容范围长度为零”:

原始物理行 is_empty
"\n" True
"\r\n" True
"" 不会由空文档产生 PhysicalLine
" \n" False
"\t\n" False

本轮不保留或公开 is_blank 别名。它原来属于私有模块,没有公共兼容承诺;继续保留会同时留下误导名称和无用 source 参数。 项目若要把 ASCII space / Tab 行定义为空白,应明确写出自己的条件,例如:

line.content(source).strip(" \t") == ""

是否允许其他 Unicode whitespace 属于项目规则,不能由底层工具默认扩大。

7. 扫描函数

7.1 惰性扫描

def iter_physical_lines(source: str) -> Iterator[PhysicalLine]:
    ...
  • 调用时先验证 sourcestr,非法类型立即抛出 TypeError,不等到第一次迭代才暴露;
  • 单次从左到右扫描,不预先复制 source;
  • 只把 \n\r\r\n 识别为行尾;
  • CRLF 是一个物理行尾,范围长度为两个 code point;
  • Unicode line separator、paragraph separator、vertical tab、form feed 和 NUL 都保留在内容中;
  • 产生顺序严格按 source offset 递增,范围不重叠且完整覆盖 source;
  • 返回迭代器,不承诺可以重复迭代同一对象。

为保证非法输入在函数调用时立即失败,实现可以使用普通包装函数返回私有 generator;公共签名和可观察行为以上述契约为准。

7.2 急切扫描

def physical_lines(source: str) -> tuple[PhysicalLine, ...]:
    ...

它返回 iter_physical_lines() 的完整不可变 tuple,不另写扫描逻辑。适合需要前后行、重复组或多次遍历的 Modifier。

7.3 行尾样式

def line_ending_styles(source: str) -> frozenset[str]:
    ...

它返回扫描结果中实际出现的非空行尾集合,只可能包含:

"\n"
"\r"
"\r\n"

无换行或空文档返回空 frozenset。它复用 iter_physical_lines(),不维护另一份 splitlines() 逻辑。

8. 精确边界示例

以下 tuple 写作 (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),)U+2028 属于内容

特别说明:

  • 末尾有行尾不额外产生一条虚构空行;
  • "a\n\n" 的第二个 \n 是一条真实零长度物理行;
  • 空文档没有任何物理行;
  • 拼接所有 [content_start, full_end) 切片必须精确还原 source。

这些行为保持当前私有扫描算法的结果,不改变现有 Modifier 的换行语义。

9. 与 Markdown 和编辑契约的关系

text_ranges 不根据 Markdown 语义改变任何范围。例如:

# Heading

> quote

```text
code
```

对它来说都只是物理行。标题、引用和围栏只有调用它的 Modifier 才能解释。

范围也不自动成为修改。项目仍必须构造:

TextEdit(
    snapshot_sha256=snapshot.sha256,
    span=TextSpan(line.content_start, line.full_end),
    expected_text=snapshot.markdown[line.content_start : line.full_end],
    replacement="",
)

执行器随后验证哈希、范围和原文。物理行接口不能跳过这层保护,也不承诺手工构造的 PhysicalLine 自动安全。

10. 源码、兼容与版本

批准后计划:

src/mdpolish/
├── text_ranges.py                 # 公共唯一实现
└── modifiers/
    ├── mapped_line_join.py        # 改用公共模块路径
    └── html_table_layout.py       # 改用公共模块路径

tests/
└── test_text_ranges.py            # 公共接口直接测试

research-wiki/reference/
└── physical-line-ranges.md        # 稳定边界查询口径

实现时删除私有源码文件 _text_ranges.py,不保留双份算法。mdpolish._text_ranges 从未受支持;本轮不为私有导入提供弃用期。 现有公开的 ModifierPipelineTextSpanmapped_line_join() 和 HTML Modifier 签名、身份、参数、输出及审计行为不变。

这是新增公共扩展接口,计划包版本为 0.6.0v0.5.0 的机器投影保持独立交付范围;如果 v0.5.0 尚未完成合并和发布, 本功能仍使用单独提交与验证记录,不把两个功能写成同一项能力。

批准后实现完成并验证时才更新根 README 的当前能力、候选版本和实际检查结果。设计批准不自动授权 commit、push、tag、 GitHub Release 或 wheel 发布。

11. 测试与验收

11.1 公共扫描契约

直接测试至少覆盖:

  • 第 8 节所有精确范围;
  • 空文档、单行、首行为空、连续空行和无末尾换行;
  • 纯 LF、纯 CRLF、纯 CR 和混合行尾;
  • 中文、补充平面字符、组合字符、BOM、U+2028、NUL、Tab 和 form feed
  • 每条 content()line_ending() 与 source 精确切片一致;
  • 所有完整行切片按顺序拼接后逐 code point 等于原 source
  • physical_lines(source) == tuple(iter_physical_lines(source))
  • line_ending_styles() 只返回实际非空样式;
  • is_empty 只对零长度内容为真,space / Tab 不自动算空;
  • 相同 source 重复扫描得到值相等、顺序相同的不可变结果。

11.2 失败关闭

至少拒绝:

  • 扫描函数接收非 str,包括 bytes、Path 和 None
  • dataclass 字段为 bool、float、字符串或其他非严格 int;
  • 负数、逆序范围和超过两个 code point 的行尾范围;
  • content() / line_ending() 接收非字符串或比 full_end 更短的 source
  • line_ending() 指向不是 "" / LF / CR / CRLF 的切片。

错误使用 TypeErrorValueError,消息只说明字段和契约,不输出 source 内容。

11.3 现有行为回归

  • mapped_line_join() 全部精确、正则、词典、结构、换行和链式测试结果不变;
  • html_table_layout() 的单/混合行尾行为不变;
  • 其他核心、评审和机器投影测试不受影响;
  • mypy strict 和 Ruff 通过;
  • 核心运行依赖仍为空。

11.4 Wheel 消费

构建候选 wheel 后在仓库外全新虚拟环境确认:

from mdpolish.text_ranges import PhysicalLine, iter_physical_lines, line_ending_styles, physical_lines

可以导入并得到第 8 节结果。wheel 必须包含 mdpolish/text_ranges.pypy.typed,不包含 tests、Wiki、真实数据或报告。

本轮不修改 wheel-pilot。它升级正式 wheel、删除私有扫描计划和验证四个项目 Modifier,仍需在消费仓自己的未冻结 design 中 调整并获得授权。

12. 风险与代价

  • 公共表面积增加: range 字段、空文档和尾换行语义发布后需要兼容维护;直接测试和 reference 用于锁定行为。
  • 低层工具可能被误认为 Markdown parser 模块、README 和 docstring 都要明确它只识别 CR/LF,不解释块结构。
  • 每个 Modifier 可能重复 O(n) 扫描: 当前快照会逐阶段变化,简单重扫比跨阶段缓存更可靠;有真实性能证据后再设计共享索引。
  • 手工范围仍可能来自错误 source 访问器做局部验证,最终 TextEdit 的快照和 expected text 继续承担权威保护。
  • 删除私有路径可能影响越界使用者: 下划线路径从未承诺兼容;已知正式消费项目明确没有导入它。
  • is_empty 不等于 Markdown blank line 这是有意边界;项目必须显式定义 space、Tab 或 Unicode whitespace。
  • 新版本依赖发布顺序: wheel-pilot 只有安装含公共模块的正式 wheel 后才能删除自己的扫描计划,不能从相邻源码树偷导入。

13. 批准后的实施边界

用户明确批准本文后,只授权:

  1. 把私有物理行唯一实现迁移到 mdpolish.text_ranges,实现第 5 至 8 节公共契约;
  2. 更新两个现有通用 Modifier 的内部导入和 is_empty 调用,不改变其清洗语义;
  3. 增加第 11 节合成测试、reference 和 README 说明;
  4. 把包版本候选更新为 0.6.0,执行根 README 当时列出的全套检查和 wheel smoke test
  5. 保留并隔离工作区已有的 AGENTS.mdCLAUDE.mdsrc/mdpolish/regex.py 等用户改动。

批准本文不授权:

  • 提交、push、创建 PR、tag、GitHub Release 或上传 wheel
  • 修改 wheel-pilot、真实文档、外部数据或其他仓库;
  • 新增 Markdown parser、全局预处理、共享扫描缓存、通用删除 Modifier、CLI 或文件适配器;
  • 改变现有清洗规则、Pipeline 顺序、机器投影 schema 或报告内容。