Skip to main content

rustledger_wasm/
parsed_ledger.rs

1//! Stateful ledger classes for WASM.
2//!
3//! Provides two classes:
4//! - [`ParsedLedger`]: Single-file ledger with full editor features (completions, hover, etc.)
5//! - [`Ledger`]: Multi-file ledger for queries and validation (no position-based editor features)
6
7use std::collections::HashMap;
8use std::path::Path;
9use wasm_bindgen::prelude::*;
10
11use rustledger_core::Directive;
12use rustledger_parser::ParseResult as ParserResult;
13
14use crate::cache;
15use crate::convert::directive_to_json;
16use crate::editor;
17use crate::helpers::{load_and_book, run_validation, to_js};
18#[cfg(feature = "plugins")]
19use crate::types::PluginResult;
20use crate::types::{Error, FormatResult, LedgerOptions, PadResult, QueryResult};
21
22// =============================================================================
23// Shared query/directive logic (used by both ParsedLedger and Ledger)
24// =============================================================================
25
26fn execute_query(directives: &[Directive], query_str: &str) -> Result<JsValue, JsError> {
27    use crate::convert::value_to_cell;
28    use rustledger_query::{Executor, parse as parse_query};
29
30    let query = match parse_query(query_str) {
31        Ok(q) => q,
32        Err(e) => {
33            let result = QueryResult {
34                columns: Vec::new(),
35                rows: Vec::new(),
36                errors: vec![Error::new(e.to_string())],
37            };
38            return to_js(&result);
39        }
40    };
41
42    // BQL is a balance-computing consumer; expand pads explicitly
43    // before handing the directive list to the executor (#1288).
44    // `ParsedLedger.directives` / `Ledger.directives` are
45    // source-faithful per the architectural rule documented on
46    // `rustledger_loader::Ledger.directives`; the executor needs
47    // the expanded view to compute `sum(position)` with pad effects
48    // included.
49    let expanded = rustledger_booking::merge_with_padding(directives);
50    let mut executor = Executor::new(&expanded);
51    match executor.execute(&query) {
52        Ok(result) => {
53            let rows: Vec<Vec<_>> = result
54                .rows
55                .iter()
56                .map(|row| row.iter().map(value_to_cell).collect())
57                .collect();
58
59            let query_result = QueryResult {
60                columns: result.columns,
61                rows,
62                errors: Vec::new(),
63            };
64            to_js(&query_result)
65        }
66        Err(e) => {
67            let result = QueryResult {
68                columns: Vec::new(),
69                rows: Vec::new(),
70                errors: vec![Error::new(format!("Query execution error: {e}"))],
71            };
72            to_js(&result)
73        }
74    }
75}
76
77fn execute_expand_pads(directives: &[Directive]) -> Result<JsValue, JsError> {
78    use rustledger_booking::process_pads;
79
80    let pad_result = process_pads(directives);
81
82    let result = PadResult {
83        // The source stream, verbatim — `process_pads` no longer
84        // echoes its input back, so read it from the directives we
85        // were handed instead of from the result.
86        directives: directives.iter().map(directive_to_json).collect(),
87        padding_transactions: pad_result
88            .padding_transactions
89            .iter()
90            .map(|txn| directive_to_json(&Directive::Transaction(txn.clone())))
91            .collect(),
92        errors: pad_result
93            .errors
94            .iter()
95            .map(|e| Error::new(e.message.clone()))
96            .collect(),
97    };
98    to_js(&result)
99}
100
101#[cfg(feature = "plugins")]
102fn execute_plugin(directives: &[Directive], plugin_name: &str) -> Result<JsValue, JsError> {
103    use rustledger_plugin::{
104        NativePluginRegistry, PluginInput, PluginOptions, directives_to_wrappers,
105        wrappers_to_directives,
106    };
107
108    let registry = NativePluginRegistry::global();
109    // External API runs plugins on already-booked input — synth
110    // plugins are a loader-internal concern and would re-emit Opens
111    // for accounts the booking pass already opened.
112    let Some(plugin) = registry.find_regular(plugin_name) else {
113        let result = PluginResult {
114            directives: Vec::new(),
115            errors: vec![Error::new(format!("Unknown plugin: {plugin_name}"))],
116        };
117        return to_js(&result);
118    };
119
120    let wrappers = directives_to_wrappers(directives);
121    let input = PluginInput {
122        directives: wrappers,
123        options: PluginOptions::default(),
124        config: None,
125    };
126
127    let input_dirs = input.directives.clone();
128    let output = plugin.process(input);
129    let materialized = crate::api::materialize_plugin_ops(&input_dirs, &output);
130
131    let output_directives = match wrappers_to_directives(&materialized) {
132        Ok(dirs) => dirs,
133        Err(e) => {
134            let result = PluginResult {
135                directives: Vec::new(),
136                errors: vec![Error::new(format!("Conversion error: {e}"))],
137            };
138            return to_js(&result);
139        }
140    };
141
142    let result = PluginResult {
143        directives: output_directives.iter().map(directive_to_json).collect(),
144        errors: output
145            .errors
146            .iter()
147            .map(|e| match e.severity {
148                rustledger_plugin::PluginErrorSeverity::Warning => {
149                    Error::warning(e.message.clone())
150                }
151                rustledger_plugin::PluginErrorSeverity::Error => Error::new(e.message.clone()),
152            })
153            .collect(),
154    };
155    to_js(&result)
156}
157
158// =============================================================================
159// ParsedLedger: Single-file with full editor features
160// =============================================================================
161
162/// A parsed and validated single-file ledger with editor features.
163///
164/// Use this class for single-file ledgers where you need completions, hover,
165/// go-to-definition, and other editor integration features.
166///
167/// For multi-file ledgers, use [`Ledger`] instead.
168///
169/// # Example (JavaScript)
170///
171/// ```javascript
172/// const ledger = new ParsedLedger(source);
173/// if (ledger.isValid()) {
174///     const balances = ledger.query("BALANCES");
175///     const completions = ledger.getCompletions(line, char);
176/// }
177/// ```
178#[wasm_bindgen(skip_typescript)]
179pub struct ParsedLedger {
180    /// The original source text.
181    source: String,
182    /// The raw parse result (for editor features).
183    parse_result: ParserResult,
184    /// The booked directives.
185    directives: Vec<Directive>,
186    /// Ledger options.
187    options: LedgerOptions,
188    /// Parse errors.
189    parse_errors: Vec<Error>,
190    /// Validation errors.
191    validation_errors: Vec<Error>,
192    /// Cached editor data (accounts, currencies, payees, line index).
193    editor_cache: editor::EditorCache,
194}
195
196#[wasm_bindgen]
197impl ParsedLedger {
198    /// Create a new `ParsedLedger` from a single source string.
199    ///
200    /// Parses, books, and validates the source. Call `isValid()` to check for errors.
201    #[wasm_bindgen(constructor)]
202    pub fn new(source: &str) -> Self {
203        let load = load_and_book(source);
204        let validation_errors = run_validation(&load);
205        let editor_cache = editor::EditorCache::new(source, &load.parse_result);
206
207        Self {
208            source: source.to_string(),
209            parse_result: load.parse_result,
210            directives: load.directives,
211            options: load.options,
212            parse_errors: load.errors,
213            validation_errors,
214            editor_cache,
215        }
216    }
217
218    /// Check if the ledger is valid (no parse or validation errors).
219    #[wasm_bindgen(js_name = "isValid")]
220    pub fn is_valid(&self) -> bool {
221        self.parse_errors.is_empty() && self.validation_errors.is_empty()
222    }
223
224    /// Get all errors (parse + validation).
225    #[wasm_bindgen(js_name = "getErrors")]
226    pub fn get_errors(&self) -> Result<JsValue, JsError> {
227        let mut all_errors = self.parse_errors.clone();
228        all_errors.extend(self.validation_errors.clone());
229        to_js(&all_errors)
230    }
231
232    /// Get parse errors only.
233    #[wasm_bindgen(js_name = "getParseErrors")]
234    pub fn get_parse_errors(&self) -> Result<JsValue, JsError> {
235        to_js(&self.parse_errors)
236    }
237
238    /// Get validation errors only.
239    #[wasm_bindgen(js_name = "getValidationErrors")]
240    pub fn get_validation_errors(&self) -> Result<JsValue, JsError> {
241        to_js(&self.validation_errors)
242    }
243
244    /// Get the parsed directives.
245    #[wasm_bindgen(js_name = "getDirectives")]
246    pub fn get_directives(&self) -> Result<JsValue, JsError> {
247        let directives: Vec<_> = self.directives.iter().map(directive_to_json).collect();
248        to_js(&directives)
249    }
250
251    /// Get the ledger options.
252    #[wasm_bindgen(js_name = "getOptions")]
253    pub fn get_options(&self) -> Result<JsValue, JsError> {
254        to_js(&self.options)
255    }
256
257    /// Get the number of directives.
258    #[wasm_bindgen(js_name = "directiveCount")]
259    pub fn directive_count(&self) -> usize {
260        self.directives.len()
261    }
262
263    /// Run a BQL query on this ledger.
264    #[wasm_bindgen]
265    pub fn query(&self, query_str: &str) -> Result<JsValue, JsError> {
266        if !self.parse_errors.is_empty() {
267            let result = QueryResult {
268                columns: Vec::new(),
269                rows: Vec::new(),
270                errors: self.parse_errors.clone(),
271            };
272            return to_js(&result);
273        }
274        execute_query(&self.directives, query_str)
275    }
276
277    /// Get account balances (shorthand for query("BALANCES")).
278    #[wasm_bindgen]
279    pub fn balances(&self) -> Result<JsValue, JsError> {
280        self.query("BALANCES")
281    }
282
283    /// Format the ledger source.
284    ///
285    /// Reformats the original source preserving comments, blank lines, and
286    /// non-directive content with file-wide aligned columns.
287    #[wasm_bindgen]
288    pub fn format(&self) -> Result<JsValue, JsError> {
289        use rustledger_parser::format::format_source_with_parsed;
290
291        if !self.parse_errors.is_empty() {
292            let result = FormatResult {
293                formatted: None,
294                errors: self.parse_errors.clone(),
295            };
296            return to_js(&result);
297        }
298
299        // Reuse the cached `ParseResult` we already own instead of
300        // re-parsing `self.source`. Byte-identical output to
301        // `format_source(&self.source)` per the parser-side
302        // `format_source_with_parsed_matches_format_source` test.
303        // On large ledgers loaded into a long-lived WASM session,
304        // this cuts the per-format cost roughly in half.
305        let formatted = format_source_with_parsed(&self.parse_result, &self.source);
306
307        let result = FormatResult {
308            formatted: Some(formatted),
309            errors: Vec::new(),
310        };
311        to_js(&result)
312    }
313
314    /// Expand pad directives.
315    #[wasm_bindgen(js_name = "expandPads")]
316    pub fn expand_pads(&self) -> Result<JsValue, JsError> {
317        if !self.parse_errors.is_empty() {
318            let result = PadResult {
319                directives: Vec::new(),
320                padding_transactions: Vec::new(),
321                errors: self.parse_errors.clone(),
322            };
323            return to_js(&result);
324        }
325        execute_expand_pads(&self.directives)
326    }
327
328    /// Run a native plugin on this ledger.
329    #[cfg(feature = "plugins")]
330    #[wasm_bindgen(js_name = "runPlugin")]
331    pub fn run_plugin(&self, plugin_name: &str) -> Result<JsValue, JsError> {
332        if !self.parse_errors.is_empty() {
333            let result = PluginResult {
334                directives: Vec::new(),
335                errors: self.parse_errors.clone(),
336            };
337            return to_js(&result);
338        }
339        execute_plugin(&self.directives, plugin_name)
340    }
341
342    // =========================================================================
343    // Editor Integration (LSP-like features)
344    // =========================================================================
345
346    /// Get completions at the given position.
347    #[wasm_bindgen(js_name = "getCompletions")]
348    pub fn get_completions(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
349        let result =
350            editor::get_completions_cached(&self.source, line, character, &self.editor_cache);
351        to_js(&result)
352    }
353
354    /// Get hover information at the given position.
355    #[wasm_bindgen(js_name = "getHoverInfo")]
356    pub fn get_hover_info(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
357        let result = editor::get_hover_info_cached(
358            &self.source,
359            line,
360            character,
361            &self.parse_result,
362            &self.editor_cache,
363        );
364        to_js(&result)
365    }
366
367    /// Get the definition location for the symbol at the given position.
368    #[wasm_bindgen(js_name = "getDefinition")]
369    pub fn get_definition(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
370        let result = editor::get_definition_cached(
371            &self.source,
372            line,
373            character,
374            &self.parse_result,
375            &self.editor_cache,
376        );
377        to_js(&result)
378    }
379
380    /// Get all document symbols for the outline view.
381    #[wasm_bindgen(js_name = "getDocumentSymbols")]
382    pub fn get_document_symbols(&self) -> Result<JsValue, JsError> {
383        let result = editor::get_document_symbols_cached(&self.parse_result, &self.editor_cache);
384        to_js(&result)
385    }
386
387    /// Find all references to the symbol at the given position.
388    #[wasm_bindgen(js_name = "getReferences")]
389    pub fn get_references(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
390        let result = editor::get_references_cached(
391            &self.source,
392            line,
393            character,
394            &self.parse_result,
395            &self.editor_cache,
396        );
397        to_js(&result)
398    }
399
400    // =========================================================================
401    // Serialization / Caching
402    // =========================================================================
403
404    /// Serialize this ledger to a compact binary blob (rkyv).
405    ///
406    /// Store the bytes in OPFS or `IndexedDB` alongside a source fingerprint
407    /// (see [`crate::hash_sources`]) and restore later with [`ParsedLedger::from_cache`].
408    #[wasm_bindgen]
409    pub fn serialize(&self) -> Result<Vec<u8>, JsError> {
410        // Clone fields into the payload. rkyv's Serialize derive requires owned
411        // types; a zero-copy borrowed serializer would add significant complexity
412        // for minimal gain since serialize() is called once per cache write.
413        let payload = cache::ParsedLedgerPayload {
414            directives: self.directives.clone(),
415            options: self.options.clone(),
416            parse_errors: self.parse_errors.clone(),
417            validation_errors: self.validation_errors.clone(),
418        };
419        cache::serialize_parsed(&payload).map_err(|e| JsError::new(&e))
420    }
421
422    /// Restore a `ParsedLedger` from bytes produced by [`ParsedLedger::serialize`].
423    ///
424    /// The `source` parameter must be the same source text used when the cache
425    /// was created; it is re-parsed (but not re-booked or re-validated) so that
426    /// editor features continue to work.
427    ///
428    /// # Errors
429    ///
430    /// Returns an error if the bytes are invalid or were produced by a different
431    /// library version.
432    #[wasm_bindgen(js_name = "fromCache")]
433    pub fn from_cache(bytes: &[u8], source: &str) -> Result<Self, JsError> {
434        let mut payload = cache::deserialize_parsed(bytes).map_err(|e| JsError::new(&e))?;
435
436        // Re-intern strings to deduplicate identical Arc<str> allocations.
437        rustledger_loader::reintern_plain_directives(&mut payload.directives);
438
439        // Re-parse source for editor spans (cheap; booking is the expensive part).
440        let parse_result = rustledger_parser::parse(source);
441        let editor_cache = editor::EditorCache::new(source, &parse_result);
442
443        Ok(Self {
444            source: source.to_string(),
445            parse_result,
446            directives: payload.directives,
447            options: payload.options,
448            parse_errors: payload.parse_errors,
449            validation_errors: payload.validation_errors,
450            editor_cache,
451        })
452    }
453}
454
455// =============================================================================
456// Ledger: Multi-file with queries and cross-file completions
457// =============================================================================
458
459/// A fully processed multi-file ledger for queries and validation.
460///
461/// Use this class for ledgers that span multiple files with `include` directives.
462/// Caches the processed result for efficient repeated queries.
463///
464/// For single-file ledgers with editor features, use [`ParsedLedger`] instead.
465///
466/// # Example (JavaScript)
467///
468/// ```javascript
469/// const ledger = Ledger.fromFiles({
470///     "main.beancount": 'include "accounts.beancount"\n...',
471///     "accounts.beancount": "2024-01-01 open Assets:Bank USD\n..."
472/// }, "main.beancount");
473///
474/// if (ledger.isValid()) {
475///     const balances = ledger.query("BALANCES");
476///     const completions = ledger.getCompletions(currentSource, line, char);
477/// }
478/// ```
479#[wasm_bindgen(skip_typescript)]
480pub struct Ledger {
481    /// The booked directives from all files.
482    directives: Vec<Directive>,
483    /// Ledger options.
484    options: LedgerOptions,
485    /// Processing errors (load, booking, validation).
486    errors: Vec<Error>,
487    /// Editor cache for cross-file completions.
488    editor_cache: editor::EditorCache,
489}
490
491#[wasm_bindgen]
492impl Ledger {
493    /// Create a `Ledger` from multiple files with include resolution.
494    ///
495    /// Loads and runs the same processing pipeline as the CLI:
496    /// sort → synth-plugins → Early validation → book → regular-plugins → Late validation → finalize.
497    ///
498    /// # Arguments
499    ///
500    /// * `files` - A JavaScript object mapping file paths to their contents.
501    /// * `entry_point` - The main file to start loading from (must exist in `files`).
502    #[wasm_bindgen(js_name = "fromFiles")]
503    pub fn from_files(files: JsValue, entry_point: &str) -> Result<Self, JsError> {
504        use rustledger_loader::{FileSystem, LoadOptions, Loader, VirtualFileSystem, process};
505
506        let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
507            .map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
508
509        if file_map.is_empty() {
510            return Err(JsError::new("Files map cannot be empty"));
511        }
512
513        let vfs = VirtualFileSystem::from_files(file_map);
514
515        if !vfs.exists(Path::new(entry_point)) {
516            return Err(JsError::new(&format!(
517                "Entry point '{entry_point}' not found in files map"
518            )));
519        }
520
521        let mut loader = Loader::new().with_filesystem(Box::new(vfs));
522
523        let load_result = match loader.load(Path::new(entry_point)) {
524            Ok(result) => result,
525            Err(e) => {
526                return Ok(Self {
527                    directives: Vec::new(),
528                    options: LedgerOptions::default(),
529                    errors: vec![Error::new(format!("Load error: {e}"))],
530                    editor_cache: editor::EditorCache::from_directives(&[]),
531                });
532            }
533        };
534
535        let options = LedgerOptions {
536            title: load_result.options.title.clone(),
537            operating_currencies: load_result.options.operating_currency.clone(),
538        };
539
540        let load_options = LoadOptions {
541            validate: true,
542            ..Default::default()
543        };
544
545        match process(load_result, &load_options) {
546            Ok(ledger) => {
547                let directives: Vec<Directive> =
548                    ledger.directives.into_iter().map(|s| s.value).collect();
549                let mut errors: Vec<Error> = ledger.errors.into_iter().map(Error::from).collect();
550                // Include option warnings (E7001–E7006) so WASM consumers
551                // see the same diagnostics as `rledger check` and the LSP.
552                for w in &ledger.options.warnings {
553                    errors.push(Error::new(format!("[{}] {}", w.code, w.message)));
554                }
555                let editor_cache = editor::EditorCache::from_directives(&directives);
556
557                Ok(Self {
558                    directives,
559                    options,
560                    errors,
561                    editor_cache,
562                })
563            }
564            Err(e) => Ok(Self {
565                directives: Vec::new(),
566                options,
567                errors: vec![Error::new(format!("Processing error: {e}"))],
568                editor_cache: editor::EditorCache::from_directives(&[]),
569            }),
570        }
571    }
572
573    /// Check if the ledger is valid (no errors).
574    #[wasm_bindgen(js_name = "isValid")]
575    pub fn is_valid(&self) -> bool {
576        self.errors.is_empty()
577    }
578
579    /// Get all errors.
580    #[wasm_bindgen(js_name = "getErrors")]
581    pub fn get_errors(&self) -> Result<JsValue, JsError> {
582        to_js(&self.errors)
583    }
584
585    /// Get the parsed directives.
586    #[wasm_bindgen(js_name = "getDirectives")]
587    pub fn get_directives(&self) -> Result<JsValue, JsError> {
588        let directives: Vec<_> = self.directives.iter().map(directive_to_json).collect();
589        to_js(&directives)
590    }
591
592    /// Get the ledger options.
593    #[wasm_bindgen(js_name = "getOptions")]
594    pub fn get_options(&self) -> Result<JsValue, JsError> {
595        to_js(&self.options)
596    }
597
598    /// Get the number of directives.
599    #[wasm_bindgen(js_name = "directiveCount")]
600    pub fn directive_count(&self) -> usize {
601        self.directives.len()
602    }
603
604    /// Run a BQL query on this ledger.
605    #[wasm_bindgen]
606    pub fn query(&self, query_str: &str) -> Result<JsValue, JsError> {
607        execute_query(&self.directives, query_str)
608    }
609
610    /// Get account balances (shorthand for query("BALANCES")).
611    #[wasm_bindgen]
612    pub fn balances(&self) -> Result<JsValue, JsError> {
613        self.query("BALANCES")
614    }
615
616    /// Expand pad directives.
617    #[wasm_bindgen(js_name = "expandPads")]
618    pub fn expand_pads(&self) -> Result<JsValue, JsError> {
619        execute_expand_pads(&self.directives)
620    }
621
622    /// Run a native plugin on this ledger.
623    #[cfg(feature = "plugins")]
624    #[wasm_bindgen(js_name = "runPlugin")]
625    pub fn run_plugin(&self, plugin_name: &str) -> Result<JsValue, JsError> {
626        execute_plugin(&self.directives, plugin_name)
627    }
628
629    /// Get completions for a source string using cross-file data.
630    ///
631    /// Pass the source text of the file currently being edited.
632    /// Completions use accounts, currencies, and payees from all loaded files.
633    #[wasm_bindgen(js_name = "getCompletions")]
634    pub fn get_completions(
635        &self,
636        source: &str,
637        line: u32,
638        character: u32,
639    ) -> Result<JsValue, JsError> {
640        let result = editor::get_completions_cached(source, line, character, &self.editor_cache);
641        to_js(&result)
642    }
643
644    // =========================================================================
645    // Serialization / Caching
646    // =========================================================================
647
648    /// Serialize this ledger to a compact binary blob (rkyv).
649    ///
650    /// Store the bytes in OPFS or `IndexedDB` alongside a source fingerprint
651    /// (see [`crate::hash_sources`]) and restore later with [`Ledger::from_cache`].
652    #[wasm_bindgen]
653    pub fn serialize(&self) -> Result<Vec<u8>, JsError> {
654        let payload = cache::LedgerPayload {
655            directives: self.directives.clone(),
656            options: self.options.clone(),
657            errors: self.errors.clone(),
658        };
659        cache::serialize_ledger(&payload).map_err(|e| JsError::new(&e))
660    }
661
662    /// Restore a `Ledger` from bytes produced by [`Ledger::serialize`].
663    ///
664    /// # Errors
665    ///
666    /// Returns an error if the bytes are invalid or were produced by a different
667    /// library version.
668    #[wasm_bindgen(js_name = "fromCache")]
669    pub fn from_cache(bytes: &[u8]) -> Result<Self, JsError> {
670        let mut payload = cache::deserialize_ledger(bytes).map_err(|e| JsError::new(&e))?;
671
672        // Re-intern strings to deduplicate identical Arc<str> allocations.
673        rustledger_loader::reintern_plain_directives(&mut payload.directives);
674
675        let editor_cache = editor::EditorCache::from_directives(&payload.directives);
676
677        Ok(Self {
678            directives: payload.directives,
679            options: payload.options,
680            errors: payload.errors,
681            editor_cache,
682        })
683    }
684}