Skip to main content

rustledger_wasm/
api.rs

1//! Public WASM API functions.
2//!
3//! These functions are exposed to JavaScript via wasm-bindgen.
4
5use std::collections::HashMap;
6use std::path::Path;
7use wasm_bindgen::prelude::*;
8
9use rustledger_core::Directive;
10use rustledger_loader::{FileSystem, LoadError, LoadResult};
11use rustledger_parser::parse as parse_beancount;
12
13use crate::convert::{directive_to_json, value_to_cell};
14use crate::helpers::{extract_options, load_and_book, run_validation, to_js};
15#[cfg(feature = "completions")]
16use crate::types::{CompletionJson, CompletionResultJson};
17use crate::types::{
18    Error, FormatResult, Ledger, PadResult, ParseResult, QueryResult, Severity, ValidationResult,
19};
20#[cfg(feature = "plugins")]
21use crate::types::{PluginInfo, PluginResult};
22use crate::utils::LineLookup;
23
24/// Convert [`LoadResult`] errors to detailed Error objects with line/column info.
25///
26/// This preserves parse error details that would be lost by simple `to_string()`.
27fn load_errors_to_errors(load_result: &LoadResult) -> Vec<Error> {
28    let mut errors = Vec::new();
29
30    for load_error in &load_result.errors {
31        match load_error {
32            LoadError::ParseErrors {
33                path,
34                errors: parse_errors,
35            } => {
36                // Expand parse errors with file path and line info
37                for parse_error in parse_errors {
38                    let span = parse_error.span();
39                    // Try to get line number from source map
40                    let line = load_result
41                        .source_map
42                        .get_by_path(path)
43                        .map(|file| file.line_col(span.0).0 as u32);
44
45                    let msg = format!("{}: {}", path.display(), parse_error);
46                    if let Some(line_num) = line {
47                        errors.push(Error::with_line(msg, line_num));
48                    } else {
49                        errors.push(Error::new(msg));
50                    }
51                }
52            }
53            other => {
54                // Other errors use default string conversion
55                errors.push(Error::new(other.to_string()));
56            }
57        }
58    }
59
60    errors
61}
62
63/// Parse a Beancount source string.
64///
65/// Returns a `ParseResult` with the parsed ledger and any errors.
66#[wasm_bindgen]
67pub fn parse(source: &str) -> Result<JsValue, JsError> {
68    let result = parse_beancount(source);
69    let lookup = LineLookup::new(source);
70
71    let errors: Vec<Error> = result
72        .errors
73        .iter()
74        .map(|e| Error::with_line(e.to_string(), lookup.byte_to_line(e.span().0)))
75        .collect();
76
77    // Extract options from parsed result
78    let options = extract_options(&result.options);
79
80    let ledger = Some(Ledger {
81        directives: result
82            .directives
83            .iter()
84            .map(|spanned| directive_to_json(&spanned.value))
85            .collect(),
86        options,
87    });
88
89    let parse_result = ParseResult { ledger, errors };
90    to_js(&parse_result)
91}
92
93/// Validate a Beancount source string.
94///
95/// Parses, interpolates, and validates in one step.
96/// Returns a `ValidationResult` indicating whether the ledger is valid.
97#[wasm_bindgen(js_name = "validateSource")]
98pub fn validate_source(source: &str) -> Result<JsValue, JsError> {
99    let load = load_and_book(source);
100    let validation_errors = run_validation(&load);
101    let mut errors = load.errors;
102    errors.extend(validation_errors);
103
104    let result = ValidationResult {
105        valid: errors.is_empty(),
106        errors,
107    };
108    to_js(&result)
109}
110
111/// Run a BQL query on a Beancount source string.
112///
113/// Parses the source, interpolates, then executes the query.
114/// Returns a `QueryResult` with columns, rows, and any errors.
115#[wasm_bindgen]
116pub fn query(source: &str, query_str: &str) -> Result<JsValue, JsError> {
117    use rustledger_booking::merge_with_padding;
118    use rustledger_query::{Executor, parse as parse_query};
119
120    let load = load_and_book(source);
121
122    // Return early if there were parse/interpolation errors
123    if !load.errors.is_empty() {
124        let result = QueryResult {
125            columns: Vec::new(),
126            rows: Vec::new(),
127            errors: load.errors,
128        };
129        return to_js(&result);
130    }
131
132    // Parse the query
133    let query = match parse_query(query_str) {
134        Ok(q) => q,
135        Err(e) => {
136            let result = QueryResult {
137                columns: Vec::new(),
138                rows: Vec::new(),
139                errors: vec![Error::new(e.to_string())],
140            };
141            return to_js(&result);
142        }
143    };
144
145    // Merge pad-synthesized transactions: query is a balance-
146    // computing consumer (#1288). `merge_with_padding` preserves
147    // Pad directives so `FROM #entries WHERE type = 'pad'` audits
148    // continue to enumerate them, AND handles multi-pad shadowing
149    // (#1300) correctly by construction via `process_pads`.
150    let directives = merge_with_padding(&load.directives);
151    let mut executor = Executor::new(&directives);
152    match executor.execute(&query) {
153        Ok(result) => {
154            let rows: Vec<Vec<_>> = result
155                .rows
156                .iter()
157                .map(|row| row.iter().map(value_to_cell).collect())
158                .collect();
159
160            let query_result = QueryResult {
161                columns: result.columns,
162                rows,
163                errors: Vec::new(),
164            };
165            to_js(&query_result)
166        }
167        Err(e) => {
168            let result = QueryResult {
169                columns: Vec::new(),
170                rows: Vec::new(),
171                errors: vec![Error::new(format!("Query execution error: {e}"))],
172            };
173            to_js(&result)
174        }
175    }
176}
177
178/// Get version information.
179///
180/// Returns the version string of the rustledger-wasm package.
181#[wasm_bindgen]
182pub fn version() -> String {
183    env!("CARGO_PKG_VERSION").to_string()
184}
185
186/// Format a Beancount source string.
187///
188/// Parses and reformats with consistent alignment.
189/// Returns a `FormatResult` with the formatted source or errors.
190#[wasm_bindgen]
191pub fn format(source: &str) -> Result<JsValue, JsError> {
192    use rustledger_parser::format::format_source_with_parsed;
193
194    let parse_result = parse_beancount(source);
195    let lookup = LineLookup::new(source);
196
197    if !parse_result.errors.is_empty() {
198        let result = FormatResult {
199            formatted: None,
200            errors: parse_result
201                .errors
202                .iter()
203                .map(|e| Error::with_line(e.to_string(), lookup.byte_to_line(e.span().0)))
204                .collect(),
205        };
206        return to_js(&result);
207    }
208
209    // Reuse the `parse_result` we produced for the error gate above
210    // instead of letting `format_source` re-parse. Byte-identical
211    // output per parser-side `format_source_with_parsed_matches_format_source`.
212    let formatted = format_source_with_parsed(&parse_result, source);
213
214    let result = FormatResult {
215        formatted: Some(formatted),
216        errors: Vec::new(),
217    };
218    to_js(&result)
219}
220
221/// Process pad directives and expand them.
222///
223/// Returns directives with pad-generated transactions included.
224#[wasm_bindgen(js_name = "expandPads")]
225pub fn expand_pads(source: &str) -> Result<JsValue, JsError> {
226    use rustledger_booking::process_pads;
227
228    let load = load_and_book(source);
229
230    // Return early if there were parse/interpolation errors
231    if !load.errors.is_empty() {
232        let result = PadResult {
233            directives: Vec::new(),
234            padding_transactions: Vec::new(),
235            errors: load.errors,
236        };
237        return to_js(&result);
238    }
239
240    // Process pads
241    let pad_result = process_pads(&load.directives);
242
243    let result = PadResult {
244        // The source stream, verbatim — `process_pads` no longer
245        // echoes its input back, so read it from the directives we
246        // already loaded instead of from the result.
247        directives: load.directives.iter().map(directive_to_json).collect(),
248        padding_transactions: pad_result
249            .padding_transactions
250            .iter()
251            .map(|txn| directive_to_json(&Directive::Transaction(txn.clone())))
252            .collect(),
253        errors: pad_result
254            .errors
255            .iter()
256            .map(|e| Error::new(e.message.clone()))
257            .collect(),
258    };
259    to_js(&result)
260}
261
262/// Materialize a plugin's `ops` against its input wrapper list,
263/// producing the resulting flat wrapper list. Used by WASM entry
264/// points that need to round-trip a plugin's output back to
265/// `Vec<Directive>` for JSON serialization.
266#[cfg(feature = "plugins")]
267pub fn materialize_plugin_ops(
268    input: &[rustledger_plugin::types::DirectiveWrapper],
269    output: &rustledger_plugin::types::PluginOutput,
270) -> Vec<rustledger_plugin::types::DirectiveWrapper> {
271    let mut out = Vec::with_capacity(output.ops.len());
272    for op in &output.ops {
273        match op {
274            rustledger_plugin::PluginOp::Keep(i) => {
275                if let Some(w) = input.get(*i) {
276                    out.push(w.clone());
277                }
278            }
279            rustledger_plugin::PluginOp::Modify(_, w) | rustledger_plugin::PluginOp::Insert(w) => {
280                out.push(w.clone());
281            }
282            rustledger_plugin::PluginOp::Delete(_) => {}
283        }
284    }
285    out
286}
287
288/// Run a single named plugin against a Beancount source and return
289/// the resulting directives as JSON.
290#[cfg(feature = "plugins")]
291#[wasm_bindgen(js_name = "runPlugin")]
292pub fn run_plugin(source: &str, plugin_name: &str) -> Result<JsValue, JsError> {
293    use rustledger_plugin::{
294        NativePluginRegistry, PluginInput, PluginOptions, directives_to_wrappers,
295        wrappers_to_directives,
296    };
297
298    let load = load_and_book(source);
299
300    // Return early if there were parse/interpolation errors
301    if !load.errors.is_empty() {
302        let result = PluginResult {
303            directives: Vec::new(),
304            errors: load.errors,
305        };
306        return to_js(&result);
307    }
308
309    // Find and run the plugin
310    let registry = NativePluginRegistry::global();
311    // External API runs plugins on already-booked input — synth
312    // plugins are a loader-internal concern and would re-emit Opens
313    // for accounts the booking pass already opened.
314    let Some(plugin) = registry.find_regular(plugin_name) else {
315        let result = PluginResult {
316            directives: Vec::new(),
317            errors: vec![Error::new(format!("Unknown plugin: {plugin_name}"))],
318        };
319        return to_js(&result);
320    };
321
322    // Convert directives to plugin format and run
323    let wrappers = directives_to_wrappers(&load.directives);
324    let input = PluginInput {
325        directives: wrappers,
326        options: PluginOptions::default(),
327        config: None,
328    };
329
330    let input_dirs = input.directives.clone();
331    let output = plugin.process(input);
332
333    // Materialize ops back to wrappers, then convert.
334    let materialized_wrappers = materialize_plugin_ops(&input_dirs, &output);
335    let output_directives = match wrappers_to_directives(&materialized_wrappers) {
336        Ok(dirs) => dirs,
337        Err(e) => {
338            let result = PluginResult {
339                directives: Vec::new(),
340                errors: vec![Error::new(format!("Conversion error: {e}"))],
341            };
342            return to_js(&result);
343        }
344    };
345
346    let result = PluginResult {
347        directives: output_directives.iter().map(directive_to_json).collect(),
348        errors: output
349            .errors
350            .iter()
351            .map(|e| match e.severity {
352                rustledger_plugin::PluginErrorSeverity::Warning => {
353                    Error::warning(e.message.clone())
354                }
355                rustledger_plugin::PluginErrorSeverity::Error => Error::new(e.message.clone()),
356            })
357            .collect(),
358    };
359    to_js(&result)
360}
361
362/// List available native plugins.
363///
364/// Returns an array of `PluginInfo` objects with name and description.
365#[cfg(feature = "plugins")]
366#[wasm_bindgen(js_name = "listPlugins")]
367pub fn list_plugins() -> Result<JsValue, JsError> {
368    use rustledger_plugin::NativePluginRegistry;
369
370    let registry = NativePluginRegistry::global();
371    let plugins: Vec<PluginInfo> = registry
372        .iter()
373        .map(|p| PluginInfo {
374            name: p.name().to_string(),
375            description: p.description().to_string(),
376        })
377        .collect();
378
379    to_js(&plugins)
380}
381
382/// Calculate account balances.
383///
384/// Shorthand for `query(source, "BALANCES")`.
385#[wasm_bindgen]
386pub fn balances(source: &str) -> Result<JsValue, JsError> {
387    query(source, "BALANCES")
388}
389
390/// Get BQL query completions at cursor position.
391///
392/// Returns context-aware completions for the BQL query language.
393#[cfg(feature = "completions")]
394#[wasm_bindgen(js_name = "bqlCompletions")]
395pub fn bql_completions(partial_query: &str, cursor_pos: usize) -> Result<JsValue, JsError> {
396    use rustledger_query::completions;
397
398    let result = completions::complete(partial_query, cursor_pos);
399
400    let json_result = CompletionResultJson {
401        completions: result
402            .completions
403            .into_iter()
404            .map(|c| CompletionJson {
405                text: c.text,
406                category: c.category.as_str().to_string(),
407                description: c.description,
408            })
409            .collect(),
410        context: format!("{:?}", result.context),
411    };
412
413    to_js(&json_result)
414}
415
416/// Parse multiple Beancount files with include resolution.
417///
418/// This function accepts a map of file paths to file contents and an entry point,
419/// resolving `include` directives across the files. This enables multi-file ledgers
420/// in WASM environments where filesystem access is not available.
421///
422/// # Arguments
423///
424/// * `files` - A JavaScript object mapping file paths to their contents.
425///   Example: `{ "main.beancount": "include \"accounts.beancount\"", "accounts.beancount": "..." }`
426/// * `entry_point` - The main file to start loading from (must exist in `files`).
427///
428/// # Returns
429///
430/// A `ParseResult` with the parsed ledger from all files and any errors.
431///
432/// # Example (JavaScript)
433///
434/// ```javascript
435/// const result = parseMultiFile({
436///   "main.beancount": `
437///     include "accounts.beancount"
438///     2024-01-15 * "Coffee"
439///       Expenses:Food  5.00 USD
440///       Assets:Bank
441///   `,
442///   "accounts.beancount": `
443///     2024-01-01 open Assets:Bank USD
444///     2024-01-01 open Expenses:Food USD
445///   `
446/// }, "main.beancount");
447/// ```
448#[wasm_bindgen(js_name = "parseMultiFile")]
449pub fn parse_multi_file(files: JsValue, entry_point: &str) -> Result<JsValue, JsError> {
450    use rustledger_booking::interpolate;
451    use rustledger_loader::{Loader, VirtualFileSystem};
452
453    // Parse the JavaScript object to a HashMap
454    let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
455        .map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
456
457    if file_map.is_empty() {
458        return Err(JsError::new("Files map cannot be empty"));
459    }
460
461    // Create virtual filesystem with all files
462    let vfs = VirtualFileSystem::from_files(file_map);
463
464    // Check entry point exists using VFS path normalization
465    if !vfs.exists(Path::new(entry_point)) {
466        return Err(JsError::new(&format!(
467            "Entry point '{entry_point}' not found in files map"
468        )));
469    }
470
471    // Create loader with virtual filesystem
472    let mut loader = Loader::new().with_filesystem(Box::new(vfs));
473
474    // Load from entry point
475    let load_result = match loader.load(Path::new(entry_point)) {
476        Ok(result) => result,
477        Err(e) => {
478            let result = ParseResult {
479                ledger: None,
480                errors: vec![Error::new(format!("Load error: {e}"))],
481            };
482            return to_js(&result);
483        }
484    };
485
486    // Collect load errors with detailed parse error info
487    let mut errors = load_errors_to_errors(&load_result);
488
489    // Extract options from loader options
490    let options = crate::types::LedgerOptions {
491        title: load_result.options.title.clone(),
492        operating_currencies: load_result.options.operating_currency.clone(),
493    };
494
495    // Extract and interpolate directives
496    let mut directives: Vec<Directive> = load_result
497        .directives
498        .into_iter()
499        .map(|s| s.value)
500        .collect();
501
502    // Interpolate transactions (fill in missing amounts)
503    if errors.is_empty() {
504        for directive in &mut directives {
505            if let Directive::Transaction(txn) = directive {
506                match interpolate(txn) {
507                    Ok(result) => {
508                        *txn = result.transaction;
509                    }
510                    Err(e) => {
511                        errors.push(Error::new(e.to_string()));
512                    }
513                }
514            }
515        }
516    }
517
518    let ledger = Some(Ledger {
519        directives: directives.iter().map(directive_to_json).collect(),
520        options,
521    });
522
523    let result = ParseResult { ledger, errors };
524    to_js(&result)
525}
526
527/// Validate multiple Beancount files with include resolution.
528///
529/// Similar to `parseMultiFile`, but also runs validation.
530/// Returns a `ValidationResult` indicating whether the ledger is valid.
531#[wasm_bindgen(js_name = "validateMultiFile")]
532pub fn validate_multi_file(files: JsValue, entry_point: &str) -> Result<JsValue, JsError> {
533    use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
534
535    // Parse the JavaScript object to a HashMap
536    let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
537        .map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
538
539    if file_map.is_empty() {
540        return Err(JsError::new("Files map cannot be empty"));
541    }
542
543    // Create virtual filesystem with all files
544    let vfs = VirtualFileSystem::from_files(file_map);
545
546    // Check entry point exists using VFS path normalization
547    if !vfs.exists(Path::new(entry_point)) {
548        return Err(JsError::new(&format!(
549            "Entry point '{entry_point}' not found in files map"
550        )));
551    }
552
553    // Create loader with virtual filesystem
554    let mut loader = Loader::new().with_filesystem(Box::new(vfs));
555
556    // Load from entry point
557    let load_result = match loader.load(Path::new(entry_point)) {
558        Ok(result) => result,
559        Err(e) => {
560            let result = ValidationResult {
561                valid: false,
562                errors: vec![Error::new(format!("Load error: {e}"))],
563            };
564            return to_js(&result);
565        }
566    };
567
568    // Check for parse errors first (preserves detailed per-error line info)
569    let parse_errors = load_errors_to_errors(&load_result);
570    if !parse_errors.is_empty() {
571        let result = ValidationResult {
572            valid: false,
573            errors: parse_errors,
574        };
575        return to_js(&result);
576    }
577
578    // Run the shared processing pipeline:
579    // sort → synth-plugins → Early validation → book → regular-plugins → Late validation → finalize
580    let options = LoadOptions {
581        validate: true,
582        ..Default::default()
583    };
584
585    let ledger = match process(load_result, &options) {
586        Ok(ledger) => ledger,
587        Err(e) => {
588            let result = ValidationResult {
589                valid: false,
590                errors: vec![Error::new(format!("Processing error: {e}"))],
591            };
592            return to_js(&result);
593        }
594    };
595
596    let errors: Vec<Error> = ledger.errors.into_iter().map(Error::from).collect();
597
598    let result = ValidationResult {
599        valid: errors.is_empty(),
600        errors,
601    };
602    to_js(&result)
603}
604
605/// Run a BQL query on multiple Beancount files.
606///
607/// Similar to `query`, but accepts multiple files with include resolution.
608///
609/// Note: Glob patterns in `include` directives are not supported in multi-file mode
610/// since there is no real filesystem to enumerate. Use explicit file paths instead.
611#[wasm_bindgen(js_name = "queryMultiFile")]
612pub fn query_multi_file(
613    files: JsValue,
614    entry_point: &str,
615    query_str: &str,
616) -> Result<JsValue, JsError> {
617    use rustledger_booking::merge_with_padding;
618    use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
619    use rustledger_query::{Executor, parse as parse_query};
620
621    // Parse the JavaScript object to a HashMap
622    let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
623        .map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
624
625    if file_map.is_empty() {
626        return Err(JsError::new("Files map cannot be empty"));
627    }
628
629    // Create virtual filesystem with all files
630    let vfs = VirtualFileSystem::from_files(file_map);
631
632    // Check entry point exists using VFS path normalization
633    if !vfs.exists(Path::new(entry_point)) {
634        return Err(JsError::new(&format!(
635            "Entry point '{entry_point}' not found in files map"
636        )));
637    }
638
639    // Create loader with virtual filesystem
640    let mut loader = Loader::new().with_filesystem(Box::new(vfs));
641
642    // Load from entry point
643    let load_result = match loader.load(Path::new(entry_point)) {
644        Ok(result) => result,
645        Err(e) => {
646            let result = QueryResult {
647                columns: Vec::new(),
648                rows: Vec::new(),
649                errors: vec![Error::new(format!("Load error: {e}"))],
650            };
651            return to_js(&result);
652        }
653    };
654
655    // Check for parse errors first (preserves detailed per-error line info)
656    let parse_errors = load_errors_to_errors(&load_result);
657    if !parse_errors.is_empty() {
658        let result = QueryResult {
659            columns: Vec::new(),
660            rows: Vec::new(),
661            errors: parse_errors,
662        };
663        return to_js(&result);
664    }
665
666    // Run the shared processing pipeline (queries skip validation):
667    // sort → synth-plugins → book → regular-plugins → finalize
668    let options = LoadOptions {
669        validate: false,
670        ..Default::default()
671    };
672
673    let ledger = match process(load_result, &options) {
674        Ok(ledger) => ledger,
675        Err(e) => {
676            let result = QueryResult {
677                columns: Vec::new(),
678                rows: Vec::new(),
679                errors: vec![Error::new(format!("Processing error: {e}"))],
680            };
681            return to_js(&result);
682        }
683    };
684
685    // Only abort on actual errors, not warnings (matching CLI query behavior)
686    let errors: Vec<Error> = ledger.errors.into_iter().map(Error::from).collect();
687    let has_errors = errors.iter().any(|e| e.severity == Severity::Error);
688    if has_errors {
689        let result = QueryResult {
690            columns: Vec::new(),
691            rows: Vec::new(),
692            errors,
693        };
694        return to_js(&result);
695    }
696
697    // Merge pad-synthesized transactions into the directive stream
698    // (matching CLI query pipeline). See `wasm::query` above for the
699    // architectural rule.
700    let booked_directives: Vec<_> = ledger.directives.into_iter().map(|s| s.value).collect();
701    let directives = merge_with_padding(&booked_directives);
702
703    // Parse the query
704    let query = match parse_query(query_str) {
705        Ok(q) => q,
706        Err(e) => {
707            let result = QueryResult {
708                columns: Vec::new(),
709                rows: Vec::new(),
710                errors: vec![Error::new(e.to_string())],
711            };
712            return to_js(&result);
713        }
714    };
715
716    // Execute query
717    let mut executor = Executor::new(&directives);
718    match executor.execute(&query) {
719        Ok(result) => {
720            let rows: Vec<Vec<_>> = result
721                .rows
722                .iter()
723                .map(|row| row.iter().map(value_to_cell).collect())
724                .collect();
725
726            let query_result = QueryResult {
727                columns: result.columns,
728                rows,
729                errors: Vec::new(),
730            };
731            to_js(&query_result)
732        }
733        Err(e) => {
734            let result = QueryResult {
735                columns: Vec::new(),
736                rows: Vec::new(),
737                errors: vec![Error::new(format!("Query execution error: {e}"))],
738            };
739            to_js(&result)
740        }
741    }
742}
743
744/// Compute a SHA-256 fingerprint of one or more source strings.
745///
746/// Returns the fingerprint as a lowercase hex string. Store this value
747/// alongside serialized ledger bytes and compare on subsequent loads to
748/// detect whether the source has changed.
749///
750/// Each string is separated by a NUL byte before hashing so that
751/// `["ab", "c"]` produces a different fingerprint from `["a", "bc"]`.
752///
753/// The fingerprint is order-sensitive: `["a", "b"]` hashes differently
754/// from `["b", "a"]`. Callers using an unordered collection should sort
755/// by filename first for deterministic results.
756#[wasm_bindgen(js_name = "hashSources")]
757#[allow(clippy::needless_pass_by_value)] // wasm-bindgen requires owned Vec<String>
758pub fn hash_sources(sources: Vec<String>) -> String {
759    let refs: Vec<&str> = sources.iter().map(String::as_str).collect();
760    crate::cache::hash_sources(&refs)
761}