///|
fn plugin_accounts(entry : @ast.Entry) -> Array[String] {
match entry.directive {
DOpen(x) => [x.account]
DClose(x) => [x.account]
DBalance(x) => [x.account]
DPad(x) => [x.account, x.source_account]
DNote(x) => [x.account]
DDocument(x) => [x.account]
DTransaction(x) => x.postings.map(fn(p) { p.account })
_ => []
}
}
///|
fn plugin_error(
errors : Array[@ast.Diagnostic],
code : String,
message : String,
loc : @ast.Loc,
) -> Unit {
errors.push(@ast.Diagnostic::new("validation." + code, message, loc))
}
///|
fn auto_accounts(entries : Array[@ast.Entry]) -> Array[@ast.Entry] {
let opened : Map[String, Bool] = Map([])
for entry in entries {
if entry.directive is DOpen(o) {
opened.set(o.account, true)
}
}
let first : Map[String, @ast.Entry] = Map([])
for entry in sorted_entries(entries) {
for account in plugin_accounts(entry) {
if !opened.contains(account) && !first.contains(account) {
first.set(account, entry)
}
}
}
let names = first.keys().to_array()
names.sort_by(fn(a, b) { a.lexical_compare(b) })
let result = entries.copy()
for i in 0..", i))
result.push(generated)
}
sorted_entries(result)
}
///|
fn implicit_prices(entries : Array[@ast.Entry]) -> Array[@ast.Entry] {
let result = entries.copy()
let seen : Map[String, Array[@decimal.Decimal]] = Map([])
let inventory : Map[String, @decimal.Decimal] = Map([])
for entry in sorted_entries(entries) {
if entry.directive is DTransaction(txn) {
for posting in txn.postings {
if posting.units is Some(units) {
let mut reducing = false
if posting.cost is Some(cost) {
let lot = posting.account +
"|" +
units.currency +
"|" +
cost.to_string()
let previous = inventory
.get(lot)
.unwrap_or(@decimal.Decimal::zero())
reducing = !previous.is_zero() &&
previous.is_negative() != units.number.is_negative()
inventory.set(lot, previous.add(units.number))
}
let mut origin = "from_price"
let amount = match posting.price {
Some(PerUnit(a)) => Some(a)
Some(Total(_)) => None
None => {
origin = "from_cost"
if reducing {
None
} else {
match posting.cost {
Some(cost) =>
match (cost.number_per, cost.currency) {
(Some(n), Some(c)) => Some(@ast.Amount::new(n, c))
_ => None
}
None => None
}
}
}
}
let amount = match posting.price {
Some(Total(a)) =>
if units.number.is_zero() && a.number.is_zero() {
Some(@ast.Amount::new(@decimal.Decimal::zero(), a.currency))
} else {
match a.number.div(units.number.abs()) {
Some(n) => Some(@ast.Amount::new(n, a.currency))
None => None
}
}
_ => amount
}
if amount is Some(amount) {
let key = entry.date.to_string() +
"|" +
units.currency +
"|" +
amount.currency
let numbers = seen.get_or_init(key, fn() { [] })
if !numbers.any(fn(n) { n.equal(amount.number) }) {
numbers.push(amount.number)
let generated = @ast.Entry::new(
entry.date,
entry.loc,
DPrice({ currency: units.currency, amount, }),
)
generated.meta.entries.push(
("__implicit_prices__", @ast.MetaValue::MString(origin)),
)
result.push(generated)
}
}
}
}
}
}
sorted_entries(result)
}
///|
fn check_commodity(
entries : Array[@ast.Entry],
errors : Array[@ast.Diagnostic],
exemptions : Array[(@string.Regex, @string.Regex)],
) -> Unit {
let declared : Map[String, Bool] = Map([])
let used : Map[String, (@ast.Loc, String)] = Map([])
let exempted : Map[String, Bool] = Map([])
for entry in entries {
if entry.directive is DCommodity(c) {
declared.set(c.currency, true)
}
let currencies : Array[(String, String)] = []
match entry.directive {
DOpen(x) =>
for c in x.currencies {
currencies.push((c, x.account))
}
DBalance(x) => currencies.push((x.amount.currency, x.account))
DPrice(_) => ()
DTransaction(txn) =>
for p in txn.postings {
if p.units is Some(a) {
currencies.push((a.currency, p.account))
}
if p.price is Some(a) {
currencies.push((a.amount().currency, p.account))
}
if p.cost is Some(c) {
if c.currency is Some(c) {
currencies.push((c, p.account))
}
}
}
_ => ()
}
for (currency, account) in currencies {
if currency != "" {
if exemptions.any(fn(pair) {
pair.0.execute(account) is Some(_) &&
pair.1.execute(currency) is Some(_)
}) {
exempted.set(currency, true)
} else if !used.contains(currency) {
used.set(currency, (entry.loc, account))
}
}
}
}
// Account exemptions suppress price-only uses regardless of directive order.
for entry in entries {
if entry.directive is DPrice(p) {
for currency in [p.currency, p.amount.currency] {
if !used.contains(currency) && !exempted.contains(currency) {
used.set(currency, (entry.loc, "Price Directive Context"))
}
}
}
}
for currency in used.keys().to_array() {
if !declared.contains(currency) {
if used.get(currency) is Some((loc, account)) {
plugin_error(
errors,
"check_commodity",
"Missing Commodity directive for '" +
currency +
"' in '" +
account +
"'",
loc,
)
}
}
}
}
///|
fn unique_prices(
entries : Array[@ast.Entry],
errors : Array[@ast.Diagnostic],
) -> Unit {
let seen : Map[String, (@ast.Amount, @ast.Loc)] = Map([])
let reported : Map[String, Bool] = Map([])
for entry in entries {
if entry.directive is DPrice(p) {
let key = entry.date.to_string() +
"|" +
p.currency +
"|" +
p.amount.currency
match seen.get(key) {
Some((first, loc)) =>
if !first.number.equal(p.amount.number) && !reported.contains(key) {
reported.set(key, true)
plugin_error(
errors, "unique_prices", "Disagreeing price entries", loc,
)
}
None => seen.set(key, (p.amount, entry.loc))
}
}
}
}
///|
fn leafonly(
entries : Array[@ast.Entry],
errors : Array[@ast.Diagnostic],
) -> Unit {
let accounts : Map[String, @ast.Loc] = Map([])
let posted : Map[String, @ast.Loc] = Map([])
for entry in entries {
for name in plugin_accounts(entry) {
accounts.set(name, entry.loc)
}
let references = match entry.directive {
DOpen(_) | DBalance(_) => []
DCustom(x) =>
x.values.fold(init=[], fn(accounts, value) {
if value is CAccount(account) {
accounts.push(account)
}
accounts
})
_ => plugin_accounts(entry)
}
for account in references {
accounts.set(account, entry.loc)
if !posted.contains(account) {
posted.set(account, entry.loc)
}
}
}
for account in posted.keys().to_array() {
if accounts
.keys()
.to_array()
.any(fn(name) { name.has_prefix(account + ":") }) {
let mut loc = posted.get(account).unwrap()
for entry in entries {
if entry.directive is DOpen(x) {
if x.account == account {
loc = entry.loc
break
}
}
}
plugin_error(
errors,
"leafonly",
"Non-leaf account '" + account + "' has postings on it",
loc,
)
}
}
}
///|
fn run_plugins(
ledger : @ast.Ledger,
errors : Array[@ast.Diagnostic],
warnings : Array[@ast.Diagnostic],
) -> @ast.Ledger {
let mut entries = sorted_entries(ledger.entries)
for plugin in ledger.plugins {
if plugin.config is Some(_) &&
[
"beancount.plugins.auto_accounts", "beancount.plugins.implicit_prices", "beancount.plugins.unique_prices",
"beancount.plugins.leafonly",
].contains(plugin.name) {
warnings.push(
@ast.Diagnostic::new(
"validation.plugin",
"未实现的插件配置 '" + plugin.name + "';不执行",
plugin.loc,
),
)
continue
}
match plugin.name {
"beancount.plugins.auto_accounts" => entries = auto_accounts(entries)
"beancount.plugins.implicit_prices" => entries = implicit_prices(entries)
"beancount.plugins.check_commodity" =>
match commodity_exemptions(plugin.config) {
Some(exemptions) => check_commodity(entries, errors, exemptions)
None =>
warnings.push(
@ast.Diagnostic::new(
"validation.plugin",
"未实现的插件配置 '" +
plugin.name +
"';需要正则字符串字典,且正则须受 MoonBit 支持",
plugin.loc,
),
)
}
"beancount.plugins.unique_prices" => unique_prices(entries, errors)
"beancount.plugins.leafonly" => leafonly(entries, errors)
_ =>
warnings.push(
@ast.Diagnostic::new(
"validation.plugin",
"未实现的插件 '" + plugin.name + "';仅记录,不执行",
plugin.loc,
),
)
}
}
{ ..ledger, entries, }
}
///|
priv struct PluginConfigParser {
chars : Array[Char]
mut pos : Int32
}
///|
fn PluginConfigParser::take(self : PluginConfigParser, c : Char) -> Bool {
while self.pos < self.chars.length() &&
(
self.chars[self.pos] == ' ' ||
self.chars[self.pos] == '\n' ||
self.chars[self.pos] == '\t'
) {
self.pos += 1
}
if self.pos < self.chars.length() && self.chars[self.pos] == c {
self.pos += 1
true
} else {
false
}
}
///|
fn PluginConfigParser::quoted(self : PluginConfigParser) -> String? {
let quote = if self.take('\'') {
'\''
} else if self.take('"') {
'"'
} else {
return None
}
let text = StringBuilder()
while self.pos < self.chars.length() {
let mut c = self.chars[self.pos]
self.pos += 1
if c == quote {
return Some(text.to_string())
}
if c == '\\' {
if self.pos >= self.chars.length() {
return None
}
c = self.chars[self.pos]
self.pos += 1
if c != '\\' && c != '\'' && c != '"' {
text.write_char('\\')
}
}
text.write_char(c)
}
None
}
///|
fn commodity_exemptions(
config : String?,
) -> Array[(@string.Regex, @string.Regex)]? {
guard config is Some(config) else { return Some([]) }
let parser = PluginConfigParser::{ chars: config.iter().to_array(), pos: 0, }
if !parser.take('{') {
return None
}
let result = []
while !parser.take('}') {
guard parser.quoted() is Some(account) else { return None }
if !parser.take(':') {
return None
}
guard parser.quoted() is Some(currency) else { return None }
let account = @string.Regex("^(?:" + account + ")") catch {
_ => return None
}
let currency = @string.Regex("^(?:" + currency + ")") catch {
_ => return None
}
result.push((account, currency))
if !parser.take(',') {
if !parser.take('}') {
return None
}
break
}
}
if parser.pos < parser.chars.length() {
if parser.chars[parser.pos:].any(fn(c) {
c != ' ' && c != '\n' && c != '\t'
}) {
return None
}
}
Some(result)
}