diff --git a/CLAUDE.md b/CLAUDE.md
index 93b271f..08646eb 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -7,8 +7,15 @@ This is a **beancount language server** implementation written in Rust that prov
This is a Rust workspace with the following structure:
- **`crates/lsp/`** - Main Rust language server implementation
+ - **`src/checkers/`** - Bean-check validation implementations (Strategy pattern)
+ - **`mod.rs`** - Strategy trait and factory pattern
+ - **`system_call.rs`** - Traditional bean-check binary execution
+ - **`python.rs`** - Python script integration via subprocess
+ - **`pyo3_embedded.rs`** - PyO3 embedded Python integration
+ - **`types.rs`** - Shared data structures
- **`vscode/`** - VSCode extension (TypeScript)
- **`python/`** - Python utilities for beancount integration
+ - **`bean_check.py`** - Enhanced Python validation script with JSON output
- **Root workspace** - Cargo workspace configuration
## Key Files
@@ -27,9 +34,15 @@ This is a Rust workspace with the following structure:
# Build the language server
cargo build
+# Build with PyO3 embedded Python support (experimental)
+cargo build --features python-embedded
+
# Run tests with coverage
cargo llvm-cov --all-features --locked --workspace --lcov --output-path lcov.info -- --include-ignored
+# Run tests with PyO3 feature
+cargo test --features python-embedded
+
# Format code
cargo fmt
@@ -83,11 +96,24 @@ nix flake check
### Language Server Features
-- **Diagnostics** - Provided via beancount Python integration
+- **Diagnostics** - Multi-method validation system with pluggable checkers
+ - **System Call** - Traditional bean-check binary execution (default)
+ - **Python Script** - Enhanced Python script with JSON output (experimental)
+ - **PyO3 Embedded** - Direct Python library integration (experimental)
- **Formatting** - Generates edits similar to bean-format
- **Completions** - Shows completions for Payees, Accounts, Dates
- **Future planned**: definitions, folding, hover, rename
+### Bean-check Integration Architecture
+
+The diagnostics system uses a Strategy pattern with three implementations:
+
+1. **SystemCallChecker** - Executes bean-check binary via subprocess and parses stderr
+2. **PythonChecker** - Runs Python script that outputs structured JSON
+3. **PyO3EmbeddedChecker** - Calls beancount library directly via embedded Python
+
+Factory pattern in `checkers/mod.rs` handles creation based on configuration.
+
### Key Dependencies
- **tree-sitter-beancount** - Parsing via tree-sitter
@@ -95,13 +121,19 @@ nix flake check
- **ropey** - Efficient text rope data structure
- **tracing** - Structured logging
- **anyhow** / **thiserror** - Error handling
-- **regex** - Pattern matching
+- **regex** - Pattern matching for error parsing
- **chrono** - Date/time handling
+- **serde** / **serde_json** - JSON serialization for Python integration
+- **pyo3** - Python integration (optional, feature-gated)
## Configuration
Language server accepts configuration via LSP initialization:
- **journal_file** - Path to main beancount journal file
+- **bean_check.method** - Validation method: "system" (default), "python-script", or "python-embedded"
+- **bean_check.bean_check_cmd** - Path to bean-check binary (for system method)
+- **bean_check.python_cmd** - Path to Python executable (for Python methods)
+- **bean_check.python_script** - Path to Python validation script (for python-script method)
## Testing
@@ -146,7 +178,9 @@ Supports multiple editors:
- **Add new LSP feature**: Modify `crates/lsp/src/handlers.rs` and related provider files
- **Update completions**: Modify `crates/lsp/src/providers/completion.rs`
-- **Add diagnostics**: Integrate with beancount via `python/bean_check.py`
+- **Add new checker method**: Implement `BeancountChecker` trait in `crates/lsp/src/checkers/`
+- **Modify bean-check integration**: Update checker implementations or factory in `crates/lsp/src/checkers/`
+- **Enhance Python validation**: Modify `python/bean_check.py` script
- **Update VSCode extension**: Modify files in `vscode/src/`
## External Dependencies
diff --git a/Cargo.lock b/Cargo.lock
index 6ceee95..1fe86ac 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -110,6 +110,7 @@ dependencies = [
"lsp-server",
"lsp-types",
"nucleo",
+ "pyo3",
"regex",
"ropey",
"serde",
@@ -477,6 +478,12 @@ dependencies = [
"hashbrown 0.15.4",
]
+[[package]]
+name = "indoc"
+version = "2.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
+
[[package]]
name = "insta"
version = "1.41.1"
@@ -604,6 +611,15 @@ version = "2.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f665ee40bc4a3c5590afb1e9677db74a508659dfd71e126420da8274909a0167"
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
[[package]]
name = "nu-ansi-term"
version = "0.46.0"
@@ -707,6 +723,12 @@ version = "0.2.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58"
+[[package]]
+name = "portable-atomic"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
+
[[package]]
name = "proc-macro2"
version = "1.0.89"
@@ -716,6 +738,68 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "pyo3"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8970a78afe0628a3e3430376fc5fd76b6b45c4d43360ffd6cdd40bdde72b682a"
+dependencies = [
+ "indoc",
+ "libc",
+ "memoffset",
+ "once_cell",
+ "portable-atomic",
+ "pyo3-build-config",
+ "pyo3-ffi",
+ "pyo3-macros",
+ "unindent",
+]
+
+[[package]]
+name = "pyo3-build-config"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "458eb0c55e7ece017adeba38f2248ff3ac615e53660d7c71a238d7d2a01c7598"
+dependencies = [
+ "once_cell",
+ "target-lexicon",
+]
+
+[[package]]
+name = "pyo3-ffi"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7114fe5457c61b276ab77c5055f206295b812608083644a5c5b2640c3102565c"
+dependencies = [
+ "libc",
+ "pyo3-build-config",
+]
+
+[[package]]
+name = "pyo3-macros"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8725c0a622b374d6cb051d11a0983786448f7785336139c3c94f5aa6bef7e50"
+dependencies = [
+ "proc-macro2",
+ "pyo3-macros-backend",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pyo3-macros-backend"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4109984c22491085343c05b0dbc54ddc405c3cf7b4374fc533f5c3313a572ccc"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "pyo3-build-config",
+ "quote",
+ "syn",
+]
+
[[package]]
name = "quote"
version = "1.0.35"
@@ -875,9 +959,9 @@ dependencies = [
[[package]]
name = "serde_json"
-version = "1.0.140"
+version = "1.0.142"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
+checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7"
dependencies = [
"indexmap",
"itoa",
@@ -962,6 +1046,12 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "target-lexicon"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e502f78cdbb8ba4718f566c418c52bc729126ffd16baee5baa718cf25dd5a69a"
+
[[package]]
name = "tempfile"
version = "3.10.1"
@@ -1188,6 +1278,12 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+[[package]]
+name = "unindent"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3"
+
[[package]]
name = "url"
version = "2.5.2"
diff --git a/README.md b/README.md
index e0353d1..a962bf3 100644
--- a/README.md
+++ b/README.md
@@ -12,13 +12,13 @@ A [Language Server Protocol](https://microsoft.github.io/language-server-protoco
### 🚀 Currently Implemented
-| LSP Feature | Description | Status |
-|-------------|-------------|---------|
-| **Completions** | Smart autocompletion for accounts, payees, dates, narration, tags, links, and transaction types | ✅ |
-| **Diagnostics** | Real-time error checking and validation via beancount Python integration | ✅ |
-| **Formatting** | Document formatting compatible with `bean-format`, with support for prefix-width, num-width, and currency-column options | ✅ |
-| **Rename** | Rename symbols across files | ✅ |
-| **References** | Find all references to accounts, payees, etc. | ✅ |
+| LSP Feature | Description | Status |
+| --------------- | ------------------------------------------------------------------------------------------------------------------------ | ------ |
+| **Completions** | Smart autocompletion for accounts, payees, dates, narration, tags, links, and transaction types | ✅ |
+| **Diagnostics** | Real-time error checking and validation via beancount Python integration | ✅ |
+| **Formatting** | Document formatting compatible with `bean-format`, with support for prefix-width, num-width, and currency-column options | ✅ |
+| **Rename** | Rename symbols across files | ✅ |
+| **References** | Find all references to accounts, payees, etc. | ✅ |
### 📋 Completion Types
@@ -32,17 +32,17 @@ A [Language Server Protocol](https://microsoft.github.io/language-server-protoco
### 🔮 Planned Features
-| LSP Feature | Description | Priority |
-|-------------|-------------|----------|
-| **Hover** | Show account balances, transaction details, account metadata | High |
-| **Go to Definition** | Jump to account/payee/commodity definitions | High |
-| **Document Symbols** | Outline view showing accounts, transactions, and structure | High |
-| **Folding Ranges** | Fold transactions, account hierarchies, and multi-line entries | Medium |
-| **Semantic Highlighting** | Advanced syntax highlighting with semantic information | Medium |
-| **Code Actions** | Quick fixes, refactoring, auto-balance transactions | Medium |
-| **Inlay Hints** | Show computed balances, exchange rates, running totals | Low |
-| **Signature Help** | Help with transaction syntax and directive parameters | Low |
-| **Workspace Symbols** | Find accounts, payees, commodities across all files | Low |
+| LSP Feature | Description | Priority |
+| ------------------------- | -------------------------------------------------------------- | -------- |
+| **Hover** | Show account balances, transaction details, account metadata | High |
+| **Go to Definition** | Jump to account/payee/commodity definitions | High |
+| **Document Symbols** | Outline view showing accounts, transactions, and structure | High |
+| **Folding Ranges** | Fold transactions, account hierarchies, and multi-line entries | Medium |
+| **Semantic Highlighting** | Advanced syntax highlighting with semantic information | Medium |
+| **Code Actions** | Quick fixes, refactoring, auto-balance transactions | Medium |
+| **Inlay Hints** | Show computed balances, exchange rates, running totals | Low |
+| **Signature Help** | Help with transaction syntax and directive parameters | Low |
+| **Workspace Symbols** | Find accounts, payees, commodities across all files | Low |
## 📦 Installation
@@ -57,8 +57,9 @@ cargo install beancount-language-server
Download the latest release for your platform from the [releases page](https://github.com/polarmutex/beancount-language-server/releases).
**Supported Platforms:**
+
- Linux (x86_64, aarch64, loongarch64)
-- macOS (x86_64, aarch64)
+- macOS (x86_64, aarch64)
- Windows (x86_64)
### Method 3: Homebrew (macOS/Linux)
@@ -85,7 +86,12 @@ nix develop
```bash
git clone https://github.com/polarmutex/beancount-language-server.git
cd beancount-language-server
+
+# Standard build
cargo build --release
+
+# Build with PyO3 embedded Python support (experimental)
+cargo build --release --features python-embedded
```
The binary will be available at `target/release/beancount-language-server`.
@@ -93,16 +99,25 @@ The binary will be available at `target/release/beancount-language-server`.
## 🔧 Requirements
### Required
+
- **Beancount**: Install the Python beancount package for diagnostics
- ```bash
- pip install beancount
- ```
+ ```bash
+ pip install beancount
+ ```
### Optional
+
- **Bean-format**: The language server includes built-in formatting that's fully compatible with bean-format. Installing bean-format is optional for comparison or standalone use
- ```bash
- pip install bean-format
- ```
+ ```bash
+ pip install bean-format
+ ```
+
+### Experimental Features
+
+- **PyO3 Embedded Python**: For improved performance, build with embedded Python support
+ ```bash
+ cargo build --features python-embedded
+ ```
## ⚙️ Configuration
@@ -110,41 +125,117 @@ The language server accepts configuration via LSP initialization options:
```json
{
- "journal_file": "/path/to/main.beancount",
- "formatting": {
- "prefix_width": 30,
- "num_width": 10,
- "currency_column": 60,
- "account_amount_spacing": 2,
- "number_currency_spacing": 1
- }
+ "journal_file": "/path/to/main.beancount",
+ "bean_check": {
+ "method": "system",
+ "bean_check_cmd": "bean-check"
+ },
+ "formatting": {
+ "prefix_width": 30,
+ "num_width": 10,
+ "currency_column": 60,
+ "account_amount_spacing": 2,
+ "number_currency_spacing": 1
+ }
}
```
### Configuration Options
-| Option | Type | Description | Default |
-|--------|------|-------------|---------|
-| `journal_file` | string | Path to the main beancount journal file | None |
+| Option | Type | Description | Default |
+| -------------- | ------ | --------------------------------------- | ------- |
+| `journal_file` | string | Path to the main beancount journal file | None |
+
+### Bean-check Configuration
+
+| Option | Type | Description | Default |
+| --------------------------- | ------ | ------------------------------------------------------------------ | ------------------------ |
+| `bean_check.method` | string | Validation method: "system", "python-script", or "python-embedded" | "system" |
+| `bean_check.bean_check_cmd` | string | Path to bean-check binary (for "system" method) | "bean-check" |
+| `bean_check.python_cmd` | string | Path to Python executable (for Python methods) | "python3" |
+| `bean_check.python_script` | string | Path to Python validation script (for "python-script" method) | "./python/bean_check.py" |
+
+#### Bean-check Methods
+
+The language server supports three different methods for validating beancount files:
+
+**System Method** (default):
+
+- Uses the traditional `bean-check` binary via subprocess
+- Fastest startup time, lower memory usage
+- Requires `bean-check` binary to be installed and available in PATH
+- Compatible with all existing bean-check installations
+
+**Python Script Method** (experimental):
+
+- Executes a Python script that uses the beancount library directly
+- Provides structured JSON output for better error handling
+- Supports both validation errors and flagged entry detection
+- Requires Python with beancount library installed
+
+**Python Embedded Method** (experimental):
+
+- Uses PyO3 to embed Python directly in the Rust process
+- Highest performance with no subprocess overhead
+- Best error handling and flagged entry support
+- Requires compilation with `python-embedded` feature
+- Must have beancount library available to embedded Python
+
+#### Configuration Examples
+
+**Traditional system call approach:**
+
+```json
+{
+ "bean_check": {
+ "method": "system",
+ "bean_check_cmd": "/usr/local/bin/bean-check"
+ }
+}
+```
+
+**Python script with custom paths:**
+
+```json
+{
+ "bean_check": {
+ "method": "python-script",
+ "python_cmd": "/usr/bin/python3",
+ "python_script": "./python/bean_check.py"
+ }
+}
+```
+
+**Embedded Python (requires python-embedded feature):**
+
+```json
+{
+ "bean_check": {
+ "method": "python-embedded"
+ }
+}
+```
### Formatting Options
-| Option | Type | Description | Default | Bean-format Equivalent |
-|--------|------|-------------|---------|----------------------|
-| `prefix_width` | number | Fixed width for account names (overrides auto-detection) | Auto-calculated | `--prefix-width` (`-w`) |
-| `num_width` | number | Fixed width for number alignment (overrides auto-detection) | Auto-calculated | `--num-width` (`-W`) |
-| `currency_column` | number | Align currencies at this specific column | None (right-align) | `--currency-column` (`-c`) |
-| `account_amount_spacing` | number | Minimum spaces between account names and amounts | 2 | N/A |
-| `number_currency_spacing` | number | Number of spaces between number and currency | 1 | N/A |
+| Option | Type | Description | Default | Bean-format Equivalent |
+| ------------------------- | ------ | ----------------------------------------------------------- | ------------------ | -------------------------- |
+| `prefix_width` | number | Fixed width for account names (overrides auto-detection) | Auto-calculated | `--prefix-width` (`-w`) |
+| `num_width` | number | Fixed width for number alignment (overrides auto-detection) | Auto-calculated | `--num-width` (`-W`) |
+| `currency_column` | number | Align currencies at this specific column | None (right-align) | `--currency-column` (`-c`) |
+| `account_amount_spacing` | number | Minimum spaces between account names and amounts | 2 | N/A |
+| `number_currency_spacing` | number | Number of spaces between number and currency | 1 | N/A |
#### Formatting Modes
**Default Mode** (no `currency_column` specified):
+
- Accounts are left-aligned
- Numbers are right-aligned with consistent end positions
- Behaves like `bean-format` with no special options
**Currency Column Mode** (`currency_column` specified):
+
- Currencies are aligned at the specified column
- Numbers are positioned to place currencies at the target column
- Equivalent to `bean-format --currency-column N`
@@ -152,53 +243,59 @@ The language server accepts configuration via LSP initialization options:
#### Examples
**Basic formatting with auto-detection:**
+
```json
{
- "formatting": {}
+ "formatting": {}
}
```
**Fixed prefix width (like `bean-format -w 25`):**
+
```json
{
- "formatting": {
- "prefix_width": 25
- }
+ "formatting": {
+ "prefix_width": 25
+ }
}
```
**Currency column alignment (like `bean-format -c 60`):**
+
```json
{
- "formatting": {
- "currency_column": 60
- }
+ "formatting": {
+ "currency_column": 60
+ }
}
```
**Number-currency spacing control:**
+
```json
{
- "formatting": {
- "number_currency_spacing": 2
- }
+ "formatting": {
+ "number_currency_spacing": 2
+ }
}
```
This controls the whitespace between numbers and currency codes:
+
- `0`: No space (`100.00USD`)
- `1`: Single space (`100.00 USD`) - default
- `2`: Two spaces (`100.00 USD`)
**Combined options:**
+
```json
{
- "formatting": {
- "prefix_width": 30,
- "currency_column": 65,
- "account_amount_spacing": 3,
- "number_currency_spacing": 1
- }
+ "formatting": {
+ "prefix_width": 30,
+ "currency_column": 65,
+ "account_amount_spacing": 3,
+ "number_currency_spacing": 1
+ }
}
```
@@ -208,16 +305,20 @@ This controls the whitespace between numbers and currency codes:
1. Install the [Beancount extension](https://marketplace.visualstudio.com/items?itemName=polarmutex.beancount-langserver) from the marketplace
2. Configure in `settings.json`:
- ```json
- {
- "beancount.journal_file": "/path/to/main.beancount",
- "beancount.formatting": {
- "prefix_width": 30,
- "currency_column": 60,
- "number_currency_spacing": 1
- }
- }
- ```
+ ```json
+ {
+ "beancount.journal_file": "/path/to/main.beancount",
+ "beancount.bean_check": {
+ "method": "python-embedded",
+ "python_cmd": "python3"
+ },
+ "beancount.formatting": {
+ "prefix_width": 30,
+ "currency_column": 60,
+ "number_currency_spacing": 1
+ }
+ }
+ ```
### Neovim
@@ -229,6 +330,11 @@ local lspconfig = require('lspconfig')
lspconfig.beancount.setup({
init_options = {
journal_file = "/path/to/main.beancount",
+ bean_check = {
+ method = "python-script",
+ python_cmd = "python3",
+ python_script = "./python/bean_check.py",
+ },
formatting = {
prefix_width = 30,
currency_column = 60,
@@ -239,6 +345,7 @@ lspconfig.beancount.setup({
```
**File type detection**: Ensure beancount files are detected. Add to your config:
+
```lua
vim.filetype.add({
extension = {
@@ -260,6 +367,10 @@ args = ["--stdio"]
[language-server.beancount-language-server.config]
journal_file = "/path/to/main.beancount"
+[language-server.beancount-language-server.config.bean_check]
+method = "system"
+bean_check_cmd = "bean-check"
+
[language-server.beancount-language-server.config.formatting]
prefix_width = 30
currency_column = 60
@@ -285,6 +396,7 @@ Using [lsp-mode](https://github.com/emacs-lsp/lsp-mode):
:server-id 'beancount-language-server
:initialization-options
(lambda () (list :journal_file "/path/to/main.beancount"
+ :bean_check '(:method "python-embedded")
:formatting '(:prefix_width 30 :currency_column 60 :number_currency_spacing 1))))))
```
@@ -300,6 +412,10 @@ if executable('beancount-language-server')
\ 'allowlist': ['beancount'],
\ 'initialization_options': {
\ 'journal_file': '/path/to/main.beancount',
+ \ 'bean_check': {
+ \ 'method': 'system',
+ \ 'bean_check_cmd': 'bean-check'
+ \ },
\ 'formatting': {
\ 'prefix_width': 30,
\ 'currency_column': 60,
@@ -315,23 +431,24 @@ endif
Using [LSP](https://packagecontrol.io/packages/LSP):
Add to LSP settings:
+
```json
{
- "clients": {
- "beancount-language-server": {
- "enabled": true,
- "command": ["beancount-language-server"],
- "selector": "source.beancount",
- "initializationOptions": {
- "journal_file": "/path/to/main.beancount",
- "formatting": {
- "prefix_width": 30,
- "currency_column": 60,
- "number_currency_spacing": 1
+ "clients": {
+ "beancount-language-server": {
+ "enabled": true,
+ "command": ["beancount-language-server"],
+ "selector": "source.beancount",
+ "initializationOptions": {
+ "journal_file": "/path/to/main.beancount",
+ "formatting": {
+ "prefix_width": 30,
+ "currency_column": 60,
+ "number_currency_spacing": 1
+ }
+ }
}
- }
}
- }
}
```
@@ -355,7 +472,8 @@ Add to LSP settings:
- **LSP Server**: Main Rust application handling LSP protocol
- **Tree-sitter Parser**: Fast, incremental parsing of Beancount syntax
- **Completion Engine**: Smart autocompletion with context awareness
-- **Diagnostic Provider**: Integration with beancount Python for validation
+- **Diagnostic Provider**: Multi-method validation system with pluggable checkers
+- **Bean-check Integration**: Three validation methods (system, python-embedded)
- **Formatter**: Code formatting fully compatible with bean-format, supporting prefix-width, num-width, and currency-column options
### Project Structure
@@ -366,9 +484,13 @@ beancount-language-server/
│ ├── src/
│ │ ├── handlers.rs # LSP request/notification handlers
│ │ ├── providers/ # Feature providers (completion, diagnostics, etc.)
+│ │ ├── checkers/ # Bean-check validation implementations
+│ │ │ ├── mod.rs # Strategy trait and factory pattern
+│ │ │ ├── system_call.rs # Traditional bean-check binary
+│ │ │ ├── pyo3_embedded.rs # PyO3 embedded Python
+│ │ │ └── types.rs # Shared data structures
│ │ └── server.rs # Core LSP server logic
├── vscode/ # VS Code extension
-├── python/ # Python integration utilities
└── flake.nix # Nix development environment
```
@@ -383,11 +505,13 @@ beancount-language-server/
### Development Environment
**Using Nix (Recommended):**
+
```bash
nix develop
```
**Manual Setup:**
+
```bash
# Install Rust dependencies
cargo build
@@ -408,6 +532,9 @@ cargo test
# Run with coverage
cargo llvm-cov --all-features --locked --workspace --lcov --output-path lcov.info
+# Run tests with PyO3 feature
+cargo test --features python-embedded
+
# Run specific test
cargo test test_completion
```
@@ -430,9 +557,9 @@ cargo fmt -- --check
1. **Make changes** to the Rust code
2. **Test locally** with `cargo test`
3. **Run LSP server** in development mode:
- ```bash
- cargo run --bin beancount-language-server
- ```
+ ```bash
+ cargo run --bin beancount-language-server
+ ```
4. **Test with editor** by configuring it to use the local binary
### VS Code Extension Development
@@ -450,20 +577,22 @@ The project uses [cargo-dist](https://opensource.axo.dev/cargo-dist/) for automa
1. **Tag a release**: `git tag v1.0.0 && git push --tags`
2. **GitHub Actions** automatically builds and publishes:
- - Binaries for all supported platforms
- - Crates.io release
- - GitHub release with assets
+ - Binaries for all supported platforms
+ - Crates.io release
+ - GitHub release with assets
## 🤝 Contributing
Contributions are welcome! Here are some ways to help:
### 🐛 Bug Reports
+
- Search existing issues first
- Include beancount file examples that trigger the bug
- Provide editor and OS information
### 💡 Feature Requests
+
- Check the [planned features](#-planned-features) list
- Describe the use case and expected behavior
- Consider the LSP specification constraints
@@ -481,6 +610,7 @@ Contributions are welcome! Here are some ways to help:
### 🎯 Good First Issues
Look for issues labeled `good-first-issue`:
+
- Add new completion types
- Improve error messages
- Add editor configuration examples
@@ -508,4 +638,4 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
Happy Beancounting! 📊✨
-
\ No newline at end of file
+
diff --git a/beancount-language-server.log b/beancount-language-server.log
new file mode 100644
index 0000000..cf9e153
--- /dev/null
+++ b/beancount-language-server.log
@@ -0,0 +1,19 @@
+2025-07-27T17:47:09.835410Z INFO ThreadId(01) Starting beancount-language-server v1.4.1
+2025-07-27T17:47:09.835450Z INFO ThreadId(01) beancount-language-server started
+2025-07-27T19:17:36.369616Z INFO ThreadId(01) Starting beancount-language-server v1.4.1
+2025-07-27T19:17:36.369687Z DEBUG ThreadId(01) Command line args: stdio=true, log_level=Some("debug")
+2025-07-27T19:17:36.369726Z INFO ThreadId(01) beancount-language-server started
+2025-07-27T19:17:36.369746Z DEBUG ThreadId(01) Setting up stdio connections
+2025-07-27T19:17:36.369862Z DEBUG ThreadId(01) Waiting for client initialization
+2025-07-27T19:17:53.455800Z INFO ThreadId(01) Starting beancount-language-server v1.4.1
+2025-07-27T19:17:53.455872Z INFO ThreadId(01) beancount-language-server started
+2025-07-27T19:19:05.776081Z INFO ThreadId(01) Starting beancount-language-server v1.4.1
+2025-07-27T19:19:05.776151Z DEBUG ThreadId(01) Command line args: stdio=true, log_to_file=true, log_level=None
+2025-07-27T19:19:05.776191Z INFO ThreadId(01) beancount-language-server started
+2025-07-27T19:19:05.776205Z DEBUG ThreadId(01) Setting up stdio connections
+2025-07-27T19:19:05.776317Z DEBUG ThreadId(01) Waiting for client initialization
+2025-07-27T19:19:14.817888Z INFO ThreadId(01) Starting beancount-language-server v1.4.1
+2025-07-27T19:19:14.817958Z DEBUG ThreadId(01) Command line args: stdio=true, log_to_file=true, log_level=Some("trace")
+2025-07-27T19:19:14.817998Z INFO ThreadId(01) beancount-language-server started
+2025-07-27T19:19:14.818019Z DEBUG ThreadId(01) Setting up stdio connections
+2025-07-27T19:19:14.818161Z DEBUG ThreadId(01) Waiting for client initialization
diff --git a/crates/lsp/Cargo.toml b/crates/lsp/Cargo.toml
index 07f2a8d..f010c6e 100644
--- a/crates/lsp/Cargo.toml
+++ b/crates/lsp/Cargo.toml
@@ -50,6 +50,9 @@ dashmap = "6.1"
bytes = "1.10"
linked-list = "0.1"
+# Python integration (optional)
+pyo3 = { version = "0.25", features = ["auto-initialize"], optional = true }
+
# Logging
tracing-subscriber = { version = "0.3", default-features = false, features = [
"env-filter",
@@ -71,3 +74,7 @@ dist = true
[package.metadata.release]
tag = true
+
+[features]
+default = []
+python-embedded = ["pyo3"]
diff --git a/crates/lsp/src/checkers/mod.rs b/crates/lsp/src/checkers/mod.rs
new file mode 100644
index 0000000..73b1aa6
--- /dev/null
+++ b/crates/lsp/src/checkers/mod.rs
@@ -0,0 +1,142 @@
+use anyhow::Result;
+use std::path::{Path, PathBuf};
+use tracing::debug;
+
+pub mod pyo3_embedded;
+pub mod system_call;
+pub mod types;
+
+pub use pyo3_embedded::PyO3EmbeddedChecker;
+pub use system_call::SystemCallChecker;
+pub use types::*;
+
+/// Trait for different bean-check execution strategies.
+///
+/// This allows the language server to support multiple ways of running
+/// bean-check validation, including system calls and Python integration.
+pub trait BeancountChecker: Send + Sync {
+ /// Execute bean-check validation on the given journal file.
+ ///
+ /// # Arguments
+ /// * `journal_file` - Path to the main beancount journal file to validate
+ ///
+ /// # Returns
+ /// * `Ok(BeancountCheckResult)` - Validation results with errors and flagged entries
+ /// * `Err(anyhow::Error)` - Execution error (command not found, parsing failed, etc.)
+ fn check(&self, journal_file: &Path) -> Result;
+
+ /// Get a human-readable name for this checker implementation.
+ /// Used for logging and debugging purposes.
+ fn name(&self) -> &'static str;
+
+ /// Check if this checker implementation is available on the current system.
+ /// For example, system call checker needs bean-check binary, Python checker needs Python.
+ fn is_available(&self) -> bool;
+}
+
+/// Configuration for bean-check execution method selection.
+#[derive(Debug, Clone)]
+pub enum BeancountCheckMethod {
+ /// Use system call to execute bean-check binary (traditional approach)
+ SystemCall,
+ /// Use embedded Python via PyO3 to call beancount library directly (best performance)
+ PythonEmbedded,
+}
+
+impl Default for BeancountCheckMethod {
+ fn default() -> Self {
+ // Default to system call for backward compatibility
+ Self::SystemCall
+ }
+}
+
+/// Configuration options for bean-check execution.
+#[derive(Debug, Clone)]
+pub struct BeancountCheckConfig {
+ /// Which execution method to use
+ pub method: BeancountCheckMethod,
+ /// Path to bean-check executable (for SystemCall method)
+ pub bean_check_cmd: PathBuf,
+ /// Path to Python executable (for Python method)
+ pub python_cmd: PathBuf,
+ /// Path to the Python script (for Python method)
+ pub python_script_path: PathBuf,
+}
+
+impl Default for BeancountCheckConfig {
+ fn default() -> Self {
+ Self {
+ method: BeancountCheckMethod::default(),
+ bean_check_cmd: PathBuf::from("bean-check"),
+ python_cmd: PathBuf::from("python3"),
+ python_script_path: PathBuf::from("python/bean_check.py"),
+ }
+ }
+}
+
+/// Factory function to create a checker based on configuration.
+///
+/// # Arguments
+/// * `config` - Configuration specifying which checker to create and its options
+///
+/// # Returns
+/// * Boxed trait object implementing BeancountChecker
+pub fn create_checker(config: &BeancountCheckConfig) -> Box {
+ debug!("Creating bean checker with method: {:?}", config.method);
+
+ let checker: Box = match config.method {
+ BeancountCheckMethod::SystemCall => {
+ debug!(
+ "Creating SystemCallChecker with command: {}",
+ config.bean_check_cmd.display()
+ );
+ Box::new(SystemCallChecker::new(config.bean_check_cmd.clone()))
+ }
+ BeancountCheckMethod::PythonEmbedded => {
+ debug!("Creating PyO3EmbeddedChecker");
+ Box::new(PyO3EmbeddedChecker::new())
+ }
+ };
+
+ debug!(
+ "Created checker: {}, availability: {}",
+ checker.name(),
+ checker.is_available()
+ );
+ checker
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_default_config() {
+ let config = BeancountCheckConfig::default();
+ assert!(matches!(config.method, BeancountCheckMethod::SystemCall));
+ assert_eq!(config.bean_check_cmd, PathBuf::from("bean-check"));
+ assert_eq!(config.python_cmd, PathBuf::from("python3"));
+ }
+
+ #[test]
+ fn test_factory_system_call() {
+ let config = BeancountCheckConfig::default();
+ let checker = create_checker(&config);
+ assert_eq!(checker.name(), "SystemCall");
+ }
+
+ #[test]
+ fn test_factory_python_embedded() {
+ let config = BeancountCheckConfig {
+ method: BeancountCheckMethod::PythonEmbedded,
+ ..Default::default()
+ };
+ let checker = create_checker(&config);
+
+ #[cfg(feature = "python-embedded")]
+ assert_eq!(checker.name(), "PyO3Embedded");
+
+ #[cfg(not(feature = "python-embedded"))]
+ assert_eq!(checker.name(), "PyO3Embedded (disabled)");
+ }
+}
diff --git a/crates/lsp/src/checkers/pyo3_embedded.rs b/crates/lsp/src/checkers/pyo3_embedded.rs
new file mode 100644
index 0000000..1cabab7
--- /dev/null
+++ b/crates/lsp/src/checkers/pyo3_embedded.rs
@@ -0,0 +1,615 @@
+#[cfg(feature = "python-embedded")]
+use super::types::*;
+#[cfg(feature = "python-embedded")]
+use super::BeancountChecker;
+#[cfg(feature = "python-embedded")]
+use anyhow::{Context, Result};
+#[cfg(feature = "python-embedded")]
+use pyo3::prelude::*;
+#[cfg(feature = "python-embedded")]
+use pyo3::types::{PyDict, PyList, PyString};
+#[cfg(feature = "python-embedded")]
+use std::path::{Path, PathBuf};
+#[cfg(feature = "python-embedded")]
+use std::sync::OnceLock;
+#[cfg(feature = "python-embedded")]
+use tracing::{debug, warn};
+
+#[cfg(feature = "python-embedded")]
+/// Bean-check implementation using embedded Python via PyO3.
+///
+/// This approach embeds the Python interpreter directly into the Rust process
+/// and calls the beancount library functions directly without subprocess overhead.
+/// Provides the best performance and error handling of all implementations.
+#[derive(Debug)]
+pub struct PyO3EmbeddedChecker {
+ /// Whether to cache the Python code compilation (future optimization)
+ _cache_compiled_code: bool,
+}
+
+/// Cache for the beancount.loader module to avoid repeated imports
+#[cfg(feature = "python-embedded")]
+static BEANCOUNT_LOADER_CACHE: OnceLock> = OnceLock::new();
+
+#[cfg(feature = "python-embedded")]
+impl PyO3EmbeddedChecker {
+ /// Create a new PyO3 embedded checker.
+ pub fn new() -> Self {
+ Self {
+ _cache_compiled_code: true,
+ }
+ }
+
+ /// Execute beancount validation using embedded Python.
+ fn execute_beancount_check(&self, journal_file: &Path) -> Result {
+ debug!(
+ "PyO3EmbeddedChecker: starting embedded beancount check for file: {}",
+ journal_file.display()
+ );
+
+ // Check if file exists before proceeding
+ if !journal_file.exists() {
+ warn!(
+ "PyO3EmbeddedChecker: journal file does not exist: {}",
+ journal_file.display()
+ );
+ return Err(anyhow::anyhow!(
+ "Journal file does not exist: {}",
+ journal_file.display()
+ ));
+ }
+
+ debug!("PyO3EmbeddedChecker: entering Python GIL context");
+ Python::with_gil(|py| {
+ debug!("PyO3EmbeddedChecker: successfully acquired Python GIL");
+
+ // Import required Python modules (cached for performance)
+ debug!("PyO3EmbeddedChecker: attempting to import beancount.loader");
+ let beancount_loader = py
+ .import("beancount.loader")
+ .context("Failed to import beancount.loader - ensure beancount is installed")?;
+ debug!("PyO3EmbeddedChecker: successfully imported beancount.loader");
+
+ // Convert file path to Python string
+ debug!("PyO3EmbeddedChecker: converting file path to Python string");
+ let file_path_str = journal_file
+ .to_str()
+ .context("File path contains invalid UTF-8")?;
+ let py_file_path = PyString::new(py, file_path_str);
+ debug!(
+ "PyO3EmbeddedChecker: created Python string for file path: {}",
+ file_path_str
+ );
+
+ // Call beancount.loader.load_file(file_path)
+ debug!(
+ "PyO3EmbeddedChecker: calling beancount.loader.load_file with path: {}",
+ file_path_str
+ );
+ let load_result = beancount_loader
+ .call_method1("load_file", (py_file_path,))
+ .context("Failed to call beancount.loader.load_file")?;
+ debug!("PyO3EmbeddedChecker: beancount.loader.load_file completed successfully");
+
+ // Extract the tuple (entries, errors, options)
+ debug!("PyO3EmbeddedChecker: extracting results tuple from load_file");
+ let (entries, errors, _options): (Bound, Bound, Bound) =
+ load_result
+ .extract()
+ .context("Failed to extract load_file result tuple")?;
+
+ debug!(
+ "PyO3EmbeddedChecker: extracted {} entries, {} errors from beancount",
+ entries.len(),
+ errors.len()
+ );
+
+ // Process errors (pre-allocate capacity for performance)
+ debug!(
+ "PyO3EmbeddedChecker: processing {} beancount errors",
+ errors.len()
+ );
+ let mut beancount_errors = Vec::with_capacity(errors.len());
+ for (error_index, error_obj) in errors.iter().enumerate() {
+ debug!(
+ "PyO3EmbeddedChecker: processing error {} of {}",
+ error_index + 1,
+ errors.len()
+ );
+ match self.extract_error_info(py, &error_obj, journal_file) {
+ Ok(error) => {
+ debug!(
+ "PyO3EmbeddedChecker: successfully extracted error: {}:{} - {}",
+ error.file.display(),
+ error.line,
+ error.message
+ );
+ beancount_errors.push(error);
+ }
+ Err(e) => {
+ warn!(
+ "PyO3EmbeddedChecker: failed to extract error info for error {}: {}",
+ error_index + 1,
+ e
+ );
+ // Add a generic error as fallback
+ beancount_errors.push(BeancountError::new(
+ journal_file.to_path_buf(),
+ 0,
+ format!("Failed to process beancount error: {e}"),
+ ));
+ }
+ }
+ }
+
+ // Process flagged entries (estimate capacity and optimize early returns)
+ debug!(
+ "PyO3EmbeddedChecker: processing {} entries for flags",
+ entries.len()
+ );
+ let mut flagged_entries = Vec::new();
+ for (entry_index, entry_obj) in entries.iter().enumerate() {
+ debug!(
+ "PyO3EmbeddedChecker: processing entry {} of {} for flags",
+ entry_index + 1,
+ entries.len()
+ );
+ match self.extract_flagged_entry_info(py, &entry_obj) {
+ Ok(Some(flagged)) => {
+ debug!(
+ "PyO3EmbeddedChecker: found flagged entry: {}:{} - {}",
+ flagged.file.display(),
+ flagged.line,
+ flagged.message
+ );
+ flagged_entries.push(flagged);
+ }
+ Ok(None) => {
+ // Not flagged, skip silently for performance
+ }
+ Err(e) => {
+ debug!("PyO3EmbeddedChecker: failed to extract flagged entry info for entry {}: {}", entry_index + 1, e);
+ // Non-critical, continue processing
+ }
+ }
+ }
+
+ debug!(
+ "PyO3EmbeddedChecker: processing complete - {} errors, {} flagged entries found",
+ beancount_errors.len(),
+ flagged_entries.len()
+ );
+
+ let result = BeancountCheckResult {
+ errors: beancount_errors,
+ flagged_entries,
+ };
+
+ debug!(
+ "PyO3EmbeddedChecker: returning result with {} errors and {} flagged entries",
+ result.errors.len(),
+ result.flagged_entries.len()
+ );
+
+ Ok(result)
+ })
+ }
+
+ /// Extract error information from a Python error object.
+ fn extract_error_info(
+ &self,
+ _py: Python,
+ error_obj: &Bound<'_, PyAny>,
+ default_file: &Path,
+ ) -> Result {
+ // Get the error source (filename and line number)
+ let source = error_obj
+ .getattr("source")
+ .context("Error object missing 'source' attribute")?;
+
+ let (filename, line_number) = if source.is_none() {
+ // No source information, use defaults
+ (default_file.to_path_buf(), 0)
+ } else {
+ // Source is a dictionary, not an object with attributes
+ let filename_attr = source
+ .get_item("filename")
+ .and_then(|f| f.extract::())
+ .unwrap_or_else(|_| default_file.to_string_lossy().to_string());
+
+ let line_number = source
+ .get_item("lineno")
+ .and_then(|l| l.extract::())
+ .unwrap_or(0);
+
+ (PathBuf::from(filename_attr), line_number)
+ };
+
+ // Get the error message
+ let message = error_obj
+ .getattr("message")
+ .and_then(|m| m.extract::())
+ .or_else(|_| error_obj.str().map(|s| s.to_string()))
+ .unwrap_or_else(|_| "Unknown beancount error".to_string());
+
+ Ok(BeancountError::new(filename, line_number, message))
+ }
+
+ /// Extract flagged entry information from a Python entry object.
+ fn extract_flagged_entry_info(
+ &self,
+ _py: Python,
+ entry_obj: &Bound<'_, PyAny>,
+ ) -> Result