okane_core/syntax/
display.rs

1//! Defines data & functions for displaying syntax types.
2
3use super::*;
4use crate::syntax::pretty_decimal::PrettyDecimal;
5
6use decoration::AsUndecorated;
7use std::collections::HashMap;
8use unicode_width::UnicodeWidthStr;
9
10/// Context information to control the formatting of the transaction.
11#[derive(Default)]
12pub struct DisplayContext {
13    pub precisions: HashMap<String, u8>,
14}
15
16impl DisplayContext {
17    /// Returns given object reference wrapped with a context for `fmt::Display`.
18    pub fn as_display<'a, T>(&'a self, value: &'a T) -> WithContext<'a, T>
19    where
20        WithContext<'a, T>: fmt::Display,
21    {
22        WithContext {
23            value,
24            context: self,
25        }
26    }
27}
28
29/// Object combined with the `DisplayContext`.
30pub struct WithContext<'a, T> {
31    value: &'a T,
32    context: &'a DisplayContext,
33}
34
35impl<'a, T> WithContext<'a, T> {
36    fn pass_context<U>(&self, other: &'a U) -> WithContext<'a, U> {
37        WithContext {
38            value: other,
39            context: self.context,
40        }
41    }
42}
43
44impl<'a, Deco: Decoration> fmt::Display for WithContext<'a, LedgerEntry<'_, Deco>> {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match &self.value {
47            LedgerEntry::Txn(txn) => self.pass_context(txn).fmt(f),
48            LedgerEntry::Comment(v) => v.fmt(f),
49            LedgerEntry::ApplyTag(v) => v.fmt(f),
50            LedgerEntry::EndApplyTag => writeln!(f, "end apply tag"),
51            LedgerEntry::Include(v) => v.fmt(f),
52            LedgerEntry::Account(v) => v.fmt(f),
53            LedgerEntry::Commodity(v) => self.pass_context(v).fmt(f),
54        }
55    }
56}
57
58#[derive(Debug)]
59struct LineWrapStr<'a> {
60    prefix: &'static str,
61    content: &'a str,
62}
63
64impl<'a> LineWrapStr<'a> {
65    fn wrap(prefix: &'static str, content: &'a str) -> Self {
66        Self { prefix, content }
67    }
68}
69
70impl<'a> fmt::Display for LineWrapStr<'a> {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        for line in self.content.lines() {
73            writeln!(f, "{}{}", self.prefix, line)?;
74        }
75        Ok(())
76    }
77}
78
79impl fmt::Display for TopLevelComment<'_> {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        LineWrapStr::wrap(";", &self.0).fmt(f)
82    }
83}
84
85impl fmt::Display for ApplyTag<'_> {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        write!(f, "apply tag {}", self.key)?;
88        match &self.value {
89            None => writeln!(f),
90            Some(v) => writeln!(f, "{}", v),
91        }
92    }
93}
94
95impl fmt::Display for IncludeFile<'_> {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        writeln!(f, "include {}", self.0)
98    }
99}
100
101impl fmt::Display for AccountDeclaration<'_> {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        writeln!(f, "account {}", self.name)?;
104        for detail in &self.details {
105            detail.fmt(f)?;
106        }
107        Ok(())
108    }
109}
110impl fmt::Display for AccountDetail<'_> {
111    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
112        match self {
113            AccountDetail::Comment(v) => LineWrapStr::wrap("    ; ", v).fmt(f),
114            AccountDetail::Note(v) => LineWrapStr::wrap("    note ", v).fmt(f),
115            AccountDetail::Alias(v) => writeln!(f, "    alias {}", v),
116        }
117    }
118}
119
120impl<'a> fmt::Display for WithContext<'a, CommodityDeclaration<'_>> {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        writeln!(f, "commodity {}", self.value.name)?;
123        for detail in &self.value.details {
124            self.pass_context(detail).fmt(f)?;
125        }
126        Ok(())
127    }
128}
129impl<'a> fmt::Display for WithContext<'a, CommodityDetail<'_>> {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        match self.value {
132            CommodityDetail::Comment(v) => LineWrapStr::wrap("    ; ", v).fmt(f),
133            CommodityDetail::Note(v) => LineWrapStr::wrap("    note ", v).fmt(f),
134            CommodityDetail::Alias(v) => writeln!(f, "    alias {}", v),
135            CommodityDetail::Format(v) => writeln!(f, "    format {}", self.pass_context(v)),
136        }
137    }
138}
139impl<'a, Deco: Decoration> fmt::Display for WithContext<'a, Transaction<'_, Deco>> {
140    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
141        let xact = self.value;
142        write!(f, "{}", xact.date.format("%Y/%m/%d"))?;
143        if let Some(edate) = &xact.effective_date {
144            write!(f, "={}", edate.format("%Y/%m/%d"))?;
145        }
146        write!(f, " {}", print_clear_state(xact.clear_state))?;
147        if let Some(code) = &xact.code {
148            write!(f, "({}) ", code)?;
149        }
150        writeln!(f, "{}", xact.payee)?;
151        for m in &xact.metadata {
152            writeln!(f, "    ; {}", m)?;
153        }
154        for post in &xact.posts {
155            write!(f, "{}", self.context.as_display(post.as_undecorated()))?;
156        }
157        Ok(())
158    }
159}
160
161impl fmt::Display for Metadata<'_> {
162    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
163        match self {
164            Metadata::WordTags(tags) => {
165                write!(f, ":")?;
166                for tag in tags {
167                    write!(f, "{}:", tag)?;
168                }
169            }
170            Metadata::KeyValueTag { key, value } => write!(f, "{}{}", key, value)?,
171            Metadata::Comment(s) => write!(f, "{}", s)?,
172        };
173        Ok(())
174    }
175}
176
177impl fmt::Display for MetadataValue<'_> {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        match self {
180            MetadataValue::Expr(expr) => write!(f, ":: {}", expr),
181            MetadataValue::Text(text) => write!(f, ": {}", text),
182        }
183    }
184}
185
186impl<'a, Deco: Decoration> fmt::Display for WithContext<'a, Posting<'_, Deco>> {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        let post = self.value;
189        let post_clear = print_clear_state(post.clear_state);
190        write!(f, "    {}{}", post_clear, post.account)?;
191        let account_width =
192            UnicodeWidthStr::width_cjk(post.account.as_ref()) + UnicodeWidthStr::width(post_clear);
193        if let Some(amount) = &post.amount {
194            let mut amount_str = String::new();
195            let alignment = self
196                .pass_context(amount.amount.as_undecorated())
197                .fmt_with_alignment(&mut amount_str)?
198                .absolute();
199            write!(
200                f,
201                "{:>width$}{}",
202                "",
203                amount_str.as_str(),
204                width = get_column(48, account_width + alignment, 2)
205            )?;
206            write!(f, "{}", self.pass_context(&amount.lot))?;
207            if let Some(exchange) = &amount.cost {
208                match exchange.as_undecorated() {
209                    Exchange::Rate(v) => write!(f, " @ {}", self.pass_context(v)),
210                    Exchange::Total(v) => write!(f, " @@ {}", self.pass_context(v)),
211                }?
212            }
213        }
214        if let Some(balance) = &post.balance {
215            let mut balance_str = String::new();
216            let alignment = self
217                .pass_context(balance.as_undecorated())
218                .fmt_with_alignment(&mut balance_str)?
219                .absolute();
220            let trailing = UnicodeWidthStr::width_cjk(balance_str.as_str()) - alignment;
221            let balance_padding = if post.amount.is_some() {
222                0
223            } else {
224                get_column(50 + trailing, account_width, 2)
225            };
226            write!(
227                f,
228                "{:>width$} {}",
229                " =",
230                self.pass_context(balance.as_undecorated()),
231                width = balance_padding
232            )?;
233        }
234        writeln!(f)?;
235        for m in &post.metadata {
236            writeln!(f, "    ; {}", m)?;
237        }
238        Ok(())
239    }
240}
241
242impl<'a, Deco: Decoration> fmt::Display for WithContext<'a, Lot<'_, Deco>> {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        if let Some(price) = &self.value.price {
245            match price.as_undecorated() {
246                Exchange::Total(e) => write!(f, " {{{{{}}}}}", self.pass_context(e)),
247                Exchange::Rate(e) => write!(f, " {{{}}}", self.pass_context(e)),
248            }?;
249        }
250        if let Some(date) = &self.value.date {
251            write!(f, " [{}]", date.format("%Y/%m/%d"))?;
252        }
253        if let Some(note) = &self.value.note {
254            write!(f, " ({})", note)?;
255        }
256        Ok(())
257    }
258}
259
260/// Alignment of the expression.
261#[derive(Debug, PartialEq, Copy, Clone)]
262enum Alignment {
263    /// Still alignment wasn't found.
264    Partial(usize),
265    /// Already alignment was found.
266    Complete(usize),
267}
268
269impl Alignment {
270    fn absolute(self) -> usize {
271        match self {
272            Alignment::Complete(x) => x,
273            Alignment::Partial(x) => x,
274        }
275    }
276
277    fn plus(self, prefix_length: usize, suffix_length: usize) -> Alignment {
278        match self {
279            Alignment::Partial(x) => Alignment::Partial(prefix_length + x + suffix_length),
280            Alignment::Complete(x) => Alignment::Complete(prefix_length + x),
281        }
282    }
283}
284
285trait DisplayWithAlignment {
286    fn fmt_with_alignment<W: fmt::Write>(&self, f: &mut W) -> Result<Alignment, fmt::Error>;
287}
288
289impl<'a, T> fmt::Display for WithContext<'a, T>
290where
291    Self: DisplayWithAlignment,
292{
293    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294        self.fmt_with_alignment(f).map(|_| ())
295    }
296}
297
298/// Prints expression under the context, and also returns the length until the alignment.
299/// Example:
300/// - `0` -> returns 1.
301/// - `(1 + 1)` -> returns 7.
302/// - `2 USD` -> returns 1.
303/// - `(1 USD * 2)` -> returns 2.
304/// - `(2 * 1 USD)` -> returns 6.
305impl<'a> DisplayWithAlignment for WithContext<'a, expr::ValueExpr<'_>> {
306    fn fmt_with_alignment<W: fmt::Write>(&self, f: &mut W) -> Result<Alignment, fmt::Error> {
307        match self.value {
308            expr::ValueExpr::Amount(a) => self.pass_context(a).fmt_with_alignment(f),
309            expr::ValueExpr::Paren(expr) => {
310                write!(f, "(")?;
311                let alignment = self.pass_context(expr).fmt_with_alignment(f)?;
312                write!(f, ")")?;
313                Ok(alignment.plus(1, 1))
314            }
315        }
316    }
317}
318
319impl<'a> DisplayWithAlignment for WithContext<'a, expr::Expr<'_>> {
320    fn fmt_with_alignment<W: fmt::Write>(&self, f: &mut W) -> Result<Alignment, fmt::Error> {
321        match self.value {
322            expr::Expr::Unary(e) => {
323                write!(f, "{}", e.op)?;
324                self.pass_context(e.expr.as_ref())
325                    .fmt_with_alignment(f)
326                    .map(|x| x.plus(1, 0))
327            }
328            expr::Expr::Binary(e) => {
329                let a1 = self.pass_context(e.lhs.as_ref()).fmt_with_alignment(f)?;
330                write!(f, " {} ", e.op)?;
331                let a2 = self.pass_context(e.rhs.as_ref()).fmt_with_alignment(f)?;
332                Ok(match a1.plus(0, 3) {
333                    Alignment::Complete(x) => Alignment::Complete(x),
334                    Alignment::Partial(x) => a2.plus(x, 0),
335                })
336            }
337            expr::Expr::Value(e) => self.pass_context(e.as_ref()).fmt_with_alignment(f),
338        }
339    }
340}
341
342impl<'a> DisplayWithAlignment for WithContext<'a, expr::Amount<'_>> {
343    fn fmt_with_alignment<W: fmt::Write>(&self, f: &mut W) -> Result<Alignment, fmt::Error> {
344        let amount_str = rescale(self.value, self.context).to_string();
345        // TODO: Implement prefix-amount.
346        if self.value.commodity.is_empty() {
347            write!(f, "{}", amount_str)?;
348            return Ok(Alignment::Partial(amount_str.as_str().len()));
349        }
350        write!(f, "{} {}", amount_str, self.value.commodity)?;
351        // Given the amount is only [0-9.], it's ok to count bytes.
352        Ok(Alignment::Complete(amount_str.as_str().len()))
353    }
354}
355
356/// Returns column shift size so that the string will be located at `colsize`.
357/// At least `padding` is guaranteed to be spaced.
358fn get_column(colsize: usize, left: usize, padding: usize) -> usize {
359    if left + padding < colsize {
360        colsize - left
361    } else {
362        padding
363    }
364}
365
366fn rescale(x: &expr::Amount, context: &DisplayContext) -> PrettyDecimal {
367    let mut v = x.value.clone();
368    v.rescale(std::cmp::max(
369        v.scale(),
370        context
371            .precisions
372            .get(x.commodity.as_ref())
373            .cloned()
374            .unwrap_or(0) as u32,
375    ));
376    v
377}
378
379fn print_clear_state(v: ClearState) -> &'static str {
380    match v {
381        ClearState::Uncleared => "",
382        ClearState::Cleared => "* ",
383        ClearState::Pending => "! ",
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    use maplit::hashmap;
392    use pretty_assertions::assert_eq;
393    use rust_decimal::Decimal;
394    use rust_decimal_macros::dec;
395
396    fn amount<'a, T, U>(value: T, commodity: U) -> expr::ValueExpr<'a>
397    where
398        T: Into<Decimal>,
399        U: Into<Cow<'a, str>>,
400    {
401        let value: Decimal = value.into();
402        expr::ValueExpr::Amount(expr::Amount {
403            commodity: commodity.into(),
404            value: PrettyDecimal::unformatted(value),
405        })
406    }
407
408    fn amount_expr<T: Into<Decimal>>(value: T, commodity: &'static str) -> expr::Expr {
409        let value: Decimal = value.into();
410        expr::Expr::Value(Box::new(amount(value, commodity)))
411    }
412
413    #[test]
414    fn display_ledger_entries_no_txn() {
415        let ctx = DisplayContext::default();
416        assert_eq!(
417            concat!(";this\n", ";is\n", ";a pen pineapple apple pen.\n"),
418            format!(
419                "{}",
420                ctx.as_display(&plain::LedgerEntry::Comment(TopLevelComment(
421                    Cow::Borrowed("this\nis\na pen pineapple apple pen."),
422                )))
423            )
424        );
425        assert_eq!(
426            "apply tag foo\n",
427            format!(
428                "{}",
429                ctx.as_display(&tracked::LedgerEntry::ApplyTag(ApplyTag {
430                    key: Cow::Borrowed("foo"),
431                    value: None
432                })),
433            )
434        );
435        assert_eq!(
436            "apply tag foo: bar\n",
437            format!(
438                "{}",
439                ctx.as_display(&plain::LedgerEntry::ApplyTag(ApplyTag {
440                    key: Cow::Borrowed("foo"),
441                    value: Some(MetadataValue::Text(Cow::Borrowed("bar")))
442                }))
443            ),
444        );
445        assert_eq!(
446            "apply tag foo:: 100\n",
447            format!(
448                "{}",
449                ctx.as_display(&tracked::LedgerEntry::ApplyTag(ApplyTag {
450                    key: Cow::Borrowed("foo"),
451                    value: Some(MetadataValue::Expr(Cow::Borrowed("100")))
452                }))
453            ),
454        );
455        assert_eq!(
456            "end apply tag\n",
457            format!("{}", ctx.as_display(&plain::LedgerEntry::EndApplyTag))
458        );
459    }
460
461    #[test]
462    fn display_txn() {
463        let got = format!(
464            "{}",
465            DisplayContext::default().as_display(&LedgerEntry::Txn(plain::Transaction {
466                date: NaiveDate::from_ymd_opt(2022, 12, 23).unwrap(),
467                effective_date: None,
468                clear_state: ClearState::Uncleared,
469                code: None,
470                payee: Cow::Borrowed("Example Grocery"),
471                posts: vec![Posting {
472                    account: Cow::Borrowed("Assets"),
473                    clear_state: ClearState::Uncleared,
474                    amount: Some(PostingAmount {
475                        amount: amount(dec!(123.45), "USD"),
476                        cost: None,
477                        lot: Lot::default(),
478                    }),
479                    balance: None,
480                    metadata: Vec::new(),
481                }],
482                metadata: Vec::new(),
483            }))
484        );
485        let want = concat!(
486            "2022/12/23 Example Grocery\n",
487            "    Assets                                    123.45 USD\n",
488        );
489        assert_eq!(want, got);
490    }
491
492    #[test]
493    fn posting_non_expr() {
494        let all = Posting {
495            amount: Some(PostingAmount {
496                amount: amount(1, "USD"),
497                cost: Some(Exchange::Rate(amount(100, "JPY"))),
498                lot: plain::Lot {
499                    price: Some(Exchange::Rate(amount(dec!(1.1), "USD"))),
500                    date: Some(NaiveDate::from_ymd_opt(2022, 5, 20).unwrap()),
501                    note: Some(Cow::Borrowed("printable note")),
502                },
503            }),
504            balance: Some(amount(1, "USD")),
505            ..Posting::new("Account")
506        };
507        let costbalance = Posting {
508            amount: Some(PostingAmount {
509                amount: amount(1, "USD"),
510                cost: Some(Exchange::Rate(amount(100, "JPY"))),
511                lot: plain::Lot::default(),
512            }),
513            balance: Some(amount(1, "USD")),
514            ..Posting::new("Account")
515        };
516        let total = Posting {
517            amount: Some(PostingAmount {
518                amount: amount(1, "USD"),
519                cost: Some(Exchange::Total(amount(100, "JPY"))),
520                lot: plain::Lot::default(),
521            }),
522            ..Posting::new("Account")
523        };
524        let nocost = Posting {
525            amount: Some(PostingAmount {
526                amount: amount(1, "USD"),
527                cost: None,
528                lot: plain::Lot::default(),
529            }),
530            balance: Some(amount(1, "USD")),
531            ..Posting::new("Account")
532        };
533        let noamount = plain::Posting {
534            amount: None,
535            balance: Some(amount(1, "USD")),
536            ..Posting::new("Account")
537        };
538        let zerobalance = plain::Posting {
539            amount: None,
540            balance: Some(amount(0, "")),
541            ..Posting::new("Account")
542        };
543
544        assert_eq!(
545            concat!(
546                //       10        20        30        40        50        60        70
547                // 34567890123456789012345678901234567890123456789012345678901234567890
548                "    Account                                        1 USD {1.1 USD} [2022/05/20] (printable note) @ 100 JPY = 1 USD\n",
549                "    Account                                        1 USD @ 100 JPY = 1 USD\n",
550                "    Account                                        1 USD @@ 100 JPY\n",
551                "    Account                                        1 USD = 1 USD\n",
552                "    Account                                              = 1 USD\n",
553                // we don't have shared state to determine where = should be aligned
554                "    Account                                          = 0\n"
555            ),
556            format!(
557                "{}{}{}{}{}{}",
558                DisplayContext::default().as_display(&all),
559                DisplayContext::default().as_display(&costbalance),
560                DisplayContext::default().as_display(&total),
561                DisplayContext::default().as_display(&nocost),
562                DisplayContext::default().as_display(&noamount),
563                DisplayContext::default().as_display(&zerobalance),
564            ),
565        );
566
567        let ctx = DisplayContext {
568            precisions: hashmap! {"USD".to_string() => 4},
569        };
570        assert_eq!(
571            concat!(
572                //       10        20        30        40        50        60        70
573                // 34567890123456789012345678901234567890123456789012345678901234567890
574                "    Account                                   1.0000 USD {1.1000 USD} [2022/05/20] (printable note) @ 100 JPY = 1.0000 USD\n",
575                "    Account                                   1.0000 USD @ 100 JPY = 1.0000 USD\n",
576                "    Account                                   1.0000 USD @@ 100 JPY\n",
577                "    Account                                   1.0000 USD = 1.0000 USD\n",
578                "    Account                                              = 1.0000 USD\n",
579                "    Account                                          = 0\n"
580            ),
581            format!(
582                "{}{}{}{}{}{}",
583                ctx.as_display(&all),
584                ctx.as_display(&costbalance),
585                ctx.as_display(&total),
586                ctx.as_display(&nocost),
587                ctx.as_display(&noamount),
588                ctx.as_display(&zerobalance),
589            ),
590        );
591    }
592
593    #[test]
594    fn fmt_with_alignment_simple_amount_without_commodity() {
595        let mut buffer = String::new();
596        let alignment = DisplayContext::default()
597            .as_display(&amount(123i8, ""))
598            .fmt_with_alignment(&mut buffer)
599            .unwrap();
600        assert_eq!("123", buffer.as_str());
601        assert_eq!(Alignment::Partial(3), alignment);
602    }
603
604    #[test]
605    fn fmt_with_alignment_simple_amount_with_commodity() {
606        let mut buffer = String::new();
607        let usd123 = amount(123i8, "USD");
608        let alignment = DisplayContext::default()
609            .as_display(&usd123)
610            .fmt_with_alignment(&mut buffer)
611            .unwrap();
612        assert_eq!("123 USD", buffer.as_str());
613        assert_eq!(Alignment::Complete(3), alignment);
614
615        buffer.clear();
616        let alignment = DisplayContext {
617            precisions: hashmap! {"USD".to_string() => 2},
618        }
619        .as_display(&usd123)
620        .fmt_with_alignment(&mut buffer)
621        .unwrap();
622        assert_eq!("123.00 USD", buffer.as_str());
623        assert_eq!(Alignment::Complete(6), alignment);
624    }
625
626    #[test]
627    fn test_fmt_with_alignment_complex_expr() {
628        // ((1.20 + 2.67) * 3.1 USD + 5 USD)
629        let expr = expr::ValueExpr::Paren(expr::Expr::Binary(expr::BinaryOpExpr {
630            lhs: Box::new(expr::Expr::Binary(expr::BinaryOpExpr {
631                lhs: Box::new(expr::Expr::Value(Box::new(expr::ValueExpr::Paren(
632                    expr::Expr::Binary(expr::BinaryOpExpr {
633                        lhs: Box::new(amount_expr(dec!(1.20), "")),
634                        op: expr::BinaryOp::Add,
635                        rhs: Box::new(amount_expr(dec!(2.67), "")),
636                    }),
637                )))),
638                op: expr::BinaryOp::Mul,
639                rhs: Box::new(amount_expr(dec!(3.1), "USD")),
640            })),
641            op: expr::BinaryOp::Add,
642            rhs: Box::new(amount_expr(5i32, "USD")),
643        }));
644        let mut got = String::new();
645        let alignment = DisplayContext::default()
646            .as_display(&expr)
647            .fmt_with_alignment(&mut got)
648            .unwrap();
649        assert_eq!("((1.20 + 2.67) * 3.1 USD + 5 USD)", got.as_str());
650        assert_eq!(Alignment::Complete(20), alignment);
651    }
652}