目录

08 · parser 数字层:从账本文本到 Decimal 与 Amount

核对基线 · 范围 · 依赖

核对基线:beancount 仓库 commit 97472138(2026-08-22)。路径相对仓库根目录,引用格式 文件:起-止行 (名称),行号已逐条用 sed -n / grep -n 核对。 本篇范围beancount/parser/lexer.l(498 行)、beancount/parser/tokens.c(197 行)与 tokens.h(135 行)、beancount/parser/grammar.y(911 行)、beancount/parser/grammar.py(1142 行)中与数字相关的部分;测试 lexer_test.py(773 行)、grammar_test.py(2611 行)、tokens_test.c(112 行)。 上游依赖beancount/parser/decimal.h:13-26PyDec_FromCString)、beancount/core/number.py:31-32MISSING,见 02 篇)、beancount/core/amount.py(见 01 篇)、beancount/core/display_context.py(见 06 篇)。 下游使用者Posting.units / .price / .cost 携带 DecimalMISSING 交给 beancount/parser/booking_full.pybeancount/core/interpolate.py(见 07 篇);grammar.py:171-173 创建并缓存 DisplayContextgrammar.py:225-234 (get_options) 将其写入 options_map["dcontext"]

1. 这一层解决什么问题

数字相关处理分布在词法层的 flex 正则识别、C 层的 Decimal 合法性校验与构造、以及 bison 语法规则与 Builder 语义动作三处:哪些字符序列能成为数字(flex 正则)、字符串转 Decimal 是否合法(tokens.c)、这个数字在一条 posting 里扮演什么角色(bison 规则 + Builder)。它还要允许用户故意留空——省略金额让系统推算是核心特性,"没写"必须成为能往下游传的值,而不是解析失败。

2. 结构一览

名称 位置 作用
NUMBER 正则 lexer.l:281-284 ([0-9]+|[0-9][0-9,]+[0-9])(\.[0-9]*)?
DATE 正则 lexer.l:266-269 (17|18|19|20)[0-9]{2}[\-/][0-9]+[\-/][0-9]+
CURRENCY 正则 lexer.l:203-208 两条,故意排在 SLASH 规则之前
运算符 token lexer.l:218-226 COMMA / TILDE / PLUS / MINUS / SLASH / LPAREN / RPAREN / HASH / ASTERISK
TOKEN 宏与 build_NUMBER tokens.h:29-33,42 词法动作里就地构造 Python 对象
validate_decimal_number tokens.c:9-53 校验千分位分组并删逗号
pydecimal_from_cstring tokens.c:55-67 校验失败抛 ValueError,否则调 PyDec_FromCString
number_expr grammar.y:316-350 四则运算、括号、一元正负
优先级声明 grammar.y:268-274 %left MINUS PLUS / %left ASTERISK SLASH / %precedence NEGATIVE
maybe_number / maybe_currency grammar.y:636-650 %empty 分支产出 MISSING_OBJ
amount / incomplete_amount / compound_amount grammar.y:614-619,652-675 三条规则都调 Builder 的 amountcompound_amount
cost_spec / cost_comp_list grammar.y:677-719 {}{{}}%empty 三态
Builder.amount / compound_amount grammar.py:461-497 构造 Amount / CompoundAmount,并更新显示上下文
Builder.cost_spec grammar.py:503-611 拼装 CostSpec,处理 {{...}} 语义
Builder.posting grammar.py:868-948 负价格、总价折算、成本币种校验
Builder._dcupdate grammar.py:175-178 amount/compound_amount 构造、带有效币种的数字更新到 DisplayContext

3. 词法层:什么算一个数字

3.1 NUMBER 正则允许与拒绝什么

 /* Numbers. */
([0-9]+|[0-9][0-9,]+[0-9])(\.[0-9]*)? {
    return TOKEN(NUMBER, yytext);
}

lexer.l:281-284。三条硬边界:

正则本身不检查千分位分组是否三位一组,452,34.00 在词法阶段是能匹配的;分组由 tokens.c 补。lexer_test.py:194-209 (test_number_okay) 用十二行覆盖了正负号、有无小数、有无逗号的组合。相邻规则的交互也锁定了:555.00 CAD.11 切成 NUMBER + CURRENCY(:234-246),1.234.00 因为第二个点无处安放而落到默认规则报错(:218-223)。

3.2 tokens.c 的分组校验:比注释里的正则宽

if (str[n] == ',') {
    if (n == 0 || (n > 2 && digits != 3) || dot)
        return -EINVAL;
    comma = true;
    digits = 0;
    continue;
}

tokens.c:19-26,配合 :33-39(点之前若已有逗号则前一段必须恰好三位)与 :46-48(结尾若有逗号无点则末段必须三位)。tokens.h:49-60 的注释声称等价于 ^(\d+|\d{1,3}(,\d{3})+)(\.\d+)?$

实际不等价:第一个条件用下标 n > 2 而不是"是否已经出现过逗号"来判断首段,于是下标 ≤ 2 处的第二个逗号被放过。抽取 tokens.c:9-53 单独编译验证:1,,234 返回 4、缓冲区内容 1234,而 12,,3451,,,2341,2345452,34.00 都返回 -EINVAL。词法正则 [0-9][0-9,]+[0-9] 同样允许连续逗号,所以账本里写 1,,234 USD 会被当成 1234 USD 静默接受。

第二个性质::28-31 只有 isdigit 才把字符拷进输出缓冲,:33 只处理点,没有 else 分支,其余字符被静默丢弃。单独拿这个函数校验 "1e3" 会得到 "13"。它之所以安全,是因为 flex 正则已经保证输入只含数字、逗号、点。缓冲区固定 256 字节(:57),写满即返回 -ENOMEM:41-43),tokens_test.c:67-73 用长度 8 和 7 两个调用锁定了这条边界。

3.3 从 C 字符串到 Decimal

tokens.h:29-33TOKEN 宏在词法动作里直接构造 Python 对象,build_NUMBER 展开为 pydecimal_from_cstring:42)。后者校验通过后调 PyDec_FromCStringtokens.c:66)。decimal.h:4-11 说明由来:"Python does not expose a C-API to create Decimal objects",于是仿照 datetime,用 PyDecimal_IMPORT 缓存 decimal.Decimal 类型对象(:18-21),PyDec_FromCStringPyObject_CallFunction(decimal_type, "s#", str, len):26)。结论两点:账本数字是用清洗后的字符串Decimal(str) 构造的,不经过 float,也不经过 beancount.core.number.D;构造失败(ValueError)由 TOKEN 宏转成词法错误(tokens.h:46)。

3.4 DATE 正则限制年份,避让减法表达式

 /* Dates. */
(17|18|19|20)[0-9]{2}[\-/][0-9]+[\-/][0-9]+ {
    return TOKEN(DATE, yytext);
}

lexer.l:266-269。flex 取最长匹配,2013-05-18 整体成为 DATE 而非三个 NUMBER。年份前缀限制是 2026-05-17 的 commit f5d2f2c0(Fixed #986)加的,之前是 [0-9]{4,},导致算术表达式 1000-12-32 被当成日期、再因 PyDate_FromDateValueError 而报错。改后 1000-12-32 走 NUMBER/MINUS 路径算成 956(grammar_test.py:1715-1729lexer_test.py:295-311)。

代价是 1700–2099 年份区间被日期语法占用:2013-12-98 仍是 DATE token,构造日期失败即报错(lexer_test.py:262-279),无法当减法写。日期字符串到 datetime.date 的转换在 tokens.c:167-191

3.5 非法输入的恢复

默认规则 .lexer.l:307-311)把字符退回并切到 INVALID 状态,<INVALID>[^ \t\n\r]+:319-325)吃到下一个空白,记一条 "Invalid token" 错误并返回 YYerror。所以 .2347 USD.2347 整段非法,而非逐字符报错。

4. 语法层:number_expr 与 MISSING

4.1 四则运算是"临时方案"

/* FIXME: This needs be made more general, dealing with precedence.
   I just need this right now, so I'm putting it in, in a way that will.
   be backwards compatible, so this is just a bit of a temporary hack
   (blais, 2015-04-18). */
number_expr:
  NUMBER
  | number_expr PLUS number_expr
  | number_expr MINUS number_expr
  | number_expr ASTERISK number_expr
  | number_expr SLASH number_expr
  | MINUS number_expr %prec NEGATIVE
  | PLUS number_expr %prec NEGATIVE
  | LPAREN number_expr RPAREN

grammar.y:312-350(省略各分支动作代码)。四个二元分支分别调 PyNumber_Add / Subtract / Multiply / TrueDivide:320,325,330,335),一元减调 PyNumber_Negative:340),一元加与括号透传(:345,349)。运算在 C 层对 Decimal 对象进行,除法调用 Decimal 真除法,遵循调用线程当前的 decimal 上下文:默认精度 28 位,需要舍入的非终止商按该精度截断(12/7 得到 28 位有效数字),能精确表示的商则保留其自然位数(Decimal("12") / Decimal("3") 就是 Decimal("4"),不会补零到 28 位)。Beancount 在这里没有建立固定的 localcontext,调用方若修改全局 decimal 精度会改变解析结果。

优先级不写在规则里,而是靠 grammar.y:268-274 的三行声明,注释注明 "This is pulled straight out of the textbook example" 并给出 bison 手册的 Infix-Calc 链接。%expect 8:280)声明了 8 个预期的移进/归约冲突,注释说在 eol 处。

number_expr 被六类产生式直接引用,源码里共七行引用:元数据值 key_value_value:473)、amount:615)、amount_tolerance 的两个分支(:622,629)、maybe_number:637)、compound_amount:658)、custom_value:779)。grammar_test.py:1731-1747 (test_number_expr__different_places) 锁定 units、成本、价格、balance 金额、元数据里都能写表达式。

4.2 正负号是独立 token

lexer.l:220-221+ - 分别返回 PLUS / MINUS,NUMBER 正则里没有符号位。后果是符号与数字之间可以有空白:- 1002.00 USD 合法(lexer_test.py:211-216grammar_test.py:1663-1675)。- 在语法上永远是运算符,1000-12-32 这类三段式在年份不落入日期区间时自然变成减法。

4.3 MISSING 的产生点

MISSING 由 Python 侧传入 C 层(parser.c:327beancount.core.number.MISSING,经 yylex_initialize 存进 scanner 的 extra 数据,lexer.l:37,397),语法动作用宏 MISSING_OBJ 取用(grammar.y:81)。产生点四处:

Python 侧还有两处:Builder.cost_speccost_comp_list 为空列表时返回 CostSpec(MISSING, None, MISSING, None, None, False)grammar.py:518-519),在有 {...} 但其中没有金额分量时把 number_percurrencyMISSING:586-587)。空 {} 之所以能与"没有成本"区分,是因为 cost_comp_list%empty 分支返回一个空 PyListgrammar.y:694-699)而不是 None,而 cost_spec%empty 分支返回 Py_None:688-692)、根本不调用 Builder。

{...} 里的日期与标签永远不是 MISSINGparser.py:66-69 说明两项是可选输入,缺失即缺失,不需推算。

4.4 单位、成本、价格三条路径

账本写法 走的规则 结果
100.00 USD incomplete_amountBuilder.amount Amount(D("100.00"), "USD")
USD(只有币种) maybe_number Amount(MISSING, "USD")
{45.23 USD} compound_amountcost_spec CostSpec(D("45.23"), None, "USD", None, None, False)
{150 # 5 USD} maybe_number HASH maybe_number currency:663-668 CostSpec(D("150"), D("5"), "USD", ...)
{{2000 USD}} LCURLCURL ... RCURLCURLis_total=True CostSpec(ZERO, D("2000"), "USD", ...)
@ 1.2 USD price_annotationincomplete_amount price = Amount(D("1.2"), "USD")
@@ 2000.00 USD 同上,istotal=True Builder 里折成每股价

amountincomplete_amount 调的是同一个 Builder 方法(grammar.y:617,673),差别只在 maybe_* 是否允许空。amount_tolerance~ 容差数(:629-634)不经过 Builder.amount,作为裸 Decimal 传出。

5. 解析阶段的"报错但继续"

解析阶段有三处就地纠正:记一条 ParserError 然后改写数据继续解析,而不是中断。

# Prices may not be negative.
if price and isinstance(price.number, Decimal) and price.number < ZERO:
    self.errors.append(ParserError(meta, ("Negative prices are not allowed: {} ...")))
    # Fix it and continue.
    price = Amount(abs(price.number), price.currency)

grammar.py:886-899。错误信息里带一个变通做法的链接。grammar_test.py:1318-1326:1349-1357 分别锁定 @ -200.00 USD@@ -2000.00 USD 都触发。

第二处在 Builder.cost_spec:590-602):{{...}} 总价语法里又写了 # 复合形式({{100 # 2,000 USD}}),报 "Per-unit cost may not be specified using total cost syntax",把 number_perZERO、保留 number_totalgrammar_test.py:1291-1306 断言这个结果,注释写着 "Note how this gets canceled"。同分支的正常路径(:603-606)把单个数字解释成总价:{{2000 USD}}CostSpec(ZERO, 2000, "USD", ...)

第三处在 :903-914@@ 总价但 units 数值是 MISSING 时,报 "Total price on a posting without units" 并把 price 整个丢成 None。注释承认这是取巧:"we could potentially do a better job and attempt to fix this up after interpolation, but this syntax is pretty rare anyway"(:905-907)。

@@ 的正常折算是 price_number / abs(units.number),units 为零时直接取 ZERO 以避免除零(:918-920)。取绝对值意味着卖出(负 units)时价格不翻号,grammar_test.py:1338-1347 锁定 -10 MSFT @@ 2000.00 USD 的 price 是 200 USD。这个除法是 Decimal 真除法,TODO:4836-4841 收录的 issue #109 "A lot of decimal digits if using total price" 就指向它,作者的回应是"从最精确处往下取整直到超出容差",标注为"will definitely consider this and implement eventually"。

6. 语法层不做的校验

零价格、零数量、负成本、不平衡在解析阶段一律不报错:

# Note: Allow zero prices because we need them for round-trips for
# conversion entries.
#
# if price is not None and price.number == ZERO:
#     self.errors.append(
#         ParserError(meta, "Price is zero: {}".format(price), None))

grammar.py:923-928——检查代码被注释掉并留下理由:换算分录需要零价格才能往返打印再解析。grammar_test.py:889-897 (test_zero_prices) 锁定 @ 0 XFER 无错误。零数量(:899-908)注释 "Zero amount is caught only at booking time";负成本(:1259-1267)注释 "This error is caught only at booking time";借贷不平衡(:920-928)也只产生 Transaction 不产生错误。跨字段校验并非只有一处:Builder.cost_spec 检测 cost_comp_list 内成本/日期/标签/merge 分量的重复(:528-581)、Builder.posting 处理 @@ 总价在 units 缺失时的报错(:903-914,见 5 节);Builder.posting 在此之外新增的是成本币种与价格币种一致性校验(:930-946),不一致时只报错、不改数据。

7. 显示精度的采集

def _dcupdate(self, number, currency):
    """Update the display context."""
    if isinstance(number, Decimal) and currency and currency is not MISSING:
        self.display_context_update(number, currency)

grammar.py:175-178。调用点只有两个:Builder.amount:474)与 Builder.compound_amount:492-493,per 与 total 各调用一次),两处都带同一条注释 "This is relatively slow, adds about 70ms because of number.as_tuple()"(:473,491)。开销来自 display_context.py:201-205,每个数字都要 as_tuple() 取 exponent 记进分布。

三条推论:币种为 MISSING 或空的数字不参与统计;裸数值元数据(key_value_valuenumber_expr 分支,grammar.y:473)、容差数字、number_expr 中间结果都不参与统计,但写成 Amount 的元数据(如 key: 1.20 USD,走 key_value_valueamount 分支,grammar.y:465-475)会经 Builder.amount 而被采集;@@ 折算出的每股价在 Builder.posting 里算,那时 Builder.amount 已调用完毕,进入统计的是账本里写的总价而非折算后的商。Builder.finalize:180-215)在结束时把 render_commas 写进 DisplayContext,并用 display_precision 覆盖成固定精度(:206-213)。

8. 设计决策与理由

决策 理由 证据
数字在 C 词法动作里就地构造成 Decimal 避免把字符串传回 Python 再转换;commit 说明的动机是提速并简化实现 tokens.h:29-33,42;commit c90ab68d(2020-06-15,"This should be a nice optimization AND makes the code easier")
千分位校验放在 C 而非正则 flex 正则只识别候选 NUMBER,分组校验与删逗号一起放进同一次编译代码改动 lexer.l:281-284tokens.c:9-53;commit c90ab68d(同上)
缓存 Decimal 类型对象,PyDec_FromCString 直接构造 此前每次构造数字都执行 PyImport_ImportModule("decimal") 与属性查找,改为 PyDecimal_IMPORT 一次性缓存 decimal.h:4-26;commit abd9741c(2020-06-16)
不接受前导小数点 让词法正则保持"可处理",作者留了将来自写词法器再放开的余地 lexer_test.py:230-231
正负号作为独立 token 符号必须能参与表达式(-(3 * 4)),也允许符号与数字间有空白 lexer.l:220-221grammar.y:338-346
算术只做四则与括号,靠 bison 优先级声明 作者自称临时方案,从 bison 手册例子直接搬来 grammar.y:312-315,268-274
DATE 年份限制在 1700-2099 四位数减法表达式与日期形状相同,必须给算术让路 commit f5d2f2c0(2026-05-17,Fixed #986)
CURRENCY 规则排在 SLASH 之前 /NQH21 这类期货代码要优先于除号匹配 lexer.l:197-199
缺失值用 MISSING 而非解析失败 省略金额是核心特性,缺口要传到 booking/插值 grammar.y:446-450,636-650parser.py:21-28
三处错误就地纠正而非中止 一个文件里的多处错误应当一次报全,解析继续才能收集后续错误 grammar.py:886-899,590-602,903-914
零价格显式放行 换算分录需要零价格才能往返 grammar.py:923-924
带成本的零数量与负成本推迟到 booking/interpolation 检查 语法层构造 CostSpec 时尚未插值,成本/数量的最终值可能仍是 MISSING;无成本的零数量本身可以通过 booking booking_full.py:1027-1047booking_test.py:25-67
交易不平衡推迟到插件转换之后的 validation 阶段检查 允许用户插件先"修正"尚未平衡的输入,再在插件处理完的结果上做最终校验 ops/validation.py:350-386loader.py:604-627
Builder.amount/compound_amount 构造时更新 DisplayContext 输出精度由解析期间统计到的数字分布决定 grammar.py:175-178,474,492-493

9. 行为细节与边界

现象 后果 证据
1,,234 被接受为 1234 分组检查用下标 n > 2 判首段,下标 ≤2 处的第二个逗号漏检;词法正则也允许连续逗号 tokens.c:21lexer.l:282(抽出函数编译验证:返回 4,缓冲区 1234
【文档漂移】tokens.h:54 声称等价于 ^(\d+|\d{1,3}(,\d{3})+)(\.\d+)?$ 与实现有两处不符:漏检连续逗号(见上一条);(\.\d+)? 要求小数点后至少一位数字,但 tokens.c. 的校验没有此要求,100. 实际合法(见下一条) tokens.h:49-60tokens.c:19-48
100. 合法 小数部分是 \.[0-9]*Decimal("100.") 的 exponent 为 0,与 100 同精度 lexer.l:282
45234.000,000 不报错 切成 NUMBER、COMMA、NUMBER 三个 token,错误推迟到语法层 lexer_test.py:752-769,注释 "this is going to get parsed as two numbers but that will cause an error downstream"
清洗(删逗号)后的数字字符达到 256 字节 validate_decimal_number 返回 -ENOMEM,报 "Invalid number format" 而非"太长";缓冲区 256 字节,故最多容纳 255 个清洗后字符加结尾 NUL——源 token 若含合法千分位逗号可以远超 255 个字符(逗号不占缓冲区);普通 256 位整数(无逗号)才会触发 -ENOMEM tokens.c:9-50,57,61-63
tokens.c 对未知字符静默跳过 该函数不能单独当校验器用;安全性靠 flex 正则在前把关 tokens.c:28-39(无 else 分支)
1700-2099 年份的三段式无法写成减法 2013-12-98 是 DATE token,日期构造失败报错 lexer.l:267lexer_test.py:262-279
日期分隔符不校验 2013/05-18 也能构成 DATE lexer.l:267tokens.c:167-180
除法结果不量化 12 / 7 得 28 位有效数字,直接进入 units;@@ 折算同理 grammar.y:333-337grammar.py:918-919
@@ 折算用 abs(units.number) 负数量时价格不翻号 grammar.py:919grammar_test.py:1338-1347
@@ 折算后的价格不进 DisplayContext 统计到的是账本里写的总价 grammar.py:474 早于 :918-921 执行
{{...}} 单数字被重解释为总价 {{2000 USD}}number_per 变成 ZERO 而不是 None grammar.py:603-606grammar_test.py:1284-1289
{} 与无成本可区分 前者 CostSpec(MISSING, None, MISSING, ...),后者 cost is None grammar.y:688-699grammar.py:518-519grammar_test.py:1017-1028
【文档漂移】booking_full.py:1032-1035 注释称 "we don't allow either a cost value of zero" 实际条件只有 cost.number < ZERO:1042),等于零的成本放行;booking_test.py:47-55 (test_cost_zero) 断言零错误 booking_full.py:1027-1047booking_test.py:47-55
【文档漂移】parser.py:40 写 "You must always specify the currency" maybe_currency 的空分支允许省略币种,-100.00 得到 Amount(D("-100.00"), MISSING) grammar.y:644-650grammar_test.py:2274-2282
【文档漂移】parser.py:104{2015-09-21} 的 merge 位是 True 实际是 Falsemerge 只由 {*} 置位 grammar.py:551-553,608-609grammar_test.py:1043-1057
{*} 恒定报错 "Cost merging is not supported yet",但仍返回 merge=TrueCostSpec grammar.py:551-559grammar_test.py:2505-2513
{45.23 USD / 2015-07-16} 是语法错误 / 分隔符在 2016-10-30 被移除,只能用逗号 grammar_test.py:1207-1216CHANGES:1788-1802
【文档漂移】Builder.transaction 的 docstring 称 "the transaction is balanced here, incomplete postings are completed" 方法体只组装 postings/tags/links/元数据、返回 Transaction;真正的 booking 由 loader.py:604-607 调用 booking.book(),最终不平衡校验在 loader.py:622-627 grammar.py:1025-1142(docstring 在 1030-1033)

10. 测试锁定了什么

覆盖方式:lexer_test.py(773 行)通读;grammar_test.py(2611 行)先用 grep -n 'def test\|^class' 取索引,再按名字读 TestArithmetic:1612-1747)、TestTotalsAndSigns:1240-1365)、TestParseLots:1001-1216)、TestIncompleteInputs:2240-2541)、TestTransactions 中的 zero/imbalance 段(:889-928)、TestBalance:1368-1396);tokens_test.c 全文读。

测试 位置 锁定的行为
test_number_okay / test_number_space lexer_test.py:193-216 正负号、小数、千分位十二种组合;符号与数字间可有空白
test_number_dots / test_number_no_integer :218-232 1.234.00.2347 均报错
test_currency_number / test_currency_dash :234-260 555.00 CAD.11 切成 NUMBER + CURRENCY;带连字符的 TEST-DA 是单个 CURRENCY token、零错误
test_bad_date / test_date_followed_by_number / test_lexer_exception_DATE :262-293,644-659 日期形状但日期非法时报错并继续
test_number_expr_not_lexed_as_date :295-311 1000-12-32 切成 NUMBER MINUS NUMBER MINUS NUMBER,零错误
test_valid_commas_in_number 等三个 :723-769 45,234.00 通过;452,34.00 报错;45234.000,000 切成两个数
test_validate_decimal_number tokens_test.c:9-75 十四个调用:四个合法形态、八种非法逗号位置、两个缓冲区边界
TestArithmetic 十个用例 grammar_test.py:1612-1747 加减乘除、一元正负、优先级、括号、1000-12-32 算术、表达式出现在五种位置
test_zero_prices / test_zero_units / test_zero_costs / test_imbalance :889-928 四种"可疑但合法"输入在解析阶段零错误
test_total_cost / test_total_cost__invalid :1269-1306 {{2000 USD}}(ZERO, 2000);复合形式报错且 number_per 归零
test_price_negative / test_total_price_inverted :1318-1326,1349-1357 负价格报 "Negative...allowed"
test_total_price_positive / _negative :1327-1347 @@ 折算,负数量不翻号
test_total_price_with_missing :1358-1365 units 缺失时 @@ 报错并丢弃 price
TestParseLots 十七个用例 :1001-1216 {}{金额}{日期}{标签}{*}{a # b}、重复分量、/ 分隔符报错
TestIncompleteInputs 二十六个用例 :2240-2541 units / price / cost 各种缺失组合对应的 MISSING 布局
test_signatures :2594-2607 所有 Builder 公开方法前三个参数必须是 self, filename, lineno

没有测试覆盖的行为1,,234 这类连续逗号(C 测试与 Python 测试都没有);100. 尾随小数点;超长数字触发 -ENOMEM 的账本路径(只有 tokens_test.c 直调函数);日期分隔符混用 2013/05-18;除法结果的精度(无断言检查位数);number_expr 除零(12 / 0)。_dcupdateMISSING 币种的跳过分支则是执行到了但没有直接断言:test_units_missing_currencygrammar_test.py:2274-2282)、test_price_missing_currency:2388-2397)、test_cost_missing_currency:2455-2463)都会构造 currency is MISSING 的调用,实际走进跳过分支,但这些用例只断言解析结果,全仓库没有测试断言 dcontext/options_map["dcontext"] 未因此变化。

11. 演变史

2017 年之前仓库布局带 src/python/ 前缀,下表 2015 年的提交路径为 src/python/beancount/parser/grammar.y

日期 提交 / 记录 变化
2014-02-25 de7ac683 语法加入总成本支持
2014-10-22 d0eaca7a 采纳 Nathan Grigg 的补丁,数字支持千分位逗号
2015-04-18 77f51ef6299515946a2aba78 依次实现除法、改用 PyNumberMethods 提速、加入乘法;number_expr 的 FIXME 注释即此日期
2015-07-12 e401d951cbee6677c88df851 加减法与括号及优先级;优先级 token 改名 NEGATIVE 并支持一元正号;修复元数据字段里的算术
2015-07-16 6263b052 总成本分隔符由 ~ 改为 #,避让 balance 的 ~ 容差语法
2015-10-10 85d33162 进一步放宽币种与数字的省略
2016-10-30 CHANGES:1788-1802 {{...}} 从"仅 / 分隔 + 日期"升级到与 {...} 同语法;复合形式改为报错,单个数字解释为总价
2017-05-24 c1f05e4fCHANGES:1348-1350 加入千分位分组校验(当时在 beancount/parser/lexer.py),1,245,449,72.00 不再解析
2020-06-15 c90ab68d token 值的构造从 Python 移到编译代码,分组校验随之进入 C
2020-06-16 abd9741cf29e420c 新增 decimal.c/h 缓存 Decimal 类型对象;补上 C 层单元测试
2020-09-19 b3fa45df tokens.h 里的函数实现移入独立编译单元 tokens.c
2021-03-16 / 03-20 bfbd900dd2d0a35e 币种语法扩展:/ 前缀期货代码;CURRENCY 规则前置于 SLASH;d2d0a35e 提交说明明确单字母币种当时只在 v3 C++ 词法器可用,GNU flex 测试仍把 VP 断言为 error
2023-10-02 345efb19 单个大写字母可直接作 flag 或币种:新增 CAPITAL token,currency 产生式加入 CAPITAL 分支,GNU flex/Python 解析器由此支持单字母币种
2026-05-17 f5d2f2c0 DATE 年份限制为 1700-2099,修 #986

12. 与其他模块的关系

13. 参考索引

beancount/parser/lexer.l:37、397 missing_obj;139-148 EOL/INDENT;166-201 币种注释;203-208 CURRENCY;211-227 特殊字符(218 COMMA、219 TILDE、220 PLUS、221 MINUS、222 SLASH、223-224 括号、225 HASH、226 ASTERISK);240-243 CAPITAL;266-269 DATE;281-284 NUMBER;301-311 忽略行与默认规则;319-325 INVALID 状态;375-405 yylex_initialize

beancount/parser/tokens.c:9-53 validate_decimal_number(19-26 逗号、28-39 拷贝与点、41-43 缓冲区、46-48 结尾分组);55-67 pydecimal_from_cstring;167-191 日期转换。tokens.h:29-33 TOKEN;42 build_NUMBER;46 build_EXCEPTION;49-60 校验函数注释。decimal.h:4-11 说明;18-21 PyDecimal_IMPORT;26 PyDec_FromCStringtokens_test.c:9-75。

beancount/parser/grammar.y:81 MISSING_OBJ;268-274 优先级;279-280 %expect;312-350 number_expr;352-360 currency;420-421 price_annotation;430-450 posting;465-479 key_value_value;614-619 amount;621-634 amount_tolerance;636-650 maybe_number/maybe_currency;652-668 compound_amount;670-675 incomplete_amount;677-692 cost_spec;694-719 cost_comp_list/cost_comp;779-783 custom_value

beancount/parser/grammar.py:171-173 dcontext;175-178 _dcupdate;180-215 finalize;225-234 get_options;367-438 option(398-407 converter 应用);461-475 amount;477-497 compound_amount;503-611 cost_spec(518-519、551-559、586-587、590-606);868-948 posting(886-899、903-921、923-928、930-946);1025-1142 transaction(1030-1033 docstring)。

beancount/parser/booking_full.py:798-1049 interpolate_group;1027-1047 零数量/负成本检查。beancount/core/interpolate.py:72-94 compute_residual;97-244 infer_tolerances;364-394 quantize_with_tolerancebeancount/parser/booking_test.py:25-67 TestInvalidAmountsErrorsbeancount/ops/validation.py:350-386 validate_check_transaction_balancesbeancount/loader.py:604-627。beancount/parser/options.py:60-89 options_validate_tolerance(_map);474-482,505-512,536-541 Decimal 类 Opt;638 long_string_maxlines

其它beancount/parser/parser.py:21-28,32-104,144-185beancount/parser/parser.c:327beancount/core/number.py:31-32,41-71beancount/core/display_context.py:201-205,277-284beancount/core/amount.py:30-37TODO:4836-4841CHANGES:1348-1350,1788-1802

commitde7ac683(2014-02-25)、d0eaca7a(2014-10-22)、77f51ef6/29951594/6a2aba78(2015-04-18)、e401d951/cbee6677/c88df851(2015-07-12)、6263b052(2015-07-16)、85d33162(2015-10-10)、c1f05e4f(2017-05-24)、c90ab68d(2020-06-15)、abd9741c/f29e420c(2020-06-16)、b3fa45df(2020-09-19)、bfbd900d(2021-03-16)、d2d0a35e(2021-03-20)、345efb19(2023-10-02)、f5d2f2c0(2026-05-17)。