Packages

An idiomatic Elixir interface to Beancount that serves as the long-term behavioral oracle for a future native Elixir General Ledger.

Current section

Files

Jump to
beancount_ex lib beancount renderer.ex
Raw

lib/beancount/renderer.ex

defmodule Beancount.Renderer do
@moduledoc """
Deterministic rendering of directive streams into Beancount text.
Rendering is intentionally pure and deterministic: rendering the same
directive stream twice always produces byte-identical output. This property
is essential for golden-file regression testing and for using Beancount as a
behavioral oracle.
The module also exposes the low-level formatting helpers
(`format_date/1`, `format_decimal/1`, `quote_string/1`, ...) used by the
individual `Beancount.Directive` implementations.
"""
alias Beancount.{CostSpec, Directive, Value}
@indent " "
@doc """
Render a list of directives into a complete `.bean` document.
Directives are separated by a single blank line and the document ends with a
trailing newline.
## Examples
iex> ledger = [
...> Beancount.open(~D[2026-01-01], "Assets:Bank", ["USD"]),
...> Beancount.close(~D[2026-12-31], "Assets:Bank")
...> ]
iex> Beancount.Renderer.render(ledger)
"2026-01-01 open Assets:Bank USD\\n\\n2026-12-31 close Assets:Bank\\n"
"""
@spec render([Directive.t()]) :: binary()
def render(directives) when is_list(directives) do
body =
directives
|> Enum.map(&Directive.to_bean/1)
|> Enum.map_join("\n\n", &IO.iodata_to_binary/1)
case body do
"" -> ""
text -> text <> "\n"
end
end
@doc """
Format a `Date` as an ISO-8601 `YYYY-MM-DD` string.
## Examples
iex> Beancount.Renderer.format_date(~D[2026-01-31])
"2026-01-31"
"""
@spec format_date(Date.t()) :: binary()
def format_date(%Date{} = date), do: Date.to_iso8601(date)
@doc """
Format a `Decimal` using plain (non-scientific) notation.
## Examples
iex> Beancount.Renderer.format_decimal(Decimal.new("-5000"))
"-5000"
iex> Beancount.Renderer.format_decimal(Decimal.new("12.50"))
"12.50"
"""
@spec format_decimal(Decimal.t()) :: binary()
def format_decimal(%Decimal{} = decimal), do: Decimal.to_string(decimal, :normal)
@doc """
Quote and escape a string the way Beancount expects.
## Examples
iex> Beancount.Renderer.quote_string(~S(a "quoted" value))
~S("a \\"quoted\\" value")
"""
@spec quote_string(binary()) :: binary()
def quote_string(string) when is_binary(string) do
escaped =
string
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
"\"" <> escaped <> "\""
end
@doc """
Render a scalar value as used in metadata and `custom` directives.
- binaries become quoted strings
- `Decimal` and numbers become bare numbers
- `Date` becomes an ISO date
- booleans become `TRUE`/`FALSE`
- atoms become barewords (useful for accounts/currencies)
- `Beancount.Value.Account`, `Tag`, and `Amount` for custom directives
## Examples
iex> Beancount.Renderer.format_value("hello") == Beancount.Renderer.quote_string("hello")
true
iex> Beancount.Renderer.format_value(Decimal.new("42"))
"42"
iex> Beancount.Renderer.format_value(~D[2026-01-01])
"2026-01-01"
iex> Beancount.Renderer.format_value(Beancount.account_value("Assets:Bank"))
"Assets:Bank"
"""
@spec format_value(term()) :: binary()
def format_value(%Value.Account{name: name}), do: name
def format_value(%Value.Tag{name: name}), do: "#" <> name
def format_value(%Value.Amount{number: %Decimal{} = number, currency: currency}) do
format_decimal(number) <> " " <> currency
end
def format_value(value) when is_binary(value), do: quote_string(value)
def format_value(%Decimal{} = value), do: format_decimal(value)
def format_value(%Date{} = value), do: format_date(value)
def format_value(true), do: "TRUE"
def format_value(false), do: "FALSE"
def format_value(value) when is_integer(value), do: Integer.to_string(value)
def format_value(value) when is_atom(value) and value != nil, do: Atom.to_string(value)
def format_value(value) when is_float(value) do
value |> Decimal.from_float() |> format_decimal()
end
def format_value(nil) do
raise ArgumentError, "cannot render nil as a Beancount metadata/custom value"
end
def format_value(other) do
raise ArgumentError,
"unsupported Beancount value type #{inspect(other)}; use a scalar or Beancount.Value.* wrapper"
end
@doc """
Render a metadata map into indented `key: value` lines.
Keys are emitted in deterministic (sorted) order. Returns an empty list when
there is no metadata.
## Examples
iex> lines = Beancount.Renderer.render_metadata(%{"note" => "reviewed"})
iex> hd(lines) =~ "note:"
true
iex> Beancount.Renderer.render_metadata(%{})
[]
"""
@spec render_metadata(map(), non_neg_integer()) :: [binary()]
def render_metadata(metadata, depth \\ 1)
def render_metadata(metadata, _depth) when metadata == %{}, do: []
def render_metadata(metadata, depth) when is_map(metadata) do
indent = String.duplicate(@indent, depth)
metadata
|> Enum.sort_by(fn {key, _value} -> to_string(key) end)
|> Enum.map(fn {key, value} ->
indent <> to_string(key) <> ": " <> format_value(value)
end)
end
@doc """
Render `#tag` and `^link` suffixes for a transaction header.
Tags are rendered before links, each in sorted order for determinism.
## Examples
iex> Beancount.Renderer.render_tags_and_links(["trip"], ["invoice-1"])
" #trip ^invoice-1"
"""
@spec render_tags_and_links([binary()], [binary()]) :: binary()
def render_tags_and_links(tags, links) do
tag_part = tags |> Enum.sort() |> Enum.map_join("", &(" #" <> &1))
link_part = links |> Enum.sort() |> Enum.map_join("", &(" ^" <> &1))
tag_part <> link_part
end
@doc """
Render the postings of a transaction as aligned, indented lines.
Amounts are right-aligned so that decimal values line up, matching the
conventional Beancount layout. Posting-level metadata is rendered indented
beneath its posting.
## Examples
iex> postings = [
...> Beancount.posting("Assets:Bank", Decimal.new("100"), "USD"),
...> Beancount.posting("Income:Salary", Decimal.new("-100"), "USD")
...> ]
iex> lines = Beancount.Renderer.render_postings(postings)
iex> Enum.all?(lines, &String.starts_with?(&1, " "))
true
"""
@spec render_postings([Beancount.Directives.Posting.t()]) :: [binary()]
def render_postings(postings) do
target = posting_amount_column(postings)
Enum.flat_map(postings, fn posting ->
[render_posting_line(posting, target) | render_metadata(posting.metadata, 2)]
end)
end
defp posting_amount_column(postings) do
postings
|> Enum.map(&posting_column_width/1)
|> Enum.max(fn -> 0 end)
end
defp posting_column_width(posting) do
prefix_len = String.length(posting_prefix(posting))
cond do
has_amount?(posting) ->
prefix_len + 2 + String.length(posting_number(posting))
commodity_price?(posting) ->
prefix_len + 2 + String.length(posting.currency) +
String.length(cost_suffix(posting) <> price_suffix(posting))
true ->
prefix_len
end
end
defp render_posting_line(posting, target) do
prefix = posting_prefix(posting)
cond do
has_amount?(posting) ->
number = posting_number(posting)
pad = max(target - String.length(prefix) - String.length(number), 2)
tail = posting_amount_tail(posting)
prefix <> String.duplicate(" ", pad) <> number <> tail
commodity_price?(posting) ->
commodity = posting.currency
pad = max(target - String.length(prefix) - String.length(commodity), 2)
tail = cost_suffix(posting) <> price_suffix(posting)
prefix <> String.duplicate(" ", pad) <> commodity <> tail
true ->
prefix
end
end
defp commodity_price?(%{amount: nil, currency: currency, price: %{amount: %Decimal{}}})
when is_binary(currency),
do: true
defp commodity_price?(_), do: false
defp posting_amount_tail(%{currency: currency} = posting) when is_binary(currency) do
" " <> currency <> cost_suffix(posting) <> price_suffix(posting)
end
defp posting_amount_tail(posting) do
cost_suffix(posting) <> price_suffix(posting)
end
defp posting_prefix(posting) do
flag = if posting.flag, do: posting.flag <> " ", else: ""
@indent <> flag <> posting.account
end
defp posting_number(%{amount: %Decimal{} = amount}), do: format_decimal(amount)
defp has_amount?(%{amount: %Decimal{}}), do: true
defp has_amount?(_posting), do: false
defp cost_suffix(%{cost: nil}), do: ""
defp cost_suffix(%{cost: cost}) do
cost |> CostSpec.normalize() |> CostSpec.to_string() |> then(&(" " <> &1))
end
defp price_suffix(%{price: nil}), do: ""
defp price_suffix(%{price: %{amount: %Decimal{} = amount, currency: currency, type: :total}}) do
" @@ " <> format_decimal(amount) <> " " <> currency
end
defp price_suffix(%{price: %{amount: %Decimal{} = amount, currency: currency}}) do
" @ " <> format_decimal(amount) <> " " <> currency
end
@doc """
Join header text, metadata lines and body lines into a single fragment.
Used by directives that span multiple lines (such as transactions).
## Examples
iex> Beancount.Renderer.lines_to_fragment(["header", " posting"])
"header\\n posting"
"""
@spec lines_to_fragment([binary()]) :: binary()
def lines_to_fragment(lines), do: Enum.join(lines, "\n")
end