Procházet zdrojové kódy

Reworked ledger parsing to record spans and added ledger printing.

Kestrel před 2 dny
rodič
revize
101d0b6858
8 změnil soubory, kde provedl 337 přidání a 156 odebrání
  1. 0 0
      src/check.rs
  2. 112 12
      src/data.rs
  3. 14 136
      src/data/ledger.rs
  4. 161 0
      src/data/ledger/parse.rs
  5. 35 0
      src/data/ledger/print.rs
  6. 1 1
      src/data/spec.rs
  7. 7 1
      src/main.rs
  8. 7 6
      testdata/ledger

+ 0 - 0
src/check.rs


+ 112 - 12
src/data.rs

@@ -5,7 +5,7 @@ use std::rc::Rc;
 
 use ariadne::Cache;
 
-mod ledger;
+pub mod ledger;
 mod spec;
 mod unit;
 
@@ -96,14 +96,106 @@ impl From<ariadne::Report<'static, (SourceFile, std::ops::Range<usize>)>> for Da
 
 impl std::error::Error for DataError {}
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
+pub struct Span {
+    range: (usize, usize),
+    context: SourceFile
+}
+
+impl chumsky::span::Span for Span {
+    type Offset = usize;
+    type Context = SourceFile;
+
+    fn new(context: Self::Context, range: std::ops::Range<Self::Offset>) -> Self {
+        Self { context, range: (range.start, range.end) }
+    }
+
+    fn start(&self) -> Self::Offset {
+        self.range.0
+    }
+
+    fn end(&self) -> Self::Offset {
+        self.range.1
+    }
+
+    fn context(&self) -> Self::Context {
+        self.context
+    }
+
+    fn to_end(&self) -> Self {
+        Self {
+            context: self.context,
+            range: (self.range.1, self.range.1)
+        }
+    }
+}
+
+#[derive(Debug, Clone, Copy)]
+pub struct Spanned<T>(pub T, pub Span);
+
+impl<T> Spanned<T> {
+    pub fn new(t: T, span: Span) -> Self {
+        Self(t, span)
+    }
+
+    pub fn span(&self) -> Span {
+        self.1
+    }
+}
+
+impl<T> std::ops::Deref for Spanned<T> {
+    type Target = T;
+    fn deref(&self) -> &Self::Target {
+        &self.0
+    }
+}
+
+impl<T> std::ops::DerefMut for Spanned<T> {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.0
+    }
+}
+
+impl<T> AsRef<T> for Spanned<T> {
+    fn as_ref(&self) -> &T {
+        &self.0
+    }
+}
+
+impl<T: PartialEq> PartialEq for Spanned<T> {
+    fn eq(&self, other: &Self) -> bool {
+        self.0.eq(&other.0)
+    }
+}
+
+impl<T: Eq> Eq for Spanned<T> { }
+
+impl<T: PartialOrd> PartialOrd for Spanned<T> {
+    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+        self.0.partial_cmp(&other.0)
+    }
+}
+
+impl<T: Ord> Ord for Spanned<T> {
+    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+        self.0.cmp(&other.0)
+    }
+}
+
+impl<T: std::fmt::Display> std::fmt::Display for Spanned<T> {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        self.0.fmt(f)
+    }
+}
+
 #[derive(Debug)]
 pub struct Root {
     path: std::path::PathBuf,
-    root_spec: spec::RootSpec,
+    spec_root: spec::SpecRoot,
 
-    ledger_data: Vec<ledger::LedgerEntry>,
+    ledger_data: Vec<Spanned<ledger::LedgerEntry>>,
 
-    account_ledger_data: HashMap<AccountName, Vec<ledger::LedgerEntry>>,
+    account_ledger_data: HashMap<AccountName, Vec<Spanned<ledger::LedgerEntry>>>,
 }
 
 impl Root {
@@ -111,11 +203,11 @@ impl Root {
         let sf = SourceFile::new(path.as_os_str());
         let root_data = fsdata.fetch(&sf).unwrap();
 
-        match toml::from_str::<spec::RootSpec>(root_data.text()) {
-            Ok(mut root_spec) => {
+        match toml::from_str::<spec::SpecRoot>(root_data.text()) {
+            Ok(mut spec_root) => {
                 let initial_name = AccountName::from("initial");
                 if let std::collections::hash_map::Entry::Vacant(ve) =
-                    root_spec.accounts.entry(initial_name)
+                    spec_root.accounts.entry(initial_name)
                 {
                     ve.insert(spec::AccountSpec {
                         title: None,
@@ -131,7 +223,7 @@ impl Root {
 
                 let mut r = Self {
                     path: path.into(),
-                    root_spec,
+                    spec_root,
                     ledger_data: vec![],
                     account_ledger_data: Default::default(),
                 };
@@ -180,7 +272,7 @@ impl Root {
             let s = SourceFile::new(path.as_os_str());
             let data = fsdata.fetch(&s).unwrap();
             self.ledger_data
-                .extend(ledger::parse_ledger(s, &self.root_spec, data.text())?);
+                .extend(ledger::parse_ledger(s, &self.spec_root, data.text())?);
         }
 
         Ok(())
@@ -189,7 +281,7 @@ impl Root {
     fn load_ledgers(&mut self, fsdata: &mut FilesystemData) -> Result<(), DataError> {
         let mut ledger_path = self.path.to_owned();
         ledger_path.pop();
-        ledger_path.push(&self.root_spec.ledger_path);
+        ledger_path.push(&self.spec_root.ledger_path);
 
         self.load_ledger(fsdata, &mut ledger_path)?;
 
@@ -202,14 +294,22 @@ impl Root {
         for entry in &self.ledger_data {
             for bal in &entry.balances {
                 self.account_ledger_data
-                    .entry(bal.account)
+                    .entry(*bal.account)
                     .or_default()
                     .push(entry.clone());
             }
         }
     }
 
-    pub fn ledger_data_for(&self, aname: AccountName) -> Option<&[ledger::LedgerEntry]> {
+    pub fn all_ledger_data(&self) -> &[Spanned<ledger::LedgerEntry>] {
+        self.ledger_data.as_slice()
+    }
+
+    pub fn ledger_data_for(&self, aname: AccountName) -> Option<&[Spanned<ledger::LedgerEntry>]> {
         self.account_ledger_data.get(&aname).map(Vec::as_slice)
     }
+
+    pub fn account_spec(&self, aname: AccountName) -> Option<&spec::AccountSpec> {
+        self.spec_root.accounts.get(&aname)
+    }
 }

+ 14 - 136
src/data/ledger.rs

@@ -1,41 +1,39 @@
-use super::{AccountName, UnitName, spec::RootSpec};
+use super::{AccountName, UnitName, Spanned};
 
-use chumsky::{prelude::*, text::inline_whitespace};
+mod parse;
+pub use parse::parse_ledger;
 
-#[derive(Clone, Copy, Hash, PartialEq, PartialOrd, Debug, Ord, Eq)]
-pub enum Direction {
-    Deposit,
-    Withdrawal,
-}
+mod print;
+pub use print::print_ledger;
 
 #[derive(Clone, Debug, PartialEq, PartialOrd, Ord, Eq)]
 pub struct Balance {
-    pub account: AccountName,
-    pub amount: rust_decimal::Decimal,
-    pub unit: UnitName,
+    pub account: Spanned<AccountName>,
+    pub amount: Spanned<rust_decimal::Decimal>,
+    pub unit: Spanned<UnitName>,
 }
 
 #[derive(Clone, Debug, PartialEq, PartialOrd, Eq, Ord)]
 pub struct LedgerEntry {
     pub datestamp: (u16, u8, u8),
     pub title: Option<String>,
-    pub balances: Vec<Balance>,
+    pub balances: Vec<Spanned<Balance>>,
 }
 
 impl LedgerEntry {
     pub fn modifies(&self, account: AccountName) -> bool {
-        self.balances.iter().any(|b| b.account == account)
+        self.balances.iter().any(|b| b.account.as_ref() == &account)
     }
 
-    pub fn balance_for(&self, account: AccountName) -> Option<&Balance> {
-        self.balances.iter().find(|b| b.account == account)
+    pub fn balance_for(&self, account: AccountName) -> Option<&Spanned<Balance>> {
+        self.balances.iter().find(|b| b.account.as_ref() == &account)
     }
 
     pub fn split_balances(
         &self,
         account: AccountName,
-    ) -> Option<(&Balance, impl Iterator<Item = &Balance>)> {
-        let index = self.balances.iter().position(|b| b.account == account)?;
+    ) -> Option<(&Spanned<Balance>, impl Iterator<Item = &Spanned<Balance>>)> {
+        let index = self.balances.iter().position(|b| b.account.as_ref() == &account)?;
         Some((
             &self.balances[index],
             self.balances[0..index]
@@ -44,123 +42,3 @@ impl LedgerEntry {
         ))
     }
 }
-
-struct Spanned<T>(T, SimpleSpan);
-
-fn ledger_parser<'a>() -> impl Parser<
-    'a,
-    &'a str,
-    Vec<LedgerEntry>,
-    chumsky::extra::Full<
-        chumsky::error::Rich<'a, char>,
-        chumsky::extra::SimpleState<&'a RootSpec>,
-        (),
-    >,
-> {
-    let int = chumsky::text::digits(10)
-        .to_slice()
-        .map(|v: &str| v.parse::<usize>().unwrap());
-
-    let datestamp = group((int, just('-').ignored(), int, just('-').ignored(), int))
-        .map(|(y, _, m, _, d)| (y as u16, m as u8, d as u8));
-
-    let mark = |m| just(m).padded_by(inline_whitespace());
-
-    let decimal_digit = one_of("0123456789.,");
-    let decimal_digits = decimal_digit
-        .or(just(' ').repeated().ignore_then(decimal_digit))
-        .repeated();
-
-    let decimal = choice((just('-').ignored(), just('+').ignored(), empty()))
-        .then(decimal_digits)
-        .to_slice()
-        .try_map(|s: &str, span| {
-            rust_decimal::Decimal::from_str_exact(s.trim()).map_err(|e| {
-                Rich::custom(span, format!("Failed to parse '{s}' as a decimal number"))
-            })
-        });
-
-    let balance = group((
-        mark('-'),
-        none_of(": \n\t").repeated().to_slice().map_with(|v, e| Spanned(stringstore::StoredString::new(v), e.span())),
-        mark(':'),
-        decimal,
-        choice((
-            inline_whitespace()
-                .at_least(1)
-                .ignore_then(chumsky::text::ident())
-                .then_ignore(inline_whitespace())
-                .map_with(|u, e| Some(Spanned(UnitName::new(u), e.span()))),
-            inline_whitespace().map(|_| None),
-        ))
-        .then_ignore(chumsky::text::newline()),
-    ))
-    .try_map_with(|(_, Spanned(acc_name, acc_span), _, amount, unit_info, ), e| {
-        let span = e.span();
-        let spec: &mut chumsky::extra::SimpleState<&RootSpec> = e.state();
-
-        let Some(acc_spec) = spec.accounts.get(&acc_name) else {
-            return Err(chumsky::error::Rich::custom(acc_span, "no such account"));
-        };
-
-        let (unit, unit_span) = match unit_info {
-            Some(Spanned(unit, unit_span)) => (unit, unit_span),
-            None => acc_spec.default_unit.map(|u| (u, span)).ok_or_else(||
-                chumsky::error::Rich::custom(span, format!("No unit specified and no default unit specified for account '{acc_name}'")))?
-        };
-
-        if !spec.units.contains_key(&unit) {
-            return Err(chumsky::error::Rich::custom(unit_span, format!("no such unit '{unit}' found")))
-        }
-
-        Ok(Balance {
-            account: acc_name,
-            amount,
-            unit,
-        })
-    });
-
-    let entry = group((
-        chumsky::text::whitespace(),
-        datestamp,
-        mark(':'),
-        inline_whitespace(),
-        chumsky::primitive::none_of("\n")
-            .repeated()
-            .collect::<String>(),
-        chumsky::text::newline(),
-        balance.repeated().at_least(1).collect(),
-        chumsky::text::whitespace(),
-    ))
-    .map(|(_, datestamp, _, _, title, _, balances, _)| LedgerEntry {
-        datestamp,
-        title: (!title.is_empty()).then_some(title),
-        balances,
-    });
-
-    entry.repeated().collect()
-}
-
-pub fn parse_ledger(
-    source: super::SourceFile,
-    spec: &super::spec::RootSpec,
-    data: &str,
-) -> Result<Vec<LedgerEntry>, super::DataError> {
-    let parser = ledger_parser();
-
-    let (presult, errors) = parser
-        .parse_with_state(data, &mut chumsky::extra::SimpleState(spec))
-        .into_output_errors();
-
-    if let Some(e) = errors.first() {
-        let span = e.span().start()..e.span().end();
-
-        let report = ariadne::Report::build(ariadne::ReportKind::Error, (source, span.clone()))
-            .with_label(ariadne::Label::new((source, span)).with_message(e.reason()))
-            .finish();
-
-        Err(report.into())
-    } else {
-        Ok(presult.unwrap())
-    }
-}

+ 161 - 0
src/data/ledger/parse.rs

@@ -0,0 +1,161 @@
+use crate::data::{AccountName, UnitName, Span, Spanned, spec::SpecRoot, DataError, SourceFile};
+
+use super::{LedgerEntry, Balance};
+
+use chumsky::{prelude::*, text::inline_whitespace};
+
+type InputWithContext<'a> = chumsky::input::WithContext<Span, &'a str>;
+
+/*
+#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
+struct InputWrap<'a>(SourceFile, &'a str);
+
+impl<'a> chumsky::input::Input<'a> for InputWrap<'a> {
+    type Token = char;
+    type Span = Span;
+    type Cache = ();
+    type Cursor = usize;
+    type MaybeToken = char;
+
+    fn begin(self) -> (Self::Cursor, Self::Cache) {
+        (0, ())
+    }
+
+    fn cursor_location(cursor: &Self::Cursor) -> usize {
+        *cursor
+    }
+
+    unsafe fn span(_cache: &mut Self::Cache, range: std::ops::Range<&Self::Cursor>) -> Self::Span {
+        let e : &[u8] = &[];
+        Span::new(stringstore::StoredOsString::from(e), (*range.start..*range.end))
+    }
+
+    unsafe fn next_maybe(
+        cache: &mut Self::Cache,
+        cursor: &mut Self::Cursor,
+    ) -> Option<Self::MaybeToken> {
+        None
+    }
+}
+*/
+
+fn ledger_parser<'a>() -> impl Parser<
+    'a,
+    InputWithContext<'a>,
+    Vec<Spanned<LedgerEntry>>,
+    chumsky::extra::Full<
+        chumsky::error::Rich<'a, char, Span>,
+        chumsky::extra::SimpleState<&'a SpecRoot>,
+        (),
+    >,
+> {
+    let int = chumsky::text::digits(10)
+        .to_slice()
+        .map(|v: &str| v.parse::<usize>().unwrap());
+
+    let datestamp = group((int, just('-').ignored(), int, just('-').ignored(), int))
+        .map(|(y, _, m, _, d)| (y as u16, m as u8, d as u8));
+
+    let mark = |m| just(m).padded_by(inline_whitespace());
+
+    let decimal_digit = one_of("0123456789.,");
+    let decimal_digits = decimal_digit
+        .or(just(' ').repeated().ignore_then(decimal_digit))
+        .repeated();
+
+    let decimal = choice((just('-').ignored(), just('+').ignored(), empty()))
+        .then(decimal_digits)
+        .to_slice()
+        .try_map(|s: &str, span| {
+            Ok(Spanned::new(
+                rust_decimal::Decimal::from_str_exact(s.trim()).map_err(|e| {
+                    Rich::custom(span, format!("Failed to parse '{s}' as a decimal number"))
+                })?,
+                span
+            ))
+        });
+
+    let balance = group((
+        mark('-'),
+        none_of(": \n\t").repeated().to_slice().map_with(|v, e| Spanned(stringstore::StoredString::new(v), e.span())),
+        mark(':'),
+        decimal,
+        choice((
+            inline_whitespace()
+                .at_least(1)
+                .ignore_then(chumsky::text::ident())
+                .then_ignore(inline_whitespace())
+                .map_with(|u, e| Some(Spanned(UnitName::new(u), e.span()))),
+            inline_whitespace().map(|_| None),
+        ))
+        .then_ignore(chumsky::text::newline()),
+    ))
+    .try_map_with(|(_, acc, _, amount, unit, ), e| {
+        let span = e.span();
+        let spec: &mut chumsky::extra::SimpleState<&SpecRoot> = e.state();
+
+        let Some(acc_spec) = spec.accounts.get(acc.as_ref()) else {
+            return Err(chumsky::error::Rich::custom(acc.span(), "no such account"));
+        };
+
+        let unit = match unit {
+            Some(sunit) => sunit,
+            None => acc_spec.default_unit.map(|u| Spanned(u, span)).ok_or_else(||
+                chumsky::error::Rich::custom(span, format!("No unit specified and no default unit specified for account '{}'", acc.as_ref())))?
+        };
+
+        if !spec.units.contains_key(&unit) {
+            return Err(chumsky::error::Rich::custom(unit.span(), format!("no such unit '{unit}' found")))
+        }
+
+        Ok(Spanned::new(Balance {
+            account: acc,
+            amount,
+            unit,
+        }, span))
+    });
+
+    let entry = group((
+        chumsky::text::whitespace(),
+        datestamp,
+        mark(':'),
+        inline_whitespace(),
+        chumsky::primitive::none_of("\n")
+            .repeated()
+            .collect::<String>(),
+        chumsky::text::newline(),
+        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()));
+
+    entry.repeated().collect()
+}
+
+pub fn parse_ledger(
+    source: SourceFile,
+    spec: &SpecRoot,
+    data: &str,
+) -> Result<Vec<Spanned<LedgerEntry>>, DataError> {
+    let parser = ledger_parser();
+
+    let (presult, errors) = parser
+        .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 report = ariadne::Report::build(ariadne::ReportKind::Error, (source, span.clone()))
+            .with_label(ariadne::Label::new((source, span)).with_message(e.reason()))
+            .finish();
+
+        Err(report.into())
+    } else {
+        Ok(presult.unwrap())
+    }
+}

+ 35 - 0
src/data/ledger/print.rs

@@ -0,0 +1,35 @@
+use std::collections::BTreeMap;
+
+use crate::data::{SourceFile, Spanned, Span, Root};
+
+use super::{Balance, LedgerEntry};
+
+fn print_ledger_entry(root: &Root, entry: &LedgerEntry) {
+    println!("{}-{:02}-{:02}: {}", entry.datestamp.0, entry.datestamp.1, entry.datestamp.2, entry.title.as_ref().map(String::as_str).unwrap_or(""));
+    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().default_unit.as_ref() {
+            println!(" - {:10}: {spacer}{}", bal.account, bal.amount);
+        } else {
+            println!(" - {:10}: {spacer}{} {}", bal.account, bal.amount, bal.unit);
+        }
+    }
+    // empty line afterwards
+    println!("");
+}
+
+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());
+    });
+
+    for (filename, entries) in ordering {
+        println!("==== file {} ====", std::path::Path::new(filename.as_ref()).display());
+        for (_span, le) in entries {
+            print_ledger_entry(root, le);
+        }
+    }
+}

+ 1 - 1
src/data/spec.rs

@@ -22,7 +22,7 @@ pub struct UnitSpec {
 
 #[derive(Debug, serde::Deserialize)]
 #[serde(deny_unknown_fields)]
-pub struct RootSpec {
+pub struct SpecRoot {
     pub ledger_path: std::path::PathBuf,
 
     pub units: HashMap<UnitName, UnitSpec>,

+ 7 - 1
src/main.rs

@@ -18,6 +18,7 @@ struct Invocation {
 enum Command {
     Summarize,
     Ledger { account: String },
+    Reformat,
 }
 
 fn load_data(
@@ -46,7 +47,7 @@ impl Command {
                 /*for shortname in data.iter_account_shortnames() {
                     println!("account {shortname}");
                 }*/
-            }
+            },
             Self::Ledger { account } => {
                 let data = load_data(&mut fsdata, inv)?;
 
@@ -70,6 +71,11 @@ impl Command {
                 // let data = data::Root::load(&mut fsdata, &inv.file);
 
                 // data.account(account)?;
+            },
+            Self::Reformat => {
+                let data = load_data(&mut fsdata, inv)?;
+
+                data::ledger::print_ledger(&data, data.all_ledger_data().iter());
             }
         }
         Ok(())

+ 7 - 6
testdata/ledger

@@ -1,11 +1,12 @@
 2001-01-05: initial balance
-  - initial: -400.00 CAD
-  - chequing: +400.00
+ - initial   : -400.00 CAD
+ - chequing  : 400.00 CAD
 
 2001-01-07: transfer to savings
-  - chequing: -300.00
-  - savings: +300.00
+ - chequing  : -300.00 CAD
+ - savings   : 300.00 CAD
 
 2001-02-07: test for unusual account name
- - initial: -4.00 USD
- - a.b/c: +4.00 USD
+ - initial   : -4.00 USD
+ - a.b/c     : 4.00 USD
+