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
36mod convert;
37mod editor;
38pub mod types;
39mod utils;
40
41use std::collections::HashMap;
42use wasm_bindgen::prelude::*;
43
44use rustledger_booking::interpolate;
45use rustledger_core::Directive;
46use rustledger_parser::{ParseResult as ParserResult, parse as parse_beancount};
47use rustledger_validate::validate as validate_ledger;
48
49use convert::{directive_to_json, value_to_cell};
50#[cfg(feature = "completions")]
51use types::{CompletionJson, CompletionResultJson};
52use types::{
53    Error, FormatResult, Ledger, LedgerOptions, PadResult, ParseResult, QueryResult, Severity,
54    ValidationResult,
55};
56#[cfg(feature = "plugins")]
57use types::{PluginInfo, PluginResult};
58use utils::LineLookup;
59
60// =============================================================================
61// TypeScript Type Definitions
62// =============================================================================
63
64#[wasm_bindgen(typescript_custom_section)]
65const TS_TYPES: &'static str = r#"
66/** Error severity level. */
67export type Severity = 'error' | 'warning';
68
69/** Error with source location information. */
70export interface BeancountError {
71    message: string;
72    line?: number;
73    column?: number;
74    severity: Severity;
75}
76
77/** Amount with number and currency. */
78export interface Amount {
79    number: string;
80    currency: string;
81}
82
83/** Posting cost specification. */
84export interface PostingCost {
85    number_per?: string;
86    currency?: string;
87    date?: string;
88    label?: string;
89}
90
91/** A posting within a transaction. */
92export interface Posting {
93    account: string;
94    units?: Amount;
95    cost?: PostingCost;
96    price?: Amount;
97}
98
99/** Base directive with date. */
100interface BaseDirective {
101    date: string;
102}
103
104/** Transaction directive. */
105export interface TransactionDirective extends BaseDirective {
106    type: 'transaction';
107    flag: string;
108    payee?: string;
109    narration?: string;
110    tags: string[];
111    links: string[];
112    postings: Posting[];
113}
114
115/** Balance assertion directive. */
116export interface BalanceDirective extends BaseDirective {
117    type: 'balance';
118    account: string;
119    amount: Amount;
120}
121
122/** Open account directive. */
123export interface OpenDirective extends BaseDirective {
124    type: 'open';
125    account: string;
126    currencies: string[];
127    booking?: string;
128}
129
130/** Close account directive. */
131export interface CloseDirective extends BaseDirective {
132    type: 'close';
133    account: string;
134}
135
136/** All directive types. */
137export type Directive =
138    | TransactionDirective
139    | BalanceDirective
140    | OpenDirective
141    | CloseDirective
142    | { type: 'commodity'; date: string; currency: string }
143    | { type: 'pad'; date: string; account: string; source_account: string }
144    | { type: 'event'; date: string; event_type: string; value: string }
145    | { type: 'note'; date: string; account: string; comment: string }
146    | { type: 'document'; date: string; account: string; path: string }
147    | { type: 'price'; date: string; currency: string; amount: Amount }
148    | { type: 'query'; date: string; name: string; query_string: string }
149    | { type: 'custom'; date: string; custom_type: string };
150
151/** Ledger options. */
152export interface LedgerOptions {
153    operating_currencies: string[];
154    title?: string;
155}
156
157/** Parsed ledger. */
158export interface Ledger {
159    directives: Directive[];
160    options: LedgerOptions;
161}
162
163/** Result of parsing a Beancount file. */
164export interface ParseResult {
165    ledger?: Ledger;
166    errors: BeancountError[];
167}
168
169/** Result of validation. */
170export interface ValidationResult {
171    valid: boolean;
172    errors: BeancountError[];
173}
174
175/** Cell value in query results. */
176export type CellValue =
177    | null
178    | string
179    | number
180    | boolean
181    | Amount
182    | { units: Amount; cost?: { number: string; currency: string; date?: string; label?: string } }
183    | { positions: Array<{ units: Amount }> }
184    | string[];
185
186/** Result of a BQL query. */
187export interface QueryResult {
188    columns: string[];
189    rows: CellValue[][];
190    errors: BeancountError[];
191}
192
193/** Result of formatting. */
194export interface FormatResult {
195    formatted?: string;
196    errors: BeancountError[];
197}
198
199/** Result of pad expansion. */
200export interface PadResult {
201    directives: Directive[];
202    padding_transactions: Directive[];
203    errors: BeancountError[];
204}
205
206/** Result of running a plugin. */
207export interface PluginResult {
208    directives: Directive[];
209    errors: BeancountError[];
210}
211
212/** Plugin information. */
213export interface PluginInfo {
214    name: string;
215    description: string;
216}
217
218/** BQL completion suggestion. */
219export interface Completion {
220    text: string;
221    category: string;
222    description?: string;
223}
224
225/** Result of BQL completion request. */
226export interface CompletionResult {
227    completions: Completion[];
228    context: string;
229}
230
231// =============================================================================
232// Editor Integration Types (LSP-like features)
233// =============================================================================
234
235/** The kind of a completion item. */
236export type EditorCompletionKind = 'keyword' | 'account' | 'accountsegment' | 'currency' | 'payee' | 'date' | 'text';
237
238/** A completion item for Beancount source editing. */
239export interface EditorCompletion {
240    label: string;
241    kind: EditorCompletionKind;
242    detail?: string;
243    insertText?: string;
244}
245
246/** Result of an editor completion request. */
247export interface EditorCompletionResult {
248    completions: EditorCompletion[];
249    context: string;
250}
251
252/** A range in the document. */
253export interface EditorRange {
254    start_line: number;
255    start_character: number;
256    end_line: number;
257    end_character: number;
258}
259
260/** Hover information for a symbol. */
261export interface EditorHoverInfo {
262    contents: string;
263    range?: EditorRange;
264}
265
266/** A location in the document. */
267export interface EditorLocation {
268    line: number;
269    character: number;
270}
271
272/** The kind of a symbol. */
273export type SymbolKind = 'transaction' | 'account' | 'balance' | 'commodity' | 'posting' | 'pad' | 'event' | 'note' | 'document' | 'price' | 'query' | 'custom';
274
275/** A document symbol for the outline view. */
276export interface EditorDocumentSymbol {
277    name: string;
278    detail?: string;
279    kind: SymbolKind;
280    range: EditorRange;
281    children?: EditorDocumentSymbol[];
282    deprecated?: boolean;
283}
284
285/** The kind of reference. */
286export type ReferenceKind = 'account' | 'currency' | 'payee';
287
288/** A reference to a symbol in the document. */
289export interface EditorReference {
290    range: EditorRange;
291    kind: ReferenceKind;
292    is_definition: boolean;
293    context?: string;
294}
295
296/** Result of a find-references request. */
297export interface EditorReferencesResult {
298    symbol: string;
299    kind: ReferenceKind;
300    references: EditorReference[];
301}
302
303/**
304 * A parsed and validated ledger that caches the parse result.
305 * Use this class when you need to perform multiple operations on the same
306 * source without re-parsing each time.
307 */
308export class ParsedLedger {
309    constructor(source: string);
310    free(): void;
311
312    /** Check if the ledger is valid (no parse or validation errors). */
313    isValid(): boolean;
314
315    /** Get all errors (parse + validation). */
316    getErrors(): BeancountError[];
317
318    /** Get parse errors only. */
319    getParseErrors(): BeancountError[];
320
321    /** Get validation errors only. */
322    getValidationErrors(): BeancountError[];
323
324    /** Get the parsed directives. */
325    getDirectives(): Directive[];
326
327    /** Get the ledger options. */
328    getOptions(): LedgerOptions;
329
330    /** Get the number of directives. */
331    directiveCount(): number;
332
333    /** Run a BQL query on this ledger. */
334    query(queryStr: string): QueryResult;
335
336    /** Get account balances (shorthand for query("BALANCES")). */
337    balances(): QueryResult;
338
339    /** Format the ledger source. */
340    format(): FormatResult;
341
342    /** Expand pad directives. */
343    expandPads(): PadResult;
344
345    /** Run a native plugin on this ledger. */
346    runPlugin(pluginName: string): PluginResult;
347
348    // =========================================================================
349    // Editor Integration (LSP-like features)
350    // =========================================================================
351
352    /** Get completions at the given position. */
353    getCompletions(line: number, character: number): EditorCompletionResult;
354
355    /** Get hover information at the given position. */
356    getHoverInfo(line: number, character: number): EditorHoverInfo | null;
357
358    /** Get the definition location for the symbol at the given position. */
359    getDefinition(line: number, character: number): EditorLocation | null;
360
361    /** Get all document symbols for the outline view. */
362    getDocumentSymbols(): EditorDocumentSymbol[];
363
364    /** Find all references to the symbol at the given position. */
365    getReferences(line: number, character: number): EditorReferencesResult | null;
366}
367"#;
368
369// =============================================================================
370// Initialization
371// =============================================================================
372
373/// Initialize the WASM module.
374///
375/// This sets up panic hooks for better error messages in the browser console.
376/// Call this once before using any other functions.
377#[wasm_bindgen(start)]
378pub fn init() {
379    // Set up panic hook for better error messages
380    console_error_panic_hook::set_once();
381}
382
383// =============================================================================
384// Internal Helpers
385// =============================================================================
386
387/// Result of loading and interpolating a source file.
388struct LoadResult {
389    directives: Vec<Directive>,
390    options: LedgerOptions,
391    errors: Vec<Error>,
392    lookup: LineLookup,
393    parse_result: ParserResult,
394}
395
396/// Parse and interpolate a Beancount source string.
397///
398/// This is the common entry point for all processing functions.
399fn load_and_interpolate(source: &str) -> LoadResult {
400    let parse_result = parse_beancount(source);
401    let lookup = LineLookup::new(source);
402
403    // Collect parse errors
404    let mut errors: Vec<Error> = parse_result
405        .errors
406        .iter()
407        .map(|e| Error::with_line(e.to_string(), lookup.byte_to_line(e.span().0)))
408        .collect();
409
410    // Extract options
411    let options = extract_options(&parse_result.options);
412
413    // Extract directives
414    let mut directives: Vec<_> = parse_result
415        .directives
416        .iter()
417        .map(|s| s.value.clone())
418        .collect();
419
420    // Interpolate transactions (fill in missing amounts)
421    if errors.is_empty() {
422        for (i, directive) in directives.iter_mut().enumerate() {
423            if let Directive::Transaction(txn) = directive {
424                match interpolate(txn) {
425                    Ok(result) => {
426                        *txn = result.transaction;
427                    }
428                    Err(e) => {
429                        let line = lookup.byte_to_line(parse_result.directives[i].span.start);
430                        errors.push(Error::with_line(e.to_string(), line));
431                    }
432                }
433            }
434        }
435    }
436
437    LoadResult {
438        directives,
439        options,
440        errors,
441        lookup,
442        parse_result,
443    }
444}
445
446/// Run validation on a loaded ledger and return validation errors.
447fn run_validation(load: &LoadResult) -> Vec<Error> {
448    if !load.errors.is_empty() {
449        return Vec::new();
450    }
451
452    let mut date_to_line: HashMap<String, u32> = HashMap::new();
453    for spanned in &load.parse_result.directives {
454        let line = load.lookup.byte_to_line(spanned.span.start);
455        let date = spanned.value.date().to_string();
456        date_to_line.entry(date).or_insert(line);
457    }
458
459    validate_ledger(&load.directives)
460        .into_iter()
461        .map(|err| {
462            let line = date_to_line.get(&err.date.to_string()).copied();
463            Error {
464                message: err.message,
465                line,
466                column: None,
467                severity: Severity::Error,
468            }
469        })
470        .collect()
471}
472
473/// Serialize a value to `JsValue` using JSON-compatible settings.
474///
475/// This ensures:
476/// - `None` serializes as `null` (not `undefined`)
477/// - Maps serialize as plain objects (not ES2015 `Map`)
478fn to_js<T: serde::Serialize>(value: &T) -> Result<JsValue, JsError> {
479    let serializer = serde_wasm_bindgen::Serializer::json_compatible();
480    value
481        .serialize(&serializer)
482        .map_err(|e| JsError::new(&e.to_string()))
483}
484
485// =============================================================================
486// Public API
487// =============================================================================
488
489/// Parse a Beancount source string.
490///
491/// Returns a `ParseResult` with the parsed ledger and any errors.
492#[wasm_bindgen]
493pub fn parse(source: &str) -> Result<JsValue, JsError> {
494    let result = parse_beancount(source);
495    let lookup = LineLookup::new(source);
496
497    let errors: Vec<Error> = result
498        .errors
499        .iter()
500        .map(|e| Error::with_line(e.to_string(), lookup.byte_to_line(e.span().0)))
501        .collect();
502
503    // Extract options from parsed result
504    let options = extract_options(&result.options);
505
506    let ledger = Some(Ledger {
507        directives: result
508            .directives
509            .iter()
510            .map(|spanned| directive_to_json(&spanned.value))
511            .collect(),
512        options,
513    });
514
515    let parse_result = ParseResult { ledger, errors };
516    to_js(&parse_result)
517}
518
519/// Extract [`LedgerOptions`] from parsed option directives.
520fn extract_options(options: &[(String, String, rustledger_parser::Span)]) -> LedgerOptions {
521    let mut ledger_options = LedgerOptions::default();
522
523    for (key, value, _span) in options {
524        match key.as_str() {
525            "title" => ledger_options.title = Some(value.clone()),
526            "operating_currency" => {
527                ledger_options.operating_currencies.push(value.clone());
528            }
529            _ => {} // Ignore other options for now
530        }
531    }
532
533    ledger_options
534}
535
536/// Validate a Beancount source string.
537///
538/// Parses, interpolates, and validates in one step.
539/// Returns a `ValidationResult` indicating whether the ledger is valid.
540#[wasm_bindgen(js_name = "validateSource")]
541pub fn validate_source(source: &str) -> Result<JsValue, JsError> {
542    let load = load_and_interpolate(source);
543    let validation_errors = run_validation(&load);
544    let mut errors = load.errors;
545    errors.extend(validation_errors);
546
547    let result = ValidationResult {
548        valid: errors.is_empty(),
549        errors,
550    };
551    to_js(&result)
552}
553
554/// Run a BQL query on a Beancount source string.
555///
556/// Parses the source, interpolates, then executes the query.
557/// Returns a `QueryResult` with columns, rows, and any errors.
558#[wasm_bindgen]
559pub fn query(source: &str, query_str: &str) -> Result<JsValue, JsError> {
560    use rustledger_query::{Executor, parse as parse_query};
561
562    let load = load_and_interpolate(source);
563
564    // Return early if there were parse/interpolation errors
565    if !load.errors.is_empty() {
566        let result = QueryResult {
567            columns: Vec::new(),
568            rows: Vec::new(),
569            errors: load.errors,
570        };
571        return to_js(&result);
572    }
573
574    // Parse the query
575    let query = match parse_query(query_str) {
576        Ok(q) => q,
577        Err(e) => {
578            let result = QueryResult {
579                columns: Vec::new(),
580                rows: Vec::new(),
581                errors: vec![Error::new(format!("Query parse error: {e}"))],
582            };
583            return to_js(&result);
584        }
585    };
586
587    let mut executor = Executor::new(&load.directives);
588    match executor.execute(&query) {
589        Ok(result) => {
590            let rows: Vec<Vec<_>> = result
591                .rows
592                .iter()
593                .map(|row| row.iter().map(value_to_cell).collect())
594                .collect();
595
596            let query_result = QueryResult {
597                columns: result.columns,
598                rows,
599                errors: Vec::new(),
600            };
601            to_js(&query_result)
602        }
603        Err(e) => {
604            let result = QueryResult {
605                columns: Vec::new(),
606                rows: Vec::new(),
607                errors: vec![Error::new(format!("Query execution error: {e}"))],
608            };
609            to_js(&result)
610        }
611    }
612}
613
614/// Get version information.
615///
616/// Returns the version string of the rustledger-wasm package.
617#[wasm_bindgen]
618pub fn version() -> String {
619    env!("CARGO_PKG_VERSION").to_string()
620}
621
622/// Format a Beancount source string.
623///
624/// Parses and reformats with consistent alignment.
625/// Returns a `FormatResult` with the formatted source or errors.
626#[wasm_bindgen]
627pub fn format(source: &str) -> Result<JsValue, JsError> {
628    use rustledger_core::{FormatConfig, format_directive};
629
630    let parse_result = parse_beancount(source);
631    let lookup = LineLookup::new(source);
632
633    if !parse_result.errors.is_empty() {
634        let result = FormatResult {
635            formatted: None,
636            errors: parse_result
637                .errors
638                .iter()
639                .map(|e| Error::with_line(e.to_string(), lookup.byte_to_line(e.span().0)))
640                .collect(),
641        };
642        return to_js(&result);
643    }
644
645    let config = FormatConfig::default();
646    let mut formatted = String::new();
647
648    for spanned in &parse_result.directives {
649        formatted.push_str(&format_directive(&spanned.value, &config));
650        formatted.push('\n');
651    }
652
653    let result = FormatResult {
654        formatted: Some(formatted),
655        errors: Vec::new(),
656    };
657    to_js(&result)
658}
659
660/// Process pad directives and expand them.
661///
662/// Returns directives with pad-generated transactions included.
663#[wasm_bindgen(js_name = "expandPads")]
664pub fn expand_pads(source: &str) -> Result<JsValue, JsError> {
665    use rustledger_booking::process_pads;
666
667    let load = load_and_interpolate(source);
668
669    // Return early if there were parse/interpolation errors
670    if !load.errors.is_empty() {
671        let result = PadResult {
672            directives: Vec::new(),
673            padding_transactions: Vec::new(),
674            errors: load.errors,
675        };
676        return to_js(&result);
677    }
678
679    // Process pads
680    let pad_result = process_pads(&load.directives);
681
682    let result = PadResult {
683        directives: pad_result
684            .directives
685            .iter()
686            .map(directive_to_json)
687            .collect(),
688        padding_transactions: pad_result
689            .padding_transactions
690            .iter()
691            .map(|txn| directive_to_json(&Directive::Transaction(txn.clone())))
692            .collect(),
693        errors: pad_result
694            .errors
695            .iter()
696            .map(|e| Error::new(e.message.clone()))
697            .collect(),
698    };
699    to_js(&result)
700}
701
702/// Run a native plugin on the source.
703///
704/// Available plugins can be listed with `listPlugins()`.
705#[cfg(feature = "plugins")]
706#[wasm_bindgen(js_name = "runPlugin")]
707pub fn run_plugin(source: &str, plugin_name: &str) -> Result<JsValue, JsError> {
708    use rustledger_plugin::{
709        NativePluginRegistry, PluginInput, PluginOptions, directives_to_wrappers,
710        wrappers_to_directives,
711    };
712
713    let load = load_and_interpolate(source);
714
715    // Return early if there were parse/interpolation errors
716    if !load.errors.is_empty() {
717        let result = PluginResult {
718            directives: Vec::new(),
719            errors: load.errors,
720        };
721        return to_js(&result);
722    }
723
724    // Find and run the plugin
725    let registry = NativePluginRegistry::new();
726    let Some(plugin) = registry.find(plugin_name) else {
727        let result = PluginResult {
728            directives: Vec::new(),
729            errors: vec![Error::new(format!("Unknown plugin: {plugin_name}"))],
730        };
731        return to_js(&result);
732    };
733
734    // Convert directives to plugin format and run
735    let wrappers = directives_to_wrappers(&load.directives);
736    let input = PluginInput {
737        directives: wrappers,
738        options: PluginOptions::default(),
739        config: None,
740    };
741
742    let output = plugin.process(input);
743
744    // Convert back
745    let output_directives = match wrappers_to_directives(&output.directives) {
746        Ok(dirs) => dirs,
747        Err(e) => {
748            let result = PluginResult {
749                directives: Vec::new(),
750                errors: vec![Error::new(format!("Conversion error: {e}"))],
751            };
752            return to_js(&result);
753        }
754    };
755
756    let result = PluginResult {
757        directives: output_directives.iter().map(directive_to_json).collect(),
758        errors: output
759            .errors
760            .iter()
761            .map(|e| match e.severity {
762                rustledger_plugin::PluginErrorSeverity::Warning => {
763                    Error::warning(e.message.clone())
764                }
765                rustledger_plugin::PluginErrorSeverity::Error => Error::new(e.message.clone()),
766            })
767            .collect(),
768    };
769    to_js(&result)
770}
771
772/// List available native plugins.
773///
774/// Returns an array of `PluginInfo` objects with name and description.
775#[cfg(feature = "plugins")]
776#[wasm_bindgen(js_name = "listPlugins")]
777pub fn list_plugins() -> Result<JsValue, JsError> {
778    use rustledger_plugin::NativePluginRegistry;
779
780    let registry = NativePluginRegistry::new();
781    let plugins: Vec<PluginInfo> = registry
782        .list()
783        .iter()
784        .map(|p| PluginInfo {
785            name: p.name().to_string(),
786            description: p.description().to_string(),
787        })
788        .collect();
789
790    to_js(&plugins)
791}
792
793/// Calculate account balances.
794///
795/// Shorthand for `query(source, "BALANCES")`.
796#[wasm_bindgen]
797pub fn balances(source: &str) -> Result<JsValue, JsError> {
798    query(source, "BALANCES")
799}
800
801/// Get BQL query completions at cursor position.
802///
803/// Returns context-aware completions for the BQL query language.
804#[cfg(feature = "completions")]
805#[wasm_bindgen(js_name = "bqlCompletions")]
806pub fn bql_completions(partial_query: &str, cursor_pos: usize) -> Result<JsValue, JsError> {
807    use rustledger_query::completions;
808
809    let result = completions::complete(partial_query, cursor_pos);
810
811    let json_result = CompletionResultJson {
812        completions: result
813            .completions
814            .into_iter()
815            .map(|c| CompletionJson {
816                text: c.text,
817                category: c.category.as_str().to_string(),
818                description: c.description,
819            })
820            .collect(),
821        context: format!("{:?}", result.context),
822    };
823
824    to_js(&json_result)
825}
826
827// =============================================================================
828// Stateful Ledger Class
829// =============================================================================
830
831/// A parsed and validated ledger that caches the parse result.
832///
833/// Use this class when you need to perform multiple operations on the same
834/// source without re-parsing each time.
835///
836/// # Example (JavaScript)
837///
838/// ```javascript
839/// const ledger = new ParsedLedger(source);
840/// if (ledger.isValid()) {
841///     const balances = ledger.query("BALANCES");
842///     const formatted = ledger.format();
843/// }
844/// ```
845#[wasm_bindgen]
846pub struct ParsedLedger {
847    /// The original source text.
848    source: String,
849    /// The raw parse result (for editor features).
850    parse_result: ParserResult,
851    /// The interpolated directives.
852    directives: Vec<Directive>,
853    /// Ledger options.
854    options: LedgerOptions,
855    /// Parse errors.
856    parse_errors: Vec<Error>,
857    /// Validation errors.
858    validation_errors: Vec<Error>,
859    /// Cached editor data (accounts, currencies, payees, line index).
860    editor_cache: editor::EditorCache,
861}
862
863#[wasm_bindgen]
864impl ParsedLedger {
865    /// Create a new `ParsedLedger` from source text.
866    ///
867    /// Parses, interpolates, and validates the source. Call `isValid()` to check for errors.
868    #[wasm_bindgen(constructor)]
869    pub fn new(source: &str) -> Self {
870        let load = load_and_interpolate(source);
871        let validation_errors = run_validation(&load);
872
873        // Build editor cache once for efficient editor operations
874        let editor_cache = editor::EditorCache::new(source, &load.parse_result);
875
876        Self {
877            source: source.to_string(),
878            parse_result: load.parse_result,
879            directives: load.directives,
880            options: load.options,
881            parse_errors: load.errors,
882            validation_errors,
883            editor_cache,
884        }
885    }
886
887    /// Check if the ledger is valid (no parse or validation errors).
888    #[wasm_bindgen(js_name = "isValid")]
889    pub fn is_valid(&self) -> bool {
890        self.parse_errors.is_empty() && self.validation_errors.is_empty()
891    }
892
893    /// Get all errors (parse + validation).
894    #[wasm_bindgen(js_name = "getErrors")]
895    pub fn get_errors(&self) -> Result<JsValue, JsError> {
896        let mut all_errors = self.parse_errors.clone();
897        all_errors.extend(self.validation_errors.clone());
898        to_js(&all_errors)
899    }
900
901    /// Get parse errors only.
902    #[wasm_bindgen(js_name = "getParseErrors")]
903    pub fn get_parse_errors(&self) -> Result<JsValue, JsError> {
904        to_js(&self.parse_errors)
905    }
906
907    /// Get validation errors only.
908    #[wasm_bindgen(js_name = "getValidationErrors")]
909    pub fn get_validation_errors(&self) -> Result<JsValue, JsError> {
910        to_js(&self.validation_errors)
911    }
912
913    /// Get the parsed directives.
914    #[wasm_bindgen(js_name = "getDirectives")]
915    pub fn get_directives(&self) -> Result<JsValue, JsError> {
916        let directives: Vec<_> = self.directives.iter().map(directive_to_json).collect();
917        to_js(&directives)
918    }
919
920    /// Get the ledger options.
921    #[wasm_bindgen(js_name = "getOptions")]
922    pub fn get_options(&self) -> Result<JsValue, JsError> {
923        to_js(&self.options)
924    }
925
926    /// Get the number of directives.
927    #[wasm_bindgen(js_name = "directiveCount")]
928    pub fn directive_count(&self) -> usize {
929        self.directives.len()
930    }
931
932    /// Run a BQL query on this ledger.
933    #[wasm_bindgen]
934    pub fn query(&self, query_str: &str) -> Result<JsValue, JsError> {
935        use rustledger_query::{Executor, parse as parse_query};
936
937        if !self.parse_errors.is_empty() {
938            let result = QueryResult {
939                columns: Vec::new(),
940                rows: Vec::new(),
941                errors: self.parse_errors.clone(),
942            };
943            return to_js(&result);
944        }
945
946        let query = match parse_query(query_str) {
947            Ok(q) => q,
948            Err(e) => {
949                let result = QueryResult {
950                    columns: Vec::new(),
951                    rows: Vec::new(),
952                    errors: vec![Error::new(format!("Query parse error: {e}"))],
953                };
954                return to_js(&result);
955            }
956        };
957
958        let mut executor = Executor::new(&self.directives);
959        match executor.execute(&query) {
960            Ok(result) => {
961                let rows: Vec<Vec<_>> = result
962                    .rows
963                    .iter()
964                    .map(|row| row.iter().map(value_to_cell).collect())
965                    .collect();
966
967                let query_result = QueryResult {
968                    columns: result.columns,
969                    rows,
970                    errors: Vec::new(),
971                };
972                to_js(&query_result)
973            }
974            Err(e) => {
975                let result = QueryResult {
976                    columns: Vec::new(),
977                    rows: Vec::new(),
978                    errors: vec![Error::new(format!("Query execution error: {e}"))],
979                };
980                to_js(&result)
981            }
982        }
983    }
984
985    /// Get account balances (shorthand for query("BALANCES")).
986    #[wasm_bindgen]
987    pub fn balances(&self) -> Result<JsValue, JsError> {
988        self.query("BALANCES")
989    }
990
991    /// Format the ledger source.
992    #[wasm_bindgen]
993    pub fn format(&self) -> Result<JsValue, JsError> {
994        use rustledger_core::{FormatConfig, format_directive};
995
996        if !self.parse_errors.is_empty() {
997            let result = FormatResult {
998                formatted: None,
999                errors: self.parse_errors.clone(),
1000            };
1001            return to_js(&result);
1002        }
1003
1004        let config = FormatConfig::default();
1005        let mut formatted = String::new();
1006
1007        for directive in &self.directives {
1008            formatted.push_str(&format_directive(directive, &config));
1009            formatted.push('\n');
1010        }
1011
1012        let result = FormatResult {
1013            formatted: Some(formatted),
1014            errors: Vec::new(),
1015        };
1016        to_js(&result)
1017    }
1018
1019    /// Expand pad directives.
1020    #[wasm_bindgen(js_name = "expandPads")]
1021    pub fn expand_pads(&self) -> Result<JsValue, JsError> {
1022        use rustledger_booking::process_pads;
1023
1024        if !self.parse_errors.is_empty() {
1025            let result = PadResult {
1026                directives: Vec::new(),
1027                padding_transactions: Vec::new(),
1028                errors: self.parse_errors.clone(),
1029            };
1030            return to_js(&result);
1031        }
1032
1033        let pad_result = process_pads(&self.directives);
1034
1035        let result = PadResult {
1036            directives: pad_result
1037                .directives
1038                .iter()
1039                .map(directive_to_json)
1040                .collect(),
1041            padding_transactions: pad_result
1042                .padding_transactions
1043                .iter()
1044                .map(|txn| directive_to_json(&Directive::Transaction(txn.clone())))
1045                .collect(),
1046            errors: pad_result
1047                .errors
1048                .iter()
1049                .map(|e| Error::new(e.message.clone()))
1050                .collect(),
1051        };
1052        to_js(&result)
1053    }
1054
1055    /// Run a native plugin on this ledger.
1056    #[cfg(feature = "plugins")]
1057    #[wasm_bindgen(js_name = "runPlugin")]
1058    pub fn run_plugin(&self, plugin_name: &str) -> Result<JsValue, JsError> {
1059        use rustledger_plugin::{
1060            NativePluginRegistry, PluginInput, PluginOptions, directives_to_wrappers,
1061            wrappers_to_directives,
1062        };
1063
1064        if !self.parse_errors.is_empty() {
1065            let result = PluginResult {
1066                directives: Vec::new(),
1067                errors: self.parse_errors.clone(),
1068            };
1069            return to_js(&result);
1070        }
1071
1072        let registry = NativePluginRegistry::new();
1073        let Some(plugin) = registry.find(plugin_name) else {
1074            let result = PluginResult {
1075                directives: Vec::new(),
1076                errors: vec![Error::new(format!("Unknown plugin: {plugin_name}"))],
1077            };
1078            return to_js(&result);
1079        };
1080
1081        let wrappers = directives_to_wrappers(&self.directives);
1082        let input = PluginInput {
1083            directives: wrappers,
1084            options: PluginOptions::default(),
1085            config: None,
1086        };
1087
1088        let output = plugin.process(input);
1089
1090        let output_directives = match wrappers_to_directives(&output.directives) {
1091            Ok(dirs) => dirs,
1092            Err(e) => {
1093                let result = PluginResult {
1094                    directives: Vec::new(),
1095                    errors: vec![Error::new(format!("Conversion error: {e}"))],
1096                };
1097                return to_js(&result);
1098            }
1099        };
1100
1101        let result = PluginResult {
1102            directives: output_directives.iter().map(directive_to_json).collect(),
1103            errors: output
1104                .errors
1105                .iter()
1106                .map(|e| match e.severity {
1107                    rustledger_plugin::PluginErrorSeverity::Warning => {
1108                        Error::warning(e.message.clone())
1109                    }
1110                    rustledger_plugin::PluginErrorSeverity::Error => Error::new(e.message.clone()),
1111                })
1112                .collect(),
1113        };
1114        to_js(&result)
1115    }
1116
1117    // =========================================================================
1118    // Editor Integration (LSP-like features)
1119    // =========================================================================
1120
1121    /// Get completions at the given position.
1122    ///
1123    /// Returns context-aware completions for accounts, currencies, directives, etc.
1124    /// Uses cached account/currency/payee data for efficiency.
1125    #[wasm_bindgen(js_name = "getCompletions")]
1126    pub fn get_completions(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
1127        let result =
1128            editor::get_completions_cached(&self.source, line, character, &self.editor_cache);
1129        to_js(&result)
1130    }
1131
1132    /// Get hover information at the given position.
1133    ///
1134    /// Returns documentation for accounts, currencies, and directive keywords.
1135    #[wasm_bindgen(js_name = "getHoverInfo")]
1136    pub fn get_hover_info(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
1137        let result = editor::get_hover_info_cached(
1138            &self.source,
1139            line,
1140            character,
1141            &self.parse_result,
1142            &self.editor_cache,
1143        );
1144        to_js(&result)
1145    }
1146
1147    /// Get the definition location for the symbol at the given position.
1148    ///
1149    /// Returns the location of the `open` or `commodity` directive for accounts/currencies.
1150    /// Uses cached `LineIndex` for O(log n) position lookups.
1151    #[wasm_bindgen(js_name = "getDefinition")]
1152    pub fn get_definition(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
1153        let result = editor::get_definition_cached(
1154            &self.source,
1155            line,
1156            character,
1157            &self.parse_result,
1158            &self.editor_cache,
1159        );
1160        to_js(&result)
1161    }
1162
1163    /// Get all document symbols for the outline view.
1164    ///
1165    /// Returns a hierarchical list of all directives with their positions.
1166    /// Uses cached `LineIndex` for O(log n) position lookups.
1167    #[wasm_bindgen(js_name = "getDocumentSymbols")]
1168    pub fn get_document_symbols(&self) -> Result<JsValue, JsError> {
1169        let result = editor::get_document_symbols_cached(&self.parse_result, &self.editor_cache);
1170        to_js(&result)
1171    }
1172
1173    /// Find all references to the symbol at the given position.
1174    ///
1175    /// Returns all occurrences of accounts, currencies, or payees in the document.
1176    /// Uses cached data for efficient lookup.
1177    #[wasm_bindgen(js_name = "getReferences")]
1178    pub fn get_references(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
1179        let result = editor::get_references_cached(
1180            &self.source,
1181            line,
1182            character,
1183            &self.parse_result,
1184            &self.editor_cache,
1185        );
1186        to_js(&result)
1187    }
1188}
1189
1190// =============================================================================
1191// Tests
1192// =============================================================================
1193
1194#[cfg(test)]
1195mod tests {
1196    use super::*;
1197
1198    #[test]
1199    fn test_parse_simple() {
1200        let source = r#"
12012024-01-01 open Assets:Bank USD
1202
12032024-01-15 * "Coffee Shop" "Morning coffee"
1204  Expenses:Food:Coffee  5.00 USD
1205  Assets:Bank          -5.00 USD
1206"#;
1207
1208        let result = parse_beancount(source);
1209        assert!(result.errors.is_empty());
1210        assert_eq!(result.directives.len(), 2);
1211    }
1212
1213    #[test]
1214    fn test_version() {
1215        let v = version();
1216        assert!(!v.is_empty());
1217    }
1218
1219    #[test]
1220    fn test_load_and_interpolate() {
1221        // Valid ledger
1222        let source = r#"
12232024-01-01 open Assets:Bank USD
12242024-01-01 open Expenses:Food USD
1225
12262024-01-15 * "Coffee"
1227  Expenses:Food  5.00 USD
1228  Assets:Bank   -5.00 USD
1229"#;
1230        let load = load_and_interpolate(source);
1231        assert!(load.errors.is_empty());
1232        assert_eq!(load.directives.len(), 3);
1233
1234        // Invalid ledger (unopened account)
1235        let source = r#"
12362024-01-01 open Assets:Bank USD
1237
12382024-01-15 * "Coffee"
1239  Expenses:Food  5.00 USD
1240  Assets:Bank   -5.00 USD
1241"#;
1242        let load = load_and_interpolate(source);
1243        assert!(load.errors.is_empty()); // Parse succeeds
1244        let validation_errors = validate_ledger(&load.directives);
1245        assert!(
1246            !validation_errors.is_empty(),
1247            "should detect Expenses:Food not opened"
1248        );
1249    }
1250}