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.beancount
Resolution:
- main.beancount includes yearly/2024.beancount → /home/user/finances/yearly/2024.beancount
- 2024.beancount includes q1.beancount → /home/user/finances/yearly/q1.beancount
Absolute Paths¶
Absolute paths are used as-is:
include "/shared/common/accounts.beancount"
Path Normalization¶
Before resolution:
1. Expand ~ to home directory (platform-specific)
2. Normalize . and .. components
3. Convert separators to platform native
Cycle Detection¶
Circular includes MUST be detected and rejected:
a.beancount → b.beancount → c.beancount → a.beancount
Detection 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 content
Cycle Error¶
error: Circular include detected
--> c.beancount:5:1
|
5 | include "a.beancount"
| ^^^^^^^^^^^^^^^^^^^^^ creates cycle
|
= chain: a.beancount → b.beancount → c.beancount → a.beancount
Option 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 used
Result: 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-tag
Cross-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: 1. 2024-01-15: "January entry" (from other.beancount) 2. 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'] = 42
Error 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.beancount
common.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