From 11349e007a2773960cadc2bd57908e11f4614c06 Mon Sep 17 00:00:00 2001
From: Bepr4 <63661977@qq.com>
Date: Wed, 26 Aug 2026 20:52:42 +0800
Subject: [PATCH] feat: generalize mapped line joining
---
README.md | 29 +-
pyproject.toml | 7 +
.../0009-generalized-mapped-line-join.md | 762 +++++++++
src/mdpolish/modifiers/mapped_line_join.py | 1440 +++++++++++++++--
tests/test_mapped_line_join.py | 552 ++++++-
5 files changed, 2647 insertions(+), 143 deletions(-)
create mode 100644 research-wiki/design/0009-generalized-mapped-line-join.md
diff --git a/README.md b/README.md
index 40c82c2..aa67dc3 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
| 精确编辑执行器 | 校验快照、范围、原文、重复和冲突后原子应用一个批次 | 不判断项目业务语义 |
| `Pipeline` | 按调用方顺序运行修改器,并对最终快照做只读稳定性复查 | 不自动选规则、不重排、不循环执行 |
| `regex_replace()` | 把非空正则匹配转换为精确编辑 | 不提供规则注册表、配置加载或默认模式 |
-| `mapped_line_join()` | 按调用方提供的映射合并跨行片段 | 库内没有默认词表,不猜测未知词 |
+| `mapped_line_join()` | 用精确、正则或可选本地词典规则合并跨行片段 | 无默认规则;代码、表格、未知结构和歧义失败关闭 |
| HTML 表格修改器 | 处理严格表格子集的实体和单行布局 | 不是完整 HTML parser,也不是 HTML→GFM 转换器 |
一次运行会返回 `success`、`failed` 或 `unstable`:
@@ -40,7 +40,14 @@ python -m venv .venv
.venv/bin/python -m pip install -e '.[dev]'
```
-运行时只依赖 Python 标准库,支持 Python 3.11 及以上版本。
+核心运行时只依赖 Python 标准库,支持 Python 3.11 及以上版本。自动词典规则需要调用方明确安装并选择对应 extra:
+
+```bash
+python -m pip install '/path/to/mdpolish[lexical]' # pyspellchecker + Pyphen
+python -m pip install '/path/to/mdpolish[frequency]' # wordfreq,体积和传递依赖更大
+```
+
+安装 extra 不会自动启用规则,也不会触发在线下载或改变精确/正则规则行为。
## 组装自己的流水线
@@ -142,8 +149,16 @@ remove_marker = Modifier(
## 通用修改器的严格边界
-`mapped_line_join()` 只使用调用方显式传入的三元组:左片段、右片段和最终文本。它只处理相邻物理行或中间恰好一个
-同风格空行的情况,并检查 ASCII 词边界。
+`mapped_line_join()` 保留 `(left, right, replacement)` 三元组,也接受模块
+`mdpolish.modifiers.mapped_line_join` 中的不可变规则值。规则可以使用精确片段、两侧命名正则、只约束右侧的行尾正则,
+或从行尾与行首自动生成 `JOINED` / `HYPHENATED` / `SPACED` 候选并查询显式选择的本地词典。自动规则不要求逐词维护
+映射;精确规则和 `KeepLineJoinRule` 用于项目词、例外与否决。
+
+调用方还要显式决定块范围、换行处理、冲突策略、大小写和 Unicode 规范化。默认区分大小写且不规范化;支持相邻行或
+中间最多一个同风格空行,并可在一次提议内完成多行链式合并。保守词法扫描只正向识别段落、ATX 标题 continuation、
+列表 continuation 和同深度引用;代码块、GFM pipe table、raw HTML table、混合候选行尾及无法确认的容器保持原文。
+完整公共模型、选择流程和限制见
+[`0009-generalized-mapped-line-join.md`](research-wiki/design/0009-generalized-mapped-line-join.md)。
`html_table_entity_unescape()` 只在严格完整的 `
` / ` | ` 文本中处理 `<`、`>` 和
`&`。`html_table_layout()` 只调整严格单行表格的外层行布局,并保留标签、属性和单元格内容。
@@ -174,7 +189,7 @@ research-wiki/
- 文件适配器、公共 CLI、配置文件、profile 或批处理协议;
- 自动规则发现、注册表或默认流水线;
-- Markdown AST、完整 HTML parser 或新运行依赖;
+- Markdown AST、完整 HTML parser 或必装的第三方运行依赖;
- artifact、报告、Web/桌面评审器;
- 任何业务项目的规则、固定参数、文档 ID、数据或验收统计。
@@ -202,7 +217,7 @@ git status --short
```
上述检查已于 2026-08-26 在 Python 3.13.11 环境实际运行:Ruff 通过,mypy 检查 21 个源码和测试文件无问题,
-pytest 共 104 项测试通过,`mdpolish-0.2.0-py3-none-any.whl` 构建成功。wheel 内容已单独检查,只包含通用 Python
-包、类型标记和包元数据,不包含项目规则、实验脚本、评审器或 Node.js 文件。
+pytest 共 168 项通过、2 项因本环境未安装可选词典 backend 而跳过,`mdpolish-0.2.0-py3-none-any.whl` 构建成功。
+wheel 内容已单独检查,只包含通用 Python 包、类型标记和包元数据,不包含项目规则、实验脚本、评审器或 Node.js 文件。
`pyproject.toml` 声明的 Python 3.11 及以上为支持范围;本次结果不表示已经在每个受支持版本上完成兼容性验证。
diff --git a/pyproject.toml b/pyproject.toml
index 1c978f5..f517f6c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,6 +10,13 @@ requires-python = ">=3.11"
dependencies = []
[project.optional-dependencies]
+frequency = [
+ "wordfreq>=3.1.1,<4",
+]
+lexical = [
+ "pyphen>=0.18.1,<1",
+ "pyspellchecker>=0.9,<1",
+]
dev = [
"mypy>=1.15,<2",
"pytest>=8.3,<10",
diff --git a/research-wiki/design/0009-generalized-mapped-line-join.md b/research-wiki/design/0009-generalized-mapped-line-join.md
new file mode 100644
index 0000000..99deb51
--- /dev/null
+++ b/research-wiki/design/0009-generalized-mapped-line-join.md
@@ -0,0 +1,762 @@
+# 0009:通用映射驱动的跨行片段合并
+
+## 状态
+
+已批准(2026-08-26)。
+
+`supersedes: 0008`(范围有限):本文替代 `0008` 第 6.1 节中“保留当前左片段、右片段、结果文本算法”
+的窄接口,并为调用方显式启用的词典规则放宽“运行时只依赖标准库”这一点。核心安装仍然零依赖;精确和正则规则
+仍然只处理内存字符串。词典规则只允许在构造修改器时读取已安装 optional package 自带的版本化词典资源,
+不读取调用方文档、任意路径或网络。`propose()` 仍然是只读快照的纯函数。
+
+`0008` 确定的函数式 `Modifier`、无默认启用行为、无项目固定词表、无网络和无模型推断等边界继续有效。
+
+本文批准后才能实施。批准本文不等于批准提交、推送、创建 PR、发布、修改其他仓库或处理真实材料。
+
+## 1. 问题与可观察现象
+
+当前 `mapped_line_join()` 只接受 `(left, right, replacement)` 三元组。它能处理调用方已经确认的少量精确断词,
+但遇到下列情况时,调用方只能复制整个修改器或在库外另写一套边界扫描逻辑:
+
+- 同一类断词需要用命名正则捕获不同词干;
+- 只需要匹配下一行开头,左侧只要求位于当前行尾;
+- OCR 把一个词连续拆到三行以上,需要在一次提议中完成链式合并;
+- 同一位置有多条规则,需要显式选择第一条、最高优先级或报错;
+- 规则只允许用于段落、标题、列表项或引用,不能进入表格和代码块;
+- 合并后需要保留换行、删除换行、换成空格或形成段落分隔;
+- 文本和规则可能使用不同 Unicode 规范形式,或调用方明确需要忽略大小写。
+
+更根本的问题是:如果每个 `example`、`international`、`database` 都要先手写一条映射,调用方实际上要自己维护
+一份英语词典。成熟的通用方案应能从行尾和行首自动生成候选词,查询明确配置的词典或词频资源;精确映射只负责
+项目术语、误判否决和其他例外,不应成为处理普通英文断词的唯一入口。
+
+当前实现还会在整个物理行内容上匹配。它不知道 Markdown 围栏、缩进代码、pipe table、列表和引用前缀,
+因此无法可靠表达“只在某类块中生效”。
+
+这些都属于清洗语义、规则格式和冲突策略变化,不能作为 `0008` 已批准实现的机械扩展。
+
+## 2. 调研结论及其边界
+
+本次调研只用于确定库边界,不把外部工具行为直接变成 `mdpolish` 的默认规则。
+
+### 2.1 Pandoc:软换行语义与源码重排是两件事
+
+Pandoc 默认把段落内普通换行当作空格。`hard_line_breaks` 会把段落内每个换行解释为硬换行,
+`ignore_line_breaks` 和 `east_asian_line_breaks` 则提供另外两种读取语义。输出侧的 `--wrap=auto|none|preserve`
+决定生成源码怎样折行,不负责判断 OCR 断词。
+
+当前 Pandoc 手册没有 `reflowed_text` 扩展。与“reflowed text”最接近的当前公共能力是输出侧 `--wrap`,
+不能把这个非现行名称设计成库兼容目标。
+
+来源:
+
+-
+-
+-
+
+因此,本修改器不能把所有物理换行统一解释成一种语义。每条规则必须明确怎样处理命中的边界,代码块和表格等结构
+必须先排除。
+
+### 2.2 OCR 去连字符:成熟方案仍然需要证据来源和取舍
+
+常见 OCR 后处理会组合以下信号:
+
+| 信号 | 能解决的问题 | 不能单独保证的事情 |
+| --- | --- | --- |
+| 词典查表 | 判断拼接词是否为已知词 | 专名、新词、领域词和真正带连字符的词 |
+| 词缀与形态分析 | 识别屈折、派生和复合词 | 依赖语言及词典质量 |
+| 词频或统计语言模型 | 在“连写、保留连字符、加空格”之间排序 | 结果依赖训练语料和领域分布 |
+| OCR 置信度与版面元数据 | 利用识别器对字符、词或断词的显式证据 | 普通 Markdown 通常已经丢失这些信息 |
+
+ALTO 可以用 `SUBS_TYPE=HypPart1/HypPart2` 和 `SUBS_CONTENT` 表达断词及完整词,并提供词置信度;hOCR 定义了
+`x_wconf`。这说明如果上游仍持有结构化 OCR 证据,优先在上游使用它比从 Markdown 猜测更可靠。
+
+来源:
+
+-
+-
+-
+-
+
+本仓库当前只有 Markdown 字符串,没有 OCR 置信度、坐标或上游候选。因此本轮只实现调用方显式配置的本地词典判断
+和统计打分,不实现依赖版面证据的判断或模型推断。词典结果是候选证据,不绕过歧义策略和精确覆盖规则。
+
+### 2.3 Python 词典与断词库可以提供证据,但必须显式选择
+
+| 工具 | 主要能力 | 本轮不直接集成的原因 |
+| --- | --- | --- |
+| `wordninja` | 按 unigram 概率拆分粘连英文词 | 目标是拆词,不是恢复 Markdown 跨行结构;默认模型有语言和语料偏置 |
+| `pyspellchecker` | 基于词频和编辑距离给出拼写候选 | 候选不是唯一正确修改,默认大小写和词典行为也需项目决定 |
+| `PyEnchant` | 通过 Enchant provider 检查和建议拼写 | 依赖系统 provider 与外部词典,安装结果不完全由 Python 包锁定 |
+| `wordfreq` | 查询多语言词频 | 能排序候选,但不能确定是否应保留自然连字符 |
+| `PyHyphen` / `Pyphen` | 使用 TeX/Hunspell 断词模式找合法断点 | 正向排版断词不等于逆向 OCR 去断词 |
+| Hunspell | 拼写检查、词缀、复合词和形态分析 | 需要语言词典;不同词典的形态字段和能力不同 |
+
+来源:
+
+-
+-
+-
+-
+-
+-
+-
+
+调研后的采用边界如下:
+
+- `pyspellchecker` 具有随包分发的多语言词频词典,能够同时做精确 `known()` 查询和频率比较,适合作为第一种较轻的
+ 自动词典 backend;不使用它的编辑距离纠错,因为本任务只在几个明确候选之间选择,不改写 OCR 字符;
+- `wordfreq` 提供可比较的 Zipf 频率和更广的多语言数据,适合作为可选的频率 backend;它的数据主要截至 2021 年,
+ 且官方明确说明多 token 查询会高估罕见组合,因此不能把任意短语分数当作可靠词典命中;
+- `Pyphen` 能列出一个词的合法断词位置,适合作为“这个行尾位置是否可能是排版断词”的附加门槛,但它不能单独证明
+ 拼接词真实存在;
+- `PyEnchant` / Hunspell 能处理词缀、复合词和自然连字符,但依赖系统 provider 和外部词典,环境可复现性较差;
+ 本轮记录边界但不实现该 backend;
+- `wordninja` 的目标是把粘连字符串拆成多个词,方向与本任务相反,本轮不集成。
+
+这些 backend 必须由 `LexicalLineJoinRule` 显式选择。缺少请求的 extra 时立即报错,不退回另一个词典,也不改成只靠
+正则猜测。
+
+### 2.4 Markdown lint 工具负责语法和风格,不负责 OCR 语义修复
+
+`markdownlint` 的规则集中有行长、尾随空格、空行、标题和列表等结构/风格检查;`remark-lint` 的规则检查 mdast,
+并允许项目自行编写插件。两者都没有“判断行尾连字符是不是排版断词并自动拼回”的内置规则。
+
+这不是遗漏:lint 工具可以判断 Markdown 是否符合某种书写约定,却没有词典、原始页面或 OCR 置信度来唯一决定正文
+应该怎样改。OCR 语义修复应由上游转换器、专用后处理器或项目明确规则负责。
+
+来源:
+
+-
+-
+
+## 3. 目标与非目标
+
+### 3.1 目标
+
+- 保留旧三元组精确映射的调用方式;
+- 增加不可变、带类型的精确规则和正则规则;
+- 增加自动词典规则:从行尾、separator 和行首生成候选,查询显式配置的本地词典或词频资源;
+- 让一条语言级规则可以复用于任意文档,不要求调用方逐词枚举普通英文;
+- 所有规则仍由调用方显式传入,不内置词表或模式;
+- 在一次 `propose()` 中处理独立命中和跨多行链式命中;
+- 显式配置块范围、边界替换、冲突策略、大小写和 Unicode 规范化;
+- 对代码块和表格失败关闭;
+- 最终只产生绑定当前快照的非重叠精确 `TextEdit`;
+- 规则、选项和顺序完整记录在 `Modifier.parameters` 中。
+
+### 3.2 非目标
+
+- 不实现自动语言识别、在线词典发现或模型调用;语言、backend、候选形式和阈值必须显式配置;
+- 不读取 ALTO、hOCR、PDF、DOCX、图片或外部文件;
+- 核心安装不引入 Markdown parser 或 OCR 库;词典能力只通过明确安装的 optional extra 启用;
+- 不声称这个词法块分类器覆盖全部 CommonMark、GFM、Pandoc Markdown 或任意嵌套 HTML;
+- 不自动选择规则、重排规则或循环运行整个 `Pipeline`;
+- 不修改 `_text_ranges.py`;
+- 不提供配置文件、CLI、默认映射或默认流水线。
+
+## 4. 公共规则模型
+
+新值类型均放在 `mapped_line_join.py`,使用 `@dataclass(frozen=True, slots=True)`。为避免扩大原始任务的源码范围,
+它们先通过 `mdpolish.modifiers.mapped_line_join` 模块路径提供;本轮不要求修改 `modifiers/__init__.py`。
+
+### 4.1 枚举
+
+```python
+class LineJoinBlock(StrEnum):
+ PARAGRAPH = "paragraph"
+ HEADING = "heading"
+ LIST_ITEM = "list_item"
+ BLOCK_QUOTE = "block_quote"
+
+
+class LineBreakPolicy(StrEnum):
+ PRESERVE = "preserve"
+ DELETE = "delete"
+ SPACE = "space"
+ PARAGRAPH = "paragraph"
+
+
+class LineJoinConflictPolicy(StrEnum):
+ PRIORITY = "priority"
+ FIRST = "first"
+ ERROR = "error"
+
+
+class UnicodeNormalization(StrEnum):
+ NFC = "NFC"
+ NFD = "NFD"
+ NFKC = "NFKC"
+ NFKD = "NFKD"
+
+
+class LexiconBackend(StrEnum):
+ SPELLCHECKER = "pyspellchecker"
+ WORDFREQ = "wordfreq"
+
+
+class LexicalCandidateForm(StrEnum):
+ JOINED = "joined"
+ HYPHENATED = "hyphenated"
+ SPACED = "spaced"
+
+
+class LexicalAmbiguityPolicy(StrEnum):
+ KEEP = "keep"
+ ERROR = "error"
+```
+
+默认可匹配块为四种 `LineJoinBlock` 的不可变集合。代码块和表格不是可选块类型,因为它们始终排除,调用方不能通过
+普通规则意外打开。
+
+### 4.2 精确规则
+
+```python
+@dataclass(frozen=True, slots=True)
+class ExactLineJoinRule:
+ rule_id: str
+ left: str
+ right: str
+ replacement: str
+ separator: str = ""
+ blocks: frozenset[LineJoinBlock] = ALL_LINE_JOIN_BLOCKS
+ line_break: LineBreakPolicy = LineBreakPolicy.DELETE
+ priority: int = 0
+ case_sensitive: bool = True
+ normalization: UnicodeNormalization | None = None
+ require_word_boundaries: bool = True
+```
+
+`separator` 是紧跟在 `left` 后、位于物理换行前的显式文本。它可以是 `"-"`,也可以是空字符串:
+
+```python
+ExactLineJoinRule(
+ rule_id="example.dehyphenate",
+ left="exam",
+ separator="-",
+ right="ple",
+ replacement="example",
+)
+
+ExactLineJoinRule(
+ rule_id="example.join_split_word",
+ left="exam",
+ separator="",
+ right="ple",
+ replacement="example",
+)
+```
+
+这样不需要靠“`left` 是否碰巧以连字符结尾”推断规则类型。`replacement` 是完整的替换片段;库不会自动拼接
+`left`、`separator` 或 `right`。
+
+自动词典不可避免会遇到项目专名和自然连字符例外。为让映射表能明确否决而不是只能增加修改,再提供同样不可变的
+精确保护规则:
+
+```python
+@dataclass(frozen=True, slots=True)
+class KeepLineJoinRule:
+ rule_id: str
+ left: str
+ right: str
+ separator: str = ""
+ blocks: frozenset[LineJoinBlock] = ALL_LINE_JOIN_BLOCKS
+ priority: int = 0
+ case_sensitive: bool = True
+ normalization: UnicodeNormalization | None = None
+ require_word_boundaries: bool = True
+```
+
+它命中并赢得冲突选择后保留原文,不产生无效 `TextEdit`,同时阻止低优先级词典规则处理该边界。保护规则会进入
+`Modifier.parameters`;由于没有实际修改,不伪造 `Change`。
+
+### 4.3 左右正则规则
+
+```python
+@dataclass(frozen=True, slots=True)
+class RegexLineJoinRule:
+ rule_id: str
+ left_pattern: str
+ right_pattern: str
+ replacement: str
+ separator: str = ""
+ blocks: frozenset[LineJoinBlock] = ALL_LINE_JOIN_BLOCKS
+ line_break: LineBreakPolicy = LineBreakPolicy.DELETE
+ priority: int = 0
+ case_sensitive: bool = True
+ normalization: UnicodeNormalization | None = None
+```
+
+- `left_pattern` 必须匹配左侧逻辑内容的非空后缀;
+- `right_pattern` 必须匹配右侧逻辑内容的非空前缀;
+- 捕获组必须命名,左右两侧的组名不能重复;
+- `replacement` 只支持 `\g` 命名反向引用,不支持数字组和动态回调;
+- 非捕获组、lookaround 和普通正则语法可以使用,但最终左右匹配本身必须消耗字符;
+- 正则按 Python `re` 语义编译,不启用 `MULTILINE` 或 `DOTALL`,因为物理行边界由修改器负责。
+
+示例:
+
+```python
+RegexLineJoinRule(
+ rule_id="example.named_regex",
+ left_pattern=r"(?P[A-Za-z]+)",
+ separator="-",
+ right_pattern=r"(?P[a-z]+)",
+ replacement=r"\g\g",
+)
+```
+
+### 4.4 只指定右侧的行尾正则规则
+
+```python
+@dataclass(frozen=True, slots=True)
+class LineEndRegexRule:
+ rule_id: str
+ right_pattern: str
+ separator: str = ""
+ blocks: frozenset[LineJoinBlock] = ALL_LINE_JOIN_BLOCKS
+ line_break: LineBreakPolicy = LineBreakPolicy.DELETE
+ priority: int = 0
+ case_sensitive: bool = True
+ normalization: UnicodeNormalization | None = None
+```
+
+这类规则把左侧定义为逻辑行尾的零宽锚点。`right_pattern` 必须匹配右侧逻辑内容的非空前缀,但只作为边界条件;
+右侧匹配文本不进入编辑范围,也不会被重新输出。实际编辑只覆盖可选 `separator`、物理行尾、允许的空行和右侧重复
+块前缀:
+
+```python
+LineEndRegexRule(
+ rule_id="example.line_end_and_right",
+ right_pattern=r"(?P[a-z])",
+ line_break=LineBreakPolicy.SPACE,
+)
+```
+
+上例把 `foo\nbar` 变成 `foo bar`,而不是把空格放到 `b` 之后。规则不会猜测左侧单词,只明确表示
+“当前逻辑行尾 + 下一逻辑行首命中该正则时怎样改”。它仍然是映射表规则,不是对所有小写行首自动生效的隐藏默认值。
+
+`LineEndRegexRule` 不允许 `PRESERVE`,因为它不会改变 separator、边界或右侧文本,只会产生无效编辑。
+
+### 4.5 自动词典规则
+
+普通英文断词不要求逐词映射。调用方配置一条语言级规则,修改器从每个合格边界提取左右单词片段并自动查询本地词典:
+
+```python
+@dataclass(frozen=True, slots=True)
+class LexicalLineJoinRule:
+ rule_id: str
+ left_pattern: str
+ right_pattern: str
+ separator: str
+ backend: LexiconBackend
+ language: str
+ candidate_forms: tuple[LexicalCandidateForm, ...]
+ minimum_score: float
+ minimum_score_margin: float = 0.0
+ hyphenation_language: str | None = None
+ ambiguity: LexicalAmbiguityPolicy = LexicalAmbiguityPolicy.KEEP
+ blocks: frozenset[LineJoinBlock] = ALL_LINE_JOIN_BLOCKS
+ priority: int = 0
+ case_sensitive: bool = True
+ normalization: UnicodeNormalization | None = None
+```
+
+示例配置一次后可以复用于任意英文文档:
+
+```python
+LexicalLineJoinRule(
+ rule_id="english.dictionary_dehyphenation",
+ left_pattern=r"[A-Za-z]{2,}",
+ right_pattern=r"[a-z]{2,}",
+ separator="-",
+ backend=LexiconBackend.SPELLCHECKER,
+ language="en",
+ candidate_forms=(
+ LexicalCandidateForm.JOINED,
+ LexicalCandidateForm.HYPHENATED,
+ ),
+ minimum_score=1.0,
+ minimum_score_margin=0.5,
+ hyphenation_language="en_US",
+)
+```
+
+这条规则会自动处理 `exam-\nple`、`inter-\nnational` 等候选,不需要为 `example`、`international` 分别写映射。
+规则表在这里表达的是“什么文本形态、用哪个词典、比较哪些候选、阈值多保守”,不是穷举英语单词。
+
+左右 pattern 分别锚定逻辑行尾和行首,整个非空匹配就是候选片段。`separator` 本轮只允许 `"-"` 或 `""`。
+修改器生成调用方列出的候选:
+
+| 候选 | 输出 |
+| --- | --- |
+| `JOINED` | `left + right` |
+| `HYPHENATED` | `left + "-" + right` |
+| `SPACED` | `left + " " + right` |
+
+规则必须包含 `JOINED`,并包含与源边界对应的竞争形式:`separator="-"` 时包含 `HYPHENATED`,空 separator 时包含
+`SPACED`。这样“拼接词在词典中”不会自动压过同样合理的自然连字符或两个独立单词。
+
+backend 行为:
+
+- `SPELLCHECKER` 用 `SpellChecker.known()` 判断单 token 是否存在,并把 `word_usage_frequency()` 换算到每十亿词的
+ 对数尺度。`SPACED` 或需要拆成多个单 token 验证的 `HYPHENATED` 只在各组成 token 分别已知时成立,准入分数取
+ 各 token 的最低值;这个值不是短语频率,也不能与单 token 分数按 `minimum_score_margin` 排序;
+- `WORDFREQ` 使用 `zipf_frequency(candidate, language)`;它可以评价多 token 字符串,但调用方必须考虑官方说明的
+ 罕见组合高估问题,并通过 `minimum_score_margin`、精确否决规则和合成反例收紧;
+- `hyphenation_language` 非空时,再用 Pyphen 检查 `len(left)` 是否在 `positions(left + right)` 中。这个门槛只作用于
+ `JOINED` 候选,不替代词典存在性和候选比较。
+
+`minimum_score` 和 `minimum_score_margin` 是 backend 内部的阈值,不是跨 backend 的统一物理量。对 `WORDFREQ`,二者
+都是 Zipf 尺度;对 `SPELLCHECKER`,`minimum_score` 可以过滤单 token 或各组成 token 中明显低频的候选,但
+`minimum_score_margin` 只适用于两个都有可比单 token 分数的候选。调用方不能把同一个数值配置在两个 backend 间
+直接搬用并期待相同含义。
+
+选择流程固定为:
+
+1. 为每个显式候选查询 backend,得到“是否存在”、准入分数和可选的可比分数;
+2. 丢弃不存在、准入分数低于 `minimum_score` 或未通过可选 Pyphen 门槛的候选;
+3. 没有候选时保留原文;
+4. 只有一个候选时选择它;
+5. 多个候选且所有候选都有同一尺度的可比分数时,只有存在唯一最高分,且它比第二名至少高
+ `minimum_score_margin` 才选择;
+6. 存在不可排序候选、仍然并列或差距不足时,按 `ambiguity` 处理:`KEEP` 保留原文,`ERROR` 抛出包含
+ `rule_id` 和位置但不包含正文的错误;
+7. 选中候选后,把左右片段和物理边界合成一个精确替换;若选中的是自然连字符或空格形式,也会删除物理换行,
+ 但保留 `-` 或一个空格。
+
+因此,`SPELLCHECKER + SPACED` 的两个单 token 最低频率只用于准入,不冒充短语频率。它与 `JOINED` 同时存活时属于
+不可排序歧义,不能靠 margin 自动裁决。`SPELLCHECKER + HYPHENATED` 在 backend 只能按组成 token 验证时遵循同一规则。
+
+因此,“拼起来查词典,命中就合并”是自动规则的基础,但不是唯一判断。至少还要让源形式参与竞争,并对多候选歧义
+失败关闭。项目专名、缩写和已知例外仍可用更高优先级的精确/正则规则覆盖。
+
+### 4.6 旧三元组兼容
+
+以下调用继续有效:
+
+```python
+mapped_line_join((("exam-", "ple", "example"),))
+```
+
+旧三元组等价于一条精确规则:`left` 保持原值、`separator=""`、删除换行、区分大小写、不做 Unicode 规范化、
+启用词边界并允许四种文本块。它不会被改写成词典或正则规则。
+
+旧三元组没有 `rule_id`。工厂按声明位置为审计和冲突消息生成 `legacy.0000`、`legacy.0001` 等内部 ID;
+因此旧映射的声明顺序也会进入参数记录。这里保留的是调用语法和精确映射能力,不承诺继续在代码块或表格中修改,
+因为本文明确把这两类区域改为始终排除。
+
+旧类型别名保留:
+
+```python
+LineJoinMapping = tuple[str, str, str]
+LineJoinRule = ExactLineJoinRule | KeepLineJoinRule | RegexLineJoinRule | LineEndRegexRule | LexicalLineJoinRule
+```
+
+工厂签名为:
+
+```python
+def mapped_line_join(
+ mappings: Iterable[LineJoinMapping | LineJoinRule],
+ *,
+ conflict_policy: LineJoinConflictPolicy = LineJoinConflictPolicy.PRIORITY,
+ max_intervening_blank_lines: int = 1,
+) -> Modifier:
+ ...
+```
+
+保留 `max_intervening_blank_lines=1` 是为了兼容当前“可以隔一个同风格空行”的已实现行为。调用方可以设为 `0`,
+禁止跨空行。这里只接受类型严格为 `int` 的 `0` 或 `1`,`bool`、负数和 `2` 以上都拒绝。两个及以上连续空行更像
+结构边界;在没有 OCR provenance、版面置信度或真实样本证据时,本轮不允许用一个任意整数把搜索扩大到更远段落。
+如果后续证据表明某个上游稳定地产生多空行误断,应以有上限的新设计明确扩大范围,而不是静默放宽本参数。
+
+## 5. 匹配与替换语义
+
+### 5.1 逻辑内容和锚点
+
+修改器仍使用 `_text_ranges.py` 的 `PhysicalLine` 和 `physical_lines()` 获取准确范围,不改变底层工具。
+
+目标文件内新增保守的块扫描,把每条物理行分成:
+
+- Markdown 容器/块前缀,例如 `> `、列表 marker 后的缩进或 `# `;
+- 可匹配逻辑内容;
+- 精确物理行尾。
+
+左规则只在逻辑内容末尾匹配,右规则只在逻辑内容开头匹配。删除或替换物理边界时,右行为了表达同一块而重复的
+引用前缀或 continuation 缩进一并按第 7 节处理,不会残留在拼接词中。
+
+### 5.2 精确词边界
+
+`require_word_boundaries=True` 时:
+
+- 左匹配前一个字符不能是 Unicode 字母、数字或下划线;
+- 右匹配后一个字符不能是 Unicode 字母、数字或下划线。
+
+这替代当前只检查 ASCII 字母的窄规则。调用方确实要匹配词内片段时必须显式设为 `False`。正则规则不另加自动词边界,
+调用方用正则本身表达边界。
+
+### 5.3 空规则集和无命中
+
+空映射仍创建合法的 no-op `Modifier`。没有规则命中时返回空提议,不修改换行、Unicode 或任何其他内容。
+
+## 6. Markdown 块范围
+
+本轮不引入 parser。目标文件内使用失败关闭的词法扫描,只为跨行候选提供最小结构边界。
+
+扫描器内部必须有独立的 `UNKNOWN` 分类;它不是公共 `LineJoinBlock`,任何规则都不能选择它。`PARAGRAPH` 必须由
+“普通文本行、容器前缀一致且两侧都没有未识别块 marker”正向识别,不能作为“不像其他块”的兜底。`EXCLUDED`
+用于已确认的代码和表格范围,`UNKNOWN` 用于 marker 损坏、容器深度不一致、缩进关系不足或方言结构无法确认的范围;
+两者都不产生候选。
+
+### 6.1 始终排除
+
+- backtick 或 tilde fenced code block,包括 fence 行;
+- 保守识别的四空格或 tab 缩进代码;
+- 有 delimiter row 的 GFM pipe table 整个连续区域;
+- 从 `` 的 raw HTML table 区域;不完整 opening 从该处排除到文档末尾。
+
+围栏识别支持至多三个前导空格、相同 fence 字符和不短于 opening 的 closing。它不解释 fence 内语言。
+
+### 6.2 可选块
+
+| `LineJoinBlock` | 词法范围 |
+| --- | --- |
+| `PARAGRAPH` | 不属于其他支持块的连续普通文本行 |
+| `HEADING` | ATX heading 的内容及其紧邻、没有新块 marker 的 OCR continuation;Setext underline 本身不参与匹配 |
+| `LIST_ITEM` | list marker 行与达到该 item 内容列的 continuation;遇到下一个同级 marker 或块边界结束 |
+| `BLOCK_QUOTE` | 具有相同 quote depth 的普通引用内容;引用内的 heading/list 使用更具体的块类型 |
+
+块类型取最内层可识别类型。例如引用中的列表项是 `LIST_ITEM`,引用中的普通段落是 `BLOCK_QUOTE`。
+
+Markdown 允许 lazy continuation,标题后也允许紧接普通段落;仅靠文本无法总是区分这些情况。因此:
+
+- 规则映射仍是决定修改正确性的主要证据;
+- 块分类只缩小规则适用范围,不证明词语一定应该合并;
+- 无法确认同一块、容器深度不一致或前缀损坏时归入 `UNKNOWN`,不回退成 `PARAGRAPH`,也不提出修改;
+- 合法且前缀对称的多层引用或嵌套列表可以正向识别;深度、item content column 或 continuation 缩进不一致时
+ 失败关闭。`> > >` 和多级有序列表本身不自动等于损坏语法,测试必须同时固定合法嵌套和损坏变体。
+
+## 7. 换行策略
+
+一个候选的“原始边界”包括左物理行尾、允许的空行,以及右侧重复的块前缀。精确和左右正则规则先把左右匹配片段
+变成 `replacement`,再把所选边界输出放在 replacement 之后、右侧未匹配内容之前。行尾正则规则不消费右侧匹配,
+只按下表替换原始边界:
+
+| 策略 | 输出 |
+| --- | --- |
+| `PRESERVE` | 保留原始物理行尾、空行和右侧块前缀 |
+| `DELETE` | 删除整个边界和右侧重复前缀 |
+| `SPACE` | 用一个 ASCII 空格替换整个边界和右侧重复前缀 |
+| `PARAGRAPH` | 用左物理行尾样式生成恰好一个空行,并恢复右侧所需块前缀 |
+
+`PARAGRAPH` 在普通段落中分别产生 `EOL + EOL`;在 block quote 中产生合法的空 quote 行和下一行 quote 前缀;
+在 list item 中保留 continuation 所需缩进。具体字节由合成测试固定,不根据操作系统默认换行。
+
+候选内部出现混合 CR、LF、CRLF 时失败关闭,不匹配该候选。文档其他位置可以使用不同换行风格,不做全文规范化。
+
+## 8. 冲突与链式合并
+
+### 8.1 同一边界多规则冲突
+
+每个候选边界先按调用方给出的规则顺序收集全部命中,再执行:
+
+| 策略 | 行为 |
+| --- | --- |
+| `FIRST` | 选择声明顺序最早的规则 |
+| `PRIORITY` | 选择最高 `priority`;最高值相同时选择声明顺序最早的规则 |
+| `ERROR` | 两条及以上规则命中即抛出 `ModifierContractError`,消息只含位置和 `rule_id` |
+
+自动词典规则只有在结构 pattern 命中且第 4.5 节选出了一个候选形式后才进入冲突集合;无词典候选或 `ambiguity=KEEP`
+不算命中。`ambiguity=ERROR` 的词典歧义直接报错,不先交给规则冲突策略掩盖。
+
+规则顺序因此是参数和行为的一部分,不再为了得到顺序无关结果而排序。`PRIORITY` 的同优先级 tie-break 明确采用
+声明顺序,不把集合迭代顺序或 replacement 字典序当作隐藏规则。
+
+`KeepLineJoinRule` 与其他规则参加同一次冲突选择;它只有在赢得选择后才阻止修改。这样精确项目例外可以用更高
+优先级覆盖语言级词典规则,而不会让一条低优先级保护规则意外屏蔽更明确的替换。
+
+### 8.2 跨多行链式合并
+
+修改器从前到后处理物理边界,并维护只存在于当前 `propose()` 调用内的不可变中间片段。选中一条规则后:
+
+1. 组合该边界的虚拟输出;
+2. 标记该原始物理边界已经消费;
+3. 如果输出与下一原始物理行形成新的候选,继续匹配下一边界;
+4. 每个原始物理边界最多消费一次,因此最多执行 `physical_line_count - 1` 次,不存在无界循环;
+5. 相连边界最终合成一个覆盖原始连续范围的 `TextEdit`。
+
+例如两条显式规则可以把:
+
+```text
+exam-
+ple-
+based
+```
+
+在一次提议中变成 `example-based`。修改器不会依赖 `Pipeline` 自动运行第二轮,也不会先提交两个重叠编辑再让执行器猜测。
+
+不相连的链生成不同且不重叠的 `TextEdit`。最终按原文起点排序。若内部规划仍产生重叠、无实际变化或无法映射回原始
+范围,修改器抛出契约错误,不静默丢弃整批正确候选。
+
+## 9. 大小写和 Unicode 规范化
+
+- `case_sensitive=True` 是默认值;
+- `normalization=None` 是默认值;
+- 规范化只建立匹配视图,不规范化整篇文档;
+- 固定 replacement 按调用方原样输出;
+- 正则反向引用使用映射回原文的捕获文本,不借匹配视图静默改变大小写或规范形式;
+- 如果规范化后的匹配边界不能唯一映射回 Python 原字符串索引,该候选失败关闭;
+- 兼容等价匹配、NFKC/NFKD 兼容匹配和忽略大小写都必须由规则显式开启。
+
+实现可以为一个逻辑内容建立规范化视图及源索引边界表,但不能把规范化后的全文作为 `expected_text`,也不能绕过
+`TextSpan` 的原始 Python 字符索引契约。
+
+## 10. 验证、错误与审计
+
+工厂构造时验证:
+
+- 输入可迭代且每项是合法三元组或规则 dataclass;
+- `rule_id` 非空、稳定、唯一;
+- 枚举、布尔值、整数和块集合类型准确,不接受用真值冒充布尔值;
+- 左右精确片段、separator 和 replacement 不包含 CR/LF;
+- 精确规则的 `left`、`right` 和 replacement 非空;separator 可以为空;
+- 正则可以编译,左右正则规则的两侧及行尾正则规则的右侧不会只产生零长度匹配;
+- 捕获组均有名称、左右名称不重复、replacement 引用存在;
+- 行尾正则规则不接受 `PRESERVE`;
+- 自动词典规则的 separator 只能是 `"-"` 或 `""`,pattern 必须消费非空片段;
+- 自动词典规则必须包含 `JOINED` 和对应源形式,候选形式不能重复;
+- backend、非空语言代码、有限且非负的 `minimum_score` / `minimum_score_margin` 和可选 Pyphen 语言合法;
+- 请求的 backend 或 Pyphen 不可用时明确报错,不创建行为不完整的修改器;
+- `max_intervening_blank_lines` 的类型严格为 `int` 且只能是 `0` 或 `1`;
+- 旧三元组仍拒绝空字段、换行和重复的 `(left, right)`。
+
+运行时冲突、无法映射的内部范围和不变量破坏抛出明确异常。普通无匹配、结构排除和不能唯一映射的 Unicode 候选是
+失败关闭条件,返回无提议,不是异常。
+
+每个实际链生成一个 `ProposedChange` 和一个 `TextEdit`。`reason` 包含所用 `rule_id` 序列,不记录未命中规则,
+也不输出候选周围正文。`Modifier.parameters` 记录:
+
+- 调用顺序中的完整规则;
+- 规则类型、pattern/literal、replacement、separator、块范围、换行策略、优先级、大小写和规范化选项;
+- 冲突策略和允许的空行数。
+
+修改器版本从 `1.0.0` 更新为 `2.0.0`,表示规则语义和参数记录发生变化。
+
+## 11. 依赖和函数式边界
+
+- 核心安装仍只依赖 Python 标准库;
+- `pyproject.toml` 新增 `lexical` optional extra,包含受限版本的 `pyspellchecker` 和 `pyphen`;
+- `wordfreq` 体积和传递依赖更大,放入单独的 `frequency` optional extra,不随 `lexical` 或核心安装;
+- optional import 使用模块级 `try/except ImportError` 保存“不可用”状态;只有规则明确请求对应 backend 时才报
+ `ModifierContractError`,精确和正则规则不受未安装 extra 影响;
+- backend 和 Pyphen 实例在 `mapped_line_join()` 构造修改器时建立一次,不在每个文档或每个候选上重复加载词典;
+- 参数记录 backend、第三方包版本、语言、阈值、候选形式和 Pyphen 语言;
+- 不在 import 时读取词典、环境变量、当前时间或网络;
+- 规则 dataclass、编译后的内部规则和扫描结果不可变;
+- 闭包只捕获构造时验证和冻结的数据;
+- `propose()` 只读取 `DocumentSnapshot` 并返回 tuple,不应用编辑或写文件。
+
+`pyspellchecker` 和 `wordfreq` 自带的数据版本随包版本冻结;本轮不下载更新。Pyphen 使用其已安装包内词典。
+调用方仅安装 extra 不会自动启用任何规则,因而不会出现“环境里碰巧多了一个包,清洗结果就改变”的隐式行为。
+
+PyEnchant/Hunspell backend 若以后进入运行时,需要新的 design 解决系统 provider、词典版本和可复现记录,不能在本轮
+用宽泛 `try/except` 悄悄替代已选择的 backend。
+
+## 12. 文档与兼容范围
+
+实现后的模块顶层 docstring 记录第 2 节的简短结论、支持模式和限制,不新增同目录 `NOTES.md`,避免形成第二份接口事实。
+具体签名和运行行为以代码与测试为准。
+
+根 `README.md` 当前明确写着 `mapped_line_join()` 只使用三元组并只处理当前窄边界。实现本文后该说明会过期。
+按照 README 的事实权威要求,实施范围必须允许同步更新 README 的当前能力和示例。若仍要求 Git diff 只能包含目标源码
+和 tests,则本文不能实施。
+
+本轮不承诺新 dataclass 从 `mdpolish.modifiers` 聚合模块导出。受支持导入路径是:
+
+```python
+from mdpolish.modifiers import mapped_line_join
+from mdpolish.modifiers.mapped_line_join import (
+ ExactLineJoinRule,
+ KeepLineJoinRule,
+ LexicalLineJoinRule,
+ LineEndRegexRule,
+ RegexLineJoinRule,
+)
+```
+
+旧 `mapped_line_join()` 和 `LineJoinMapping` 聚合导入保持可用。
+
+## 13. 测试与验收
+
+测试只使用虚构 Markdown,不读取真实或外部数据。至少覆盖:
+
+- 旧三元组精确匹配和新 `ExactLineJoinRule`;
+- `separator="-"` 与 `separator=""`;
+- 左右命名正则和跨两侧 backreference;
+- `LineEndRegexRule` 的行尾锚点 + 行首正则,并确认右侧条件文本不被消费;
+- 小型合成词典 backend 自动把行尾和行首组成 `JOINED`、`HYPHENATED`、`SPACED` 候选;
+- 拼接词唯一命中自动合并,不需要逐词精确映射;
+- 拼接词与源形式都有可比分数时按分差选择,分差不足按 `KEEP`/`ERROR` 处理;
+- Pyphen 合法断点门槛允许和拒绝 `JOINED` 候选;
+- 高优先级 `KeepLineJoinRule` 能否决自动词典规则;
+- 请求未安装 backend 明确失败,绝不退回另一 backend 或正则猜测;
+- 已安装 optional extra 时分别做最小真实 adapter 测试;未安装时明确 skip 并报告,不把 skip 写成通过;
+- 段落、ATX 标题 continuation、列表 continuation 和同深度引用的正向范围;
+- 合法的三层引用和正确缩进的嵌套有序列表可以合并;
+- 引用深度不一致、损坏 quote 前缀、相邻同级列表项、缩进不足的嵌套列表 continuation,以及未识别的 marker-like
+ 结构归入内部 `UNKNOWN`,不合并且绝不回退成段落;
+- heading/list/quote 前缀在四种换行策略下的准确输出;
+- fenced code、缩进代码、GFM pipe table 和 raw HTML table 不合并;
+- 空文档、空规则、文末无换行;
+- LF、CR、CRLF 以及候选内混合行尾失败关闭;
+- 隔零个或一个空行的配置,以及 `-1`、`True`、`2` 被构造期拒绝;
+- 三行及以上链式合并、同一合并后逻辑行继续匹配;
+- 一篇文档中的多个独立链按原文顺序报告;
+- `FIRST`、`PRIORITY`、同优先级 tie-break 和 `ERROR`;
+- 默认区分大小写和显式忽略大小写;
+- 默认不规范化、NFC/NFD 匹配、不能唯一回映时失败关闭;
+- Unicode 词边界和显式关闭词边界;
+- 无效 dataclass 字段、正则、捕获组、replacement 引用和重复 `rule_id` 明确失败;
+- 无效 backend、语言、候选组合、score 阈值和 margin 明确失败;
+- `WORDFREQ` 候选按 Zipf margin 比较;`SPELLCHECKER` 单 token 与 `SPACED`/拆分验证的 `HYPHENATED`
+ 同时存活时按不可排序歧义处理,不用 margin 强行选择;
+- 参数记录完整且确定;
+- 所有正向输出再次运行零修改;
+- 精确/正则规则不读取文件、网络、词典或环境;词典规则只读取已安装 extra 自带资源,且不访问网络或任意路径。
+
+实现后运行根 README 届时列出的全部基础检查:
+
+```bash
+.venv/bin/ruff check .
+.venv/bin/mypy src tests
+.venv/bin/pytest
+.venv/bin/python -m pip wheel . --no-deps --wheel-dir /tmp/mdpolish-wheel-check
+git diff --check
+git status --short
+```
+
+最终报告只贴本轮真实输出。失败必须修复或明确报告,不能放宽断言掩盖。
+
+## 14. 风险与代价
+
+- **词法块扫描不是完整 parser:** 它能保守排除常见代码和表格,并处理约定的四类块;复杂嵌套或方言结构可能漏匹配。
+- **显式正则仍可能写错:** 库保证锚点、冲突和精确编辑安全,不保证调用方规则符合具体业务语义。
+- **heading continuation 有歧义:** 紧随标题的普通行可能本来就是段落;只有映射本身已获项目确认时才应允许 heading 规则。
+- **兼容规范化可能多对一:** 无法唯一回映时选择漏处理,不猜测源范围。
+- **链式规划增加实现复杂度:** 用每个物理边界最多消费一次和单个连续编辑限制状态空间,换取一次提议内稳定完成。
+- **规则顺序成为契约:** 这是支持 `FIRST` 以及优先级 tie-break 的必要代价,调用方必须把顺序纳入版本和评审。
+- **词典仍会漏掉专名和新词:** 自动规则对无命中和歧义失败关闭;项目可用精确规则补充,不把低覆盖伪装成全文正确。
+
+## 15. 批准后的实施边界
+
+批准本文将授权:
+
+1. 重构 `src/mdpolish/modifiers/mapped_line_join.py`;
+2. 新增或扩展 `tests/test_mapped_line_join.py`,必要时只在 `tests/` 增加同主题测试文件;
+3. 更新 `pyproject.toml`,只增加第 11 节批准的 optional extras;
+4. 更新根 `README.md` 中已经过期的当前能力边界、安装方式和调用示例;
+5. 在不改变正文的前提下检查 `AGENTS.md` 与 `CLAUDE.md` 镜像;
+6. 运行第 13 节检查并查看 Git diff、wheel 内容和工作区状态。
+
+批准本文不授权修改 `_text_ranges.py`、其他 modifier、其他 Wiki 文档、真实数据、外部系统,
+也不授权提交、推送、创建 PR 或发布。
diff --git a/src/mdpolish/modifiers/mapped_line_join.py b/src/mdpolish/modifiers/mapped_line_join.py
index b74f542..8e8e75f 100644
--- a/src/mdpolish/modifiers/mapped_line_join.py
+++ b/src/mdpolish/modifiers/mapped_line_join.py
@@ -1,120 +1,1374 @@
-"""Join exact caller-mapped fragments across nearby physical lines."""
+"""Build explicit cross-line joins from literals, regexes, or local lexicons.
+
+Pandoc separates soft-break meaning from source reflow, while mature OCR
+dehyphenation combines dictionary membership, morphology, frequency, and (when
+available) OCR confidence or layout metadata. Markdown linters intentionally
+stay on the syntax/style side of that boundary. This modifier therefore guesses
+nothing: every executable pattern, block scope, candidate form, ambiguity
+choice, and local lexicon backend is supplied by the caller.
+
+``pyspellchecker`` supplies reproducible packaged membership/frequency data;
+``wordfreq`` supplies heavier Zipf-frequency evidence; and Pyphen can only
+validate a possible hyphenation position. ``wordninja`` solves the opposite
+word-splitting problem, while Enchant/Hunspell depend on system dictionaries,
+so those libraries are not runtime backends here. No backend is treated as
+ground truth or used for edit-distance correction.
+
+Public rules support exact fragments, named regular expressions, a right-side
+condition at a logical line end, explicit keep/veto rules, and local dictionary
+candidate selection. A join may preserve the physical boundary, delete it,
+replace it with one space, or emit one paragraph break. Consecutive boundaries
+are planned as one non-overlapping edit, allowing one join result to participate
+in the next join during the same ``propose()`` call.
+
+Matching is case-sensitive and performs no Unicode normalization by default.
+Code blocks, tables, damaged or unknown Markdown containers, mixed candidate
+line endings, and ambiguous dictionary candidates fail closed. The block
+scanner is a conservative Markdown subset, not a CommonMark parser. Optional
+lexicons use only resources shipped with explicitly installed packages; this
+module never downloads data, opens caller files, calls a model, or silently
+switches backend.
+"""
from __future__ import annotations
-from collections import Counter
-from collections.abc import Iterable
+import math
+import re
+import unicodedata
+from collections.abc import Iterable, Sequence
+from dataclasses import dataclass
+from enum import StrEnum
+from importlib.metadata import PackageNotFoundError
+from importlib.metadata import version as package_version
from itertools import pairwise
+from typing import Protocol, TypeAlias
-from mdpolish._text_ranges import physical_lines
+from mdpolish._text_ranges import PhysicalLine, physical_lines
from mdpolish.models import DocumentSnapshot, ProposedChange, TextEdit, TextSpan
from mdpolish.modifier import Modifier, ModifierContractError
-LineJoinMapping = tuple[str, str, str]
+try:
+ from spellchecker import SpellChecker as _SpellChecker # type: ignore[import-not-found]
+except ImportError:
+ _SpellChecker = None
+
+try:
+ from wordfreq import zipf_frequency as _zipf_frequency # type: ignore[import-not-found]
+except ImportError:
+ _zipf_frequency = None
+
+try:
+ from pyphen import Pyphen as _Pyphen # type: ignore[import-not-found]
+except ImportError:
+ _Pyphen = None
+
+
+LineJoinMapping: TypeAlias = tuple[str, str, str]
+
+
+class LineJoinBlock(StrEnum):
+ """Supported lexical Markdown block scopes."""
+
+ PARAGRAPH = "paragraph"
+ HEADING = "heading"
+ LIST_ITEM = "list_item"
+ BLOCK_QUOTE = "block_quote"
+
+
+ALL_LINE_JOIN_BLOCKS = frozenset(LineJoinBlock)
+
+
+class LineBreakPolicy(StrEnum):
+ """How a selected rule renders the consumed physical boundary."""
+
+ PRESERVE = "preserve"
+ DELETE = "delete"
+ SPACE = "space"
+ PARAGRAPH = "paragraph"
+
+
+class LineJoinConflictPolicy(StrEnum):
+ """How rules competing at one physical boundary are selected."""
+
+ PRIORITY = "priority"
+ FIRST = "first"
+ ERROR = "error"
+
+
+class UnicodeNormalization(StrEnum):
+ """Explicit Unicode matching-view normalization forms."""
+
+ NFC = "NFC"
+ NFD = "NFD"
+ NFKC = "NFKC"
+ NFKD = "NFKD"
+
+
+class LexiconBackend(StrEnum):
+ """Supported installed-package lexicon adapters."""
+
+ SPELLCHECKER = "pyspellchecker"
+ WORDFREQ = "wordfreq"
+
+
+class LexicalCandidateForm(StrEnum):
+ """Rendered candidates compared by a lexical rule."""
+
+ JOINED = "joined"
+ HYPHENATED = "hyphenated"
+ SPACED = "spaced"
+
+
+class LexicalAmbiguityPolicy(StrEnum):
+ """How a lexical rule handles candidates it cannot rank uniquely."""
+
+ KEEP = "keep"
+ ERROR = "error"
+
+
+@dataclass(frozen=True, slots=True)
+class ExactLineJoinRule:
+ """Replace exact logical-line suffix and prefix fragments."""
+
+ rule_id: str
+ left: str
+ right: str
+ replacement: str
+ separator: str = ""
+ blocks: frozenset[LineJoinBlock] = ALL_LINE_JOIN_BLOCKS
+ line_break: LineBreakPolicy = LineBreakPolicy.DELETE
+ priority: int = 0
+ case_sensitive: bool = True
+ normalization: UnicodeNormalization | None = None
+ require_word_boundaries: bool = True
+
+
+@dataclass(frozen=True, slots=True)
+class KeepLineJoinRule:
+ """Veto lower-ranked rules at one exact fragment boundary."""
+
+ rule_id: str
+ left: str
+ right: str
+ separator: str = ""
+ blocks: frozenset[LineJoinBlock] = ALL_LINE_JOIN_BLOCKS
+ priority: int = 0
+ case_sensitive: bool = True
+ normalization: UnicodeNormalization | None = None
+ require_word_boundaries: bool = True
+
+
+@dataclass(frozen=True, slots=True)
+class RegexLineJoinRule:
+ """Join named-regex suffix and prefix matches."""
+
+ rule_id: str
+ left_pattern: str
+ right_pattern: str
+ replacement: str
+ separator: str = ""
+ blocks: frozenset[LineJoinBlock] = ALL_LINE_JOIN_BLOCKS
+ line_break: LineBreakPolicy = LineBreakPolicy.DELETE
+ priority: int = 0
+ case_sensitive: bool = True
+ normalization: UnicodeNormalization | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class LineEndRegexRule:
+ """Replace a line boundary when the next logical line matches a regex."""
+
+ rule_id: str
+ right_pattern: str
+ separator: str = ""
+ blocks: frozenset[LineJoinBlock] = ALL_LINE_JOIN_BLOCKS
+ line_break: LineBreakPolicy = LineBreakPolicy.DELETE
+ priority: int = 0
+ case_sensitive: bool = True
+ normalization: UnicodeNormalization | None = None
+
+
+@dataclass(frozen=True, slots=True)
+class LexicalLineJoinRule:
+ """Select an explicitly listed join form using an installed local lexicon."""
+
+ rule_id: str
+ left_pattern: str
+ right_pattern: str
+ separator: str
+ backend: LexiconBackend
+ language: str
+ candidate_forms: tuple[LexicalCandidateForm, ...]
+ minimum_score: float
+ minimum_score_margin: float = 0.0
+ hyphenation_language: str | None = None
+ ambiguity: LexicalAmbiguityPolicy = LexicalAmbiguityPolicy.KEEP
+ blocks: frozenset[LineJoinBlock] = ALL_LINE_JOIN_BLOCKS
+ priority: int = 0
+ case_sensitive: bool = True
+ normalization: UnicodeNormalization | None = None
+
+
+LineJoinRule: TypeAlias = (
+ ExactLineJoinRule | KeepLineJoinRule | RegexLineJoinRule | LineEndRegexRule | LexicalLineJoinRule
+)
_APPLICABILITY = (
- "处理调用方显式提供的左右片段映射;片段必须位于相邻物理行或只隔一个空行,并满足 ASCII 词边界;"
- "不猜测未配置词语或段落结构。"
+ "按调用方显式规则合并段落、标题、列表项或引用中的跨行片段;可选本地词典只比较调用方列出的候选;"
+ "代码、表格、未知结构、混合候选行尾和歧义默认保持原文。"
+)
+_NAMED_REFERENCE = re.compile(r"\\g<(?P[A-Za-z_][A-Za-z0-9_]*)>")
+_ATX_HEADING = re.compile(r"^(?P {0,3}#{1,6}[ \t]+)(?P.*)$")
+_LIST_ITEM = re.compile(r"^(?P {0,3})(?P(?:[-+*]|[0-9]{1,9}[.)]))(?P[ \t]{1,4})(?P.*)$")
+_FENCE_OPEN = re.compile(r"^ {0,3}(?P`{3,}|~{3,})")
+_HTML_TABLE_OPEN = re.compile(r")", re.IGNORECASE)
+_HTML_TABLE_CLOSE = re.compile(r" ", re.IGNORECASE)
+_TABLE_DELIMITER_CELL = re.compile(r"^:?-{3,}:?$")
+_MARKER_LIKE = re.compile(
+ r"^(?: {0,3}(?:#{1,6}(?:\S|$)|[-+*](?:\S|$)|[0-9]{1,9}[.)](?:\S|$)|`{3,}|~{3,}))"
)
-def _is_ascii_letter(character: str) -> bool:
- return character in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
+@dataclass(frozen=True, slots=True)
+class _TextView:
+ text: str
+ source: str
+ source_boundaries: tuple[int | None, ...]
+
+ def source_span(self, start: int, end: int) -> tuple[int, int] | None:
+ if start < 0 or end < start or end >= len(self.source_boundaries):
+ return None
+ source_start = self.source_boundaries[start]
+ source_end = self.source_boundaries[end]
+ if source_start is None or source_end is None:
+ return None
+ return source_start, source_end
-def _validated_mappings(mappings: Iterable[LineJoinMapping]) -> tuple[LineJoinMapping, ...]:
- normalized: list[LineJoinMapping] = []
- seen_pairs: set[tuple[str, str]] = set()
+@dataclass(frozen=True, slots=True)
+class _ScannedLine:
+ physical_index: int
+ physical: PhysicalLine
+ logical_start: int
+ logical_end: int
+ prefix: str
+ block: LineJoinBlock | None
+ scope: tuple[str, int] | None
+
+ def logical_content(self, markdown: str) -> str:
+ return markdown[self.logical_start : self.logical_end]
+
+
+@dataclass(frozen=True, slots=True)
+class _ListContext:
+ quote_depth: int
+ marker_indent: int
+ content_indent: int
+ scope_id: int
+
+
+@dataclass(frozen=True, slots=True)
+class _Boundary:
+ raw: str
+ line_ending: str
+ right_prefix: str
+ blank_lines: int
+
+
+@dataclass(frozen=True, slots=True)
+class _LexicalEvidence:
+ admission_score: float
+ comparable_score: float | None
+
+
+class _LexiconAdapter(Protocol):
+ package_version: str
+
+ def evidence(
+ self,
+ candidate: str,
+ form: LexicalCandidateForm,
+ left: str,
+ right: str,
+ ) -> _LexicalEvidence | None: ...
+
+
+class _Hyphenator(Protocol):
+ def positions(self, word: str) -> Sequence[int]: ...
+
+
+@dataclass(frozen=True, slots=True)
+class _PreparedRule:
+ rule: LineJoinRule
+ order: int
+ left_regex: re.Pattern[str] | None = None
+ right_regex: re.Pattern[str] | None = None
+ lexicon: _LexiconAdapter | None = None
+ hyphenator: _Hyphenator | None = None
+ package_versions: tuple[tuple[str, str], ...] = ()
+
+
+@dataclass(frozen=True, slots=True)
+class _RuleMatch:
+ rule_id: str
+ order: int
+ priority: int
+ left_start: int
+ right_end: int
+ replacement: str
+ line_break: LineBreakPolicy
+ keep: bool = False
+
+
+@dataclass(slots=True)
+class _ChainState:
+ start: int
+ end: int
+ rendered: str
+ tail: str
+ last_line: _ScannedLine
+ rule_ids: list[str]
+ changed: bool = False
+ line_ending: str | None = None
+
+
+class _SpellcheckerAdapter:
+ def __init__(self, language: str, case_sensitive: bool) -> None:
+ if _SpellChecker is None:
+ raise ModifierContractError("pyspellchecker backend requires the 'lexical' extra")
+ self.package_version = _installed_version("pyspellchecker")
+ try:
+ self._checker = _SpellChecker(language=language, case_sensitive=case_sensitive)
+ except Exception as error:
+ raise ModifierContractError("pyspellchecker could not load the requested language") from error
+
+ def _single_score(self, token: str) -> float | None:
+ if token not in self._checker.known([token]):
+ return None
+ frequency = float(self._checker.word_usage_frequency(token))
+ if not math.isfinite(frequency) or frequency <= 0:
+ return None
+ return math.log10(frequency * 1_000_000_000)
+
+ def evidence(
+ self,
+ candidate: str,
+ form: LexicalCandidateForm,
+ left: str,
+ right: str,
+ ) -> _LexicalEvidence | None:
+ if form is not LexicalCandidateForm.SPACED:
+ direct_score = self._single_score(candidate)
+ if direct_score is not None:
+ return _LexicalEvidence(direct_score, direct_score)
+ if form is LexicalCandidateForm.JOINED:
+ return None
+ separator = " " if form is LexicalCandidateForm.SPACED else "-"
+ components = tuple(part for part in candidate.split(separator) if part)
+ if len(components) < 2:
+ components = (left, right)
+ component_scores = tuple(self._single_score(component) for component in components)
+ if any(score is None for score in component_scores):
+ return None
+ admission_score = min(score for score in component_scores if score is not None)
+ return _LexicalEvidence(admission_score, None)
+
+
+class _WordfreqAdapter:
+ def __init__(self, language: str) -> None:
+ if _zipf_frequency is None:
+ raise ModifierContractError("wordfreq backend requires the 'frequency' extra")
+ self.package_version = _installed_version("wordfreq")
+ self._language = language
+
+ def evidence(
+ self,
+ candidate: str,
+ form: LexicalCandidateForm,
+ left: str,
+ right: str,
+ ) -> _LexicalEvidence | None:
+ del form, left, right
+ assert _zipf_frequency is not None
+ try:
+ score = float(_zipf_frequency(candidate, self._language))
+ except Exception as error:
+ raise ModifierContractError("wordfreq could not score the requested language") from error
+ if not math.isfinite(score) or score <= 0:
+ return None
+ return _LexicalEvidence(score, score)
+
+
+def _installed_version(distribution: str) -> str:
try:
- iterator = iter(mappings)
+ return package_version(distribution)
+ except PackageNotFoundError as error:
+ raise ModifierContractError(f"installed optional package has no version metadata: {distribution}") from error
+
+
+def _transform_text(text: str, normalization: UnicodeNormalization | None, case_sensitive: bool) -> str:
+ transformed = unicodedata.normalize(normalization.value, text) if normalization is not None else text
+ return transformed if case_sensitive else transformed.casefold()
+
+
+def _text_view(text: str, normalization: UnicodeNormalization | None, case_sensitive: bool) -> _TextView:
+ transformed = _transform_text(text, normalization, case_sensitive)
+ if normalization is None and case_sensitive:
+ return _TextView(transformed, text, tuple(range(len(text) + 1)))
+ boundary_candidates: list[list[int]] = [[] for _ in range(len(transformed) + 1)]
+ for source_index in range(len(text) + 1):
+ prefix = _transform_text(text[:source_index], normalization, case_sensitive)
+ if not transformed.startswith(prefix):
+ continue
+ boundary_candidates[len(prefix)].append(source_index)
+ boundary_map = tuple(candidates[0] if len(candidates) == 1 else None for candidates in boundary_candidates)
+ return _TextView(transformed, text, boundary_map)
+
+
+def _is_word_character(character: str) -> bool:
+ return character == "_" or character.isalnum()
+
+
+def _word_boundaries_hold(left_text: str, left_start: int, right_text: str, right_end: int) -> bool:
+ return not (
+ (left_start > 0 and _is_word_character(left_text[left_start - 1]))
+ or (right_end < len(right_text) and _is_word_character(right_text[right_end]))
+ )
+
+
+def _quote_prefix(text: str) -> tuple[int, int]:
+ position = 0
+ depth = 0
+ while True:
+ marker_start = position
+ spaces = 0
+ while position < len(text) and text[position] == " " and spaces < 3:
+ position += 1
+ spaces += 1
+ if position >= len(text) or text[position] != ">":
+ position = marker_start
+ break
+ position += 1
+ depth += 1
+ if position < len(text) and text[position] in " \t":
+ position += 1
+ return position, depth
+
+
+def _table_delimiter(text: str) -> bool:
+ stripped = text.strip()
+ if not stripped:
+ return False
+ cells = stripped.strip("|").split("|")
+ return bool(cells) and all(_TABLE_DELIMITER_CELL.fullmatch(cell.strip()) is not None for cell in cells)
+
+
+def _outer_container_content(text: str) -> str:
+ quote_end, _ = _quote_prefix(text)
+ content = text[quote_end:]
+ list_match = _LIST_ITEM.match(content)
+ return list_match.group("content") if list_match is not None else content
+
+
+def _structural_exclusions(lines: tuple[PhysicalLine, ...], markdown: str) -> frozenset[int]:
+ excluded: set[int] = set()
+ fence_character: str | None = None
+ fence_length = 0
+ html_table = False
+ for index, line in enumerate(lines):
+ raw = line.content(markdown)
+ content = _outer_container_content(raw)
+ if fence_character is not None:
+ excluded.add(index)
+ closing = re.match(rf"^ {{0,3}}{re.escape(fence_character)}{{{fence_length},}}[ \t]*$", content)
+ if closing is not None:
+ fence_character = None
+ fence_length = 0
+ continue
+ opening = _FENCE_OPEN.match(content)
+ if opening is not None:
+ fence = opening.group("fence")
+ fence_character = fence[0]
+ fence_length = len(fence)
+ excluded.add(index)
+ continue
+ if html_table:
+ excluded.add(index)
+ if _HTML_TABLE_CLOSE.search(content) is not None:
+ html_table = False
+ continue
+ if _HTML_TABLE_OPEN.search(content) is not None:
+ excluded.add(index)
+ if _HTML_TABLE_CLOSE.search(content) is None:
+ html_table = True
+
+ for index, line in enumerate(lines):
+ if index in excluded:
+ continue
+ raw = line.content(markdown)
+ content = _outer_container_content(raw)
+ if not _table_delimiter(content) or index == 0:
+ continue
+ previous = lines[index - 1].content(markdown)
+ previous_content = _outer_container_content(previous)
+ if "|" not in content and "|" not in previous_content:
+ continue
+ excluded.update((index - 1, index))
+ following = index + 1
+ while following < len(lines):
+ following_text = lines[following].content(markdown)
+ if not following_text.strip() or "|" not in following_text:
+ break
+ excluded.add(following)
+ following += 1
+ return frozenset(excluded)
+
+
+def _unknown_line(index: int, line: PhysicalLine) -> _ScannedLine:
+ return _ScannedLine(index, line, line.content_start, line.content_end, "", None, None)
+
+
+def _scan_lines(markdown: str) -> tuple[_ScannedLine, ...]:
+ lines = physical_lines(markdown)
+ excluded = _structural_exclusions(lines, markdown)
+ scanned: list[_ScannedLine] = []
+ list_stack: list[_ListContext] = []
+ active_heading: tuple[int, int] | None = None
+ next_scope = 0
+ blank_run = 0
+ for index, line in enumerate(lines):
+ raw = line.content(markdown)
+ if line.is_blank(markdown):
+ blank_run += 1
+ active_heading = None
+ if blank_run > 1:
+ list_stack.clear()
+ continue
+ if index in excluded:
+ scanned.append(_unknown_line(index, line))
+ list_stack.clear()
+ active_heading = None
+ blank_run = 0
+ continue
+
+ quote_end, quote_depth = _quote_prefix(raw)
+ quote_prefix = raw[:quote_end]
+ content = raw[quote_end:]
+ list_match = _LIST_ITEM.match(content)
+ if list_match is not None:
+ marker_indent = len(list_match.group("indent"))
+ content_indent = list_match.start("content")
+ while list_stack and (
+ list_stack[-1].quote_depth != quote_depth or marker_indent <= list_stack[-1].marker_indent
+ ):
+ list_stack.pop()
+ if list_stack and marker_indent < list_stack[-1].content_indent:
+ scanned.append(_unknown_line(index, line))
+ list_stack.clear()
+ active_heading = None
+ blank_run = 0
+ continue
+ next_scope += 1
+ context = _ListContext(quote_depth, marker_indent, content_indent, next_scope)
+ list_stack.append(context)
+ logical_start = line.content_start + quote_end + content_indent
+ scanned.append(
+ _ScannedLine(
+ index,
+ line,
+ logical_start,
+ line.content_end,
+ markdown[line.content_start:logical_start],
+ LineJoinBlock.LIST_ITEM,
+ (LineJoinBlock.LIST_ITEM.value, context.scope_id),
+ )
+ )
+ active_heading = None
+ blank_run = 0
+ continue
+
+ leading_spaces = len(content) - len(content.lstrip(" "))
+ continuation = next(
+ (
+ context
+ for context in reversed(list_stack)
+ if context.quote_depth == quote_depth and leading_spaces >= context.content_indent
+ ),
+ None,
+ )
+ if continuation is not None:
+ while list_stack and list_stack[-1] != continuation:
+ list_stack.pop()
+ logical_start = line.content_start + quote_end + leading_spaces
+ scanned.append(
+ _ScannedLine(
+ index,
+ line,
+ logical_start,
+ line.content_end,
+ markdown[line.content_start:logical_start],
+ LineJoinBlock.LIST_ITEM,
+ (LineJoinBlock.LIST_ITEM.value, continuation.scope_id),
+ )
+ )
+ active_heading = None
+ blank_run = 0
+ continue
+ if list_stack:
+ scanned.append(_unknown_line(index, line))
+ list_stack.clear()
+ active_heading = None
+ blank_run = 0
+ continue
+
+ if content.startswith("\t") or content.startswith(" "):
+ scanned.append(_unknown_line(index, line))
+ active_heading = None
+ blank_run = 0
+ continue
+
+ heading_match = _ATX_HEADING.match(content)
+ if heading_match is not None:
+ next_scope += 1
+ active_heading = (quote_depth, next_scope)
+ logical_start = line.content_start + quote_end + heading_match.end("prefix")
+ scanned.append(
+ _ScannedLine(
+ index,
+ line,
+ logical_start,
+ line.content_end,
+ markdown[line.content_start:logical_start],
+ LineJoinBlock.HEADING,
+ (LineJoinBlock.HEADING.value, next_scope),
+ )
+ )
+ blank_run = 0
+ continue
+ if (
+ active_heading is not None
+ and active_heading[0] == quote_depth
+ and blank_run == 0
+ and not _MARKER_LIKE.match(content)
+ ):
+ scope_id = active_heading[1]
+ logical_start = line.content_start + quote_end
+ scanned.append(
+ _ScannedLine(
+ index,
+ line,
+ logical_start,
+ line.content_end,
+ quote_prefix,
+ LineJoinBlock.HEADING,
+ (LineJoinBlock.HEADING.value, scope_id),
+ )
+ )
+ blank_run = 0
+ continue
+
+ active_heading = None
+ if _MARKER_LIKE.match(content):
+ scanned.append(_unknown_line(index, line))
+ elif quote_depth > 0:
+ logical_start = line.content_start + quote_end
+ scanned.append(
+ _ScannedLine(
+ index,
+ line,
+ logical_start,
+ line.content_end,
+ quote_prefix,
+ LineJoinBlock.BLOCK_QUOTE,
+ (LineJoinBlock.BLOCK_QUOTE.value, quote_depth),
+ )
+ )
+ elif raw.startswith("\t") or raw.startswith(" ") or _MARKER_LIKE.match(raw):
+ scanned.append(_unknown_line(index, line))
+ else:
+ scanned.append(
+ _ScannedLine(
+ index,
+ line,
+ line.content_start,
+ line.content_end,
+ "",
+ LineJoinBlock.PARAGRAPH,
+ (LineJoinBlock.PARAGRAPH.value, 0),
+ )
+ )
+ blank_run = 0
+ return tuple(scanned)
+
+
+def _validate_common_rule(rule: LineJoinRule) -> None:
+ if not isinstance(rule.rule_id, str) or not rule.rule_id.strip():
+ raise ModifierContractError("line join rule_id must be a non-empty string")
+ if type(rule.priority) is not int:
+ raise ModifierContractError("line join rule priority must be an integer")
+ if type(rule.case_sensitive) is not bool:
+ raise ModifierContractError("line join case_sensitive must be a boolean")
+ if rule.normalization is not None and not isinstance(rule.normalization, UnicodeNormalization):
+ raise ModifierContractError("line join normalization must be a UnicodeNormalization value")
+ if not isinstance(rule.blocks, frozenset) or any(not isinstance(block, LineJoinBlock) for block in rule.blocks):
+ raise ModifierContractError("line join blocks must be a frozenset of LineJoinBlock values")
+ if not isinstance(rule.separator, str) or "\r" in rule.separator or "\n" in rule.separator:
+ raise ModifierContractError("line join separator must be a string without line endings")
+
+
+def _validate_literal(value: object, field_name: str, *, allow_empty: bool = False) -> str:
+ if not isinstance(value, str) or (not allow_empty and not value):
+ qualifier = "a string" if allow_empty else "a non-empty string"
+ raise ModifierContractError(f"{field_name} must be {qualifier}")
+ if "\r" in value or "\n" in value:
+ raise ModifierContractError(f"{field_name} cannot contain line endings")
+ return value
+
+
+def _compile_pattern(pattern: object, field_name: str, *, case_sensitive: bool = True) -> re.Pattern[str]:
+ validated = _validate_literal(pattern, field_name)
+ try:
+ compiled = re.compile(validated, 0 if case_sensitive else re.IGNORECASE)
+ except re.error as error:
+ raise ModifierContractError(f"{field_name} must be a valid regular expression") from error
+ empty_match = compiled.search("")
+ if empty_match is not None and empty_match.start() == empty_match.end():
+ raise ModifierContractError(f"{field_name} must consume text")
+ return compiled
+
+
+def _validate_replacement(replacement: object, group_names: frozenset[str]) -> str:
+ validated = _validate_literal(replacement, "line join regex replacement")
+ references = tuple(_NAMED_REFERENCE.finditer(validated))
+ without_references = _NAMED_REFERENCE.sub("", validated)
+ if "\\" in without_references:
+ raise ModifierContractError("line join regex replacement supports only named backreferences")
+ if any(match.group("name") not in group_names for match in references):
+ raise ModifierContractError("line join regex replacement references an unknown named group")
+ return validated
+
+
+def _validate_rule(rule: LineJoinRule) -> tuple[re.Pattern[str] | None, re.Pattern[str] | None]:
+ _validate_common_rule(rule)
+ if isinstance(rule, (ExactLineJoinRule, KeepLineJoinRule)):
+ _validate_literal(rule.left, "line join left fragment")
+ _validate_literal(rule.right, "line join right fragment")
+ if isinstance(rule, ExactLineJoinRule):
+ _validate_literal(rule.replacement, "line join replacement")
+ if not isinstance(rule.line_break, LineBreakPolicy):
+ raise ModifierContractError("line join line_break must be a LineBreakPolicy value")
+ if type(rule.require_word_boundaries) is not bool:
+ raise ModifierContractError("line join require_word_boundaries must be a boolean")
+ return None, None
+ if isinstance(rule, RegexLineJoinRule):
+ if not isinstance(rule.line_break, LineBreakPolicy):
+ raise ModifierContractError("line join line_break must be a LineBreakPolicy value")
+ left = _compile_pattern(rule.left_pattern, "line join left_pattern", case_sensitive=rule.case_sensitive)
+ right = _compile_pattern(rule.right_pattern, "line join right_pattern", case_sensitive=rule.case_sensitive)
+ if left.groups != len(left.groupindex) or right.groups != len(right.groupindex):
+ raise ModifierContractError("line join regex capture groups must be named")
+ left_names = frozenset(left.groupindex)
+ right_names = frozenset(right.groupindex)
+ if left_names & right_names:
+ raise ModifierContractError("line join regex group names must be unique across both sides")
+ _validate_replacement(rule.replacement, left_names | right_names)
+ return left, right
+ if isinstance(rule, LineEndRegexRule):
+ if not isinstance(rule.line_break, LineBreakPolicy) or rule.line_break is LineBreakPolicy.PRESERVE:
+ raise ModifierContractError("line-end regex line_break must be DELETE, SPACE, or PARAGRAPH")
+ right = _compile_pattern(rule.right_pattern, "line join right_pattern", case_sensitive=rule.case_sensitive)
+ if right.groups != len(right.groupindex):
+ raise ModifierContractError("line join regex capture groups must be named")
+ return None, right
+
+ if not isinstance(rule.backend, LexiconBackend):
+ raise ModifierContractError("line join backend must be a LexiconBackend value")
+ if not isinstance(rule.language, str) or not rule.language.strip():
+ raise ModifierContractError("line join lexical language must be a non-empty string")
+ if rule.separator not in {"", "-"}:
+ raise ModifierContractError("line join lexical separator must be '-' or empty")
+ if not isinstance(rule.candidate_forms, tuple) or any(
+ not isinstance(form, LexicalCandidateForm) for form in rule.candidate_forms
+ ):
+ raise ModifierContractError("line join candidate_forms must be a tuple of LexicalCandidateForm values")
+ if len(set(rule.candidate_forms)) != len(rule.candidate_forms):
+ raise ModifierContractError("line join candidate_forms cannot repeat")
+ required_source = LexicalCandidateForm.HYPHENATED if rule.separator == "-" else LexicalCandidateForm.SPACED
+ if LexicalCandidateForm.JOINED not in rule.candidate_forms or required_source not in rule.candidate_forms:
+ raise ModifierContractError("line join lexical candidates must include JOINED and the source form")
+ for value, field_name in (
+ (rule.minimum_score, "minimum_score"),
+ (rule.minimum_score_margin, "minimum_score_margin"),
+ ):
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(value) or value < 0:
+ raise ModifierContractError(f"line join {field_name} must be a finite non-negative number")
+ if rule.hyphenation_language is not None and (
+ not isinstance(rule.hyphenation_language, str) or not rule.hyphenation_language.strip()
+ ):
+ raise ModifierContractError("line join hyphenation_language must be a non-empty string or None")
+ if not isinstance(rule.ambiguity, LexicalAmbiguityPolicy):
+ raise ModifierContractError("line join ambiguity must be a LexicalAmbiguityPolicy value")
+ return (
+ _compile_pattern(rule.left_pattern, "line join left_pattern", case_sensitive=rule.case_sensitive),
+ _compile_pattern(rule.right_pattern, "line join right_pattern", case_sensitive=rule.case_sensitive),
+ )
+
+
+def _legacy_rule(mapping: object, order: int) -> ExactLineJoinRule:
+ if not isinstance(mapping, tuple) or len(mapping) != 3 or any(not isinstance(value, str) for value in mapping):
+ raise ModifierContractError("line join mappings must be three-string tuples or rule values")
+ left, right, replacement = mapping
+ if not left or not right or not replacement:
+ raise ModifierContractError("legacy line join mapping fields must be non-empty")
+ if any(character.isspace() for value in mapping for character in value):
+ raise ModifierContractError("legacy line join mapping fields cannot contain whitespace")
+ return ExactLineJoinRule(rule_id=f"legacy.{order:04d}", left=left, right=right, replacement=replacement)
+
+
+def _build_lexicon(rule: LexicalLineJoinRule) -> _LexiconAdapter:
+ if rule.backend is LexiconBackend.SPELLCHECKER:
+ return _SpellcheckerAdapter(rule.language, rule.case_sensitive)
+ return _WordfreqAdapter(rule.language)
+
+
+def _build_hyphenator(language: str) -> tuple[_Hyphenator, str]:
+ if _Pyphen is None:
+ raise ModifierContractError("Pyphen validation requires the 'lexical' extra")
+ pyphen_version = _installed_version("pyphen")
+ try:
+ return _Pyphen(lang=language), pyphen_version
+ except Exception as error:
+ raise ModifierContractError("Pyphen could not load the requested language") from error
+
+
+def _prepared_rules(items: Iterable[LineJoinMapping | LineJoinRule]) -> tuple[_PreparedRule, ...]:
+ try:
+ iterator = iter(items)
except TypeError as error:
raise ModifierContractError("line join mappings must be iterable") from error
- for mapping in iterator:
- if not isinstance(mapping, tuple) or len(mapping) != 3 or any(not isinstance(value, str) for value in mapping):
- raise ModifierContractError("line join mappings must be three-string tuples")
- left, right, replacement = mapping
- if not left or not right or not replacement:
- raise ModifierContractError("line join mapping fields must be non-empty")
- if any(character.isspace() for value in mapping for character in value):
- raise ModifierContractError("line join mapping fields cannot contain whitespace")
- pair = (left, right)
- if pair in seen_pairs:
- raise ModifierContractError("line join mappings cannot repeat a fragment pair")
- seen_pairs.add(pair)
- normalized.append(mapping)
- return tuple(sorted(normalized))
-
-
-def mapped_line_join(mappings: Iterable[LineJoinMapping]) -> Modifier:
- """Create a modifier from exact left-fragment, right-fragment, replacement mappings."""
- normalized_mappings = _validated_mappings(mappings)
-
- def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
- lines = physical_lines(snapshot.markdown)
- matches: list[tuple[int, int, str, str, str]] = []
- for index, left_line in enumerate(lines[:-1]):
- left_text = left_line.content(snapshot.markdown)
- left_ending = left_line.line_ending(snapshot.markdown)
- if not left_ending:
- continue
- for left, right, replacement in normalized_mappings:
- if not left_text.endswith(left):
- continue
- left_start = left_line.content_end - len(left)
- if left_start > left_line.content_start and _is_ascii_letter(snapshot.markdown[left_start - 1]):
- continue
-
- right_index = index + 1
- if lines[right_index].is_blank(snapshot.markdown):
- blank = lines[right_index]
- if blank.line_ending(snapshot.markdown) != left_ending or right_index + 1 >= len(lines):
- continue
- right_index += 1
- if lines[right_index].is_blank(snapshot.markdown):
- continue
- right_line = lines[right_index]
- right_text = right_line.content(snapshot.markdown)
- if not right_text.startswith(right):
- continue
- if len(right_text) > len(right) and _is_ascii_letter(right_text[len(right)]):
- continue
- end = right_line.content_start + len(right)
- matches.append((left_start, end, replacement, left, right))
-
- matches.sort(key=lambda item: (item[0], item[1], item[2]))
- span_counts = Counter(match[:2] for match in matches)
- unique_matches = [match for match in matches if span_counts[match[:2]] == 1]
- if any(previous[1] > current[0] for previous, current in pairwise(unique_matches)):
- return ()
-
- return tuple(
- ProposedChange(
- snapshot_sha256=snapshot.sha256,
- reason=f"按显式映射合并跨行片段:{left} + {right} → {replacement}",
- edits=(
- TextEdit(
- snapshot_sha256=snapshot.sha256,
- span=TextSpan(start, end),
- expected_text=snapshot.markdown[start:end],
- replacement=replacement,
- ),
- ),
+ prepared: list[_PreparedRule] = []
+ seen_ids: set[str] = set()
+ seen_legacy_pairs: set[tuple[str, str]] = set()
+ for order, item in enumerate(iterator):
+ if isinstance(
+ item,
+ (ExactLineJoinRule, KeepLineJoinRule, RegexLineJoinRule, LineEndRegexRule, LexicalLineJoinRule),
+ ):
+ rule = item
+ is_public_rule = True
+ else:
+ rule = _legacy_rule(item, order)
+ is_public_rule = False
+ if rule.rule_id in seen_ids:
+ raise ModifierContractError("line join rule_id values must be unique")
+ seen_ids.add(rule.rule_id)
+ if not is_public_rule:
+ assert isinstance(rule, ExactLineJoinRule)
+ pair = (rule.left, rule.right)
+ if pair in seen_legacy_pairs:
+ raise ModifierContractError("legacy line join mappings cannot repeat a fragment pair")
+ seen_legacy_pairs.add(pair)
+ left_regex, right_regex = _validate_rule(rule)
+ lexicon: _LexiconAdapter | None = None
+ hyphenator: _Hyphenator | None = None
+ versions: list[tuple[str, str]] = []
+ if isinstance(rule, LexicalLineJoinRule):
+ lexicon = _build_lexicon(rule)
+ versions.append((rule.backend.value, lexicon.package_version))
+ if rule.hyphenation_language is not None:
+ hyphenator, pyphen_version = _build_hyphenator(rule.hyphenation_language)
+ versions.append(("pyphen", pyphen_version))
+ prepared.append(
+ _PreparedRule(
+ rule=rule,
+ order=order,
+ left_regex=left_regex,
+ right_regex=right_regex,
+ lexicon=lexicon,
+ hyphenator=hyphenator,
+ package_versions=tuple(versions),
)
- for start, end, replacement, left, right in unique_matches
+ )
+ return tuple(prepared)
+
+
+def _literal_span(
+ text: str,
+ literal: str,
+ *,
+ suffix: bool,
+ normalization: UnicodeNormalization | None,
+ case_sensitive: bool,
+) -> tuple[int, int] | None:
+ view = _text_view(text, normalization, case_sensitive)
+ expected = _transform_text(literal, normalization, case_sensitive)
+ if suffix:
+ if not view.text.endswith(expected):
+ return None
+ view_start, view_end = len(view.text) - len(expected), len(view.text)
+ else:
+ if not view.text.startswith(expected):
+ return None
+ view_start, view_end = 0, len(expected)
+ return view.source_span(view_start, view_end)
+
+
+def _regex_match(
+ compiled: re.Pattern[str],
+ text: str,
+ *,
+ suffix: bool,
+ normalization: UnicodeNormalization | None,
+) -> tuple[tuple[int, int], dict[str, str]] | None:
+ view = _text_view(text, normalization, True)
+ if suffix:
+ match = next(
+ (
+ candidate
+ for start in range(len(view.text))
+ if (candidate := compiled.match(view.text, start)) is not None
+ and candidate.end() == len(view.text)
+ and candidate.start() != candidate.end()
+ ),
+ None,
+ )
+ else:
+ candidate = compiled.match(view.text)
+ match = candidate if candidate is not None and candidate.start() != candidate.end() else None
+ if match is None or match.start() == match.end():
+ return None
+ source_span = view.source_span(match.start(), match.end())
+ if source_span is None:
+ return None
+ captures: dict[str, str] = {}
+ for name in compiled.groupindex:
+ capture_start, capture_end = match.span(name)
+ if capture_start < 0:
+ captures[name] = ""
+ continue
+ capture_span = view.source_span(capture_start, capture_end)
+ if capture_span is None:
+ return None
+ captures[name] = text[capture_span[0] : capture_span[1]]
+ return source_span, captures
+
+
+def _expand_replacement(replacement: str, captures: dict[str, str]) -> str:
+ return _NAMED_REFERENCE.sub(lambda match: captures[match.group("name")], replacement)
+
+
+def _lexical_candidate(
+ prepared: _PreparedRule,
+ rule: LexicalLineJoinRule,
+ left: str,
+ right: str,
+ position: int,
+) -> str | None:
+ assert prepared.lexicon is not None
+ rendered = {
+ LexicalCandidateForm.JOINED: left + right,
+ LexicalCandidateForm.HYPHENATED: left + "-" + right,
+ LexicalCandidateForm.SPACED: left + " " + right,
+ }
+ survivors: list[tuple[int, str, _LexicalEvidence]] = []
+ for order, form in enumerate(rule.candidate_forms):
+ candidate = rendered[form]
+ query = _transform_text(candidate, rule.normalization, rule.case_sensitive)
+ evidence = prepared.lexicon.evidence(query, form, left, right)
+ if evidence is None or evidence.admission_score < rule.minimum_score:
+ continue
+ if form is LexicalCandidateForm.JOINED and prepared.hyphenator is not None:
+ joined_query = _transform_text(left + right, rule.normalization, rule.case_sensitive)
+ left_query = _transform_text(left, rule.normalization, rule.case_sensitive)
+ if len(left_query) not in prepared.hyphenator.positions(joined_query):
+ continue
+ survivors.append((order, candidate, evidence))
+ if not survivors:
+ return None
+ if len(survivors) == 1:
+ return survivors[0][1]
+ if all(candidate[2].comparable_score is not None for candidate in survivors):
+ def ranking_key(candidate: tuple[int, str, _LexicalEvidence]) -> tuple[float, int]:
+ score = candidate[2].comparable_score
+ assert score is not None
+ return -score, candidate[0]
+
+ ranked = sorted(survivors, key=ranking_key)
+ top_score = ranked[0][2].comparable_score
+ second_score = ranked[1][2].comparable_score
+ assert top_score is not None and second_score is not None
+ if top_score > second_score and top_score - second_score >= rule.minimum_score_margin:
+ return ranked[0][1]
+ if rule.ambiguity is LexicalAmbiguityPolicy.ERROR:
+ raise ModifierContractError(f"ambiguous lexical line join at position {position}: {rule.rule_id}")
+ return None
+
+
+def _match_rule(
+ prepared: _PreparedRule,
+ left_text: str,
+ right_text: str,
+ block: LineJoinBlock,
+ position: int,
+) -> _RuleMatch | None:
+ rule = prepared.rule
+ if block not in rule.blocks or not left_text.endswith(rule.separator):
+ return None
+ content_without_separator = left_text[: len(left_text) - len(rule.separator)] if rule.separator else left_text
+ if isinstance(rule, (ExactLineJoinRule, KeepLineJoinRule)):
+ left_span = _literal_span(
+ content_without_separator,
+ rule.left,
+ suffix=True,
+ normalization=rule.normalization,
+ case_sensitive=rule.case_sensitive,
+ )
+ right_span = _literal_span(
+ right_text,
+ rule.right,
+ suffix=False,
+ normalization=rule.normalization,
+ case_sensitive=rule.case_sensitive,
+ )
+ if left_span is None or right_span is None:
+ return None
+ if rule.require_word_boundaries and not _word_boundaries_hold(
+ content_without_separator, left_span[0], right_text, right_span[1]
+ ):
+ return None
+ return _RuleMatch(
+ rule_id=rule.rule_id,
+ order=prepared.order,
+ priority=rule.priority,
+ left_start=left_span[0],
+ right_end=right_span[1],
+ replacement=rule.replacement if isinstance(rule, ExactLineJoinRule) else "",
+ line_break=rule.line_break if isinstance(rule, ExactLineJoinRule) else LineBreakPolicy.PRESERVE,
+ keep=isinstance(rule, KeepLineJoinRule),
+ )
+ if isinstance(rule, RegexLineJoinRule):
+ assert prepared.left_regex is not None and prepared.right_regex is not None
+ left_match = _regex_match(
+ prepared.left_regex,
+ content_without_separator,
+ suffix=True,
+ normalization=rule.normalization,
+ )
+ right_match = _regex_match(
+ prepared.right_regex,
+ right_text,
+ suffix=False,
+ normalization=rule.normalization,
+ )
+ if left_match is None or right_match is None:
+ return None
+ captures = left_match[1] | right_match[1]
+ return _RuleMatch(
+ rule.rule_id,
+ prepared.order,
+ rule.priority,
+ left_match[0][0],
+ right_match[0][1],
+ _expand_replacement(rule.replacement, captures),
+ rule.line_break,
+ )
+ if isinstance(rule, LineEndRegexRule):
+ assert prepared.right_regex is not None
+ right_match = _regex_match(
+ prepared.right_regex,
+ right_text,
+ suffix=False,
+ normalization=rule.normalization,
+ )
+ if right_match is None:
+ return None
+ return _RuleMatch(
+ rule.rule_id,
+ prepared.order,
+ rule.priority,
+ len(content_without_separator),
+ 0,
+ "",
+ rule.line_break,
)
- records = tuple(
- {
- "left_fragment": left,
- "replacement": replacement,
- "right_fragment": right,
- }
- for left, right, replacement in normalized_mappings
+ assert prepared.left_regex is not None and prepared.right_regex is not None
+ left_match = _regex_match(
+ prepared.left_regex,
+ content_without_separator,
+ suffix=True,
+ normalization=rule.normalization,
)
+ right_match = _regex_match(
+ prepared.right_regex,
+ right_text,
+ suffix=False,
+ normalization=rule.normalization,
+ )
+ if left_match is None or right_match is None:
+ return None
+ left = content_without_separator[left_match[0][0] : left_match[0][1]]
+ right = right_text[right_match[0][0] : right_match[0][1]]
+ replacement = _lexical_candidate(prepared, rule, left, right, position)
+ if replacement is None:
+ return None
+ return _RuleMatch(
+ rule.rule_id,
+ prepared.order,
+ rule.priority,
+ left_match[0][0],
+ right_match[0][1],
+ replacement,
+ LineBreakPolicy.DELETE,
+ )
+
+
+def _boundary_between(
+ markdown: str,
+ lines: tuple[PhysicalLine, ...],
+ left: _ScannedLine,
+ right: _ScannedLine,
+ max_blank_lines: int,
+) -> _Boundary | None:
+ 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):
+ return None
+ ending = left.physical.line_ending(markdown)
+ if not ending or any(line.line_ending(markdown) != ending for line in intervening):
+ return None
+ return _Boundary(
+ markdown[left.physical.content_end : right.logical_start],
+ ending,
+ right.prefix,
+ len(intervening),
+ )
+
+
+def _compatible_blocks(left: _ScannedLine, right: _ScannedLine) -> bool:
+ return left.block is not None and left.block is right.block and left.scope == right.scope
+
+
+def _select_match(
+ prepared_rules: tuple[_PreparedRule, ...],
+ left_text: str,
+ right_text: str,
+ block: LineJoinBlock,
+ policy: LineJoinConflictPolicy,
+ position: int,
+) -> _RuleMatch | None:
+ matches = tuple(
+ match
+ for prepared in prepared_rules
+ if (match := _match_rule(prepared, left_text, right_text, block, position)) is not None
+ )
+ if not matches:
+ return None
+ if policy is LineJoinConflictPolicy.ERROR and len(matches) > 1:
+ rule_ids = ", ".join(match.rule_id for match in matches)
+ raise ModifierContractError(f"conflicting line join rules at position {position}: {rule_ids}")
+ if policy is LineJoinConflictPolicy.FIRST:
+ return matches[0]
+ return min(matches, key=lambda match: (-match.priority, match.order))
+
+
+def _paragraph_boundary(block: LineJoinBlock, ending: str, right_prefix: str) -> str:
+ quote_end, quote_depth = _quote_prefix(right_prefix)
+ if block is LineJoinBlock.BLOCK_QUOTE or quote_depth > 0:
+ return ending + right_prefix[:quote_end].rstrip() + ending + right_prefix
+ return ending + ending + right_prefix
+
+
+def _render_boundary(policy: LineBreakPolicy, boundary: _Boundary, block: LineJoinBlock) -> str:
+ if policy is LineBreakPolicy.PRESERVE:
+ return boundary.raw
+ if policy is LineBreakPolicy.DELETE:
+ return ""
+ if policy is LineBreakPolicy.SPACE:
+ return " "
+ return _paragraph_boundary(block, boundary.line_ending, boundary.right_prefix)
+
+
+def _apply_match(
+ state: _ChainState,
+ match: _RuleMatch,
+ boundary: _Boundary,
+ right: _ScannedLine,
+ right_text: str,
+ block: LineJoinBlock,
+) -> None:
+ rendered_prefix = state.rendered[: -len(state.tail)] if state.tail else state.rendered
+ left_before = state.tail[: match.left_start]
+ right_after = right_text[match.right_end :]
+ rendered_boundary = _render_boundary(match.line_break, boundary, block)
+ state.rendered = rendered_prefix + left_before + match.replacement + rendered_boundary + right_after
+ if "\r" in rendered_boundary or "\n" in rendered_boundary:
+ state.tail = right_after
+ else:
+ state.tail = left_before + match.replacement + rendered_boundary + right_after
+ state.end = right.logical_end
+ state.last_line = right
+ state.rule_ids.append(match.rule_id)
+ state.changed = True
+ if state.line_ending is None:
+ state.line_ending = boundary.line_ending
+
+
+def _proposal(snapshot: DocumentSnapshot, state: _ChainState) -> ProposedChange:
+ expected = snapshot.markdown[state.start : state.end]
+ if expected == state.rendered:
+ raise ModifierContractError("line join planning produced an unchanged edit")
+ return ProposedChange(
+ snapshot_sha256=snapshot.sha256,
+ reason=f"按显式跨行规则合并片段:{', '.join(state.rule_ids)}",
+ edits=(
+ TextEdit(
+ snapshot_sha256=snapshot.sha256,
+ span=TextSpan(state.start, state.end),
+ expected_text=expected,
+ replacement=state.rendered,
+ ),
+ ),
+ )
+
+
+def _rule_record(prepared: _PreparedRule) -> dict[str, object]:
+ rule = prepared.rule
+ record: dict[str, object] = {
+ "blocks": tuple(sorted(block.value for block in rule.blocks)),
+ "case_sensitive": rule.case_sensitive,
+ "normalization": rule.normalization.value if rule.normalization is not None else None,
+ "order": prepared.order,
+ "priority": rule.priority,
+ "rule_id": rule.rule_id,
+ "rule_type": type(rule).__name__,
+ "separator": rule.separator,
+ }
+ if isinstance(rule, (ExactLineJoinRule, KeepLineJoinRule)):
+ record.update(
+ {
+ "left": rule.left,
+ "require_word_boundaries": rule.require_word_boundaries,
+ "right": rule.right,
+ }
+ )
+ if isinstance(rule, ExactLineJoinRule):
+ record.update({"line_break": rule.line_break.value, "replacement": rule.replacement})
+ elif isinstance(rule, RegexLineJoinRule):
+ record.update(
+ {
+ "left_pattern": rule.left_pattern,
+ "line_break": rule.line_break.value,
+ "replacement": rule.replacement,
+ "right_pattern": rule.right_pattern,
+ }
+ )
+ elif isinstance(rule, LineEndRegexRule):
+ record.update({"line_break": rule.line_break.value, "right_pattern": rule.right_pattern})
+ else:
+ record.update(
+ {
+ "ambiguity": rule.ambiguity.value,
+ "backend": rule.backend.value,
+ "candidate_forms": tuple(form.value for form in rule.candidate_forms),
+ "hyphenation_language": rule.hyphenation_language,
+ "language": rule.language,
+ "left_pattern": rule.left_pattern,
+ "minimum_score": float(rule.minimum_score),
+ "minimum_score_margin": float(rule.minimum_score_margin),
+ "package_versions": prepared.package_versions,
+ "right_pattern": rule.right_pattern,
+ }
+ )
+ return record
+
+
+def mapped_line_join(
+ mappings: Iterable[LineJoinMapping | LineJoinRule],
+ *,
+ conflict_policy: LineJoinConflictPolicy = LineJoinConflictPolicy.PRIORITY,
+ max_intervening_blank_lines: int = 1,
+) -> Modifier:
+ """Create a deterministic modifier from explicit cross-line join rules."""
+ if not isinstance(conflict_policy, LineJoinConflictPolicy):
+ raise ModifierContractError("conflict_policy must be a LineJoinConflictPolicy value")
+ if type(max_intervening_blank_lines) is not int or max_intervening_blank_lines not in {0, 1}:
+ raise ModifierContractError("max_intervening_blank_lines must be integer 0 or 1")
+ rules = _prepared_rules(mappings)
+
+ def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
+ markdown = snapshot.markdown
+ physical = physical_lines(markdown)
+ scanned = _scan_lines(markdown)
+ if len(scanned) < 2 or not rules:
+ return ()
+ proposals: list[ProposedChange] = []
+ first = scanned[0]
+ first_content = first.logical_content(markdown)
+ state = _ChainState(first.logical_start, first.logical_end, first_content, first_content, first, [])
+ for right in scanned[1:]:
+ boundary = _boundary_between(
+ markdown,
+ physical,
+ state.last_line,
+ right,
+ max_intervening_blank_lines,
+ )
+ selected: _RuleMatch | None = None
+ if boundary is not None and _compatible_blocks(state.last_line, right):
+ assert right.block is not None
+ selected = _select_match(
+ rules,
+ state.tail,
+ right.logical_content(markdown),
+ right.block,
+ conflict_policy,
+ state.last_line.physical.content_end,
+ )
+ if selected is not None and not selected.keep:
+ assert boundary is not None and right.block is not None
+ if state.line_ending is not None and state.line_ending != boundary.line_ending:
+ right_content = right.logical_content(markdown)
+ state = _ChainState(
+ right.logical_start,
+ right.logical_end,
+ right_content,
+ right_content,
+ right,
+ [],
+ )
+ continue
+ _apply_match(
+ state,
+ selected,
+ boundary,
+ right,
+ right.logical_content(markdown),
+ right.block,
+ )
+ continue
+ if state.changed:
+ proposals.append(_proposal(snapshot, state))
+ right_content = right.logical_content(markdown)
+ state = _ChainState(right.logical_start, right.logical_end, right_content, right_content, right, [])
+ if state.changed:
+ proposals.append(_proposal(snapshot, state))
+ if any(
+ previous.edits[0].span.end > current.edits[0].span.start
+ for previous, current in pairwise(proposals)
+ ):
+ raise ModifierContractError("line join planning produced overlapping edits")
+ return tuple(proposals)
+
return Modifier(
modifier_id="markdown.mapped_line_join",
- version="1.0.0",
- parameters={"mappings": records},
+ version="2.0.0",
+ parameters={
+ "conflict_policy": conflict_policy.value,
+ "max_intervening_blank_lines": max_intervening_blank_lines,
+ "rules": tuple(_rule_record(rule) for rule in rules),
+ },
applicability=_APPLICABILITY,
propose=propose,
)
diff --git a/tests/test_mapped_line_join.py b/tests/test_mapped_line_join.py
index 31b104e..24d916e 100644
--- a/tests/test_mapped_line_join.py
+++ b/tests/test_mapped_line_join.py
@@ -1,19 +1,62 @@
from __future__ import annotations
+from importlib import import_module
+from typing import TYPE_CHECKING, cast
+
import pytest
-from mdpolish import ModifierContractError, Pipeline, RunStatus
+from mdpolish import DocumentSnapshot, ModifierContractError, Pipeline, RunStatus
from mdpolish.modifiers import mapped_line_join
+from mdpolish.modifiers.mapped_line_join import (
+ ExactLineJoinRule,
+ KeepLineJoinRule,
+ LexicalAmbiguityPolicy,
+ LexicalCandidateForm,
+ LexicalLineJoinRule,
+ LexiconBackend,
+ LineBreakPolicy,
+ LineEndRegexRule,
+ LineJoinBlock,
+ LineJoinConflictPolicy,
+ LineJoinRule,
+ RegexLineJoinRule,
+ UnicodeNormalization,
+)
-MAPPINGS = (
+if TYPE_CHECKING:
+ from _pytest.monkeypatch import MonkeyPatch
+
+mapped_module = import_module("mdpolish.modifiers.mapped_line_join")
+
+LEGACY_MAPPINGS = (
("exam-", "ple", "example"),
("rule-", "based", "rule-based"),
("value.", "ues", "values"),
)
-def transform(markdown: str): # type: ignore[no-untyped-def]
- return Pipeline([mapped_line_join(MAPPINGS)]).transform(markdown)
+def transform(
+ markdown: str,
+ rules: tuple[tuple[str, str, str] | LineJoinRule, ...] = LEGACY_MAPPINGS,
+ **options: object,
+) -> str:
+ modifier = mapped_line_join(rules, **options) # type: ignore[arg-type]
+ result = Pipeline((modifier,)).transform(markdown)
+ assert result.status is RunStatus.SUCCESS
+ assert result.output_markdown is not None
+ return result.output_markdown
+
+
+def exact_rule(**overrides: object) -> ExactLineJoinRule:
+ fields: dict[str, object] = {
+ "rule_id": "example.join",
+ "left": "exam",
+ "right": "ple",
+ "replacement": "example",
+ "separator": "-",
+ }
+ fields.update(overrides)
+ return ExactLineJoinRule(**fields) # type: ignore[arg-type]
@pytest.mark.parametrize(
@@ -21,70 +64,493 @@ def transform(markdown: str): # type: ignore[no-untyped-def]
[
("an exam-\nple here", "an example here"),
("an exam-\n\nple here", "an example here"),
- ("a rule-\nbased method", "a rule-based method"),
- ("the value.\n\nues differ", "the values differ"),
("an exam-\r\n\r\nple here", "an example here"),
("an exam-\r\rple here", "an example here"),
+ ("a rule-\nbased method", "a rule-based method"),
+ ("the value.\nues differ", "the values differ"),
],
)
-def test_applies_exact_mapping_across_supported_line_shapes(markdown: str, expected: str) -> None:
- result = transform(markdown)
- assert result.status is RunStatus.SUCCESS
- assert result.output_markdown == expected
- assert len(result.changes) == 1
+def test_legacy_exact_mapping_supports_lf_crlf_cr_and_one_blank_line(markdown: str, expected: str) -> None:
+ assert transform(markdown) == expected
+
+
+def test_exact_rules_distinguish_explicit_and_empty_separator() -> None:
+ hyphenated = exact_rule()
+ unseparated = exact_rule(rule_id="example.unseparated", separator="")
+
+ assert transform("exam-\nple", (hyphenated,)) == "example"
+ assert transform("exam\nple", (unseparated,)) == "example"
+ assert transform("exam\nple", (hyphenated,)) == "exam\nple"
+
+
+def test_named_regex_combines_backreferences_from_both_sides() -> None:
+ rule = RegexLineJoinRule(
+ rule_id="regex.join",
+ left_pattern=r"(?P[A-Za-z]+)",
+ right_pattern=r"(?P[a-z]+)",
+ replacement=r"\g_\g",
+ separator="-",
+ )
+
+ assert transform("prefix exam-\nple suffix", (rule,)) == "prefix exam_ple suffix"
+
+
+def test_regex_keeps_its_inline_flags() -> None:
+ rule = RegexLineJoinRule(
+ rule_id="regex.flags",
+ left_pattern=r"(?i:(?Pexam))",
+ right_pattern=r"(?i:(?Pple))",
+ replacement=r"\g\g",
+ separator="-",
+ )
+
+ assert transform("EXAM-\nPLE", (rule,)) == "EXAMPLE"
+
+
+def test_regex_case_insensitive_option_applies_to_the_pattern() -> None:
+ rule = RegexLineJoinRule(
+ rule_id="regex.ignore-case",
+ left_pattern=r"(?PEXAM)",
+ right_pattern=r"(?PPLE)",
+ replacement=r"\g\g",
+ separator="-",
+ case_sensitive=False,
+ )
+
+ assert transform("exam-\nple", (rule,)) == "example"
+
+
+def test_regex_suffix_matching_considers_overlapping_starts() -> None:
+ rule = RegexLineJoinRule(
+ rule_id="regex.overlap",
+ left_pattern=r"(?Paba)",
+ right_pattern=r"(?Px)",
+ replacement=r"\g\g",
+ separator="-",
+ )
+
+ assert transform("ababa-\nx", (rule,)) == "ababax"
+
+
+def test_line_end_regex_uses_right_match_only_as_a_condition() -> None:
+ rule = LineEndRegexRule(
+ rule_id="line-end.space",
+ right_pattern=r"(?P[a-z])",
+ line_break=LineBreakPolicy.SPACE,
+ )
+
+ assert transform("foo\nbar", (rule,)) == "foo bar"
+
+
+@pytest.mark.parametrize(
+ ("policy", "expected"),
+ [
+ (LineBreakPolicy.PRESERVE, "example\n"),
+ (LineBreakPolicy.DELETE, "example"),
+ (LineBreakPolicy.SPACE, "example "),
+ (LineBreakPolicy.PARAGRAPH, "example\n\n"),
+ ],
+)
+def test_all_line_break_policies_are_exact(policy: LineBreakPolicy, expected: str) -> None:
+ assert transform("exam-\nple", (exact_rule(line_break=policy),)) == expected
+
+
+def test_paragraph_policy_restores_quote_and_list_prefixes() -> None:
+ rule = exact_rule(line_break=LineBreakPolicy.PARAGRAPH)
+
+ assert transform("> exam-\n> ple", (rule,)) == "> example\n>\n> "
+ assert transform("- exam-\n ple", (rule,)) == "- example\n\n "
+ assert transform("> - exam-\n> ple", (rule,)) == "> - example\n>\n> "
+
+
+@pytest.mark.parametrize(
+ ("markdown", "expected"),
+ [
+ ("plain exam-\nple", "plain example"),
+ ("# exam-\nple", "# example"),
+ ("- exam-\n ple", "- example"),
+ ("> exam-\n> ple", "> example"),
+ ("> > > exam-\n> > > ple", "> > > example"),
+ ("1. outer\n 2. exam-\n ple", "1. outer\n 2. example"),
+ ],
+)
+def test_supported_markdown_blocks_and_valid_nested_containers(markdown: str, expected: str) -> None:
+ assert transform(markdown, (exact_rule(),)) == expected
+
+
+def test_block_scope_is_explicit() -> None:
+ heading_only = exact_rule(blocks=frozenset({LineJoinBlock.HEADING}))
+
+ assert transform("# exam-\nple", (heading_only,)) == "# example"
+ assert transform("exam-\nple", (heading_only,)) == "exam-\nple"
@pytest.mark.parametrize(
"markdown",
[
- "an unknown-\nword here",
- "an exam-\n\n\nple here",
- "an exam-\r\n\nple here",
- "an EXAM-\nple here",
- "an exam-\nplemore here",
- "an xrule-\nbased method",
+ "> > > exam-\n> > ple",
+ "> #broken exam-\n> ple",
+ "1. exam-\n2. ple",
+ "1. outer\n 2. exam-\n ple",
+ "- exam-\n ple",
+ "#broken exam-\nple",
],
)
-def test_unknown_or_unsafe_boundaries_are_preserved(markdown: str) -> None:
- assert transform(markdown).output_markdown == markdown
-
-
-def test_parameters_and_results_are_independent_of_mapping_order() -> None:
- first = mapped_line_join(MAPPINGS)
- second = mapped_line_join(reversed(MAPPINGS))
- markdown = "example becomes exam-\nple"
-
- first_result = Pipeline([first]).transform(markdown)
- second_result = Pipeline([second]).transform(markdown)
-
- assert first_result.modifiers == second_result.modifiers
- assert first_result.output_markdown == second_result.output_markdown
-
-
-def test_library_contains_no_default_mapping() -> None:
- modifier = mapped_line_join(())
- result = Pipeline([modifier]).transform("an exam-\nple here")
-
- assert modifier.parameters == (("mappings", ()),)
- assert result.output_markdown == "an exam-\nple here"
+def test_unknown_or_mismatched_containers_fail_closed_instead_of_becoming_paragraphs(markdown: str) -> None:
+ assert transform(markdown, (exact_rule(),)) == markdown
@pytest.mark.parametrize(
- "mappings",
+ "markdown",
+ [
+ "```text\nexam-\nple\n```",
+ " exam-\n ple",
+ "> exam-\n> ple",
+ "- ```\n exam-\n ple\n ```",
+ "| word |\n| --- |\n| exam- |\n| ple |",
+ "",
+ "\nexam-\nple",
+ ],
+)
+def test_code_and_table_regions_are_always_excluded(markdown: str) -> None:
+ assert transform(markdown, (exact_rule(),)) == markdown
+
+
+def test_three_line_chain_is_one_proposal_and_uses_virtual_output() -> None:
+ rules = (
+ exact_rule(rule_id="chain.first"),
+ ExactLineJoinRule(
+ rule_id="chain.second",
+ left="example",
+ right="based",
+ replacement="example-based",
+ separator="-",
+ ),
+ )
+ modifier = mapped_line_join(rules)
+ proposals = modifier.propose(DocumentSnapshot("exam-\nple-\nbased"))
+
+ assert len(proposals) == 1
+ assert len(proposals[0].edits) == 1
+ assert "chain.first, chain.second" in proposals[0].reason
+ assert transform("exam-\nple-\nbased", rules) == "example-based"
+
+
+def test_mixed_line_endings_fail_the_entire_connected_chain_closed() -> None:
+ rules = (
+ exact_rule(rule_id="chain.first"),
+ ExactLineJoinRule("chain.second", "example", "based", "example-based", separator="-"),
+ )
+ markdown = "exam-\r\nple-\nbased"
+
+ assert transform(markdown, rules) == markdown
+
+
+def test_multiple_independent_chains_are_reported_in_source_order() -> None:
+ modifier = mapped_line_join((exact_rule(),))
+ snapshot = DocumentSnapshot("exam-\nple and\n\nexam-\nple")
+ proposals = modifier.propose(snapshot)
+
+ assert len(proposals) == 2
+ assert proposals[0].edits[0].span.start < proposals[1].edits[0].span.start
+
+
+def test_keep_rule_can_veto_a_lower_priority_replacement() -> None:
+ keep = KeepLineJoinRule("keep.example", "exam", "ple", separator="-", priority=10)
+ replace = exact_rule(priority=0)
+
+ assert transform("exam-\nple", (replace, keep)) == "exam-\nple"
+
+
+def test_conflict_first_and_priority_are_deterministic() -> None:
+ first = exact_rule(rule_id="first", replacement="first", priority=0)
+ second = exact_rule(rule_id="second", replacement="second", priority=10)
+
+ assert transform(
+ "exam-\nple",
+ (first, second),
+ conflict_policy=LineJoinConflictPolicy.FIRST,
+ ) == "first"
+ assert transform(
+ "exam-\nple",
+ (first, second),
+ conflict_policy=LineJoinConflictPolicy.PRIORITY,
+ ) == "second"
+
+
+def test_priority_tie_uses_declaration_order() -> None:
+ first = exact_rule(rule_id="first", replacement="first")
+ second = exact_rule(rule_id="second", replacement="second")
+
+ assert transform("exam-\nple", (second, first)) == "second"
+
+
+def test_conflict_error_names_rules_but_not_surrounding_text() -> None:
+ modifier = mapped_line_join(
+ (exact_rule(rule_id="first"), exact_rule(rule_id="second")),
+ conflict_policy=LineJoinConflictPolicy.ERROR,
+ )
+
+ with pytest.raises(ModifierContractError, match=r"first, second") as raised:
+ modifier.propose(DocumentSnapshot("secret exam-\nple material"))
+ assert "secret" not in str(raised.value)
+
+
+def test_case_insensitive_matching_is_opt_in() -> None:
+ assert transform("EXAM-\nPLE", (exact_rule(),)) == "EXAM-\nPLE"
+ assert transform("EXAM-\nPLE", (exact_rule(case_sensitive=False),)) == "example"
+
+
+def test_unicode_normalization_is_opt_in_and_preserves_source_index_mapping() -> None:
+ decomposed_left = "cafe\N{COMBINING ACUTE ACCENT}"
+ markdown = "CAFÉ-\nteria"
+ default = ExactLineJoinRule(
+ "unicode.default",
+ decomposed_left,
+ "teria",
+ "cafeteria",
+ separator="-",
+ case_sensitive=False,
+ )
+ normalized = ExactLineJoinRule(
+ "unicode.nfc",
+ decomposed_left,
+ "teria",
+ "cafeteria",
+ separator="-",
+ case_sensitive=False,
+ normalization=UnicodeNormalization.NFC,
+ )
+
+ assert transform(markdown, (default,)) == markdown
+ assert transform(markdown, (normalized,)) == "cafeteria"
+
+
+def test_unicode_word_boundaries_and_explicit_override() -> None:
+ strict = exact_rule()
+ permissive = exact_rule(rule_id="permissive", require_word_boundaries=False)
+ markdown = "éexam-\nplemore"
+
+ assert transform(markdown, (strict,)) == markdown
+ assert transform(markdown, (permissive,)) == "éexamplemore"
+
+
+@pytest.mark.parametrize("max_blank_lines", [0, 1])
+def test_blank_line_limit_is_configurable(max_blank_lines: int) -> None:
+ expected = "exam-\n\nple" if max_blank_lines == 0 else "example"
+ assert transform(
+ "exam-\n\nple",
+ (exact_rule(),),
+ max_intervening_blank_lines=max_blank_lines,
+ ) == expected
+
+
+@pytest.mark.parametrize("invalid", [-1, True, 2])
+def test_invalid_blank_line_limits_are_rejected(invalid: object) -> None:
+ with pytest.raises(ModifierContractError):
+ mapped_line_join((), max_intervening_blank_lines=invalid) # type: ignore[arg-type]
+
+
+def test_empty_document_and_empty_rule_set_are_no_ops() -> None:
+ modifier = mapped_line_join(())
+
+ assert transform("", ()) == ""
+ assert transform("exam-\nple", ()) == "exam-\nple"
+ assert modifier.version == "2.0.0"
+ assert dict(modifier.parameters)["rules"] == ()
+
+
+def test_parameters_preserve_rule_order_and_record_all_options() -> None:
+ first = mapped_line_join((exact_rule(rule_id="first"), exact_rule(rule_id="second")))
+ second = mapped_line_join((exact_rule(rule_id="second"), exact_rule(rule_id="first")))
+
+ assert first.parameters != second.parameters
+ records = dict(first.parameters)["rules"]
+ assert isinstance(records, tuple)
+ first_record = cast(tuple[tuple[str, object], ...], records[0])
+ assert ("rule_id", "first") in first_record
+
+
+@pytest.mark.parametrize(
+ "rules",
[
(("", "right", "word"),),
(("left", "right", "two words"),),
(("left", "right", "word"), ("left", "right", "other")),
(("left", "right"),),
+ (exact_rule(rule_id="same"), exact_rule(rule_id="same")),
+ (exact_rule(case_sensitive=1),),
+ (exact_rule(blocks={LineJoinBlock.PARAGRAPH}),),
+ (RegexLineJoinRule("regex", "", r"(?Px)", "x"),),
+ (RegexLineJoinRule("regex", r"(x)", r"(?Px)", "x"),),
+ (RegexLineJoinRule("regex", r"(?Px)", r"(?Px)", r"\g"),),
+ (RegexLineJoinRule("regex", r"(?Px)", r"(?Px)", r"\g"),),
+ (LineEndRegexRule("line-end", r"(?Px)", line_break=LineBreakPolicy.PRESERVE),),
],
)
-def test_invalid_mappings_raise_contract_error(mappings: object) -> None:
+def test_invalid_rules_raise_contract_error(rules: object) -> None:
with pytest.raises(ModifierContractError):
- mapped_line_join(mappings) # type: ignore[arg-type]
+ mapped_line_join(rules) # type: ignore[arg-type]
+
+
+class FakeLexicon:
+ package_version = "test-lexicon"
+
+ def __init__(self, scores: dict[str, tuple[float, float | None]]) -> None:
+ self._scores = scores
+
+ def evidence(
+ self,
+ candidate: str,
+ form: LexicalCandidateForm,
+ left: str,
+ right: str,
+ ) -> object:
+ del form, left, right
+ score = self._scores.get(candidate)
+ return None if score is None else mapped_module._LexicalEvidence(*score)
+
+
+def lexical_rule(**overrides: object) -> LexicalLineJoinRule:
+ fields: dict[str, object] = {
+ "rule_id": "english.lexical",
+ "left_pattern": r"[A-Za-z]+",
+ "right_pattern": r"[a-z]+",
+ "separator": "-",
+ "backend": LexiconBackend.WORDFREQ,
+ "language": "en",
+ "candidate_forms": (LexicalCandidateForm.JOINED, LexicalCandidateForm.HYPHENATED),
+ "minimum_score": 1.0,
+ "minimum_score_margin": 0.5,
+ }
+ fields.update(overrides)
+ return LexicalLineJoinRule(**fields) # type: ignore[arg-type]
+
+
+def install_fake_lexicon(monkeypatch: MonkeyPatch, scores: dict[str, tuple[float, float | None]]) -> None:
+ def build(rule: LexicalLineJoinRule) -> FakeLexicon:
+ del rule
+ return FakeLexicon(scores)
+
+ monkeypatch.setattr(mapped_module, "_build_lexicon", build)
+
+
+def test_lexical_rule_joins_a_unique_dictionary_candidate_without_per_word_mapping(
+ monkeypatch: MonkeyPatch,
+) -> None:
+ install_fake_lexicon(monkeypatch, {"example": (5.0, 5.0)})
+
+ assert transform("exam-\nple", (lexical_rule(),)) == "example"
+
+
+def test_lexical_rule_can_select_the_natural_hyphenated_form_by_score(monkeypatch: MonkeyPatch) -> None:
+ install_fake_lexicon(monkeypatch, {"rulebased": (3.0, 3.0), "rule-based": (6.0, 6.0)})
+
+ assert transform("rule-\nbased", (lexical_rule(),)) == "rule-based"
+
+
+def test_lexical_margin_and_keep_policy_preserve_an_ambiguous_boundary(monkeypatch: MonkeyPatch) -> None:
+ install_fake_lexicon(monkeypatch, {"rulebased": (5.0, 5.0), "rule-based": (4.8, 4.8)})
+
+ assert transform("rule-\nbased", (lexical_rule(minimum_score_margin=0.5),)) == "rule-\nbased"
+
+
+def test_spellchecker_style_spaced_proxy_is_not_ranked_against_single_token(monkeypatch: MonkeyPatch) -> None:
+ install_fake_lexicon(monkeypatch, {"inside": (6.0, 6.0), "in side": (5.0, None)})
+ rule = lexical_rule(
+ separator="",
+ candidate_forms=(LexicalCandidateForm.JOINED, LexicalCandidateForm.SPACED),
+ )
+
+ assert transform("in\nside", (rule,)) == "in\nside"
+
+
+def test_lexical_ambiguity_error_is_source_safe(monkeypatch: MonkeyPatch) -> None:
+ install_fake_lexicon(monkeypatch, {"inside": (6.0, 6.0), "in side": (5.0, None)})
+ rule = lexical_rule(
+ separator="",
+ candidate_forms=(LexicalCandidateForm.JOINED, LexicalCandidateForm.SPACED),
+ ambiguity=LexicalAmbiguityPolicy.ERROR,
+ )
+ modifier = mapped_line_join((rule,))
+
+ with pytest.raises(ModifierContractError, match=r"english\.lexical") as raised:
+ modifier.propose(DocumentSnapshot("secret in\nside"))
+ assert "secret" not in str(raised.value)
+
+
+class FakeHyphenator:
+ def __init__(self, positions: tuple[int, ...]) -> None:
+ self._positions = positions
+
+ def positions(self, word: str) -> tuple[int, ...]:
+ del word
+ return self._positions
+
+
+@pytest.mark.parametrize(("positions", "expected"), [((4,), "example"), ((), "exam-ple")])
+def test_pyphen_gate_only_controls_the_joined_candidate(
+ monkeypatch: MonkeyPatch,
+ positions: tuple[int, ...],
+ expected: str,
+) -> None:
+ install_fake_lexicon(monkeypatch, {"example": (6.0, 6.0), "exam-ple": (4.0, 4.0)})
+ monkeypatch.setattr(
+ mapped_module,
+ "_build_hyphenator",
+ lambda language: (FakeHyphenator(positions), f"test-{language}"),
+ )
+ rule = lexical_rule(hyphenation_language="en_US", minimum_score_margin=0.5)
+
+ assert transform("exam-\nple", (rule,)) == expected
+
+
+@pytest.mark.parametrize(
+ "overrides",
+ [
+ {"separator": "/"},
+ {"candidate_forms": (LexicalCandidateForm.JOINED,)},
+ {"candidate_forms": (LexicalCandidateForm.JOINED, LexicalCandidateForm.JOINED)},
+ {"minimum_score": -1.0},
+ {"minimum_score": float("nan")},
+ {"minimum_score_margin": True},
+ {"language": ""},
+ {"hyphenation_language": ""},
+ ],
+)
+def test_invalid_lexical_rules_fail_before_loading_a_backend(overrides: dict[str, object]) -> None:
+ with pytest.raises(ModifierContractError):
+ mapped_line_join((lexical_rule(**overrides),))
+
+
+def test_missing_requested_optional_backend_raises_without_fallback(monkeypatch: MonkeyPatch) -> None:
+ monkeypatch.setattr(mapped_module, "_zipf_frequency", None)
+
+ with pytest.raises(ModifierContractError, match="frequency"):
+ mapped_line_join((lexical_rule(),))
+
+
+@pytest.mark.parametrize(
+ ("backend", "available"),
+ [
+ (LexiconBackend.SPELLCHECKER, mapped_module._SpellChecker is not None),
+ (LexiconBackend.WORDFREQ, mapped_module._zipf_frequency is not None),
+ ],
+)
+def test_installed_optional_backend_constructs_or_is_reported_as_skipped(
+ backend: LexiconBackend,
+ available: bool,
+) -> None:
+ if not available:
+ pytest.skip(f"optional backend is not installed: {backend.value}")
+ mapped_line_join((lexical_rule(backend=backend),))
def test_successful_output_is_idempotent() -> None:
- pipeline = Pipeline([mapped_line_join(MAPPINGS)])
+ rules = (exact_rule(),)
+ pipeline = Pipeline((mapped_line_join(rules),))
first = pipeline.transform("an exam-\n\nple here")
+
+ assert first.status is RunStatus.SUCCESS
assert first.output_markdown is not None
assert pipeline.transform(first.output_markdown).changes == ()
|