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