数字相关处理分布在词法层的 flex 正则识别、C 层的 Decimal
合法性校验与构造、以及 bison 语法规则与 Builder
语义动作三处:哪些字符序列能成为数字(flex 正则)、字符串转
Decimal 是否合法(tokens.c)、这个数字在一条
posting 里扮演什么角色(bison 规则 +
Builder)。它还要允许用户故意留空——省略金额让系统推算是核心特性,"没写"必须成为能往下游传的值,而不是解析失败。
| 名称 | 位置 | 作用 |
|---|---|---|
| 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 的 amount 或
compound_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 |
/* Numbers. */
([0-9]+|[0-9][0-9,]+[0-9])(\.[0-9]*)? {
return TOKEN(NUMBER, yytext);
}
lexer.l:281-284。三条硬边界:
.2347
不匹配,lexer_test.py:226-232 (test_number_no_integer)
断言产生错误,注释写明理由 "this is not supported in order to make the
lexer regexp more tractable. If we write our own lexer eventually we
should be able to accept these"。NaN、Infinity。字符集里只有数字、逗号、点,1e3
的 e 进不了 token。账本语法与
beancount/core/number.py:41-71 (D)
的接受范围不同:D("1e3") 接受,账本里不接受(02 篇第 4
节)。[0-9][0-9,]+[0-9] 决定——必须以数字开头结尾。小数部分是
\.[0-9]*,星号意味着 100. 合法,转出来的
Decimal("100.") 等于 Decimal("100"),exponent
为 0。正则本身不检查千分位分组是否三位一组,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)。
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,,345、1,,,234、1,2345、452,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 两个调用锁定了这条边界。
Decimaltokens.h:29-33 的 TOKEN
宏在词法动作里直接构造 Python 对象,build_NUMBER 展开为
pydecimal_from_cstring(:42)。后者校验通过后调
PyDec_FromCString(tokens.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_FromCString 即
PyObject_CallFunction(decimal_type, "s#", str, len)(:26)。结论两点:账本数字是用清洗后的字符串调
Decimal(str) 构造的,不经过 float,也不经过
beancount.core.number.D;构造失败(ValueError)由
TOKEN 宏转成词法错误(tokens.h:46)。
/* 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_FromDate 抛
ValueError 而报错。改后 1000-12-32 走
NUMBER/MINUS 路径算成
956(grammar_test.py:1715-1729、lexer_test.py:295-311)。
代价是 1700–2099 年份区间被日期语法占用:2013-12-98 仍是
DATE
token,构造日期失败即报错(lexer_test.py:262-279),无法当减法写。日期字符串到
datetime.date 的转换在 tokens.c:167-191。
默认规则
.(lexer.l:307-311)把字符退回并切到
INVALID
状态,<INVALID>[^ \t\n\r]+(:319-325)吃到下一个空白,记一条
"Invalid token" 错误并返回 YYerror。所以
.2347 USD 报 .2347
整段非法,而非逐字符报错。
number_expr 与 MISSING/* 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 金额、元数据里都能写表达式。
lexer.l:220-221 把 + -
分别返回 PLUS / MINUS,NUMBER
正则里没有符号位。后果是符号与数字之间可以有空白:- 1002.00 USD
合法(lexer_test.py:211-216、grammar_test.py:1663-1675)。-
在语法上永远是运算符,1000-12-32
这类三段式在年份不落入日期区间时自然变成减法。
MISSING 由 Python 侧传入 C 层(parser.c:327
取 beancount.core.number.MISSING,经
yylex_initialize 存进 scanner 的 extra
数据,lexer.l:37,397),语法动作用宏
MISSING_OBJ
取用(grammar.y:81)。产生点四处:
INDENT optflag account eol 直接把
MISSING_OBJ 当作 units 传给
Builder(grammar.y:446-450),得到
posting.units is MISSING(grammar_test.py:2254-2262)。maybe_number 的
%empty(:636-642):{# 5 USD}
的每股成本、{USD} 的成本数值。maybe_currency 的
%empty(:644-650):-100.00
不写币种、@ 1.2 不写计价币种。incomplete_amount(:670-675)由
maybe_number maybe_currency 组成,两边全空即
Amount(MISSING, MISSING)——@
后不写任何东西即此形态。price_annotation 是
incomplete_amount 的别名(:420-421),Builder
里没有对应方法。Python 侧还有两处:Builder.cost_spec 在
cost_comp_list 为空列表时返回
CostSpec(MISSING, None, MISSING, None, None, False)(grammar.py:518-519),在有
{...} 但其中没有金额分量时把
number_per、currency 置
MISSING(:586-587)。空 {}
之所以能与"没有成本"区分,是因为 cost_comp_list 的
%empty 分支返回一个空
PyList(grammar.y:694-699)而不是
None,而 cost_spec 的 %empty
分支返回 Py_None(:688-692)、根本不调用
Builder。
{...} 里的日期与标签永远不是
MISSING:parser.py:66-69
说明两项是可选输入,缺失即缺失,不需推算。
| 账本写法 | 走的规则 | 结果 |
|---|---|---|
100.00 USD |
incomplete_amount → Builder.amount |
Amount(D("100.00"), "USD") |
USD(只有币种) |
maybe_number 空 |
Amount(MISSING, "USD") |
{45.23 USD} |
compound_amount → cost_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 ... RCURLCURL,is_total=True |
CostSpec(ZERO, D("2000"), "USD", ...) |
@ 1.2 USD |
price_annotation → incomplete_amount |
price = Amount(D("1.2"), "USD") |
@@ 2000.00 USD |
同上,istotal=True |
Builder 里折成每股价 |
amount 与 incomplete_amount 调的是同一个
Builder 方法(grammar.y:617,673),差别只在
maybe_* 是否允许空。amount_tolerance 的
~ 容差数(:629-634)不经过
Builder.amount,作为裸 Decimal 传出。
解析阶段有三处就地纠正:记一条 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_per 置
ZERO、保留
number_total。grammar_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"。
零价格、零数量、负成本、不平衡在解析阶段一律不报错:
# 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),不一致时只报错、不改数据。
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_value 走
number_expr
分支,grammar.y:473)、容差数字、number_expr
中间结果都不参与统计,但写成 Amount 的元数据(如
key: 1.20 USD,走 key_value_value 的
amount 分支,grammar.y:465-475)会经
Builder.amount 而被采集;@@ 折算出的每股价在
Builder.posting 里算,那时 Builder.amount
已调用完毕,进入统计的是账本里写的总价而非折算后的商。Builder.finalize(:180-215)在结束时把
render_commas 写进 DisplayContext,并用
display_precision
覆盖成固定精度(:206-213)。
| 决策 | 理由 | 证据 |
|---|---|---|
数字在 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-284;tokens.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-221;grammar.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-650;parser.py:21-28 |
| 三处错误就地纠正而非中止 | 一个文件里的多处错误应当一次报全,解析继续才能收集后续错误 | grammar.py:886-899,590-602,903-914 |
| 零价格显式放行 | 换算分录需要零价格才能往返 | grammar.py:923-924 |
| 带成本的零数量与负成本推迟到 booking/interpolation 检查 | 语法层构造 CostSpec
时尚未插值,成本/数量的最终值可能仍是
MISSING;无成本的零数量本身可以通过 booking |
booking_full.py:1027-1047;booking_test.py:25-67 |
| 交易不平衡推迟到插件转换之后的 validation 阶段检查 | 允许用户插件先"修正"尚未平衡的输入,再在插件处理完的结果上做最终校验 | ops/validation.py:350-386;loader.py:604-627 |
Builder.amount/compound_amount 构造时更新
DisplayContext |
输出精度由解析期间统计到的数字分布决定 | grammar.py:175-178,474,492-493 |
| 现象 | 后果 | 证据 |
|---|---|---|
1,,234 被接受为 1234 |
分组检查用下标 n > 2 判首段,下标 ≤2
处的第二个逗号漏检;词法正则也允许连续逗号 |
tokens.c:21;lexer.l:282(抽出函数编译验证:返回
4,缓冲区 1234) |
【文档漂移】tokens.h:54 声称等价于
^(\d+|\d{1,3}(,\d{3})+)(\.\d+)?$ |
与实现有两处不符:漏检连续逗号(见上一条);(\.\d+)?
要求小数点后至少一位数字,但 tokens.c 对 .
的校验没有此要求,100. 实际合法(见下一条) |
tokens.h:49-60 对 tokens.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:267;lexer_test.py:262-279 |
| 日期分隔符不校验 | 2013/05-18 也能构成 DATE |
lexer.l:267;tokens.c:167-180 |
| 除法结果不量化 | 12 / 7 得 28 位有效数字,直接进入
units;@@ 折算同理 |
grammar.y:333-337;grammar.py:918-919 |
@@ 折算用 abs(units.number) |
负数量时价格不翻号 | grammar.py:919;grammar_test.py:1338-1347 |
@@ 折算后的价格不进 DisplayContext |
统计到的是账本里写的总价 | grammar.py:474 早于 :918-921 执行 |
{{...}} 单数字被重解释为总价 |
{{2000 USD}} 的 number_per 变成
ZERO 而不是 None |
grammar.py:603-606;grammar_test.py:1284-1289 |
空 {} 与无成本可区分 |
前者 CostSpec(MISSING, None, MISSING, ...),后者
cost is None |
grammar.y:688-699;grammar.py:518-519;grammar_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-1047;booking_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-650;grammar_test.py:2274-2282 |
【文档漂移】parser.py:104 说 {2015-09-21}
的 merge 位是 True |
实际是 False,merge 只由 {*}
置位 |
grammar.py:551-553,608-609;grammar_test.py:1043-1057 |
{*} 恒定报错 |
"Cost merging is not supported yet",但仍返回
merge=True 的 CostSpec |
grammar.py:551-559;grammar_test.py:2505-2513 |
{45.23 USD / 2015-07-16} 是语法错误 |
/ 分隔符在 2016-10-30 被移除,只能用逗号 |
grammar_test.py:1207-1216;CHANGES: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) |
覆盖方式: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)。_dcupdate 对
MISSING
币种的跳过分支则是执行到了但没有直接断言:test_units_missing_currency(grammar_test.py:2274-2282)、test_price_missing_currency(:2388-2397)、test_cost_missing_currency(:2455-2463)都会构造
currency is MISSING
的调用,实际走进跳过分支,但这些用例只断言解析结果,全仓库没有测试断言
dcontext/options_map["dcontext"]
未因此变化。
2017 年之前仓库布局带 src/python/ 前缀,下表 2015
年的提交路径为 src/python/beancount/parser/grammar.y。
| 日期 | 提交 / 记录 | 变化 |
|---|---|---|
| 2014-02-25 | de7ac683 |
语法加入总成本支持 |
| 2014-10-22 | d0eaca7a |
采纳 Nathan Grigg 的补丁,数字支持千分位逗号 |
| 2015-04-18 | 77f51ef6、29951594、6a2aba78 |
依次实现除法、改用 PyNumberMethods
提速、加入乘法;number_expr 的 FIXME 注释即此日期 |
| 2015-07-12 | e401d951、cbee6677、c88df851 |
加减法与括号及优先级;优先级 token 改名 NEGATIVE
并支持一元正号;修复元数据字段里的算术 |
| 2015-07-16 | 6263b052 |
总成本分隔符由 ~ 改为 #,避让 balance 的
~ 容差语法 |
| 2015-10-10 | 85d33162 |
进一步放宽币种与数字的省略 |
| 2016-10-30 | CHANGES:1788-1802 |
{{...}} 从"仅 / 分隔 + 日期"升级到与
{...} 同语法;复合形式改为报错,单个数字解释为总价 |
| 2017-05-24 | c1f05e4f、CHANGES:1348-1350 |
加入千分位分组校验(当时在
beancount/parser/lexer.py),1,245,449,72.00
不再解析 |
| 2020-06-15 | c90ab68d |
token 值的构造从 Python 移到编译代码,分组校验随之进入 C |
| 2020-06-16 | abd9741c、f29e420c |
新增 decimal.c/h 缓存 Decimal
类型对象;补上 C 层单元测试 |
| 2020-09-19 | b3fa45df |
tokens.h 里的函数实现移入独立编译单元
tokens.c |
| 2021-03-16 / 03-20 | bfbd900d、d2d0a35e |
币种语法扩展:/ 前缀期货代码;CURRENCY 规则前置于
SLASH;d2d0a35e 提交说明明确单字母币种当时只在 v3 C++
词法器可用,GNU flex 测试仍把 V、P 断言为
error |
| 2023-10-02 | 345efb19 |
单个大写字母可直接作 flag 或币种:新增 CAPITAL
token,currency 产生式加入 CAPITAL 分支,GNU
flex/Python 解析器由此支持单字母币种 |
| 2026-05-17 | f5d2f2c0 |
DATE 年份限制为 1700-2099,修 #986 |
decimal.h:13-26 提供构造
Decimal 的宏;beancount.core.number.MISSING 经
parser.c:327 →
yylex_initialize(lexer.l:375-405)→
grammar.y:81 的 MISSING_OBJ
进入语法动作。Builder.amount 构造
Amount(01 篇),Builder.compound_amount 构造
CompoundAmount,Builder.cost_spec 构造
CostSpec(beancount/core/position.py,03
篇),最终装进 Posting。Builder.amount /
compound_amount 构造、带有效币种的 Decimal
调用 DisplayContext.update(06 篇,写成 Amount
的元数据同样计入);finalize 设置逗号与固定精度配置后调用
get_options,将同一个 DisplayContext 对象写入
options_map["dcontext"](grammar.py:180-215,225-234)。beancount/parser/parser.py:144-185 (is_posting_incomplete / is_entry_incomplete)
用 is MISSING 判断一条 posting
是否残缺;booking_full.py 的
interpolate_group(:798-1049)负责识别并用
_replace 填补
MISSING,core/interpolate.py(07
篇)为其提供残差计算(compute_residual,:72-94)与容差推断(infer_tolerances,:97-244)等辅助函数;完成态不得残留
MISSING(parser.py:25-28)。number.D
的分工:账本里的金额、成本、价格全部走本篇这条 C
路径;option 指令语法本身只产生两个
STRING(grammar.y:827-832),是否转换取决于
Builder.option 查到的
option_descriptor.converter(grammar.py:398-407)——tolerance_multiplier、display_precision、inferred_tolerance_default
等 Decimal 选项配置了 D 或内部调用 D 的
options_validate_tolerance_map(options.py:60-89,536-541)而被转换,long_string_maxlines
等选项没有
converter,保留原始类型(options.py:638);importer 代码走
D()(02 篇第 4 节对照表)。beancount/core/amount.py:30-37 (CURRENCY_RE)
与 lexer.l:203-208 靠注释手工同步(01 篇 3.3 节)。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_FromCString。tokens_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_tolerance。beancount/parser/booking_test.py:25-67
TestInvalidAmountsErrors。beancount/ops/validation.py:350-386
validate_check_transaction_balances。beancount/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-185;beancount/parser/parser.c:327;beancount/core/number.py:31-32,41-71;beancount/core/display_context.py:201-205,277-284;beancount/core/amount.py:30-37;TODO:4836-4841;CHANGES:1348-1350,1788-1802。
commit:de7ac683(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)。