1use 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::{
15 extract_options, has_fatal, load_and_book, parse_error_to_wasm, run_validation, to_js,
16};
17#[cfg(feature = "completions")]
18use crate::types::{CompletionJson, CompletionResultJson};
19use crate::types::{
20 Error, FormatResult, Ledger, PadResult, ParseResult, QueryResult, Severity, ValidationResult,
21};
22#[cfg(feature = "plugins")]
23use crate::types::{PluginInfo, PluginResult};
24use crate::utils::LineLookup;
25
26fn load_errors_to_errors(load_result: &LoadResult) -> Vec<Error> {
30 let mut errors = Vec::new();
31
32 for load_error in &load_result.errors {
33 match load_error {
34 LoadError::ParseErrors {
35 path,
36 errors: parse_errors,
37 } => {
38 for parse_error in parse_errors {
41 let span = parse_error.span();
42 let file = load_result.source_map.get_by_path(path);
43 let mut err = Error::new(format!("{}: {}", path.display(), parse_error))
44 .with_code(format!("P{:04}", parse_error.kind_code()))
45 .with_phase("parse")
46 .with_hint(parse_error.hint.clone())
47 .with_file(Some(path.display().to_string()));
48 if let Some(file) = file {
49 let (sl, sc) = file.line_col(span.0);
50 let (el, ec) = file.line_col(span.1);
51 err = err.with_span((sl as u32, sc as u32), (el as u32, ec as u32));
52 }
53 errors.push(err);
54 }
55 }
56 other => {
57 errors.push(Error::new(other.to_string()));
59 }
60 }
61 }
62
63 errors
64}
65
66#[wasm_bindgen]
70pub fn parse(source: &str) -> Result<JsValue, JsError> {
71 let result = parse_beancount(source);
72 let lookup = LineLookup::new(source);
73
74 let errors: Vec<Error> = result
75 .errors
76 .iter()
77 .map(|e| parse_error_to_wasm(e, &lookup, None))
78 .collect();
79
80 let options = extract_options(&result.options);
82
83 let ledger = Some(Ledger {
84 directives: result
85 .directives
86 .iter()
87 .map(|spanned| directive_to_json(&spanned.value))
88 .collect(),
89 options,
90 });
91
92 let parse_result = ParseResult { ledger, errors };
93 to_js(&parse_result)
94}
95
96#[wasm_bindgen(js_name = "validateSource")]
101pub fn validate_source(source: &str) -> Result<JsValue, JsError> {
102 let load = load_and_book(source);
103 let validation_errors = run_validation(&load);
104 let mut errors = load.errors;
105 errors.extend(validation_errors);
106
107 let result = ValidationResult {
108 valid: !has_fatal(&errors),
111 errors,
112 };
113 to_js(&result)
114}
115
116#[wasm_bindgen]
121pub fn query(source: &str, query_str: &str) -> Result<JsValue, JsError> {
122 use rustledger_booking::merge_with_padding;
123 use rustledger_query::{Executor, parse as parse_query};
124
125 let load = load_and_book(source);
126
127 if has_fatal(&load.errors) {
130 let result = QueryResult {
131 columns: Vec::new(),
132 rows: Vec::new(),
133 errors: load.errors,
134 };
135 return to_js(&result);
136 }
137
138 let warnings = load.errors;
141
142 let query = match parse_query(query_str) {
144 Ok(q) => q,
145 Err(e) => {
146 let mut errors = warnings;
147 errors.push(Error::new(e.to_string()));
148 let result = QueryResult {
149 columns: Vec::new(),
150 rows: Vec::new(),
151 errors,
152 };
153 return to_js(&result);
154 }
155 };
156
157 let directives = merge_with_padding(&load.directives);
163 let mut executor = Executor::new(&directives);
164 executor.set_account_types(crate::helpers::account_types_from_raw(
167 &load.parse_result.options,
168 ));
169 match executor.execute(&query) {
170 Ok(result) => {
171 let rows: Vec<Vec<_>> = result
172 .rows
173 .iter()
174 .map(|row| row.iter().map(value_to_cell).collect())
175 .collect();
176
177 let query_result = QueryResult {
178 columns: result.columns,
179 rows,
180 errors: warnings,
181 };
182 to_js(&query_result)
183 }
184 Err(e) => {
185 let mut errors = warnings;
186 errors.push(Error::new(format!("Query execution error: {e}")));
187 let result = QueryResult {
188 columns: Vec::new(),
189 rows: Vec::new(),
190 errors,
191 };
192 to_js(&result)
193 }
194 }
195}
196
197#[wasm_bindgen]
201pub fn version() -> String {
202 env!("CARGO_PKG_VERSION").to_string()
203}
204
205#[wasm_bindgen]
210pub fn format(source: &str) -> Result<JsValue, JsError> {
211 use rustledger_parser::format::format_source_with_parsed;
212
213 let parse_result = parse_beancount(source);
214 let lookup = LineLookup::new(source);
215
216 if !parse_result.errors.is_empty() {
217 let result = FormatResult {
218 formatted: None,
219 errors: parse_result
220 .errors
221 .iter()
222 .map(|e| parse_error_to_wasm(e, &lookup, None))
223 .collect(),
224 };
225 return to_js(&result);
226 }
227
228 let formatted = format_source_with_parsed(&parse_result, source);
232
233 let result = FormatResult {
234 formatted: Some(formatted),
235 errors: Vec::new(),
236 };
237 to_js(&result)
238}
239
240#[wasm_bindgen(js_name = "expandPads")]
244pub fn expand_pads(source: &str) -> Result<JsValue, JsError> {
245 use rustledger_booking::process_pads;
246
247 let load = load_and_book(source);
248
249 if has_fatal(&load.errors) {
252 let result = PadResult {
253 directives: Vec::new(),
254 padding_transactions: Vec::new(),
255 errors: load.errors,
256 };
257 return to_js(&result);
258 }
259
260 let mut errors = load.errors;
262
263 let pad_result = process_pads(&load.directives);
265 errors.extend(
266 pad_result
267 .errors
268 .iter()
269 .map(|e| Error::new(e.message.clone())),
270 );
271
272 let result = PadResult {
273 directives: load.directives.iter().map(directive_to_json).collect(),
277 padding_transactions: pad_result
278 .padding_transactions
279 .iter()
280 .map(|txn| directive_to_json(&Directive::Transaction(txn.clone())))
281 .collect(),
282 errors,
283 };
284 to_js(&result)
285}
286
287#[cfg(feature = "plugins")]
292pub fn materialize_plugin_ops(
293 input: &[rustledger_plugin::types::DirectiveWrapper],
294 output: &rustledger_plugin::types::PluginOutput,
295) -> Vec<rustledger_plugin::types::DirectiveWrapper> {
296 let mut out = Vec::with_capacity(output.ops.len());
297 for op in &output.ops {
298 match op {
299 rustledger_plugin::PluginOp::Keep(i) => {
300 if let Some(w) = input.get(*i) {
301 out.push(w.clone());
302 }
303 }
304 rustledger_plugin::PluginOp::Modify(_, w) | rustledger_plugin::PluginOp::Insert(w) => {
305 out.push(w.clone());
306 }
307 rustledger_plugin::PluginOp::Delete(_) => {}
308 }
309 }
310 out
311}
312
313#[cfg(feature = "plugins")]
316#[wasm_bindgen(js_name = "runPlugin")]
317pub fn run_plugin(source: &str, plugin_name: &str) -> Result<JsValue, JsError> {
318 use rustledger_plugin::{
319 NativePluginRegistry, PluginInput, PluginOptions, directives_to_wrappers,
320 wrappers_to_directives,
321 };
322
323 let load = load_and_book(source);
324
325 if has_fatal(&load.errors) {
328 let result = PluginResult {
329 directives: Vec::new(),
330 errors: load.errors,
331 };
332 return to_js(&result);
333 }
334
335 let warnings = load.errors;
337
338 let registry = NativePluginRegistry::global();
340 let Some(plugin) = registry.find_regular(plugin_name) else {
344 let mut errors = warnings;
345 errors.push(Error::new(format!("Unknown plugin: {plugin_name}")));
346 let result = PluginResult {
347 directives: Vec::new(),
348 errors,
349 };
350 return to_js(&result);
351 };
352
353 let wrappers = directives_to_wrappers(&load.directives);
355 let input = PluginInput {
356 directives: wrappers,
357 options: PluginOptions::default(),
358 config: None,
359 };
360
361 let input_dirs = input.directives.clone();
362 let output = plugin.process(input);
363
364 let materialized_wrappers = materialize_plugin_ops(&input_dirs, &output);
366 let output_directives = match wrappers_to_directives(&materialized_wrappers) {
367 Ok(dirs) => dirs,
368 Err(e) => {
369 let mut errors = warnings;
370 errors.push(Error::new(format!("Conversion error: {e}")));
371 let result = PluginResult {
372 directives: Vec::new(),
373 errors,
374 };
375 return to_js(&result);
376 }
377 };
378
379 let mut errors = warnings;
380 errors.extend(output.errors.iter().map(|e| match e.severity {
381 rustledger_plugin::PluginErrorSeverity::Warning => Error::warning(e.message.clone()),
382 rustledger_plugin::PluginErrorSeverity::Error => Error::new(e.message.clone()),
383 }));
384 let result = PluginResult {
385 directives: output_directives.iter().map(directive_to_json).collect(),
386 errors,
387 };
388 to_js(&result)
389}
390
391#[cfg(feature = "plugins")]
395#[wasm_bindgen(js_name = "listPlugins")]
396pub fn list_plugins() -> Result<JsValue, JsError> {
397 use rustledger_plugin::NativePluginRegistry;
398
399 let registry = NativePluginRegistry::global();
400 let plugins: Vec<PluginInfo> = registry
401 .iter()
402 .map(|p| PluginInfo {
403 name: p.name().to_string(),
404 description: p.description().to_string(),
405 })
406 .collect();
407
408 to_js(&plugins)
409}
410
411#[wasm_bindgen]
415pub fn balances(source: &str) -> Result<JsValue, JsError> {
416 query(source, "BALANCES")
417}
418
419#[cfg(feature = "completions")]
423#[wasm_bindgen(js_name = "bqlCompletions")]
424pub fn bql_completions(partial_query: &str, cursor_pos: usize) -> Result<JsValue, JsError> {
425 use rustledger_query::completions;
426
427 let result = completions::complete(partial_query, cursor_pos);
428
429 let json_result = CompletionResultJson {
430 completions: result
431 .completions
432 .into_iter()
433 .map(|c| CompletionJson {
434 text: c.text,
435 category: c.category.as_str().to_string(),
436 description: c.description,
437 })
438 .collect(),
439 context: format!("{:?}", result.context),
440 };
441
442 to_js(&json_result)
443}
444
445#[wasm_bindgen(js_name = "parseMultiFile")]
486pub fn parse_multi_file(files: JsValue, entry_point: &str) -> Result<JsValue, JsError> {
487 use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
488
489 let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
491 .map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
492
493 if file_map.is_empty() {
494 return Err(JsError::new("Files map cannot be empty"));
495 }
496
497 let vfs = VirtualFileSystem::from_files(file_map);
499
500 if !vfs.exists(Path::new(entry_point)) {
502 return Err(JsError::new(&format!(
503 "Entry point '{entry_point}' not found in files map"
504 )));
505 }
506
507 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
509
510 let load_result = match loader.load(Path::new(entry_point)) {
512 Ok(result) => result,
513 Err(e) => {
514 let result = ParseResult {
515 ledger: None,
516 errors: vec![Error::new(format!("Load error: {e}"))],
517 };
518 return to_js(&result);
519 }
520 };
521
522 let mut errors = load_errors_to_errors(&load_result);
524
525 let options = crate::types::LedgerOptions {
527 title: load_result.options.title.clone(),
528 operating_currencies: load_result.options.operating_currency.clone(),
529 };
530
531 let directives: Vec<Directive> = if errors.is_empty() {
543 let process_options = LoadOptions {
544 validate: false,
545 ..Default::default()
546 };
547 match process(load_result, &process_options) {
548 Ok(ledger) => {
549 errors.extend(ledger.errors.into_iter().map(Error::from));
550 ledger.directives.into_iter().map(|s| s.value).collect()
551 }
552 Err(e) => {
553 let result = ParseResult {
554 ledger: None,
555 errors: vec![Error::new(format!("Processing error: {e}"))],
556 };
557 return to_js(&result);
558 }
559 }
560 } else {
561 load_result
562 .directives
563 .into_iter()
564 .map(|s| s.value)
565 .collect()
566 };
567
568 let ledger = Some(Ledger {
569 directives: directives.iter().map(directive_to_json).collect(),
570 options,
571 });
572
573 let result = ParseResult { ledger, errors };
574 to_js(&result)
575}
576
577#[wasm_bindgen(js_name = "validateMultiFile")]
582pub fn validate_multi_file(files: JsValue, entry_point: &str) -> Result<JsValue, JsError> {
583 use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
584
585 let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
587 .map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
588
589 if file_map.is_empty() {
590 return Err(JsError::new("Files map cannot be empty"));
591 }
592
593 let vfs = VirtualFileSystem::from_files(file_map);
595
596 if !vfs.exists(Path::new(entry_point)) {
598 return Err(JsError::new(&format!(
599 "Entry point '{entry_point}' not found in files map"
600 )));
601 }
602
603 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
605
606 let load_result = match loader.load(Path::new(entry_point)) {
608 Ok(result) => result,
609 Err(e) => {
610 let result = ValidationResult {
611 valid: false,
612 errors: vec![Error::new(format!("Load error: {e}"))],
613 };
614 return to_js(&result);
615 }
616 };
617
618 let parse_errors = load_errors_to_errors(&load_result);
620 if !parse_errors.is_empty() {
621 let result = ValidationResult {
622 valid: false,
623 errors: parse_errors,
624 };
625 return to_js(&result);
626 }
627
628 let options = LoadOptions {
631 validate: true,
632 ..Default::default()
633 };
634
635 let ledger = match process(load_result, &options) {
636 Ok(ledger) => ledger,
637 Err(e) => {
638 let result = ValidationResult {
639 valid: false,
640 errors: vec![Error::new(format!("Processing error: {e}"))],
641 };
642 return to_js(&result);
643 }
644 };
645
646 let errors: Vec<Error> = ledger.errors.into_iter().map(Error::from).collect();
647
648 let result = ValidationResult {
649 valid: !has_fatal(&errors),
651 errors,
652 };
653 to_js(&result)
654}
655
656#[wasm_bindgen(js_name = "queryMultiFile")]
663pub fn query_multi_file(
664 files: JsValue,
665 entry_point: &str,
666 query_str: &str,
667) -> Result<JsValue, JsError> {
668 use rustledger_booking::merge_with_padding;
669 use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
670 use rustledger_query::{Executor, parse as parse_query};
671
672 let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
674 .map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
675
676 if file_map.is_empty() {
677 return Err(JsError::new("Files map cannot be empty"));
678 }
679
680 let vfs = VirtualFileSystem::from_files(file_map);
682
683 if !vfs.exists(Path::new(entry_point)) {
685 return Err(JsError::new(&format!(
686 "Entry point '{entry_point}' not found in files map"
687 )));
688 }
689
690 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
692
693 let load_result = match loader.load(Path::new(entry_point)) {
695 Ok(result) => result,
696 Err(e) => {
697 let result = QueryResult {
698 columns: Vec::new(),
699 rows: Vec::new(),
700 errors: vec![Error::new(format!("Load error: {e}"))],
701 };
702 return to_js(&result);
703 }
704 };
705
706 let parse_errors = load_errors_to_errors(&load_result);
708 if !parse_errors.is_empty() {
709 let result = QueryResult {
710 columns: Vec::new(),
711 rows: Vec::new(),
712 errors: parse_errors,
713 };
714 return to_js(&result);
715 }
716
717 let options = LoadOptions {
720 validate: false,
721 ..Default::default()
722 };
723
724 let ledger = match process(load_result, &options) {
725 Ok(ledger) => ledger,
726 Err(e) => {
727 let result = QueryResult {
728 columns: Vec::new(),
729 rows: Vec::new(),
730 errors: vec![Error::new(format!("Processing error: {e}"))],
731 };
732 return to_js(&result);
733 }
734 };
735
736 let errors: Vec<Error> = ledger.errors.into_iter().map(Error::from).collect();
738 let has_errors = errors.iter().any(|e| e.severity == Severity::Error);
739 if has_errors {
740 let result = QueryResult {
741 columns: Vec::new(),
742 rows: Vec::new(),
743 errors,
744 };
745 return to_js(&result);
746 }
747
748 let account_types = ledger.options.to_account_types();
753 let booked_directives: Vec<_> = ledger.directives.into_iter().map(|s| s.value).collect();
754 let directives = merge_with_padding(&booked_directives);
755
756 let query = match parse_query(query_str) {
758 Ok(q) => q,
759 Err(e) => {
760 let result = QueryResult {
761 columns: Vec::new(),
762 rows: Vec::new(),
763 errors: vec![Error::new(e.to_string())],
764 };
765 return to_js(&result);
766 }
767 };
768
769 let mut executor = Executor::new(&directives);
771 executor.set_account_types(account_types);
772 match executor.execute(&query) {
773 Ok(result) => {
774 let rows: Vec<Vec<_>> = result
775 .rows
776 .iter()
777 .map(|row| row.iter().map(value_to_cell).collect())
778 .collect();
779
780 let query_result = QueryResult {
781 columns: result.columns,
782 rows,
783 errors: Vec::new(),
784 };
785 to_js(&query_result)
786 }
787 Err(e) => {
788 let result = QueryResult {
789 columns: Vec::new(),
790 rows: Vec::new(),
791 errors: vec![Error::new(format!("Query execution error: {e}"))],
792 };
793 to_js(&result)
794 }
795 }
796}
797
798#[wasm_bindgen(js_name = "hashSources")]
811#[allow(clippy::needless_pass_by_value)] pub fn hash_sources(sources: Vec<String>) -> String {
813 let refs: Vec<&str> = sources.iter().map(String::as_str).collect();
814 crate::cache::hash_sources(&refs)
815}