Files
stgsharp/src/Nitload.RegularAnalysis/COUNT_UNIT_GENERATION.md
T

6.3 KiB
Raw Blame History

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);

后续实现顺序

  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,确认不会导致生成器栈溢出或生成巨型源码。