moon-ical · 内部开发手册

开发手册:iCal 解析库 + CalDAV 服务器

本手册是项目的单一事实来源(single source of truth):定位、进度、架构、里程碑、工程约定都在这里。每完成一个里程碑提交时同步更新 §02 快照与 §04 状态。

更新日期 2026-09-08 进度 S0–S1.5 完成(S0: 0ab8978 · S1: 79752d9 · S1.5: 02b9e2a) 工具链 moon 0.1.20260827 / moonc v0.10.11(rr_moon_mod + rr_moon_pkg) 依赖 moonbitlang/async@0.21.2 · moonbitlang/x@0.5.1 状态 moon check ✓ · moon test 73/73 ✓

01定位与证据链

一句话定位:纯 MoonBit 的 iCalendar(RFC 5545)解析 / 重复事件展开库,长成 mooncakes.io 生态第一个 CalDAV(RFC 4791)日历服务器——把竞品停在「中段计算层」的流水线,接到「真实日历客户端可直连」的服务端。

立项依据(申报书可直接引用,全部 2026-09-08 核实):

差异化旗帜(相对五竞品):① 端到端——从网络取件到客户端可连,而非纯计算库;② 真实数据——RFC 5545 附录 A 对拍 + 真实客户端联调矩阵,五家全部只用自造 fixture;③ RECURRENCE-ID 单实例改期合并——五家共同空白(调研矩阵 ④b 行)。

02进度快照

包内容实现行数测试状态
ical/text折行展开(§3.1)、内容行解析(引号参数)、文本反转义、带行号 ParseError30920 用例已落地
ical/model组件树(BEGIN/END 嵌套、未知组件保留)、IcalDateTime(四态时间,Eq)、ZoneTable(固定偏移 + feed 内嵌 VTIMEZONE)、Event 类型化视图(S1:parse_events / Component::events,RRULE 原文、EXDATE、RECURRENCE-ID)84653 用例已落地
demo可运行入口:内嵌真实形态 fixture(折叠/转义/TZID/RRULE/EXDATE/全天/改期覆盖),打印事件清单(moon run demo)71—S1 起步
ical/fetch取件层:fetch_ics(url)(@http.get 薄层 + 状态检查,非 200 抛 FetchError);demo 支持 URL 参数端到端35—已落地
ical/rrule完整日历级 RRULE 展开(S2/S3/S8):四档 FREQ × INTERVAL/COUNT/UNTIL、序数 BYDAY、正负 BYMONTHDAY、BYMONTH、BYSETPOS、WKST;expand_series 合并 EXDATE、RECURRENCE-ID 改期/取消/THISANDFUTURE,ZoneTable 解析结果贯穿展开约 120031 用例(graham 第一档 23 条 + 第二档 8 条 + 合并/时区)已落地
ical/serialize组件树 → .ics 文本(S4):CRLF、75-octets 折行(多字节不劈开)、参数值按需重引号;@text.escape_value 与 unescape 对称;roundtrip 组件树相等且幂等17211 用例(roundtrip/折叠/引号/空值)已落地
ical/storevdir 存储(S5):每事件一个 .ics 文件;FNV-1a 内容寻址 ETag(同字节同 tag);事件名白名单(字母数字._- 且非点开头)防路径逃逸;get 缺失是 None 不是错1935 用例(ETag/拒绝名/磁盘往返)已落地
ical/httpdHTTP/1.1 请求层(S5):手解析请求行/头/Content-Length 体,方法是 token(WebDAV 方法天然可读);chunked 拒收(411);裸 socket 读写(ServerConnection 是帧化 reader 不适用);S6 在此之上加 WebDAV/CalDAV 语义32810 用例(MemoryReader 驱动)请求层已落地
ical/caldavWebDAV/CalDAV 语义(S6):发现链、PROPFIND Depth 0/1、calendar-query/multiget、XML 安全转义、If-Match/If-None-Match;cmd/serve 接入 well-known、MKCALENDAR 与标准 201/204/207/412 状态2925 用例 + 11 项 curl 活体验收已落地

合计:0.2.0 开发分支严格全目标测试为 wasm 150、wasm-gc 137、JavaScript 150、native 155;S5 21 项与 S6 11 项真实 curl 活体验收全绿。Thunderbird 155.0.1(Windows)已完成发现、读取、创建、原位修改与删除的真实 CalDAV 联调。

03架构与数据流

请求自上而下流经六层,每层一个包、单向依赖;层与层之间只经公开接口(.mbti)通信:

cal clientThunderbird / DAVx5 / Apple 日历 / curl
→
ical/httpdHTTP/1.1 请求层:accept + 手解析请求行/头/体;响应复用 @async 的 send_response
S5–S6
→
WebDAV/CalDAVPROPFIND / REPORT / MKCALENDAR / ETag;XML 用 Milky2018/xml
S6
→
ical/storevdir:每事件一个 .ics 文件,目录即日历集合
S5
ical/serialize · serialize组件树 → 折行 .ics 文本
S4
←
ical/model · Component / IcalDateTime / ZoneTable组件树 + 四态时间 + 偏移表
已落地
←
ical/text · unfold / parse_content_line物理行 → 逻辑行 → 内容行
已落地

已落地包的公开接口(摘自 pkg.generated.mbti)

ical/text:  unfold(String) -> Array[String]
            parse_content_line(String, Int) -> ContentLine raise
            unescape_value(String) -> String
            suberror ParseError::BadLine(line_no~, line~, message~)

ical/model: parse_components(Array[String]) -> Array[Component] raise
            parse_date_time(ContentLine, ZoneTable, line_no?) -> IcalDateTime raise
            parse_single_date_time(String, ZoneTable, line_no?) -> IcalDateTime raise
            parse_utc_offset(String, line_no~, raw~) -> Int raise
            build_zone_table(Array[Component]) -> ZoneTable
            Component::{property, properties_named, events}
            IcalDateTime::{instant_seconds, is_utc, is_floating, compare}  // derive Eq(S1 起)
            ZoneTable::{empty, builtin_common, with_builtin, insert, lookup}
            parse_events(String) -> Array[Event] raise
            parse_date_time_value(String, ZoneTable, tzid?, value_is_date?, line_no?) raise
            struct Event { uid, summary, location, description : String?
                           dtstart, dtend, recurrence_id : IcalDateTime?
                           rrule : String?  exdates : Array[IcalDateTime] }
            Event::all_day(Self) -> Bool      // impl Show
设计立场:保真优先——未知组件/属性一律保留(竞品 mooncal 是固定字段清单);时间四态显式建模;解析不了的输入带行号报错而不是静默吞掉。这三条是后续所有包必须继承的基线。

04里程碑阶梯 S0–S9

哲学:每步一个最小目标 → 代码 + 测试 → 验收 → 一次提交。单步超过半天就拆。任何一步卡住,先砍范围再加班。

#目标规模验收(过不了的验收不算完成)状态
S0清场:删模板/探针,README/moon.mod 就位,登记接缝0 行moon check && moon test 全绿,工作区干净完成 0ab8978
S1类型化事件层:Event 结构 + Component::events()(映射层,无新算法)~150demo 读本地 fixture .ics 打印事件清单完成(S1)
S1.5取件切片:ical/fetch(@http.get 薄层,非 200 抛 FetchError)+ demo 支持 URL 输入:URL → 事件清单 端到端。网络测试不进 CI(Google 源本网络不可达,演示用 calendarlabs)~130给真实可达 .ics URL,打印解析后事件清单;CI 仍走本地 fixture完成(S1.5)
S2RRULE 解析(不展开):第一档子句 → Rule;不支持子句显式报错~200graham 32 个用例的 rule 串全部按档位解析或报错完成(S2)
S3第一档展开:DAILY/WEEKLY/MONTHLY + INTERVAL/COUNT/UNTIL/BYDAY(无offset)/BYMONTHDAY(正) + DTSTART 回退特例~400RFC 附录 A 第一档 ~20 用例对拍全绿(语料:graham/rrule 测试套件,见 THIRD-PARTY-NOTICES.md)完成(S3)
S4iCalendar 序列化写回:fold(75字符)/escape/组件树→ics(CalDAV 响应体就是它,故提前)~200parse → serialize → parse 往返不变完成(S4)
S5HTTP/1.1 请求层 + vdir 存储:accept + 手解析请求行/头/Content-Length 体;OPTIONS/GET/PUT/DELETE~350curl 完成事件 PUT/GET/DELETE 往返,ETag 正确变化;chunked 请求回 411完成(S5)
S6WebDAV/CalDAV 核心:PROPFIND(Depth 0/1)、REPORT(calendar-query/multiget)、MKCALENDAR、well-known 重定向、发现链、If-Match/If-None-Match~450curl 模拟 Thunderbird 发现链 + REPORT 序列全绿(录成集成测试)完成(S6)
S7真实客户端联调:以 Thunderbird 完整 CRUD 为首版验收;DAVx5 / Apple 日历降为赛后互操作候选0.5–1 天≥1 个真实客户端完整走通;产出互操作矩阵(客户端 × 操作 × 结果)完成(Thunderbird 155.0.1 CRUD)
S8库层深度:RECURRENCE-ID 覆盖合并(改期/取消/THISANDFUTURE)+ 第二档 BY*(BYSETPOS/WKST/序数 BYDAY/负 BYMONTHDAY)+ ZoneTable 接入展开链~500–700附录 A 全 32 用例按档位通过;RECURRENCE-ID 合并用例通过完成(S8)
S9发布打磨:CI(check/test --target all + fmt + info)、mooncakes 发布、申报书、根包 re-export 门面、互操作矩阵进 README0.5 天CI 绿、moon publish 成功、申报书一页完成(0.1.0 已发布,scope frozen)
R1系列合并性能:一次索引同 UID overrides/EXDATE,按序游标应用 THISANDFUTURE~1002000 occurrences + 500 无关 overrides 的 native release 基准由 20.27 ms 降至 1.64 ms;语义回归与全目标测试通过完成(约 12.4×)
R2CalDAV REPORT 热路径:StringView 零拷贝扫描 + href 哈希选择~100500 hrefs × 500 resources 的 native release 基准由 68.17 ms 降至 3.87 ms;命名空间前缀回归与全目标测试通过完成(约 17.6×)
R30.2 根门面收口:新增整文档 parse_calendar,顶层只暴露完整 parse / recurrence / serialize 工作流~50pkg.generated.mbti 明确移除协议与文本层拼装 API;子包高级入口保留;门面往返测试通过完成(待发布)
R4通用 ICS 解析链 StringView 化:unfold、内容行、参数和 EXDATE 列表仅在所有权边界分配~1201000 VEVENT 的 native release 基准由 25.83 ms 降至 15.10 ms;既有 Unicode 折行与全目标回归通过完成(约 1.71×)
R5RRULE 预归一化:排序 BYMONTH/BYMONTHDAY,并缓存 28–31 天月份的实际候选日~80密集月度规则 10000 occurrences 的 native release 基准由 5.83 ms 降至约 5.19 ms;闰年、负月日、原 Rule 不变的回归通过完成(约 11%)
R8–R11赛后优化审计落地:测试盲区补齐(15 项)、AI 生成痕迹清理(净 -105 行)、StringView 残余迁移(R4 边界之后的全部值解析)、respond 缓冲 + ZoneTable Map 索引;HttpRequest.meth 经实测确认为避让保留字 method,不改名~-160四目标 150/137/150/155 全绿;基准保持 R 系列基线(parse 15.6 ms、multiget 3.7 ms、series 1.68 ms、monthly 5.5 ms);.mbti 仅 ZoneTable.entries 一处预期变更完成

05每步工作流(Definition of Done)

每个里程碑严格走同一循环,禁止「先写一大坨再补测试」:

  1. 写测试先行或同步:每块新行为至少一个 assert_eq / assert_true(pattern is …) 级别的断言测试;边界(空输入、畸形输入、行号)必须覆盖。
  2. 实现:块式组织,///| 分隔,每块带一句话文档注释说明「为什么」。
  3. 验证链:moon fmt && moon check && moon test && moon info,然后检查 .mbti diff——接口变化必须是预期内的,意外的 diff 说明改坏了边界。
  4. 提交:一次提交一个里程碑,消息格式 S<n>: <一句话>,正文列改动点 + 验收证据(测试数、对拍结果)。命令必须是 git -c core.hooksPath=.githooks commit …——项目钩子会跑 moon check 并拒绝「改源码但不更新 PROGRESS.md」的提交。
  5. 更新跟进文档:docs/PROGRESS.md(跨会话状态源:本地文件,不入库——§1 快照、§2 里程碑、§3 会话日志)与本手册 §02/§04/§07。钩子会校验它在上次提交后被更新过。
规模红线:单步新增代码超过计划规模 1.5 倍(如 S1 超过 ~220 行)即视为范围蔓延——停下来检查是不是把下一步的活提前做了,砍回去。

06MoonBit 工程约定与踩坑清单

新格式(rr_moon_mod + rr_moon_pkg)下的硬规则,违反即编译失败或静默 bug:

07上游接缝登记(活文档)

原则:业务代码永不 import 兜底实现,只 import trait;每个临时实现标注 UPSTREAM-GAP;探针转绿 = 上游已补齐 = 立即执行切换配方。完整表格在 docs/upstream-seams.md,此处摘要:

#能力现状(2026-09-08)本项目策略
S1js 目标本地文件@async/fs native 可用,js 空壳CalDAV 服务主目标 native,js 支持延后
S2IANA tzdb / DST 历史mooncakes 无 tzdb 包(x/time 仅 fixed_zone)ZoneTable:固定偏移 + feed 内嵌 VTIMEZONE;边界写进 README
S3HTTP 抓取@async/http client 双目标可用(M0 实测)保留 Fetcher 抽象,S9 可选支线
S4async 运行时稳定性README 自述 experimentalAPI 触点收敛在 httpd/store 两处
S5系统本地时区需自行 FFI暂不需要(服务用 UTC + 显式偏移)
S6WebDAV 方法承载RequestMethod 封闭枚举(types.mbt:16),无 PROPFIND/REPORT/MKCALENDARS5 手解析请求行(方法只是 token);上游扩展后一行切回 read_request

S5/S6 用到的上游 API(已核实)

// 响应侧(直接用,无障碍):
conn.send_response(200, "OK", extra_headers={ "Content-Type": "text/calendar" })
conn <+ body            // 字符串插值写入
conn.end_response()

// 请求侧(绕开 read_request 的方法枚举):
ServerConnection 实现了 @io.Reader(server.mbt:65)
//   → 自己读字节流,解析请求行 "PROPFIND /cal/ HTTP/1.1"、头、Content-Length 体

// XML:Milky2018/xml 0.4.1(拉式解析)或 moonbit-community/XMLParser 0.2.5
// JSON:moonbitlang/core/json(REPORT/调试输出用)

08明确不做(边界)

09风险与对策

风险等级对策
手解析 HTTP 边界情况(keep-alive、多值头、Expect: 100-continue)中明确 411/501 拒绝并写进边界;S7 联调日志驱动补齐;Radicale 哲学「够用即止」
真实客户端差异不可控(发现链/PROPFIND 形状各家不同)中S7 独立成步预留整天;优先 Thunderbird(最宽容、文档全),DAVx5 次之;问题→修复→复测记进互操作矩阵
XML 命名空间(客户端前缀任意:D:/d:/默认 ns)中按 (namespace-uri, local-name) 匹配而非前缀;测试带前缀变体
RRULE 序号算术(BYSETPOS/负值/序数 BYDAY)正确性中隔离在 S8 独立成步;附录 A 全量对拍;graham/rrule 上游源码(GitHub)随时对照
九月场规则未确认(本地技能只有八月场章程)低以八月场为底线准备(一页 MD 申报书/CI/mooncakes/≥5 提交);提交前官方渠道确认一次
规模不足(当前 962 行 vs 4–10k 参考)低S1–S8 累计估 ~2.6k 实现 + ~2.5k 测试 ≈ 5k+,落在区间;宁加用例不堆无关功能

10提交与验收映射

八月场验收硬标准 → 里程碑映射(九月场以官方章程为准):

验收项满足于
MoonBit 为主语言全程
可运行示例S1 起有 fixture demo,S5 起服务器可 moon run,S7 起真实客户端 demo
核心路径测试S1–S6 各自带测试;S3/S8 附录 A 对拍;S6 curl 序列集成测试
CIS9(moon check/test --target all + fmt + info,参考 moonbit-community/.github 的 check.yml)
发布 mooncakes.ioS9 已完成:justinwongcn/moon-ical@0.1.0 可由 moon search 检索与安装
默认分支 ≥5 真实提交S0–S9 每步一提交,自然 ≥10
申报书(一页 Markdown)S9 定稿:定位 + 竞品矩阵切分(引调研文档 §02)+ 互操作矩阵 + 边界
AI 辅助合规AI 生成内容可解释、可测试、来源明确;申报书如实声明 AI 参与