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::{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
24fn 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 for parse_error in parse_errors {
38 let span = parse_error.span();
39 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 errors.push(Error::new(other.to_string()));
56 }
57 }
58 }
59
60 errors
61}
62
63#[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 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#[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#[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 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 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 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#[wasm_bindgen]
182pub fn version() -> String {
183 env!("CARGO_PKG_VERSION").to_string()
184}
185
186#[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 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#[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 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 let pad_result = process_pads(&load.directives);
242
243 let result = PadResult {
244 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#[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#[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 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 let registry = NativePluginRegistry::global();
311 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 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 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#[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#[wasm_bindgen]
386pub fn balances(source: &str) -> Result<JsValue, JsError> {
387 query(source, "BALANCES")
388}
389
390#[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#[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 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 let vfs = VirtualFileSystem::from_files(file_map);
463
464 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 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
473
474 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 let mut errors = load_errors_to_errors(&load_result);
488
489 let options = crate::types::LedgerOptions {
491 title: load_result.options.title.clone(),
492 operating_currencies: load_result.options.operating_currency.clone(),
493 };
494
495 let mut directives: Vec<Directive> = load_result
497 .directives
498 .into_iter()
499 .map(|s| s.value)
500 .collect();
501
502 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#[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 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 let vfs = VirtualFileSystem::from_files(file_map);
545
546 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 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
555
556 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 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 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#[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 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 let vfs = VirtualFileSystem::from_files(file_map);
631
632 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 let mut loader = Loader::new().with_filesystem(Box::new(vfs));
641
642 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 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 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 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 let booked_directives: Vec<_> = ledger.directives.into_iter().map(|s| s.value).collect();
701 let directives = merge_with_padding(&booked_directives);
702
703 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 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#[wasm_bindgen(js_name = "hashSources")]
757#[allow(clippy::needless_pass_by_value)] pub 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}