bttf源码解读(二):5781行ical.rs里藏着什么?RFC 5545重复规则引擎实现全解析
bttf源码解读(二):5781行ical.rs里藏着什么?RFC 5545重复规则引擎实现全解析
bttf 是一个 Rust 编写的命令行日期时间工具,除了做日期算术与解析,它最硬核的能力是按 RFC 5545 重复规则(RRULE)生成时间序列——这正是 iCalendar 日历标准里 RRULE:FREQ=WEEKLY;BYDAY=MO,TU,TH 这类表达式的来源。整个引擎全部写在 src/ical.rs 这一个文件里,5781 行,包含实现 + 从 RFC 原文照搬的完整测试。这篇文章带你拆解它的架构。
一句话总览:一个文件里的四件大事
打开 src/ical.rs,核心代码其实就围绕四个结构展开,测试部分占了 3700 行:
| 结构 | 位置 | 职责 |
|---|---|---|
RecurrenceRule |
src/ical.rs#L36-L91 | 一条规则的最终形态,不可变、可廉价克隆 |
RecurrenceRuleBuilder |
src/ical.rs#L1023-L1334 | 收集参数、做边界校验,产出规则 |
Expander |
src/ical.rs#L104-L884 | 把"某一次周期"展开成一批候选日期 |
RecurrenceIter |
src/ical.rs#L887-L1019 | 无限迭代器,按 INTERVAL 逐个推进 |
数据结构:为什么每个 BY 字段都排好序了?
规则的内部表示 RecurrenceRuleInner 把 RFC 5545 的字段一一映射成字段:freq、interval、by_month、by_week、by_week_day 等,全部存成 Box<[i8]> 这类紧凑切片。
Builder 的 build() 方法 做了两件关键的事:
- 严格校验:
by_month必须 1..=12,by_month_day允许 -31..=-1(倒数第 N 天),编号星期只允许出现在 YEARLY/MONTHLY 频率下——这些限制直接来自 RFC 5545 第 3.8.5.3 节; - 排序去重:所有 BY 列表在构建时
sort + dedup(见 sort_and_dedup),这样运行时的satisfies_by_month_day之类的判断才能用binary_search而不是线性扫描。
另外,规则整体被包在 Arc 里,克隆成本是 O(1) 的引用计数递增——对"同一条规则跑多次"的场景很友好。
灵活入参:IntoI8Iter 是个什么设计
CLI 侧(bttf time seq)允许用户写 --month 1,3、--hour 9..=17 这类范围语法。为了把"单值 / 数组 / 范围 / 范围数组"四种输入统一塞进 builder,src/ical.rs#L1481-L1573 定义了 IntoI8Iter、IntoI16Iter、IntoI32Iter 三组 trait,对 i8、Range<i8>、[Range<i8>; N] 等类型分别实现。
注释里坦白说:不合并成一个泛型 trait 是因为 builder.by_month(5) 里的字面量 5 类型无法被推断。这是很典型的"为了 API 顺手牺牲一点抽象"的工程取舍。
核心循环:Expander 如何展开一次周期
Expander::expand 的入口 src/ical.rs#L114-L156 按频率分派到 yearly/monthly/weekly 等 7 个方法。难度和频率粗细成反比:
- SECONDLY/DAILY 很简单:逐层
satisfies_by_*过滤即可,注释里作者自嘲"This is just so deliciously easy compared to YEARLY..."; - YEARLY 最复杂:yearly() 方法 里有一棵 if-else 决策树,根据
BYDAY、BYWEEKNO、BYMONTH的组合选择不同展开路径,最后统一追加BYHOUR/BYMINUTE/BYSECONDE展开。
展开的目标容器是 RecurrenceSet:一个 civil: Vec<DateTime> 存候选的"日历日期",另有一个 zoned: Vec<Zoned> 只在出现 BYSETPOS 时才启用(因为"倒数第 N 个"必须先知道全集长度,见 src/ical.rs#L128-L155)。set.expand() 的实现非常巧妙——先用原集合长度做界,边遍历边追加,再 drain 掉原始部分,一次原地完成"每个元素替换为它的展开"。
时区难题:Gap 跳过、Fold 翻倍
候选日期是"日历时间"(civil),输出必须带上时区偏移。转换发生在 pop_civil,这里藏着两个 RFC 5545 的灰色地带:
- Gap(时区跳空,如夏令时开始的那个不存在的一小时):RFC 明确说跳过,代码直接
continue; - Fold(重复的一小时,夏令时回拨):RFC 没规定。作者选择两个都输出——"宁多勿少"是保守策略,后一个副本放进
tmp槽位排队稍后吐出,避免额外分配。
而 UNTIL 的过滤放在 pop() 里:一旦越过 until,直接清空剩余集合返回 None,迭代终止。
闰日陷阱:乘区间而不是反复加
这是整个引擎最精妙的防御。迭代器每次推进不是 cur + interval,而是 civil_start + interval × attempt(increment() 方法)。原因:如果 2024-02-29 按"每年一次"推进,朴素加法会得到 2025-02-28 → 2026-02-28……永远回不到闰日;而"乘法 + 检测日期号是否被 constrain"能把 2025-02-29 判为非法并跳到下一个 attempt。注释里专门解释了为什么不能"在结果上继续加"——constrain 行为是会传染的。
测试:把 RFC 5545 的例子原样搬进来
从 src/ical.rs#L2082 开始的 mod tests 直接采用 RFC 5545 第 3.8.5.3 节的全部示例,按 RFC 出现顺序排列(而非按 FREQ 分组),方便对照检查覆盖度。短输出用 insta 内联快照,长输出(比如"1997 年每天")落到 src/snapshots/ 目录的 10 个 .snap 文件里,例如 bttf__ical__tests__daily_until_dec24.snap。快照里能清楚看到 04:00 变 05:00 的时区偏移切换——这就是引擎正确性最直观的"证据"。
它是如何被 CLI 使用的?
bttf time seq 子命令在 src/cmd/time/seq.rs 中,把 --month、--week、--count 等 flag 解析后交给 recurrence_rule() 构建规则,然后 rrule.iter().take(count) 逐行打印。整个命令层的存在,本质上就是 ical.rs 引擎的一个薄包装。
小结:5781 行的设计哲学
- 一次校验,永久受益:builder 里把 RFC 全部约束做掉,运行期只剩
binary_search; - 日历时间做展开,带时区的时间做输出,两层分离让 BY* 逻辑不被时区污染;
- 对标准没说的灰色地带(Fold)做保守选择并写明理由;
- 测试即规格:RFC 原文示例直接变成回归测试,改引擎不怕悄悄破坏行为。
配合 Cargo.toml 里的 jiff 依赖和 src/weekdate.rs 的 ISO 周数支持,这套引擎称得上是"小项目里的大件"。想继续深挖的话,建议从 Expander::yearly 的决策树读起——那是 RFC 5545 复杂度最集中的 55 行。