Skip to main content

rustledger_wasm/
parsed_ledger.rs

1//! Stateful ledger class for WASM.
2//!
3//! Provides a cached, parsed representation of a Beancount ledger for efficient
4//! multiple operations without re-parsing.
5
6use wasm_bindgen::prelude::*;
7
8use rustledger_core::Directive;
9use rustledger_parser::ParseResult as ParserResult;
10use rustledger_query::{Executor, parse as parse_query};
11
12use crate::convert::{directive_to_json, value_to_cell};
13use crate::editor;
14use crate::helpers::{load_and_interpolate, run_validation, to_js};
15#[cfg(feature = "plugins")]
16use crate::types::PluginResult;
17use crate::types::{Error, FormatResult, LedgerOptions, PadResult, QueryResult};
18
19/// A parsed and validated ledger that caches the parse result.
20///
21/// Use this class when you need to perform multiple operations on the same
22/// source without re-parsing each time.
23///
24/// # Example (JavaScript)
25///
26/// ```javascript
27/// const ledger = new ParsedLedger(source);
28/// if (ledger.isValid()) {
29///     const balances = ledger.query("BALANCES");
30///     const formatted = ledger.format();
31/// }
32/// ```
33#[wasm_bindgen]
34pub struct ParsedLedger {
35    /// The original source text.
36    source: String,
37    /// The raw parse result (for editor features).
38    parse_result: ParserResult,
39    /// The interpolated directives.
40    directives: Vec<Directive>,
41    /// Ledger options.
42    options: LedgerOptions,
43    /// Parse errors.
44    parse_errors: Vec<Error>,
45    /// Validation errors.
46    validation_errors: Vec<Error>,
47    /// Cached editor data (accounts, currencies, payees, line index).
48    editor_cache: editor::EditorCache,
49}
50
51#[wasm_bindgen]
52impl ParsedLedger {
53    /// Create a new `ParsedLedger` from source text.
54    ///
55    /// Parses, interpolates, and validates the source. Call `isValid()` to check for errors.
56    #[wasm_bindgen(constructor)]
57    pub fn new(source: &str) -> Self {
58        let load = load_and_interpolate(source);
59        let validation_errors = run_validation(&load);
60
61        // Build editor cache once for efficient editor operations
62        let editor_cache = editor::EditorCache::new(source, &load.parse_result);
63
64        Self {
65            source: source.to_string(),
66            parse_result: load.parse_result,
67            directives: load.directives,
68            options: load.options,
69            parse_errors: load.errors,
70            validation_errors,
71            editor_cache,
72        }
73    }
74
75    /// Check if the ledger is valid (no parse or validation errors).
76    #[wasm_bindgen(js_name = "isValid")]
77    pub fn is_valid(&self) -> bool {
78        self.parse_errors.is_empty() && self.validation_errors.is_empty()
79    }
80
81    /// Get all errors (parse + validation).
82    #[wasm_bindgen(js_name = "getErrors")]
83    pub fn get_errors(&self) -> Result<JsValue, JsError> {
84        let mut all_errors = self.parse_errors.clone();
85        all_errors.extend(self.validation_errors.clone());
86        to_js(&all_errors)
87    }
88
89    /// Get parse errors only.
90    #[wasm_bindgen(js_name = "getParseErrors")]
91    pub fn get_parse_errors(&self) -> Result<JsValue, JsError> {
92        to_js(&self.parse_errors)
93    }
94
95    /// Get validation errors only.
96    #[wasm_bindgen(js_name = "getValidationErrors")]
97    pub fn get_validation_errors(&self) -> Result<JsValue, JsError> {
98        to_js(&self.validation_errors)
99    }
100
101    /// Get the parsed directives.
102    #[wasm_bindgen(js_name = "getDirectives")]
103    pub fn get_directives(&self) -> Result<JsValue, JsError> {
104        let directives: Vec<_> = self.directives.iter().map(directive_to_json).collect();
105        to_js(&directives)
106    }
107
108    /// Get the ledger options.
109    #[wasm_bindgen(js_name = "getOptions")]
110    pub fn get_options(&self) -> Result<JsValue, JsError> {
111        to_js(&self.options)
112    }
113
114    /// Get the number of directives.
115    #[wasm_bindgen(js_name = "directiveCount")]
116    pub fn directive_count(&self) -> usize {
117        self.directives.len()
118    }
119
120    /// Run a BQL query on this ledger.
121    #[wasm_bindgen]
122    pub fn query(&self, query_str: &str) -> Result<JsValue, JsError> {
123        if !self.parse_errors.is_empty() {
124            let result = QueryResult {
125                columns: Vec::new(),
126                rows: Vec::new(),
127                errors: self.parse_errors.clone(),
128            };
129            return to_js(&result);
130        }
131
132        let query = match parse_query(query_str) {
133            Ok(q) => q,
134            Err(e) => {
135                let result = QueryResult {
136                    columns: Vec::new(),
137                    rows: Vec::new(),
138                    errors: vec![Error::new(e.to_string())],
139                };
140                return to_js(&result);
141            }
142        };
143
144        let mut executor = Executor::new(&self.directives);
145        match executor.execute(&query) {
146            Ok(result) => {
147                let rows: Vec<Vec<_>> = result
148                    .rows
149                    .iter()
150                    .map(|row| row.iter().map(value_to_cell).collect())
151                    .collect();
152
153                let query_result = QueryResult {
154                    columns: result.columns,
155                    rows,
156                    errors: Vec::new(),
157                };
158                to_js(&query_result)
159            }
160            Err(e) => {
161                let result = QueryResult {
162                    columns: Vec::new(),
163                    rows: Vec::new(),
164                    errors: vec![Error::new(format!("Query execution error: {e}"))],
165                };
166                to_js(&result)
167            }
168        }
169    }
170
171    /// Get account balances (shorthand for query("BALANCES")).
172    #[wasm_bindgen]
173    pub fn balances(&self) -> Result<JsValue, JsError> {
174        self.query("BALANCES")
175    }
176
177    /// Format the ledger source.
178    #[wasm_bindgen]
179    pub fn format(&self) -> Result<JsValue, JsError> {
180        use rustledger_core::{FormatConfig, format_directive};
181
182        if !self.parse_errors.is_empty() {
183            let result = FormatResult {
184                formatted: None,
185                errors: self.parse_errors.clone(),
186            };
187            return to_js(&result);
188        }
189
190        let config = FormatConfig::default();
191        let mut formatted = String::new();
192
193        for directive in &self.directives {
194            formatted.push_str(&format_directive(directive, &config));
195            formatted.push('\n');
196        }
197
198        let result = FormatResult {
199            formatted: Some(formatted),
200            errors: Vec::new(),
201        };
202        to_js(&result)
203    }
204
205    /// Expand pad directives.
206    #[wasm_bindgen(js_name = "expandPads")]
207    pub fn expand_pads(&self) -> Result<JsValue, JsError> {
208        use rustledger_booking::process_pads;
209
210        if !self.parse_errors.is_empty() {
211            let result = PadResult {
212                directives: Vec::new(),
213                padding_transactions: Vec::new(),
214                errors: self.parse_errors.clone(),
215            };
216            return to_js(&result);
217        }
218
219        let pad_result = process_pads(&self.directives);
220
221        let result = PadResult {
222            directives: pad_result
223                .directives
224                .iter()
225                .map(directive_to_json)
226                .collect(),
227            padding_transactions: pad_result
228                .padding_transactions
229                .iter()
230                .map(|txn| directive_to_json(&Directive::Transaction(txn.clone())))
231                .collect(),
232            errors: pad_result
233                .errors
234                .iter()
235                .map(|e| Error::new(e.message.clone()))
236                .collect(),
237        };
238        to_js(&result)
239    }
240
241    /// Run a native plugin on this ledger.
242    #[cfg(feature = "plugins")]
243    #[wasm_bindgen(js_name = "runPlugin")]
244    pub fn run_plugin(&self, plugin_name: &str) -> Result<JsValue, JsError> {
245        use rustledger_plugin::{
246            NativePluginRegistry, PluginInput, PluginOptions, directives_to_wrappers,
247            wrappers_to_directives,
248        };
249
250        if !self.parse_errors.is_empty() {
251            let result = PluginResult {
252                directives: Vec::new(),
253                errors: self.parse_errors.clone(),
254            };
255            return to_js(&result);
256        }
257
258        let registry = NativePluginRegistry::new();
259        let Some(plugin) = registry.find(plugin_name) else {
260            let result = PluginResult {
261                directives: Vec::new(),
262                errors: vec![Error::new(format!("Unknown plugin: {plugin_name}"))],
263            };
264            return to_js(&result);
265        };
266
267        let wrappers = directives_to_wrappers(&self.directives);
268        let input = PluginInput {
269            directives: wrappers,
270            options: PluginOptions::default(),
271            config: None,
272        };
273
274        let output = plugin.process(input);
275
276        let output_directives = match wrappers_to_directives(&output.directives) {
277            Ok(dirs) => dirs,
278            Err(e) => {
279                let result = PluginResult {
280                    directives: Vec::new(),
281                    errors: vec![Error::new(format!("Conversion error: {e}"))],
282                };
283                return to_js(&result);
284            }
285        };
286
287        let result = PluginResult {
288            directives: output_directives.iter().map(directive_to_json).collect(),
289            errors: output
290                .errors
291                .iter()
292                .map(|e| match e.severity {
293                    rustledger_plugin::PluginErrorSeverity::Warning => {
294                        Error::warning(e.message.clone())
295                    }
296                    rustledger_plugin::PluginErrorSeverity::Error => Error::new(e.message.clone()),
297                })
298                .collect(),
299        };
300        to_js(&result)
301    }
302
303    // =========================================================================
304    // Editor Integration (LSP-like features)
305    // =========================================================================
306
307    /// Get completions at the given position.
308    ///
309    /// Returns context-aware completions for accounts, currencies, directives, etc.
310    /// Uses cached account/currency/payee data for efficiency.
311    #[wasm_bindgen(js_name = "getCompletions")]
312    pub fn get_completions(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
313        let result =
314            editor::get_completions_cached(&self.source, line, character, &self.editor_cache);
315        to_js(&result)
316    }
317
318    /// Get hover information at the given position.
319    ///
320    /// Returns documentation for accounts, currencies, and directive keywords.
321    #[wasm_bindgen(js_name = "getHoverInfo")]
322    pub fn get_hover_info(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
323        let result = editor::get_hover_info_cached(
324            &self.source,
325            line,
326            character,
327            &self.parse_result,
328            &self.editor_cache,
329        );
330        to_js(&result)
331    }
332
333    /// Get the definition location for the symbol at the given position.
334    ///
335    /// Returns the location of the `open` or `commodity` directive for accounts/currencies.
336    /// Uses cached `LineIndex` for O(log n) position lookups.
337    #[wasm_bindgen(js_name = "getDefinition")]
338    pub fn get_definition(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
339        let result = editor::get_definition_cached(
340            &self.source,
341            line,
342            character,
343            &self.parse_result,
344            &self.editor_cache,
345        );
346        to_js(&result)
347    }
348
349    /// Get all document symbols for the outline view.
350    ///
351    /// Returns a hierarchical list of all directives with their positions.
352    /// Uses cached `LineIndex` for O(log n) position lookups.
353    #[wasm_bindgen(js_name = "getDocumentSymbols")]
354    pub fn get_document_symbols(&self) -> Result<JsValue, JsError> {
355        let result = editor::get_document_symbols_cached(&self.parse_result, &self.editor_cache);
356        to_js(&result)
357    }
358
359    /// Find all references to the symbol at the given position.
360    ///
361    /// Returns all occurrences of accounts, currencies, or payees in the document.
362    /// Uses cached data for efficient lookup.
363    #[wasm_bindgen(js_name = "getReferences")]
364    pub fn get_references(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
365        let result = editor::get_references_cached(
366            &self.source,
367            line,
368            character,
369            &self.parse_result,
370            &self.editor_cache,
371        );
372        to_js(&result)
373    }
374}