1use super::*;
4use crate::syntax::pretty_decimal::PrettyDecimal;
5
6use decoration::AsUndecorated;
7use std::collections::HashMap;
8use unicode_width::UnicodeWidthStr;
9
10#[derive(Default)]
12pub struct DisplayContext {
13 pub precisions: HashMap<String, u8>,
14}
15
16impl DisplayContext {
17 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
29pub 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#[derive(Debug, PartialEq, Copy, Clone)]
262enum Alignment {
263 Partial(usize),
265 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
298impl<'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 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 Ok(Alignment::Complete(amount_str.as_str().len()))
353 }
354}
355
356fn 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 " 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 " 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 " 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 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}