Skip to main content

rustledger_core/format/
align.rs

1//! File-wide amount alignment.
2//!
3//! Beancount alignment is a whole-file property, not a per-directive one:
4//! the column at which numbers line up depends on the widest account
5//! prefix and the widest number *across every amount-bearing line in the
6//! file*. The on-disk formatter therefore works in two phases:
7//!
8//! 1. **Render** each directive into a [`FormatLine`] sequence. Amount-
9//!    bearing lines are split into `(prefix, number, suffix)` so the
10//!    alignment phase can move the number; everything else is [`Plain`]
11//!    and emitted verbatim.
12//! 2. **Align** the whole sequence at once: resolve the prefix/number
13//!    column widths from the [`Alignment`] mode, then render every line.
14//!
15//! This mirrors `beancount.scripts.format.align_beancount`, so a file
16//! formatted by `bean-format` is a fixed point of `rledger format` and
17//! vice-versa (modulo rledger's stricter canonical directive rendering).
18//!
19//! [`Plain`]: FormatLine::Plain
20
21/// How to choose the alignment column for amounts.
22#[derive(Debug, Clone)]
23pub enum Alignment {
24    /// Pick widths automatically from the file contents (the default and
25    /// `bean-format`'s default). Numbers are right-aligned in a field
26    /// sized to the widest number; that field begins two spaces past the
27    /// widest account prefix.
28    ///
29    /// `prefix_width` / `num_width` override the auto-computed values
30    /// (the `-w` / `-W` flags); `None` means "compute from contents".
31    Auto {
32        /// Forced prefix column width, or `None` to auto-size.
33        prefix_width: Option<usize>,
34        /// Forced number field width, or `None` to auto-size.
35        num_width: Option<usize>,
36    },
37    /// Align so the currency starts at a fixed 1-based column (the `-c`
38    /// flag). Overrides the auto widths.
39    CurrencyColumn(usize),
40}
41
42impl Default for Alignment {
43    fn default() -> Self {
44        Self::Auto {
45            prefix_width: None,
46            num_width: None,
47        }
48    }
49}
50
51/// A single rendered output line, classified for alignment.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum FormatLine {
54    /// Emitted verbatim — directive headers, metadata, comments, blank
55    /// lines, and amount-free directives. Carries no trailing newline.
56    Plain(String),
57    /// An amount-bearing line, split at the number so the aligner can
58    /// reposition it. `prefix` is the indented text up to (but not
59    /// including) the number with no trailing whitespace; `number` is the
60    /// numeric token; `suffix` is the currency and everything after it
61    /// (cost, price, tolerance, trailing comment).
62    Aligned {
63        /// Indented text before the number, right-trimmed.
64        prefix: String,
65        /// The numeric token (may carry a leading `-`/`+`).
66        number: String,
67        /// Currency and anything following it.
68        suffix: String,
69    },
70}
71
72/// Char-count widths used to render [`FormatLine::Aligned`] lines.
73#[derive(Debug, Clone, Copy)]
74struct ResolvedWidths {
75    prefix_width: usize,
76    num_width: usize,
77}
78
79/// Compute the auto prefix/number widths from the aligned lines, honoring
80/// any forced overrides.
81fn resolve_auto_widths(
82    lines: &[FormatLine],
83    forced_prefix: Option<usize>,
84    forced_num: Option<usize>,
85) -> ResolvedWidths {
86    let mut prefix_width = 0;
87    let mut num_width = 0;
88    for line in lines {
89        if let FormatLine::Aligned { prefix, number, .. } = line {
90            prefix_width = prefix_width.max(prefix.chars().count());
91            num_width = num_width.max(number.chars().count());
92        }
93    }
94    ResolvedWidths {
95        prefix_width: forced_prefix.unwrap_or(prefix_width),
96        num_width: forced_num.unwrap_or(num_width),
97    }
98}
99
100/// Render an aligned line in auto/forced-width mode:
101/// `{prefix:<PW}  {number:>NW} {suffix}`, trailing whitespace trimmed.
102fn render_auto(prefix: &str, number: &str, suffix: &str, widths: ResolvedWidths) -> String {
103    let prefix_pad = widths.prefix_width.saturating_sub(prefix.chars().count());
104    let num_pad = widths.num_width.saturating_sub(number.chars().count());
105    let mut out = String::with_capacity(
106        prefix.len() + prefix_pad + 2 + num_pad + number.len() + 1 + suffix.len(),
107    );
108    out.push_str(prefix);
109    out.extend(std::iter::repeat_n(' ', prefix_pad));
110    out.push_str("  ");
111    out.extend(std::iter::repeat_n(' ', num_pad));
112    out.push_str(number);
113    out.push(' ');
114    out.push_str(suffix);
115    let trimmed = out.trim_end();
116    out.truncate(trimmed.len());
117    out
118}
119
120/// Render an aligned line in currency-column mode so the currency lands
121/// at `column` (1-based): `prefix + spaces + "  " + number + " " + suffix`,
122/// trailing whitespace trimmed.
123fn render_currency_column(prefix: &str, number: &str, suffix: &str, column: usize) -> String {
124    // Matches beancount: num_of_spaces = column - len(prefix) - len(number) - 4,
125    // clamped at zero, then a fixed two-space separator follows.
126    let spaces = column
127        .saturating_sub(prefix.chars().count())
128        .saturating_sub(number.chars().count())
129        .saturating_sub(4);
130    let mut out =
131        String::with_capacity(prefix.len() + spaces + 2 + number.len() + 1 + suffix.len());
132    out.push_str(prefix);
133    out.extend(std::iter::repeat_n(' ', spaces));
134    out.push_str("  ");
135    out.push_str(number);
136    out.push(' ');
137    out.push_str(suffix);
138    let trimmed = out.trim_end();
139    out.truncate(trimmed.len());
140    out
141}
142
143/// Resolve `alignment` against a document's full line set into a concrete
144/// alignment whose widths are fixed.
145///
146/// This lets individual lines be rendered one at a time while still aligning
147/// to the file-wide columns.
148///
149/// The on-disk formatter renders the whole file in one [`render_lines`] call,
150/// so it never needs this. The LSP, however, emits a separate `TextEdit` per
151/// posting line: it resolves the file-wide widths once with this function,
152/// then renders each posting against the returned (width-fixed) alignment so
153/// editor output matches `rledger format` byte-for-byte. For
154/// [`Alignment::CurrencyColumn`] the column is already absolute, so the
155/// alignment is returned unchanged.
156#[must_use]
157pub fn resolve_alignment(lines: &[FormatLine], alignment: &Alignment) -> Alignment {
158    match *alignment {
159        Alignment::Auto {
160            prefix_width,
161            num_width,
162        } => {
163            let widths = resolve_auto_widths(lines, prefix_width, num_width);
164            Alignment::Auto {
165                prefix_width: Some(widths.prefix_width),
166                num_width: Some(widths.num_width),
167            }
168        }
169        Alignment::CurrencyColumn(column) => Alignment::CurrencyColumn(column),
170    }
171}
172
173/// Render a sequence of [`FormatLine`]s into a single string, aligning all
174/// amount-bearing lines against the file-wide widths implied by
175/// `alignment`. Every line is terminated with `\n`.
176#[must_use]
177pub fn render_lines(lines: &[FormatLine], alignment: &Alignment) -> String {
178    let mut out = String::new();
179    match *alignment {
180        Alignment::Auto {
181            prefix_width,
182            num_width,
183        } => {
184            let widths = resolve_auto_widths(lines, prefix_width, num_width);
185            for line in lines {
186                match line {
187                    FormatLine::Plain(s) => out.push_str(s),
188                    FormatLine::Aligned {
189                        prefix,
190                        number,
191                        suffix,
192                    } => out.push_str(&render_auto(prefix, number, suffix, widths)),
193                }
194                out.push('\n');
195            }
196        }
197        Alignment::CurrencyColumn(column) => {
198            for line in lines {
199                match line {
200                    FormatLine::Plain(s) => out.push_str(s),
201                    FormatLine::Aligned {
202                        prefix,
203                        number,
204                        suffix,
205                    } => out.push_str(&render_currency_column(prefix, number, suffix, column)),
206                }
207                out.push('\n');
208            }
209        }
210    }
211    out
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    fn aligned(prefix: &str, number: &str, suffix: &str) -> FormatLine {
219        FormatLine::Aligned {
220            prefix: prefix.to_string(),
221            number: number.to_string(),
222            suffix: suffix.to_string(),
223        }
224    }
225
226    #[test]
227    fn auto_aligns_numbers_to_widest_prefix() {
228        let lines = vec![
229            aligned("  Assets:Bank:Checking", "5000", "USD"),
230            aligned("  Income:Salary", "-5000", "USD"),
231        ];
232        let out = render_lines(&lines, &Alignment::default());
233        // Widest prefix is "  Assets:Bank:Checking" (22 chars); numbers
234        // are right-aligned in a 5-char field two spaces past it, so the
235        // currencies line up. The number field starts at column 24.
236        let rows: Vec<&str> = out.lines().collect();
237        assert_eq!(rows[0], "  Assets:Bank:Checking   5000 USD");
238        for row in &rows {
239            assert_eq!(row.find("USD").unwrap(), 30, "row: {row:?}");
240        }
241    }
242
243    #[test]
244    fn auto_includes_balance_prefix_in_width() {
245        // The balance line has the widest prefix, so postings align to it.
246        let lines = vec![
247            aligned("  Assets:Bank:Checking", "5000", "USD"),
248            aligned("2024-01-16 balance Assets:Bank:Checking", "5000", "USD"),
249        ];
250        let out = render_lines(&lines, &Alignment::default());
251        let widest = "2024-01-16 balance Assets:Bank:Checking";
252        for line in out.lines() {
253            let num_pos = line.find("5000").unwrap();
254            assert_eq!(num_pos, widest.chars().count() + 2, "line: {line:?}");
255        }
256    }
257
258    #[test]
259    fn plain_lines_pass_through_verbatim() {
260        let lines = vec![
261            FormatLine::Plain("; a comment".to_string()),
262            FormatLine::Plain("2024-01-01 open Assets:Bank USD".to_string()),
263        ];
264        let out = render_lines(&lines, &Alignment::default());
265        assert_eq!(out, "; a comment\n2024-01-01 open Assets:Bank USD\n");
266    }
267
268    #[test]
269    fn currency_column_places_currency_at_column() {
270        let lines = vec![aligned("  Assets:Bank", "5000", "USD")];
271        let out = render_lines(&lines, &Alignment::CurrencyColumn(60));
272        let line = out.trim_end();
273        // Currency starts at 1-based column 60 → 0-based index 59.
274        assert_eq!(line.find("USD").unwrap(), 59, "line: {line:?}");
275    }
276
277    #[test]
278    fn currency_column_keeps_min_two_spaces_when_overflowing() {
279        // Prefix + number already exceed the column → clamp to 2 spaces.
280        let lines = vec![aligned(
281            "  Assets:Very:Long:Account:Name:That:Overflows",
282            "5000",
283            "USD",
284        )];
285        let out = render_lines(&lines, &Alignment::CurrencyColumn(10));
286        assert_eq!(
287            out,
288            "  Assets:Very:Long:Account:Name:That:Overflows  5000 USD\n"
289        );
290    }
291
292    #[test]
293    fn auto_trims_trailing_space_when_suffix_empty() {
294        let lines = vec![aligned("  Assets:Bank", "5000", "")];
295        let out = render_lines(&lines, &Alignment::default());
296        assert_eq!(out, "  Assets:Bank  5000\n");
297    }
298
299    #[test]
300    fn resolve_alignment_fixes_auto_widths_for_per_line_render() {
301        // resolve_alignment must capture the file-wide widths so a single
302        // line rendered later (as the LSP does) aligns to the same column
303        // as render_lines over the whole file.
304        let lines = vec![
305            aligned("  Assets:Bank:Checking", "5000", "USD"),
306            aligned("  Income:Salary", "-5000", "USD"),
307        ];
308        let resolved = resolve_alignment(&lines, &Alignment::default());
309        match resolved {
310            Alignment::Auto {
311                prefix_width,
312                num_width,
313            } => {
314                assert_eq!(prefix_width, Some("  Assets:Bank:Checking".chars().count()));
315                assert_eq!(num_width, Some("-5000".chars().count()));
316            }
317            Alignment::CurrencyColumn(_) => panic!("expected Auto"),
318        }
319        // Rendering one line against the resolved alignment matches the
320        // whole-file column.
321        let whole = render_lines(&lines, &Alignment::default());
322        let single = render_lines(&lines[1..2], &resolved);
323        assert_eq!(whole.lines().nth(1).unwrap(), single.trim_end_matches('\n'));
324    }
325
326    #[test]
327    fn resolve_alignment_passes_currency_column_through() {
328        let lines = vec![aligned("  Assets:Bank", "5000", "USD")];
329        let resolved = resolve_alignment(&lines, &Alignment::CurrencyColumn(60));
330        assert!(matches!(resolved, Alignment::CurrencyColumn(60)));
331    }
332
333    #[test]
334    fn forced_widths_override_auto() {
335        let lines = vec![aligned("  A", "5", "USD")];
336        let out = render_lines(
337            &lines,
338            &Alignment::Auto {
339                prefix_width: Some(10),
340                num_width: Some(4),
341            },
342        );
343        // prefix "  A" padded to 10, "  ", "5" right-justified in 4, " USD".
344        let line = out.trim_end();
345        assert_eq!(line.find('5').unwrap(), 15, "line: {line:?}");
346        assert!(line.ends_with("5 USD"));
347    }
348}