Skip to main content

rustledger_wasm/
lib.rs

1//! Beancount WASM Bindings.
2//!
3//! This crate provides WebAssembly bindings for using Beancount from JavaScript/TypeScript.
4//!
5//! # Features
6//!
7//! - Parse Beancount files
8//! - Validate ledgers
9//! - Run BQL queries
10//! - Format directives
11//!
12//! # Example (JavaScript)
13//!
14//! ```javascript
15//! import init, { parse, validateSource, query } from '@rustledger/wasm';
16//!
17//! await init();
18//!
19//! const source = `
20//! 2024-01-01 open Assets:Bank USD
21//! 2024-01-15 * "Coffee"
22//!   Expenses:Food  5.00 USD
23//!   Assets:Bank   -5.00 USD
24//! `;
25//!
26//! const result = parse(source);
27//! if (result.errors.length === 0) {
28//!     const validation = validateSource(source);
29//!     console.log('Validation errors:', validation.errors);
30//! }
31//! ```
32
33#![forbid(unsafe_code)]
34#![warn(missing_docs)]
35// wasm_bindgen doesn't support const fn on exported methods
36#![allow(clippy::missing_const_for_fn)]
37
38// Internal modules
39mod convert;
40mod editor;
41mod helpers;
42mod utils;
43
44// Public modules
45pub mod types;
46
47// Public API modules
48mod api;
49mod parsed_ledger;
50
51// Re-export public API
52pub use api::{balances, format, parse, query, validate_source, version};
53
54#[cfg(feature = "completions")]
55pub use api::bql_completions;
56
57#[cfg(feature = "plugins")]
58pub use api::{list_plugins, run_plugin};
59
60pub use api::expand_pads;
61pub use parsed_ledger::ParsedLedger;
62
63use wasm_bindgen::prelude::*;
64
65// =============================================================================
66// TypeScript Type Definitions
67// =============================================================================
68
69#[wasm_bindgen(typescript_custom_section)]
70const TS_TYPES: &'static str = r#"
71/** Error severity level. */
72export type Severity = 'error' | 'warning';
73
74/** Error with source location information. */
75export interface BeancountError {
76    message: string;
77    line?: number;
78    column?: number;
79    severity: Severity;
80}
81
82/** Amount with number and currency. */
83export interface Amount {
84    number: string;
85    currency: string;
86}
87
88/** Posting cost specification. */
89export interface PostingCost {
90    number_per?: string;
91    currency?: string;
92    date?: string;
93    label?: string;
94}
95
96/** A posting within a transaction. */
97export interface Posting {
98    account: string;
99    units?: Amount;
100    cost?: PostingCost;
101    price?: Amount;
102}
103
104/** Base directive with date. */
105interface BaseDirective {
106    date: string;
107}
108
109/** Transaction directive. */
110export interface TransactionDirective extends BaseDirective {
111    type: 'transaction';
112    flag: string;
113    payee?: string;
114    narration?: string;
115    tags: string[];
116    links: string[];
117    postings: Posting[];
118}
119
120/** Balance assertion directive. */
121export interface BalanceDirective extends BaseDirective {
122    type: 'balance';
123    account: string;
124    amount: Amount;
125}
126
127/** Open account directive. */
128export interface OpenDirective extends BaseDirective {
129    type: 'open';
130    account: string;
131    currencies: string[];
132    booking?: string;
133}
134
135/** Close account directive. */
136export interface CloseDirective extends BaseDirective {
137    type: 'close';
138    account: string;
139}
140
141/** All directive types. */
142export type Directive =
143    | TransactionDirective
144    | BalanceDirective
145    | OpenDirective
146    | CloseDirective
147    | { type: 'commodity'; date: string; currency: string }
148    | { type: 'pad'; date: string; account: string; source_account: string }
149    | { type: 'event'; date: string; event_type: string; value: string }
150    | { type: 'note'; date: string; account: string; comment: string }
151    | { type: 'document'; date: string; account: string; path: string }
152    | { type: 'price'; date: string; currency: string; amount: Amount }
153    | { type: 'query'; date: string; name: string; query_string: string }
154    | { type: 'custom'; date: string; custom_type: string };
155
156/** Ledger options. */
157export interface LedgerOptions {
158    operating_currencies: string[];
159    title?: string;
160}
161
162/** Parsed ledger. */
163export interface Ledger {
164    directives: Directive[];
165    options: LedgerOptions;
166}
167
168/** Result of parsing a Beancount file. */
169export interface ParseResult {
170    ledger?: Ledger;
171    errors: BeancountError[];
172}
173
174/** Result of validation. */
175export interface ValidationResult {
176    valid: boolean;
177    errors: BeancountError[];
178}
179
180/** Cell value in query results. */
181export type CellValue =
182    | null
183    | string
184    | number
185    | boolean
186    | Amount
187    | { units: Amount; cost?: { number: string; currency: string; date?: string; label?: string } }
188    | { positions: Array<{ units: Amount }> }
189    | string[];
190
191/** Result of a BQL query. */
192export interface QueryResult {
193    columns: string[];
194    rows: CellValue[][];
195    errors: BeancountError[];
196}
197
198/** Result of formatting. */
199export interface FormatResult {
200    formatted?: string;
201    errors: BeancountError[];
202}
203
204/** Result of pad expansion. */
205export interface PadResult {
206    directives: Directive[];
207    padding_transactions: Directive[];
208    errors: BeancountError[];
209}
210
211/** Result of running a plugin. */
212export interface PluginResult {
213    directives: Directive[];
214    errors: BeancountError[];
215}
216
217/** Plugin information. */
218export interface PluginInfo {
219    name: string;
220    description: string;
221}
222
223/** BQL completion suggestion. */
224export interface Completion {
225    text: string;
226    category: string;
227    description?: string;
228}
229
230/** Result of BQL completion request. */
231export interface CompletionResult {
232    completions: Completion[];
233    context: string;
234}
235
236// =============================================================================
237// Editor Integration Types (LSP-like features)
238// =============================================================================
239
240/** The kind of a completion item. */
241export type EditorCompletionKind = 'keyword' | 'account' | 'accountsegment' | 'currency' | 'payee' | 'date' | 'text';
242
243/** A completion item for Beancount source editing. */
244export interface EditorCompletion {
245    label: string;
246    kind: EditorCompletionKind;
247    detail?: string;
248    insertText?: string;
249}
250
251/** Result of an editor completion request. */
252export interface EditorCompletionResult {
253    completions: EditorCompletion[];
254    context: string;
255}
256
257/** A range in the document. */
258export interface EditorRange {
259    start_line: number;
260    start_character: number;
261    end_line: number;
262    end_character: number;
263}
264
265/** Hover information for a symbol. */
266export interface EditorHoverInfo {
267    contents: string;
268    range?: EditorRange;
269}
270
271/** A location in the document. */
272export interface EditorLocation {
273    line: number;
274    character: number;
275}
276
277/** The kind of a symbol. */
278export type SymbolKind = 'transaction' | 'account' | 'balance' | 'commodity' | 'posting' | 'pad' | 'event' | 'note' | 'document' | 'price' | 'query' | 'custom';
279
280/** A document symbol for the outline view. */
281export interface EditorDocumentSymbol {
282    name: string;
283    detail?: string;
284    kind: SymbolKind;
285    range: EditorRange;
286    children?: EditorDocumentSymbol[];
287    deprecated?: boolean;
288}
289
290/** The kind of reference. */
291export type ReferenceKind = 'account' | 'currency' | 'payee';
292
293/** A reference to a symbol in the document. */
294export interface EditorReference {
295    range: EditorRange;
296    kind: ReferenceKind;
297    is_definition: boolean;
298    context?: string;
299}
300
301/** Result of a find-references request. */
302export interface EditorReferencesResult {
303    symbol: string;
304    kind: ReferenceKind;
305    references: EditorReference[];
306}
307
308/**
309 * A parsed and validated ledger that caches the parse result.
310 * Use this class when you need to perform multiple operations on the same
311 * source without re-parsing each time.
312 */
313export class ParsedLedger {
314    constructor(source: string);
315    free(): void;
316
317    /** Check if the ledger is valid (no parse or validation errors). */
318    isValid(): boolean;
319
320    /** Get all errors (parse + validation). */
321    getErrors(): BeancountError[];
322
323    /** Get parse errors only. */
324    getParseErrors(): BeancountError[];
325
326    /** Get validation errors only. */
327    getValidationErrors(): BeancountError[];
328
329    /** Get the parsed directives. */
330    getDirectives(): Directive[];
331
332    /** Get the ledger options. */
333    getOptions(): LedgerOptions;
334
335    /** Get the number of directives. */
336    directiveCount(): number;
337
338    /** Run a BQL query on this ledger. */
339    query(queryStr: string): QueryResult;
340
341    /** Get account balances (shorthand for query("BALANCES")). */
342    balances(): QueryResult;
343
344    /** Format the ledger source. */
345    format(): FormatResult;
346
347    /** Expand pad directives. */
348    expandPads(): PadResult;
349
350    /** Run a native plugin on this ledger. */
351    runPlugin(pluginName: string): PluginResult;
352
353    // =========================================================================
354    // Editor Integration (LSP-like features)
355    // =========================================================================
356
357    /** Get completions at the given position. */
358    getCompletions(line: number, character: number): EditorCompletionResult;
359
360    /** Get hover information at the given position. */
361    getHoverInfo(line: number, character: number): EditorHoverInfo | null;
362
363    /** Get the definition location for the symbol at the given position. */
364    getDefinition(line: number, character: number): EditorLocation | null;
365
366    /** Get all document symbols for the outline view. */
367    getDocumentSymbols(): EditorDocumentSymbol[];
368
369    /** Find all references to the symbol at the given position. */
370    getReferences(line: number, character: number): EditorReferencesResult | null;
371}
372"#;
373
374// =============================================================================
375// Initialization
376// =============================================================================
377
378/// Initialize the WASM module.
379///
380/// This sets up panic hooks for better error messages in the browser console.
381/// Call this once before using any other functions.
382#[wasm_bindgen(start)]
383pub fn init() {
384    // Set up panic hook for better error messages
385    console_error_panic_hook::set_once();
386}
387
388// =============================================================================
389// Tests
390// =============================================================================
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use rustledger_parser::parse as parse_beancount;
396    use rustledger_validate::validate as validate_ledger;
397
398    #[test]
399    fn test_parse_simple() {
400        let source = r#"
4012024-01-01 open Assets:Bank USD
402
4032024-01-15 * "Coffee Shop" "Morning coffee"
404  Expenses:Food:Coffee  5.00 USD
405  Assets:Bank          -5.00 USD
406"#;
407
408        let result = parse_beancount(source);
409        assert!(result.errors.is_empty());
410        assert_eq!(result.directives.len(), 2);
411    }
412
413    #[test]
414    fn test_version() {
415        let v = version();
416        assert!(!v.is_empty());
417    }
418
419    #[test]
420    fn test_load_and_interpolate() {
421        use helpers::load_and_interpolate;
422
423        // Valid ledger
424        let source = r#"
4252024-01-01 open Assets:Bank USD
4262024-01-01 open Expenses:Food USD
427
4282024-01-15 * "Coffee"
429  Expenses:Food  5.00 USD
430  Assets:Bank   -5.00 USD
431"#;
432        let load = load_and_interpolate(source);
433        assert!(load.errors.is_empty());
434        assert_eq!(load.directives.len(), 3);
435
436        // Invalid ledger (unopened account)
437        let source = r#"
4382024-01-01 open Assets:Bank USD
439
4402024-01-15 * "Coffee"
441  Expenses:Food  5.00 USD
442  Assets:Bank   -5.00 USD
443"#;
444        let load = load_and_interpolate(source);
445        assert!(load.errors.is_empty()); // Parse succeeds
446        let validation_errors = validate_ledger(&load.directives);
447        assert!(
448            !validation_errors.is_empty(),
449            "should detect Expenses:Food not opened"
450        );
451    }
452}