# NGRA `COUNT(UNIT)` 生成说明 本文记录 `TextRegexSourceGen.GenerateCount` 中单字符重复分支的当前设计,方便后续继续实现。 相关代码:[`Text/TextRegexSourceGen.GenClass.cs`](Text/TextRegexSourceGen.GenClass.cs)。 ## 目标 对于 `a{min,max}`,不要把必需的 `min` 次匹配生成成逐字符循环。生成器先把字符重复 `min` 次,形成一个字符串字面量,并使用一次 `StartsWith` 验证下界: ```csharp if (__remain.StartsWith("aaa")) { // 已经确认至少存在 min 个 a。 } else { // 当前工作 region 失败。 } ``` 匹配成功后一次性消费这个前缀,再只处理区间中可选的部分: ```text 必需前缀:min 次 可选尾部:max - min 次 ``` `max < 0` 表示尾部没有上限。 ## AST 数据归属 `COUNT` 配置属于操作符节点,重复的字符属于其子节点: ```csharp RegexCountPayload payload = node.PayloadAs(); RegexAstNode value = RegexAstNode.IsNullOrEmpty(node.Left) ? node.Right : node.Left; char expected = value.Value[0]; ``` 不要从 `value` 读取 `RegexCountPayload`。`value` 携带的是 `RegexLiteralPayload` 或其他被重复元素的 payload。 字符应从 `value.Value` 读取,因为它是解码后的值。使用 `value.Source.Source[0]` 会把 `\t` 等转义序列错误地识别成 `'\\'`。 ## 量词语义 | 量词 | `min` | `max` | 尾部处理 | | --- | ---: | ---: | --- | | `a{n}` | `n` | `n` | 无可选尾部 | | `a{m,n}` | `m` | `n` | 最多再消费 `n - m` 次 | | `a*` | `0` | `-1` | 无上限 | | `a+` | `1` | `-1` | 验证一次后无上限 | | `a?` | `0` | `1` | 最多消费一次 | | `a{m,}` | `m` | `-1` | 验证 `m` 次后无上限 | 有限上限可以生成: ```csharp __remain = __remain.Slice(min); for (int i = 0; i < max - min && !__remain.IsEmpty && __remain[0] == expected; i++) { __remain = __remain.Slice(1); } ``` 无上限不能计算 `max - min`,否则结果为负数、循环不会执行。应单独生成: ```csharp __remain = __remain.Slice(min); while (!__remain.IsEmpty && __remain[0] == expected) { __remain = __remain.Slice(1); } ``` `!__remain.IsEmpty` 必须位于 `__remain[0]` 之前。输入恰好在必需前缀后耗尽时,直接索引会抛出 `IndexOutOfRangeException`。 ## 贪婪与惰性 贪婪量词可以先消费尽可能多的可选尾部,再尝试后续 continuation。如果后续失败,需要逐次回退消费数量并重试。 惰性量词只应先消费 `min`,立即尝试后续 continuation;后续失败时,才逐次多消费一个匹配字符。 因此 `is_greedy` 不能只改变扫描循环,它最终需要与后续节点的 continuation 和回溯状态一起实现。若 COUNT 位于匹配末尾,贪婪模式可直接扫描至上限,惰性模式保留在 `min` 即可。 ## 失败工作 region 当前试点使用 `do { } while (false)`,使局部失败可以写成 `break`: ```csharp bool regionSucceeded = false; ReadOnlySpan checkpoint = __remain; do { if (!__remain.StartsWith(requiredPrefix)) { break; } __remain = __remain.Slice(min); // 匹配可选尾部和后续节点。 regionSucceeded = true; } while (false); if (!regionSucceeded) { __remain = checkpoint; // 向父 region 传播失败,或者在顶层返回失败结果。 } ``` 只有 COUNT 自己被 `do/while(false)` 包裹时,`break` 结束后仍会执行下一个 CONCAT 节点,不能真正表达匹配失败。最终 region 应由以下之一持有: - 包含后续 continuation 的 `CONCAT`; - 一条完整的 ALT 候选路径; - 顶层匹配方法。 父 region 负责保存 cursor 和 capture checkpoint。子节点失败后退出父 region,由父 region 统一恢复状态。ALT 和贪婪/惰性 COUNT 还需要保存多个可重试位置,不能只依赖一次 `break`。 ## 摊平阈值 不要无条件执行: ```csharp Span required = stackalloc char[min]; ``` 量词来自用户输入,`a{1000000}` 会让源生成器尝试进行巨大的栈分配,并生成巨型字符串字面量。建议设置较小阈值,例如 32 或 64: - `min <= threshold`:摊平为字面量并调用 `StartsWith`; - `min > threshold`:循环比较固定长度的摊平块,最后比较不足一个块的余数; - 非法或超过生成器限制的数量:报告 Roslyn diagnostic,而不是让生成器崩溃。 以阈值 64 为例,`a{150}` 被拆成两个 64 字符块和一个 22 字符余数块。运行时使用局部 span 逐块调用 `StartsWith`,全部成功后才一次性推进正式 cursor。这样失败时无需恢复已经提交的 `__remain`: ```csharp if (__remain.Length < 150) { break; } ReadOnlySpan __requiredRest_0 = __remain; bool __requiredMatched_0 = true; for (int __requiredChunkIndex_0 = 0; __requiredChunkIndex_0 < 2; __requiredChunkIndex_0++) { if (!__requiredRest_0.StartsWith("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) { __requiredMatched_0 = false; break; } __requiredRest_0 = __requiredRest_0.Slice(64); } if (__requiredMatched_0 && !__requiredRest_0.StartsWith("aaaaaaaaaaaaaaaaaaaaaa")) { __requiredMatched_0 = false; } if (!__requiredMatched_0) { break; } __remain = __remain.Slice(150); ``` ## 后续实现顺序 1. 从 `node` 读取 `RegexCountPayload`,并用 Empty sentinel 安全选择子节点。 2. 改用 `value.Value[0]` 获取解码字符。 3. 为所有尾部循环加入 `!__remain.IsEmpty`。 4. 分开生成有限上限和 `max < 0` 两种循环。 5. 增加摊平阈值,避免巨大 `stackalloc` 和生成源膨胀。 6. 将工作 region 上移到 CONCAT/候选路径,完成失败传播和 checkpoint 恢复。 7. 接入 greedy/lazy continuation 回溯。 8. 最后处理 capture 状态的保存与恢复。 ## 最小测试矩阵 至少覆盖以下模式和输入: - `a{0}`:空输入、`a`; - `a{3}`:`aa`、`aaa`、`aaaa`; - `a{2,4}`:长度 1 到 5; - `a*`、`a+`、`a?`、`a{2,}`; - `\t{2}`:确认使用解码字符; - `a{2,4}ab`:验证贪婪回退; - `a{2,4}?ab`:验证惰性扩展; - COUNT 位于 CONCAT、ALT 和捕获 GROUP 内的失败恢复; - 输入恰好在 `min` 处结束,确认不会索引空 span; - 超大 `min`,确认不会导致生成器栈溢出或生成巨型源码。