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 match executor.execute(&query) {
165 Ok(result) => {
166 let rows: Vec<Vec<_>> = result
167 .rows
168 .iter()
169 .map(|row| row.iter().map(value_to_cell).collect())
170 .collect();
171
172 let query_result = QueryResult {
173 columns: result.columns,
174 rows,
175 errors: warnings,
176 };
177 to_js(&query_result)
178 }
179 Err(e) => {
180 let mut errors = warnings;
181 errors.push(Error::new(format!("Query execution error: {e}")));
182 let result = QueryResult {
183 columns: Vec::new(),
184 rows: Vec::new(),
185 errors,
186 };
187 to_js(&result)
188 }
189 }
190}
191
192#[wasm_bindgen]
196pub fn version() -> String {
197 env!("CARGO_PKG_VERSION").to_string()
198}
199
200#[wasm_bindgen]
205pub fn format(source: &str) -> Result<JsValue, JsError> {
206 use rustledger_parser::format::format_source_with_parsed;
207
208 let parse_result = parse_beancount(source);
209 let lookup = LineLookup::new(source);
210
211 if !parse_result.errors.is_empty() {
212 let result = FormatResult {
213 formatted: None,
214 errors: parse_result
215 .errors
216 .iter()
217 .map(|e| parse_error_to_wasm(e, &lookup, None))
218 .collect(),
219 };
220 return to_js(&result);
221 }
222
223 let formatted = format_source_with_parsed(&parse_result, source);
227
228 let result = FormatResult {
229 formatted: Some(formatted),
230 errors: Vec::new(),
231 };
232 to_js(&result)
233}
234
235#[wasm_bindgen(js_name = "expandPads")]
239pub fn expand_pads(source: &str) -> Result<JsValue, JsError> {
240 use rustledger_booking::process_pads;
241
242 let load = load_and_book(source);
243
244 if has_fatal(&load.errors) {
247 let result = PadResult {
248 directives: Vec::new(),
249 padding_transactions: Vec::new(),
250 errors: load.errors,
251 };
252 return to_js(&result);
253 }
254
255 let mut errors = load.errors;
257
258 let pad_result = process_pads(&load.directives);
260 errors.extend(
261 pad_result
262 .errors
263 .iter()
264 .map(|e| Error::new(e.message.clone())),
265 );
266
267 let result = PadResult {
268 directives: load.directives.iter().map(directive_to_json).collect(),
272 padding_transactions: pad_result
273 .padding_transactions
274 .iter()
275 .map(|txn| directive_to_json(&Directive::Transaction(txn.clone())))
276 .collect(),
277 errors,
278 };
279 to_js(&result)
280}
281
282#[cfg(feature = "plugins")]
287pub fn materialize_plugin_ops(
288 input: &[rustledger_plugin::types::DirectiveWrapper],
289 output: &rustledger_plugin::types::PluginOutput,
290) -> Vec<rustledger_plugin::types::DirectiveWrapper> {
291 let mut out = Vec::with_capacity(output.ops.len());
292 for op in &output.ops {
293 match op {
294 rustledger_plugin::PluginOp::Keep(i) => {
295 if let Some(w) = input.get(*i) {
296 out.push(w.clone());
297 }
298 }
299 rustledger_plugin::PluginOp::Modify(_, w) | rustledger_plugin::PluginOp::Insert(w) => {
300 out.push(w.clone());
301 }
302 rustledger_plugin::PluginOp::Delete(_) => {}
303 }
304 }
305 out
306}
307
308#[cfg(feature = "plugins")]
311#[wasm_bindgen(js_name = "runPlugin")]
312pub fn run_plugin(source: &str, plugin_name: &str) -> Result<JsValue, JsError> {
313 use rustledger_plugin::{
314 NativePluginRegistry, PluginInput, PluginOptions, directives_to_wrappers,
315 wrappers_to_directives,
316 };
317
318 let load = load_and_book(source);
319
320 if has_fatal(&load.errors) {
323 let result = PluginResult {
324 directives: Vec::new(),
325 errors: load.errors,
326 };
327 return to_js(&result);
328 }
329
330 let warnings = load.errors;
332
333 let registry = NativePluginRegistry::global();
335 let Some(plugin) = registry.find_regular(plugin_name) else {
339 let mut errors = warnings;
340 errors.push(Error::new(format!("Unknown plugin: {plugin_name}")));
341 let result = PluginResult {
342 directives: Vec::new(),
343 errors,
344 };
345 return to_js(&result);
346 };
347
348 let wrappers = directives_to_wrappers(&load.directives);
350 let input = PluginInput {
351 directives: wrappers,
352 options: PluginOptions::default(),
353 config: None,
354 };
355
356 let input_dirs = input.directives.clone();
357 let output = plugin.process(input);
358
359 let materialized_wrappers = materialize_plugin_ops(&input_dirs, &output);
361 let output_directives = match wrappers_to_directives(&materialized_wrappers) {
362 Ok(dirs) => dirs,
363 Err(e) => {
364 let mut errors = warnings;
365 errors.push(Error::new(format!("Conversion error: {e}")));
366 let result = PluginResult {
367 directives: Vec::new(),
368 errors,
369 };
370 return to_js(&result);
371 }
372 };
373
374 let mut errors = warnings;
375 errors.extend(output.errors.iter().map(|e| match e.severity {
376 rustledger_plugin::PluginErrorSeverity::Warning => Error::warning(e.message.clone()),
377 rustledger_plugin::PluginErrorSeverity::Error => Error::new(e.message.clone()),
378 }));
379 let result = PluginResult {
380 directives: output_directives.iter().map(directive_to_json).collect(),
381 errors,
382 };
383 to_js(&result)
384}
385
386#[cfg(feature = "plugins")]
390#[wasm_bindgen(js_name = "listPlugins")]
391pub fn list_plugins() -> Result<JsValue, JsError> {
392 use rustledger_plugin::NativePluginRegistry;
393
394 let registry = NativePluginRegistry::global();
395 let plugins: Vec<PluginInfo> = registry
396 .iter()
397 .map(|p| PluginInfo {
398 name: p.name().to_string(),
399 description: p.description().to_string(),
400 })
401 .collect();
402
403 to_js(&plugins)
404}
405
406#[wasm_bindgen]
410pub fn balances(source: &str) -> Result<JsValue, JsError> {
411 query(source, "BALANCES")
412}
413
414#[cfg(feature = "completions")]
418#[wasm_bindgen(js_name = "bqlCompletions")]
419pub fn bql_completions(partial_query: &str, cursor_pos: usize) -> Result<JsValue, JsError> {
420 use rustledger_query::completions;
421
422 let result = completions::complete(partial_query, cursor_pos);
423
424 let json_result = CompletionResultJson {
425 completions: result
426 .completions
427 .into_iter()
428 .map(|c| CompletionJson {
429 text: c.text,
430 category: c.category.as_str().to_string(),
431 description: c.description,
432 })
433 .collect(),
434 context: format!("{:?}", result.context),
435 };
436
437 to_js(&json_result)
438}
439
440#[wasm_bindgen(js_name = "parseMultiFile")]
481pub fn parse_multi_file(files: JsValue, entry_point: &str) -> Result<JsValue, JsError> {
482 use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
483
484 let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
486 .map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
487
488 if file_map.is_empty() {
489 return Err(JsError::new("Files map cannot be empty"));
490 }
491
492 let vfs = VirtualFileSystem::from_files(file_map);
494
495 if !vfs.exists(Path::new(entry_point)) {
497 return Err(JsError::new(&format!(
498 "Entry point '{entry_point}' not found in files map"
499 )));
500 }
501
502 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
504
505 let load_result = match loader.load(Path::new(entry_point)) {
507 Ok(result) => result,
508 Err(e) => {
509 let result = ParseResult {
510 ledger: None,
511 errors: vec![Error::new(format!("Load error: {e}"))],
512 };
513 return to_js(&result);
514 }
515 };
516
517 let mut errors = load_errors_to_errors(&load_result);
519
520 let options = crate::types::LedgerOptions {
522 title: load_result.options.title.clone(),
523 operating_currencies: load_result.options.operating_currency.clone(),
524 };
525
526 let directives: Vec<Directive> = if errors.is_empty() {
538 let process_options = LoadOptions {
539 validate: false,
540 ..Default::default()
541 };
542 match process(load_result, &process_options) {
543 Ok(ledger) => {
544 errors.extend(ledger.errors.into_iter().map(Error::from));
545 ledger.directives.into_iter().map(|s| s.value).collect()
546 }
547 Err(e) => {
548 let result = ParseResult {
549 ledger: None,
550 errors: vec![Error::new(format!("Processing error: {e}"))],
551 };
552 return to_js(&result);
553 }
554 }
555 } else {
556 load_result
557 .directives
558 .into_iter()
559 .map(|s| s.value)
560 .collect()
561 };
562
563 let ledger = Some(Ledger {
564 directives: directives.iter().map(directive_to_json).collect(),
565 options,
566 });
567
568 let result = ParseResult { ledger, errors };
569 to_js(&result)
570}
571
572#[wasm_bindgen(js_name = "validateMultiFile")]
577pub fn validate_multi_file(files: JsValue, entry_point: &str) -> Result<JsValue, JsError> {
578 use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
579
580 let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
582 .map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
583
584 if file_map.is_empty() {
585 return Err(JsError::new("Files map cannot be empty"));
586 }
587
588 let vfs = VirtualFileSystem::from_files(file_map);
590
591 if !vfs.exists(Path::new(entry_point)) {
593 return Err(JsError::new(&format!(
594 "Entry point '{entry_point}' not found in files map"
595 )));
596 }
597
598 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
600
601 let load_result = match loader.load(Path::new(entry_point)) {
603 Ok(result) => result,
604 Err(e) => {
605 let result = ValidationResult {
606 valid: false,
607 errors: vec![Error::new(format!("Load error: {e}"))],
608 };
609 return to_js(&result);
610 }
611 };
612
613 let parse_errors = load_errors_to_errors(&load_result);
615 if !parse_errors.is_empty() {
616 let result = ValidationResult {
617 valid: false,
618 errors: parse_errors,
619 };
620 return to_js(&result);
621 }
622
623 let options = LoadOptions {
626 validate: true,
627 ..Default::default()
628 };
629
630 let ledger = match process(load_result, &options) {
631 Ok(ledger) => ledger,
632 Err(e) => {
633 let result = ValidationResult {
634 valid: false,
635 errors: vec![Error::new(format!("Processing error: {e}"))],
636 };
637 return to_js(&result);
638 }
639 };
640
641 let errors: Vec<Error> = ledger.errors.into_iter().map(Error::from).collect();
642
643 let result = ValidationResult {
644 valid: !has_fatal(&errors),
646 errors,
647 };
648 to_js(&result)
649}
650
651#[wasm_bindgen(js_name = "queryMultiFile")]
658pub fn query_multi_file(
659 files: JsValue,
660 entry_point: &str,
661 query_str: &str,
662) -> Result<JsValue, JsError> {
663 use rustledger_booking::merge_with_padding;
664 use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
665 use rustledger_query::{Executor, parse as parse_query};
666
667 let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
669 .map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
670
671 if file_map.is_empty() {
672 return Err(JsError::new("Files map cannot be empty"));
673 }
674
675 let vfs = VirtualFileSystem::from_files(file_map);
677
678 if !vfs.exists(Path::new(entry_point)) {
680 return Err(JsError::new(&format!(
681 "Entry point '{entry_point}' not found in files map"
682 )));
683 }
684
685 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
687
688 let load_result = match loader.load(Path::new(entry_point)) {
690 Ok(result) => result,
691 Err(e) => {
692 let result = QueryResult {
693 columns: Vec::new(),
694 rows: Vec::new(),
695 errors: vec![Error::new(format!("Load error: {e}"))],
696 };
697 return to_js(&result);
698 }
699 };
700
701 let parse_errors = load_errors_to_errors(&load_result);
703 if !parse_errors.is_empty() {
704 let result = QueryResult {
705 columns: Vec::new(),
706 rows: Vec::new(),
707 errors: parse_errors,
708 };
709 return to_js(&result);
710 }
711
712 let options = LoadOptions {
715 validate: false,
716 ..Default::default()
717 };
718
719 let ledger = match process(load_result, &options) {
720 Ok(ledger) => ledger,
721 Err(e) => {
722 let result = QueryResult {
723 columns: Vec::new(),
724 rows: Vec::new(),
725 errors: vec![Error::new(format!("Processing error: {e}"))],
726 };
727 return to_js(&result);
728 }
729 };
730
731 let errors: Vec<Error> = ledger.errors.into_iter().map(Error::from).collect();
733 let has_errors = errors.iter().any(|e| e.severity == Severity::Error);
734 if has_errors {
735 let result = QueryResult {
736 columns: Vec::new(),
737 rows: Vec::new(),
738 errors,
739 };
740 return to_js(&result);
741 }
742
743 let booked_directives: Vec<_> = ledger.directives.into_iter().map(|s| s.value).collect();
747 let directives = merge_with_padding(&booked_directives);
748
749 let query = match parse_query(query_str) {
751 Ok(q) => q,
752 Err(e) => {
753 let result = QueryResult {
754 columns: Vec::new(),
755 rows: Vec::new(),
756 errors: vec![Error::new(e.to_string())],
757 };
758 return to_js(&result);
759 }
760 };
761
762 let mut executor = Executor::new(&directives);
764 match executor.execute(&query) {
765 Ok(result) => {
766 let rows: Vec<Vec<_>> = result
767 .rows
768 .iter()
769 .map(|row| row.iter().map(value_to_cell).collect())
770 .collect();
771
772 let query_result = QueryResult {
773 columns: result.columns,
774 rows,
775 errors: Vec::new(),
776 };
777 to_js(&query_result)
778 }
779 Err(e) => {
780 let result = QueryResult {
781 columns: Vec::new(),
782 rows: Vec::new(),
783 errors: vec![Error::new(format!("Query execution error: {e}"))],
784 };
785 to_js(&result)
786 }
787 }
788}
789
790#[wasm_bindgen(js_name = "hashSources")]
803#[allow(clippy::needless_pass_by_value)] pub fn hash_sources(sources: Vec<String>) -> String {
805 let refs: Vec<&str> = sources.iter().map(String::as_str).collect();
806 crate::cache::hash_sources(&refs)
807}