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_query::{Executor, parse as parse_query};
118
119    let load = load_and_book(source);
120
121    // Return early if there were parse/interpolation errors
122    if !load.errors.is_empty() {
123        let result = QueryResult {
124            columns: Vec::new(),
125            rows: Vec::new(),
126            errors: load.errors,
127        };
128        return to_js(&result);
129    }
130
131    // Parse the query
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(&load.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 version information.
172///
173/// Returns the version string of the rustledger-wasm package.
174#[wasm_bindgen]
175pub fn version() -> String {
176    env!("CARGO_PKG_VERSION").to_string()
177}
178
179/// Format a Beancount source string.
180///
181/// Parses and reformats with consistent alignment.
182/// Returns a `FormatResult` with the formatted source or errors.
183#[wasm_bindgen]
184pub fn format(source: &str) -> Result<JsValue, JsError> {
185    use rustledger_core::{FormatConfig, format_directive};
186
187    let parse_result = parse_beancount(source);
188    let lookup = LineLookup::new(source);
189
190    if !parse_result.errors.is_empty() {
191        let result = FormatResult {
192            formatted: None,
193            errors: parse_result
194                .errors
195                .iter()
196                .map(|e| Error::with_line(e.to_string(), lookup.byte_to_line(e.span().0)))
197                .collect(),
198        };
199        return to_js(&result);
200    }
201
202    let config = FormatConfig::default();
203    let mut formatted = String::new();
204
205    for spanned in &parse_result.directives {
206        formatted.push_str(&format_directive(&spanned.value, &config));
207        formatted.push('\n');
208    }
209
210    let result = FormatResult {
211        formatted: Some(formatted),
212        errors: Vec::new(),
213    };
214    to_js(&result)
215}
216
217/// Process pad directives and expand them.
218///
219/// Returns directives with pad-generated transactions included.
220#[wasm_bindgen(js_name = "expandPads")]
221pub fn expand_pads(source: &str) -> Result<JsValue, JsError> {
222    use rustledger_booking::process_pads;
223
224    let load = load_and_book(source);
225
226    // Return early if there were parse/interpolation errors
227    if !load.errors.is_empty() {
228        let result = PadResult {
229            directives: Vec::new(),
230            padding_transactions: Vec::new(),
231            errors: load.errors,
232        };
233        return to_js(&result);
234    }
235
236    // Process pads
237    let pad_result = process_pads(&load.directives);
238
239    let result = PadResult {
240        directives: pad_result
241            .directives
242            .iter()
243            .map(directive_to_json)
244            .collect(),
245        padding_transactions: pad_result
246            .padding_transactions
247            .iter()
248            .map(|txn| directive_to_json(&Directive::Transaction(txn.clone())))
249            .collect(),
250        errors: pad_result
251            .errors
252            .iter()
253            .map(|e| Error::new(e.message.clone()))
254            .collect(),
255    };
256    to_js(&result)
257}
258
259/// Run a native plugin on the source.
260///
261/// Available plugins can be listed with `listPlugins()`.
262#[cfg(feature = "plugins")]
263#[wasm_bindgen(js_name = "runPlugin")]
264pub fn run_plugin(source: &str, plugin_name: &str) -> Result<JsValue, JsError> {
265    use rustledger_plugin::{
266        NativePluginRegistry, PluginInput, PluginOptions, directives_to_wrappers,
267        wrappers_to_directives,
268    };
269
270    let load = load_and_book(source);
271
272    // Return early if there were parse/interpolation errors
273    if !load.errors.is_empty() {
274        let result = PluginResult {
275            directives: Vec::new(),
276            errors: load.errors,
277        };
278        return to_js(&result);
279    }
280
281    // Find and run the plugin
282    let registry = NativePluginRegistry::new();
283    let Some(plugin) = registry.find(plugin_name) else {
284        let result = PluginResult {
285            directives: Vec::new(),
286            errors: vec![Error::new(format!("Unknown plugin: {plugin_name}"))],
287        };
288        return to_js(&result);
289    };
290
291    // Convert directives to plugin format and run
292    let wrappers = directives_to_wrappers(&load.directives);
293    let input = PluginInput {
294        directives: wrappers,
295        options: PluginOptions::default(),
296        config: None,
297    };
298
299    let output = plugin.process(input);
300
301    // Convert back
302    let output_directives = match wrappers_to_directives(&output.directives) {
303        Ok(dirs) => dirs,
304        Err(e) => {
305            let result = PluginResult {
306                directives: Vec::new(),
307                errors: vec![Error::new(format!("Conversion error: {e}"))],
308            };
309            return to_js(&result);
310        }
311    };
312
313    let result = PluginResult {
314        directives: output_directives.iter().map(directive_to_json).collect(),
315        errors: output
316            .errors
317            .iter()
318            .map(|e| match e.severity {
319                rustledger_plugin::PluginErrorSeverity::Warning => {
320                    Error::warning(e.message.clone())
321                }
322                rustledger_plugin::PluginErrorSeverity::Error => Error::new(e.message.clone()),
323            })
324            .collect(),
325    };
326    to_js(&result)
327}
328
329/// List available native plugins.
330///
331/// Returns an array of `PluginInfo` objects with name and description.
332#[cfg(feature = "plugins")]
333#[wasm_bindgen(js_name = "listPlugins")]
334pub fn list_plugins() -> Result<JsValue, JsError> {
335    use rustledger_plugin::NativePluginRegistry;
336
337    let registry = NativePluginRegistry::new();
338    let plugins: Vec<PluginInfo> = registry
339        .list()
340        .iter()
341        .map(|p| PluginInfo {
342            name: p.name().to_string(),
343            description: p.description().to_string(),
344        })
345        .collect();
346
347    to_js(&plugins)
348}
349
350/// Calculate account balances.
351///
352/// Shorthand for `query(source, "BALANCES")`.
353#[wasm_bindgen]
354pub fn balances(source: &str) -> Result<JsValue, JsError> {
355    query(source, "BALANCES")
356}
357
358/// Get BQL query completions at cursor position.
359///
360/// Returns context-aware completions for the BQL query language.
361#[cfg(feature = "completions")]
362#[wasm_bindgen(js_name = "bqlCompletions")]
363pub fn bql_completions(partial_query: &str, cursor_pos: usize) -> Result<JsValue, JsError> {
364    use rustledger_query::completions;
365
366    let result = completions::complete(partial_query, cursor_pos);
367
368    let json_result = CompletionResultJson {
369        completions: result
370            .completions
371            .into_iter()
372            .map(|c| CompletionJson {
373                text: c.text,
374                category: c.category.as_str().to_string(),
375                description: c.description,
376            })
377            .collect(),
378        context: format!("{:?}", result.context),
379    };
380
381    to_js(&json_result)
382}
383
384/// Parse multiple Beancount files with include resolution.
385///
386/// This function accepts a map of file paths to file contents and an entry point,
387/// resolving `include` directives across the files. This enables multi-file ledgers
388/// in WASM environments where filesystem access is not available.
389///
390/// # Arguments
391///
392/// * `files` - A JavaScript object mapping file paths to their contents.
393///   Example: `{ "main.beancount": "include \"accounts.beancount\"", "accounts.beancount": "..." }`
394/// * `entry_point` - The main file to start loading from (must exist in `files`).
395///
396/// # Returns
397///
398/// A `ParseResult` with the parsed ledger from all files and any errors.
399///
400/// # Example (JavaScript)
401///
402/// ```javascript
403/// const result = parseMultiFile({
404///   "main.beancount": `
405///     include "accounts.beancount"
406///     2024-01-15 * "Coffee"
407///       Expenses:Food  5.00 USD
408///       Assets:Bank
409///   `,
410///   "accounts.beancount": `
411///     2024-01-01 open Assets:Bank USD
412///     2024-01-01 open Expenses:Food USD
413///   `
414/// }, "main.beancount");
415/// ```
416#[wasm_bindgen(js_name = "parseMultiFile")]
417pub fn parse_multi_file(files: JsValue, entry_point: &str) -> Result<JsValue, JsError> {
418    use rustledger_booking::interpolate;
419    use rustledger_loader::{Loader, VirtualFileSystem};
420
421    // Parse the JavaScript object to a HashMap
422    let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
423        .map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
424
425    if file_map.is_empty() {
426        return Err(JsError::new("Files map cannot be empty"));
427    }
428
429    // Create virtual filesystem with all files
430    let vfs = VirtualFileSystem::from_files(file_map);
431
432    // Check entry point exists using VFS path normalization
433    if !vfs.exists(Path::new(entry_point)) {
434        return Err(JsError::new(&format!(
435            "Entry point '{entry_point}' not found in files map"
436        )));
437    }
438
439    // Create loader with virtual filesystem
440    let mut loader = Loader::new().with_filesystem(Box::new(vfs));
441
442    // Load from entry point
443    let load_result = match loader.load(Path::new(entry_point)) {
444        Ok(result) => result,
445        Err(e) => {
446            let result = ParseResult {
447                ledger: None,
448                errors: vec![Error::new(format!("Load error: {e}"))],
449            };
450            return to_js(&result);
451        }
452    };
453
454    // Collect load errors with detailed parse error info
455    let mut errors = load_errors_to_errors(&load_result);
456
457    // Extract options from loader options
458    let options = crate::types::LedgerOptions {
459        title: load_result.options.title.clone(),
460        operating_currencies: load_result.options.operating_currency.clone(),
461    };
462
463    // Extract and interpolate directives
464    let mut directives: Vec<Directive> = load_result
465        .directives
466        .into_iter()
467        .map(|s| s.value)
468        .collect();
469
470    // Interpolate transactions (fill in missing amounts)
471    if errors.is_empty() {
472        for directive in &mut directives {
473            if let Directive::Transaction(txn) = directive {
474                match interpolate(txn) {
475                    Ok(result) => {
476                        *txn = result.transaction;
477                    }
478                    Err(e) => {
479                        errors.push(Error::new(e.to_string()));
480                    }
481                }
482            }
483        }
484    }
485
486    let ledger = Some(Ledger {
487        directives: directives.iter().map(directive_to_json).collect(),
488        options,
489    });
490
491    let result = ParseResult { ledger, errors };
492    to_js(&result)
493}
494
495/// Validate multiple Beancount files with include resolution.
496///
497/// Similar to `parseMultiFile`, but also runs validation.
498/// Returns a `ValidationResult` indicating whether the ledger is valid.
499#[wasm_bindgen(js_name = "validateMultiFile")]
500pub fn validate_multi_file(files: JsValue, entry_point: &str) -> Result<JsValue, JsError> {
501    use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
502
503    // Parse the JavaScript object to a HashMap
504    let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
505        .map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
506
507    if file_map.is_empty() {
508        return Err(JsError::new("Files map cannot be empty"));
509    }
510
511    // Create virtual filesystem with all files
512    let vfs = VirtualFileSystem::from_files(file_map);
513
514    // Check entry point exists using VFS path normalization
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    // Create loader with virtual filesystem
522    let mut loader = Loader::new().with_filesystem(Box::new(vfs));
523
524    // Load from entry point
525    let load_result = match loader.load(Path::new(entry_point)) {
526        Ok(result) => result,
527        Err(e) => {
528            let result = ValidationResult {
529                valid: false,
530                errors: vec![Error::new(format!("Load error: {e}"))],
531            };
532            return to_js(&result);
533        }
534    };
535
536    // Check for parse errors first (preserves detailed per-error line info)
537    let parse_errors = load_errors_to_errors(&load_result);
538    if !parse_errors.is_empty() {
539        let result = ValidationResult {
540            valid: false,
541            errors: parse_errors,
542        };
543        return to_js(&result);
544    }
545
546    // Run the shared processing pipeline: sort → book → plugins → validate
547    let options = LoadOptions {
548        validate: true,
549        ..Default::default()
550    };
551
552    let ledger = match process(load_result, &options) {
553        Ok(ledger) => ledger,
554        Err(e) => {
555            let result = ValidationResult {
556                valid: false,
557                errors: vec![Error::new(format!("Processing error: {e}"))],
558            };
559            return to_js(&result);
560        }
561    };
562
563    let errors: Vec<Error> = ledger.errors.into_iter().map(Error::from).collect();
564
565    let result = ValidationResult {
566        valid: errors.is_empty(),
567        errors,
568    };
569    to_js(&result)
570}
571
572/// Run a BQL query on multiple Beancount files.
573///
574/// Similar to `query`, but accepts multiple files with include resolution.
575///
576/// Note: Glob patterns in `include` directives are not supported in multi-file mode
577/// since there is no real filesystem to enumerate. Use explicit file paths instead.
578#[wasm_bindgen(js_name = "queryMultiFile")]
579pub fn query_multi_file(
580    files: JsValue,
581    entry_point: &str,
582    query_str: &str,
583) -> Result<JsValue, JsError> {
584    use rustledger_booking::expand_pads;
585    use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
586    use rustledger_query::{Executor, parse as parse_query};
587
588    // Parse the JavaScript object to a HashMap
589    let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
590        .map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
591
592    if file_map.is_empty() {
593        return Err(JsError::new("Files map cannot be empty"));
594    }
595
596    // Create virtual filesystem with all files
597    let vfs = VirtualFileSystem::from_files(file_map);
598
599    // Check entry point exists using VFS path normalization
600    if !vfs.exists(Path::new(entry_point)) {
601        return Err(JsError::new(&format!(
602            "Entry point '{entry_point}' not found in files map"
603        )));
604    }
605
606    // Create loader with virtual filesystem
607    let mut loader = Loader::new().with_filesystem(Box::new(vfs));
608
609    // Load from entry point
610    let load_result = match loader.load(Path::new(entry_point)) {
611        Ok(result) => result,
612        Err(e) => {
613            let result = QueryResult {
614                columns: Vec::new(),
615                rows: Vec::new(),
616                errors: vec![Error::new(format!("Load error: {e}"))],
617            };
618            return to_js(&result);
619        }
620    };
621
622    // Check for parse errors first (preserves detailed per-error line info)
623    let parse_errors = load_errors_to_errors(&load_result);
624    if !parse_errors.is_empty() {
625        let result = QueryResult {
626            columns: Vec::new(),
627            rows: Vec::new(),
628            errors: parse_errors,
629        };
630        return to_js(&result);
631    }
632
633    // Run the shared processing pipeline: sort → book → plugins (no validation for queries)
634    let options = LoadOptions {
635        validate: false,
636        ..Default::default()
637    };
638
639    let ledger = match process(load_result, &options) {
640        Ok(ledger) => ledger,
641        Err(e) => {
642            let result = QueryResult {
643                columns: Vec::new(),
644                rows: Vec::new(),
645                errors: vec![Error::new(format!("Processing error: {e}"))],
646            };
647            return to_js(&result);
648        }
649    };
650
651    // Only abort on actual errors, not warnings (matching CLI query behavior)
652    let errors: Vec<Error> = ledger.errors.into_iter().map(Error::from).collect();
653    let has_errors = errors.iter().any(|e| e.severity == Severity::Error);
654    if has_errors {
655        let result = QueryResult {
656            columns: Vec::new(),
657            rows: Vec::new(),
658            errors,
659        };
660        return to_js(&result);
661    }
662
663    // Expand pads into synthetic transactions (matching CLI query pipeline)
664    let booked_directives: Vec<_> = ledger.directives.into_iter().map(|s| s.value).collect();
665    let directives = expand_pads(&booked_directives);
666
667    // Parse the query
668    let query = match parse_query(query_str) {
669        Ok(q) => q,
670        Err(e) => {
671            let result = QueryResult {
672                columns: Vec::new(),
673                rows: Vec::new(),
674                errors: vec![Error::new(e.to_string())],
675            };
676            return to_js(&result);
677        }
678    };
679
680    // Execute query
681    let mut executor = Executor::new(&directives);
682    match executor.execute(&query) {
683        Ok(result) => {
684            let rows: Vec<Vec<_>> = result
685                .rows
686                .iter()
687                .map(|row| row.iter().map(value_to_cell).collect())
688                .collect();
689
690            let query_result = QueryResult {
691                columns: result.columns,
692                rows,
693                errors: Vec::new(),
694            };
695            to_js(&query_result)
696        }
697        Err(e) => {
698            let result = QueryResult {
699                columns: Vec::new(),
700                rows: Vec::new(),
701                errors: vec![Error::new(format!("Query execution error: {e}"))],
702            };
703            to_js(&result)
704        }
705    }
706}
707
708/// Compute a SHA-256 fingerprint of one or more source strings.
709///
710/// Returns the fingerprint as a lowercase hex string. Store this value
711/// alongside serialized ledger bytes and compare on subsequent loads to
712/// detect whether the source has changed.
713///
714/// Each string is separated by a NUL byte before hashing so that
715/// `["ab", "c"]` produces a different fingerprint from `["a", "bc"]`.
716///
717/// The fingerprint is order-sensitive: `["a", "b"]` hashes differently
718/// from `["b", "a"]`. Callers using an unordered collection should sort
719/// by filename first for deterministic results.
720#[wasm_bindgen(js_name = "hashSources")]
721#[allow(clippy::needless_pass_by_value)] // wasm-bindgen requires owned Vec<String>
722pub fn hash_sources(sources: Vec<String>) -> String {
723    let refs: Vec<&str> = sources.iter().map(String::as_str).collect();
724    crate::cache::hash_sources(&refs)
725}