Include Processing
Overview
This document describes the semantics of file inclusion: how included files are resolved, merged, and how options and directives interact across file boundaries.
Include Directive
include "path/to/file.beancount"See directives/include.md for syntax details.
Processing Model
Phase 1: Parse
- Parse the main file
- Collect all
includedirectives - Recursively parse each included file
- Detect and reject cycles
Phase 2: Merge
- Combine all directives from all files
- Apply tag/metadata stacks per-file
- Preserve source location metadata
Phase 3: Sort
- Sort directives chronologically by date
- Within same date, apply stable ordering rules
- Undated directives (options, plugins) maintain file order
Phase 4: Process
- Apply plugins in declaration order
- Execute validation
- Generate output
File Resolution
Relative Paths
Relative paths resolve from the including file's directory:
/home/user/finances/
├── main.beancount ; include "yearly/2024.beancount"
└── yearly/
├── 2024.beancount ; include "q1.beancount"
└── q1.beancountResolution:
main.beancountincludesyearly/2024.beancount→/home/user/finances/yearly/2024.beancount2024.beancountincludesq1.beancount→/home/user/finances/yearly/q1.beancount
Absolute Paths
Absolute paths are used as-is:
include "/shared/common/accounts.beancount"Path Normalization
Before resolution:
- Expand
~to home directory (platform-specific) - Normalize
.and..components - Convert separators to platform native
Cycle Detection
Circular includes MUST be detected and rejected:
a.beancount → b.beancount → c.beancount → a.beancountDetection Algorithm
def load_file(path, include_chain=None):
if include_chain is None:
include_chain = set()
resolved = path.resolve()
if resolved in include_chain:
raise CycleError(f"Circular include: {format_chain(include_chain, resolved)}")
include_chain.add(resolved)
content = parse(resolved)
for include in content.includes:
load_file(include.path, include_chain.copy())
include_chain.remove(resolved)
return contentCycle Error
error: Circular include detected
--> c.beancount:5:1
|
5 | include "a.beancount"
| ^^^^^^^^^^^^^^^^^^^^^ creates cycle
|
= chain: a.beancount → b.beancount → c.beancount → a.beancountOption Scoping
Top-Level Options Win
Most options from included files are ignored - only the main file (entry point) values apply:
; main.beancount
option "title" "Main Ledger"
include "other.beancount"
; other.beancount
option "title" "Other Ledger" ; Ignored - main file's value usedResult: title = "Main Ledger"
Rationale
This prevents included files from unexpectedly changing global behavior.
Exception: Additive Options
The operating_currency option is additive across files - values from all files accumulate:
; main.beancount
option "operating_currency" "USD"
include "other.beancount"
; other.beancount
option "operating_currency" "EUR" ; Added to list (NOT ignored!)Result: operating_currency = ["USD", "EUR"]
Plugin Scoping
Declaration Order
Plugins are loaded in declaration order across all files:
; main.beancount
plugin "plugin_a"
include "other.beancount"
plugin "plugin_c"
; other.beancount
plugin "plugin_b"Load order: plugin_a → plugin_b → plugin_c
Plugin Application
Plugins see the merged directive stream:
# All directives from all files, sorted by date
all_directives = merge_and_sort(main_file, included_files)
# Plugins process the complete stream
for plugin in plugins:
all_directives = plugin.process(all_directives)Tag and Metadata Stacks
Per-File Scoping
Tag and metadata stacks are scoped to their file:
; main.beancount
pushtag #main-tag
include "other.beancount"
2024-01-15 * "In main" ; Has #main-tag
...
poptag #main-tag
; other.beancount
pushtag #other-tag
2024-01-10 * "In other" ; Has #other-tag, NOT #main-tag
...
poptag #other-tagCross-File Inheritance
The stack does NOT propagate into included files.
Stack Balance
Each file MUST balance its own stack:
; ERROR: Unbalanced stack
pushtag #tag
include "other.beancount"
; Missing poptag!Directive Merging
Date-Based Sorting
All directives are sorted by date after merging:
; main.beancount (loaded first)
2024-02-01 * "February entry"
...
; other.beancount (included)
2024-01-15 * "January entry"
...After merge and sort:
- 2024-01-15: "January entry" (from other.beancount)
- 2024-02-01: "February entry" (from main.beancount)
Same-Date Ordering
Directives on the same date are ordered:
- Balance assertions
- Other non-transaction directives
- Transactions
Within category, file order then line order is preserved.
Source Location
Metadata Injection
Each directive receives source location:
directive.meta['filename'] = '/path/to/file.beancount'
directive.meta['lineno'] = 42Error Reporting
Errors reference the original file:
error: Account not opened
--> yearly/q1.beancount:15:3
|
15 | Assets:Unknown 100 USD
| ^^^^^^^^^^^^^^Diamond Includes
Handling
The same file included from multiple paths is loaded once:
main.beancount
├── a.beancount → common.beancount
└── b.beancount → common.beancountcommon.beancount is parsed once; its directives appear once.
Implementation
Track loaded files by canonical path:
loaded_files = set()
def load_file(path):
canonical = path.resolve()
if canonical in loaded_files:
return [] # Already loaded
loaded_files.add(canonical)
return parse(canonical)Security
Path Traversal
See security/includes/path-traversal.md.
Symlinks
See security/includes/symlinks.md.
Cycles
See security/includes/cycles.md.
Implementation Notes
- Resolve paths relative to including file
- Canonicalize paths for cycle/diamond detection
- Track include chain for error messages
- Merge directives before sorting
- Apply options from main file only
- Process plugins after merge
- Preserve source locations for errors