瀏覽代碼

Improve summarize subcommand and start using ariadne Spans.

Kestrel 1 天之前
父節點
當前提交
ea2fd80a39
共有 7 個文件被更改,包括 234 次插入50 次删除
  1. 37 2
      src/check.rs
  2. 60 11
      src/data.rs
  3. 21 4
      src/data/ledger.rs
  4. 25 14
      src/data/ledger/parse.rs
  5. 38 10
      src/data/ledger/print.rs
  6. 42 7
      src/main.rs
  7. 11 2
      testdata/ledger

+ 37 - 2
src/check.rs

@@ -1,5 +1,40 @@
-use crate::data::{DataError, Root};
+use chumsky::span::Span;
 
-pub fn run_checks(root: &Root) -> Result<(), DataError> {
+use crate::data::{DataError, Decimal, Root};
+
+fn check_equal_sum(root: &Root) -> Result<(), DataError> {
+    log::debug!("Checking for equal sums in monounit ledger entries...");
+
+    for le in root.all_ledger_data() {
+        let Some(mono) = le.mono_unit() else { continue };
+
+        let net = le.balances.iter().fold(Some(Decimal::ZERO), |acc, b| {
+            acc.and_then(|acc| acc.checked_add(*b.amount))
+        });
+        if net != Some(Decimal::ZERO) {
+            let report = ariadne::Report::build(ariadne::ReportKind::Error, le.span()).with_labels(
+                le.balances.iter().map(|v| {
+                    let span = v.amount.span().union(v.unit.span());
+                    ariadne::Label::new(span).with_message("balance here")
+                }),
+            );
+
+            let report = if let Some(net) = net {
+                report
+                    .with_message("imbalanced transaction")
+                    .with_note(format!("net value: {net} {mono}"))
+            } else {
+                report.with_note("numeric overflow")
+            };
+
+            return Err(report.finish().into());
+        }
+    }
+
+    Ok(())
+}
+
+pub fn run_checks(root: &mut Root) -> Result<(), DataError> {
+    check_equal_sum(root)?;
     Ok(())
 }

+ 60 - 11
src/data.rs

@@ -4,6 +4,9 @@ use std::collections::HashMap;
 use std::rc::Rc;
 
 use ariadne::Cache;
+use chumsky::span::Span as CSpan;
+
+pub use rust_decimal::Decimal;
 
 pub mod ledger;
 mod spec;
@@ -72,7 +75,7 @@ impl ariadne::Cache<SourceFile> for FilesystemData {
 #[derive(Debug)]
 pub enum DataError {
     IOError(std::io::Error),
-    Report(Box<ariadne::Report<'static, (SourceFile, std::ops::Range<usize>)>>),
+    Report(Box<ariadne::Report<'static, Span>>),
     Validation(String),
 }
 
@@ -88,10 +91,16 @@ impl From<std::io::Error> for DataError {
     }
 }
 
-impl From<ariadne::Report<'static, (SourceFile, std::ops::Range<usize>)>> for DataError {
+/*impl From<ariadne::Report<'static, (SourceFile, std::ops::Range<usize>)>> for DataError {
     fn from(value: ariadne::Report<'static, (SourceFile, std::ops::Range<usize>)>) -> Self {
         Self::Report(value.into())
     }
+}*/
+
+impl From<ariadne::Report<'static, Span>> for DataError {
+    fn from(value: ariadne::Report<'static, Span>) -> Self {
+        Self::Report(value.into())
+    }
 }
 
 impl std::error::Error for DataError {}
@@ -99,7 +108,7 @@ impl std::error::Error for DataError {}
 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
 pub struct Span {
     range: (usize, usize),
-    context: SourceFile
+    context: SourceFile,
 }
 
 impl chumsky::span::Span for Span {
@@ -107,7 +116,10 @@ impl chumsky::span::Span for Span {
     type Context = SourceFile;
 
     fn new(context: Self::Context, range: std::ops::Range<Self::Offset>) -> Self {
-        Self { context, range: (range.start, range.end) }
+        Self {
+            context,
+            range: (range.start, range.end),
+        }
     }
 
     fn start(&self) -> Self::Offset {
@@ -125,11 +137,24 @@ impl chumsky::span::Span for Span {
     fn to_end(&self) -> Self {
         Self {
             context: self.context,
-            range: (self.range.1, self.range.1)
+            range: (self.range.1, self.range.1),
         }
     }
 }
 
+impl ariadne::Span for Span {
+    type SourceId = SourceFile;
+    fn source(&self) -> &Self::SourceId {
+        &self.context
+    }
+    fn start(&self) -> usize {
+        self.range.0
+    }
+    fn end(&self) -> usize {
+        self.range.1
+    }
+}
+
 #[derive(Debug, Clone, Copy)]
 pub struct Spanned<T>(pub T, pub Span);
 
@@ -168,7 +193,7 @@ impl<T: PartialEq> PartialEq for Spanned<T> {
     }
 }
 
-impl<T: Eq> Eq for Spanned<T> { }
+impl<T: Eq> Eq for Spanned<T> {}
 
 impl<T: PartialOrd> PartialOrd for Spanned<T> {
     fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
@@ -231,6 +256,8 @@ impl Root {
                 r.load_ledgers(fsdata)?;
                 r.preprocess_ledger_data();
 
+                crate::check::run_checks(&mut r)?;
+
                 Ok(r)
             }
             Err(te) => {
@@ -238,11 +265,13 @@ impl Root {
                     panic!("TOML parse error with no range: {te}");
                 };
 
-                let report =
-                    ariadne::Report::build(ariadne::ReportKind::Error, (sf, range.clone()))
-                        .with_label(ariadne::Label::new((sf, range)).with_message(te.message()))
-                        .with_message("Failed to parse root TOML")
-                        .finish();
+                let report = ariadne::Report::build(
+                    ariadne::ReportKind::Error,
+                    Span::new(sf, range.clone()),
+                )
+                .with_label(ariadne::Label::new(Span::new(sf, range)).with_message(te.message()))
+                .with_message("Failed to parse root TOML")
+                .finish();
 
                 Err(report.into())
             }
@@ -309,7 +338,27 @@ impl Root {
         self.account_ledger_data.get(&aname).map(Vec::as_slice)
     }
 
+    pub fn account_names(&self) -> impl Iterator<Item = AccountName> {
+        self.spec_root.accounts.keys().cloned()
+    }
+
     pub fn account_spec(&self, aname: AccountName) -> Option<&spec::AccountSpec> {
         self.spec_root.accounts.get(&aname)
     }
+
+    pub fn balance(&self, aname: AccountName) -> Option<HashMap<UnitName, Decimal>> {
+        let mut running = HashMap::<UnitName, Decimal>::new();
+
+        for le in self.ledger_data_for(aname)? {
+            for b in &le.balances {
+                if *b.account != aname {
+                    continue;
+                }
+                let v = running.entry(*b.unit).or_default();
+                *v = v.checked_add(*b.amount)?;
+            }
+        }
+
+        Some(running)
+    }
 }

+ 21 - 4
src/data/ledger.rs

@@ -1,4 +1,6 @@
-use super::{AccountName, UnitName, Spanned};
+use itertools::Itertools;
+
+use super::{AccountName, Decimal, Spanned, UnitName};
 
 mod parse;
 pub use parse::parse_ledger;
@@ -9,7 +11,7 @@ pub use print::print_ledger;
 #[derive(Clone, Debug, PartialEq, PartialOrd, Ord, Eq)]
 pub struct Balance {
     pub account: Spanned<AccountName>,
-    pub amount: Spanned<rust_decimal::Decimal>,
+    pub amount: Spanned<Decimal>,
     pub unit: Spanned<UnitName>,
 }
 
@@ -26,14 +28,19 @@ impl LedgerEntry {
     }
 
     pub fn balance_for(&self, account: AccountName) -> Option<&Spanned<Balance>> {
-        self.balances.iter().find(|b| b.account.as_ref() == &account)
+        self.balances
+            .iter()
+            .find(|b| b.account.as_ref() == &account)
     }
 
     pub fn split_balances(
         &self,
         account: AccountName,
     ) -> Option<(&Spanned<Balance>, impl Iterator<Item = &Spanned<Balance>>)> {
-        let index = self.balances.iter().position(|b| b.account.as_ref() == &account)?;
+        let index = self
+            .balances
+            .iter()
+            .position(|b| b.account.as_ref() == &account)?;
         Some((
             &self.balances[index],
             self.balances[0..index]
@@ -41,4 +48,14 @@ impl LedgerEntry {
                 .chain(self.balances[index + 1..].iter()),
         ))
     }
+
+    pub fn is_mono_unit(&self) -> bool {
+        self.balances.iter().unique_by(|b| *b.unit).count() == 1
+    }
+
+    pub fn mono_unit(&self) -> Option<UnitName> {
+        let mut it = self.balances.iter().unique_by(|b| *b.unit);
+        let uniq = it.next()?;
+        it.next().is_none().then_some(*uniq.unit)
+    }
 }

+ 25 - 14
src/data/ledger/parse.rs

@@ -1,6 +1,8 @@
-use crate::data::{AccountName, UnitName, Span, Spanned, spec::SpecRoot, DataError, SourceFile};
+use crate::data::{
+    AccountName, DataError, Decimal, SourceFile, Span, Spanned, UnitName, spec::SpecRoot,
+};
 
-use super::{LedgerEntry, Balance};
+use super::{Balance, LedgerEntry};
 
 use chumsky::{prelude::*, text::inline_whitespace};
 
@@ -68,10 +70,10 @@ fn ledger_parser<'a>() -> impl Parser<
         .to_slice()
         .try_map(|s: &str, span| {
             Ok(Spanned::new(
-                rust_decimal::Decimal::from_str_exact(s.trim()).map_err(|e| {
+                Decimal::from_str_exact(s.trim()).map_err(|e| {
                     Rich::custom(span, format!("Failed to parse '{s}' as a decimal number"))
                 })?,
-                span
+                span,
             ))
         });
 
@@ -103,7 +105,7 @@ fn ledger_parser<'a>() -> impl Parser<
                 return Err(chumsky::error::Rich::custom(span, "account does not have a unit specified, so all transactions must specify units"))
             }
             (Some(unit), None) => unit,
-            (None, Some(unit)) => Spanned(unit, span),
+            (None, Some(unit)) => Spanned(unit, amount.span().to_end()),
             (Some(unit1), Some(unit2)) => {
                 if *unit1 != unit2 {
                     return Err(chumsky::error::Rich::custom(span, "unit mismatch between account and transaction"))
@@ -143,11 +145,16 @@ fn ledger_parser<'a>() -> impl Parser<
         balance.repeated().at_least(1).collect(),
         chumsky::text::whitespace(),
     ))
-    .map_with(|(_, datestamp, _, _, title, _, balances, _), e| Spanned::new(LedgerEntry {
-        datestamp,
-        title: (!title.is_empty()).then_some(title),
-        balances,
-    }, e.span()));
+    .map_with(|(_, datestamp, _, _, title, _, balances, _), e| {
+        Spanned::new(
+            LedgerEntry {
+                datestamp,
+                title: (!title.is_empty()).then_some(title),
+                balances,
+            },
+            e.span(),
+        )
+    });
 
     entry.repeated().collect()
 }
@@ -160,14 +167,18 @@ pub fn parse_ledger(
     let parser = ledger_parser();
 
     let (presult, errors) = parser
-        .parse_with_state(data.with_context(source), &mut chumsky::extra::SimpleState(spec))
+        .parse_with_state(
+            data.with_context(source),
+            &mut chumsky::extra::SimpleState(spec),
+        )
         .into_output_errors();
 
     if let Some(e) = errors.first() {
-        let span = e.span().start()..e.span().end();
+        // let span = e.span().start()..e.span().end();
+        let span = *e.span();
 
-        let report = ariadne::Report::build(ariadne::ReportKind::Error, (source, span.clone()))
-            .with_label(ariadne::Label::new((source, span)).with_message(e.reason()))
+        let report = ariadne::Report::build(ariadne::ReportKind::Error, span)
+            .with_label(ariadne::Label::new(span).with_message(e.reason()))
             .finish();
 
         Err(report.into())

+ 38 - 10
src/data/ledger/print.rs

@@ -2,21 +2,44 @@ use std::collections::BTreeMap;
 
 use itertools::Itertools;
 
-use crate::data::{SourceFile, Spanned, Span, Root};
+use crate::data::{Root, SourceFile, Span, Spanned};
 
 use super::{Balance, LedgerEntry};
 
 fn print_ledger_entry(root: &Root, entry: &LedgerEntry, padding: usize) {
-    println!("{}-{:02}-{:02}: {}", entry.datestamp.0, entry.datestamp.1, entry.datestamp.2, entry.title.as_ref().map(String::as_str).unwrap_or(""));
+    println!(
+        "{}-{:02}-{:02}: {}",
+        entry.datestamp.0,
+        entry.datestamp.1,
+        entry.datestamp.2,
+        entry.title.as_ref().map(String::as_str).unwrap_or("")
+    );
 
     let force_show = entry.balances.iter().unique_by(|b| *b.account).count() > 1;
 
     for bal in &entry.balances {
-        let spacer = if bal.amount.is_sign_positive() { " " } else { "" };
-        if Some(bal.unit.as_ref()) == root.account_spec(*bal.account).unwrap().unit.as_ref() && !force_show {
-            println!(" - {:padding$}: {spacer}{}", bal.account, bal.amount, padding = padding);
+        let spacer = if bal.amount.is_sign_positive() {
+            " "
         } else {
-            println!(" - {:padding$}: {spacer}{} {}", bal.account, bal.amount, bal.unit, padding = padding);
+            ""
+        };
+        if Some(bal.unit.as_ref()) == root.account_spec(*bal.account).unwrap().unit.as_ref()
+            && !force_show
+        {
+            println!(
+                " - {:padding$}: {spacer}{}",
+                bal.account,
+                bal.amount,
+                padding = padding
+            );
+        } else {
+            println!(
+                " - {:padding$}: {spacer}{} {}",
+                bal.account,
+                bal.amount,
+                bal.unit,
+                padding = padding
+            );
         }
     }
     // empty line afterwards
@@ -24,20 +47,25 @@ fn print_ledger_entry(root: &Root, entry: &LedgerEntry, padding: usize) {
 }
 
 pub fn print_ledger<'l>(root: &Root, entries: impl Iterator<Item = &'l Spanned<LedgerEntry>>) {
-
     let mut ordering = BTreeMap::<SourceFile, BTreeMap<Span, &LedgerEntry>>::new();
 
     entries.for_each(|e| {
-        ordering.entry(e.span().context).or_default().insert(e.span(), e.as_ref());
+        ordering
+            .entry(e.span().context)
+            .or_default()
+            .insert(e.span(), e.as_ref());
     });
 
     let Some(padding) = root.spec_root.accounts.keys().map(|k| k.len()).max() else {
         // no accounts
-        return
+        return;
     };
 
     for (filename, entries) in ordering {
-        println!("==== file {} ====", std::path::Path::new(filename.as_ref()).display());
+        println!(
+            "==== file {} ====",
+            std::path::Path::new(filename.as_ref()).display()
+        );
         for (_span, le) in entries {
             print_ledger_entry(root, le, padding);
         }

+ 42 - 7
src/main.rs

@@ -1,5 +1,7 @@
-mod data;
+use itertools::Itertools;
+
 mod check;
+mod data;
 
 #[derive(clap::Parser)]
 struct Invocation {
@@ -31,7 +33,7 @@ fn load_data(
         Err(data::DataError::IOError(ioerror)) => Err(ioerror.into()),
         Err(data::DataError::Report(report)) => {
             report.eprint(fsdata)?;
-            Err(anyhow::anyhow!("Parse error"))
+            Err(anyhow::anyhow!("Error reported"))
         }
         Err(data::DataError::Validation(verr)) => Err(anyhow::anyhow!("Validation error: {verr}")),
     }
@@ -45,10 +47,43 @@ impl Command {
             Self::Summarize => {
                 let data = load_data(&mut fsdata, inv)?;
 
-                /*for shortname in data.iter_account_shortnames() {
-                    println!("account {shortname}");
-                }*/
-            },
+                let positive = console::Style::new().green();
+                let negative = console::Style::new().red();
+                let neutral = console::Style::new();
+
+                let Some(maxlen) = data.account_names().map(|v| v.len()).max() else {
+                    return Ok(());
+                };
+
+                for shortname in data.account_names().sorted_by_key(|an| an.as_str()) {
+                    if shortname.as_ref() == "initial" {
+                        continue;
+                    }
+                    if let Some(balances) = data.balance(shortname) {
+                        let balances = balances
+                            .into_iter()
+                            .filter(|(_, b)| !b.is_zero())
+                            .sorted()
+                            .map(|(u, b)| {
+                                (
+                                    u,
+                                    if b.is_sign_positive() {
+                                        positive.apply_to(b)
+                                    } else if b.is_sign_negative() {
+                                        negative.apply_to(b)
+                                    } else {
+                                        neutral.apply_to(b)
+                                    },
+                                )
+                            });
+
+                        println!(
+                            "{shortname:maxlen$} {}",
+                            balances.map(|(u, b)| format!("{b:8} {u:5}")).join(" ")
+                        );
+                    }
+                }
+            }
             Self::Ledger { account } => {
                 let data = load_data(&mut fsdata, inv)?;
 
@@ -72,7 +107,7 @@ impl Command {
                 // let data = data::Root::load(&mut fsdata, &inv.file);
 
                 // data.account(account)?;
-            },
+            }
             Self::Reformat => {
                 let data = load_data(&mut fsdata, inv)?;
 

+ 11 - 2
testdata/ledger

@@ -3,8 +3,8 @@
  - chequing  : 400.00 CAD
 
 2001-01-07: transfer to savings
- - chequing  : -300.00 CAD
- - savings   : 300.00 CAD
+ - chequing  : -300.00
+ - savings   :  300.00
 
 2001-02-08: test for unusual account name
  - initial   : -4.00 USD
@@ -14,3 +14,12 @@
  - savings    : -100.00 CAD
  - savings_usd: 80.00 USD
 
+2001-02-10: check for floating-point error
+ - initial: -0.0001 CAD
+ - chequing: -0.0002 CAD
+ - savings: 0.0003 CAD
+
+2001-02-10: clean up
+ - initial: 0.0001 CAD
+ - chequing: 0.0002 CAD
+ - savings: -0.0003 CAD