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