6.3 KiB
NGRA COUNT(UNIT) 生成说明
本文记录 TextRegexSourceGen.GenerateCount 中单字符重复分支的当前设计,方便后续继续实现。
相关代码:Text/TextRegexSourceGen.GenClass.cs。
目标
对于 a{min,max},不要把必需的 min 次匹配生成成逐字符循环。生成器先把字符重复 min 次,形成一个字符串字面量,并使用一次 StartsWith 验证下界:
if (__remain.StartsWith("aaa"))
{
// 已经确认至少存在 min 个 a。
}
else
{
// 当前工作 region 失败。
}
匹配成功后一次性消费这个前缀,再只处理区间中可选的部分:
必需前缀:min 次
可选尾部:max - min 次
max < 0 表示尾部没有上限。
AST 数据归属
COUNT 配置属于操作符节点,重复的字符属于其子节点:
RegexCountPayload payload = node.PayloadAs<RegexCountPayload>();
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 次后无上限 |
有限上限可以生成:
__remain = __remain.Slice(min);
for (int i = 0;
i < max - min &&
!__remain.IsEmpty &&
__remain[0] == expected;
i++)
{
__remain = __remain.Slice(1);
}
无上限不能计算 max - min,否则结果为负数、循环不会执行。应单独生成:
__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:
bool regionSucceeded = false;
ReadOnlySpan<char> 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。
摊平阈值
不要无条件执行:
Span<char> required = stackalloc char[min];
量词来自用户输入,a{1000000} 会让源生成器尝试进行巨大的栈分配,并生成巨型字符串字面量。建议设置较小阈值,例如 32 或 64:
min <= threshold:摊平为字面量并调用StartsWith;min > threshold:循环比较固定长度的摊平块,最后比较不足一个块的余数;- 非法或超过生成器限制的数量:报告 Roslyn diagnostic,而不是让生成器崩溃。
以阈值 64 为例,a{150} 被拆成两个 64 字符块和一个 22 字符余数块。运行时使用局部 span 逐块调用 StartsWith,全部成功后才一次性推进正式 cursor。这样失败时无需恢复已经提交的 __remain:
if (__remain.Length < 150)
{
break;
}
ReadOnlySpan<char> __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);
后续实现顺序
- 从
node读取RegexCountPayload,并用 Empty sentinel 安全选择子节点。 - 改用
value.Value[0]获取解码字符。 - 为所有尾部循环加入
!__remain.IsEmpty。 - 分开生成有限上限和
max < 0两种循环。 - 增加摊平阈值,避免巨大
stackalloc和生成源膨胀。 - 将工作 region 上移到 CONCAT/候选路径,完成失败传播和 checkpoint 恢复。
- 接入 greedy/lazy continuation 回溯。
- 最后处理 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,确认不会导致生成器栈溢出或生成巨型源码。