1#![forbid(unsafe_code)]
34#![warn(missing_docs)]
35
36mod convert;
37mod editor;
38pub mod types;
39mod utils;
40
41use std::collections::HashMap;
42use wasm_bindgen::prelude::*;
43
44use rustledger_booking::interpolate;
45use rustledger_core::Directive;
46use rustledger_parser::{ParseResult as ParserResult, parse as parse_beancount};
47use rustledger_validate::validate as validate_ledger;
48
49use convert::{directive_to_json, value_to_cell};
50#[cfg(feature = "completions")]
51use types::{CompletionJson, CompletionResultJson};
52use types::{
53 Error, FormatResult, Ledger, LedgerOptions, PadResult, ParseResult, QueryResult, Severity,
54 ValidationResult,
55};
56#[cfg(feature = "plugins")]
57use types::{PluginInfo, PluginResult};
58use utils::LineLookup;
59
60#[wasm_bindgen(typescript_custom_section)]
65const TS_TYPES: &'static str = r#"
66/** Error severity level. */
67export type Severity = 'error' | 'warning';
68
69/** Error with source location information. */
70export interface BeancountError {
71 message: string;
72 line?: number;
73 column?: number;
74 severity: Severity;
75}
76
77/** Amount with number and currency. */
78export interface Amount {
79 number: string;
80 currency: string;
81}
82
83/** Posting cost specification. */
84export interface PostingCost {
85 number_per?: string;
86 currency?: string;
87 date?: string;
88 label?: string;
89}
90
91/** A posting within a transaction. */
92export interface Posting {
93 account: string;
94 units?: Amount;
95 cost?: PostingCost;
96 price?: Amount;
97}
98
99/** Base directive with date. */
100interface BaseDirective {
101 date: string;
102}
103
104/** Transaction directive. */
105export interface TransactionDirective extends BaseDirective {
106 type: 'transaction';
107 flag: string;
108 payee?: string;
109 narration?: string;
110 tags: string[];
111 links: string[];
112 postings: Posting[];
113}
114
115/** Balance assertion directive. */
116export interface BalanceDirective extends BaseDirective {
117 type: 'balance';
118 account: string;
119 amount: Amount;
120}
121
122/** Open account directive. */
123export interface OpenDirective extends BaseDirective {
124 type: 'open';
125 account: string;
126 currencies: string[];
127 booking?: string;
128}
129
130/** Close account directive. */
131export interface CloseDirective extends BaseDirective {
132 type: 'close';
133 account: string;
134}
135
136/** All directive types. */
137export type Directive =
138 | TransactionDirective
139 | BalanceDirective
140 | OpenDirective
141 | CloseDirective
142 | { type: 'commodity'; date: string; currency: string }
143 | { type: 'pad'; date: string; account: string; source_account: string }
144 | { type: 'event'; date: string; event_type: string; value: string }
145 | { type: 'note'; date: string; account: string; comment: string }
146 | { type: 'document'; date: string; account: string; path: string }
147 | { type: 'price'; date: string; currency: string; amount: Amount }
148 | { type: 'query'; date: string; name: string; query_string: string }
149 | { type: 'custom'; date: string; custom_type: string };
150
151/** Ledger options. */
152export interface LedgerOptions {
153 operating_currencies: string[];
154 title?: string;
155}
156
157/** Parsed ledger. */
158export interface Ledger {
159 directives: Directive[];
160 options: LedgerOptions;
161}
162
163/** Result of parsing a Beancount file. */
164export interface ParseResult {
165 ledger?: Ledger;
166 errors: BeancountError[];
167}
168
169/** Result of validation. */
170export interface ValidationResult {
171 valid: boolean;
172 errors: BeancountError[];
173}
174
175/** Cell value in query results. */
176export type CellValue =
177 | null
178 | string
179 | number
180 | boolean
181 | Amount
182 | { units: Amount; cost?: { number: string; currency: string; date?: string; label?: string } }
183 | { positions: Array<{ units: Amount }> }
184 | string[];
185
186/** Result of a BQL query. */
187export interface QueryResult {
188 columns: string[];
189 rows: CellValue[][];
190 errors: BeancountError[];
191}
192
193/** Result of formatting. */
194export interface FormatResult {
195 formatted?: string;
196 errors: BeancountError[];
197}
198
199/** Result of pad expansion. */
200export interface PadResult {
201 directives: Directive[];
202 padding_transactions: Directive[];
203 errors: BeancountError[];
204}
205
206/** Result of running a plugin. */
207export interface PluginResult {
208 directives: Directive[];
209 errors: BeancountError[];
210}
211
212/** Plugin information. */
213export interface PluginInfo {
214 name: string;
215 description: string;
216}
217
218/** BQL completion suggestion. */
219export interface Completion {
220 text: string;
221 category: string;
222 description?: string;
223}
224
225/** Result of BQL completion request. */
226export interface CompletionResult {
227 completions: Completion[];
228 context: string;
229}
230
231// =============================================================================
232// Editor Integration Types (LSP-like features)
233// =============================================================================
234
235/** The kind of a completion item. */
236export type EditorCompletionKind = 'keyword' | 'account' | 'accountsegment' | 'currency' | 'payee' | 'date' | 'text';
237
238/** A completion item for Beancount source editing. */
239export interface EditorCompletion {
240 label: string;
241 kind: EditorCompletionKind;
242 detail?: string;
243 insertText?: string;
244}
245
246/** Result of an editor completion request. */
247export interface EditorCompletionResult {
248 completions: EditorCompletion[];
249 context: string;
250}
251
252/** A range in the document. */
253export interface EditorRange {
254 start_line: number;
255 start_character: number;
256 end_line: number;
257 end_character: number;
258}
259
260/** Hover information for a symbol. */
261export interface EditorHoverInfo {
262 contents: string;
263 range?: EditorRange;
264}
265
266/** A location in the document. */
267export interface EditorLocation {
268 line: number;
269 character: number;
270}
271
272/** The kind of a symbol. */
273export type SymbolKind = 'transaction' | 'account' | 'balance' | 'commodity' | 'posting' | 'pad' | 'event' | 'note' | 'document' | 'price' | 'query' | 'custom';
274
275/** A document symbol for the outline view. */
276export interface EditorDocumentSymbol {
277 name: string;
278 detail?: string;
279 kind: SymbolKind;
280 range: EditorRange;
281 children?: EditorDocumentSymbol[];
282 deprecated?: boolean;
283}
284
285/** The kind of reference. */
286export type ReferenceKind = 'account' | 'currency' | 'payee';
287
288/** A reference to a symbol in the document. */
289export interface EditorReference {
290 range: EditorRange;
291 kind: ReferenceKind;
292 is_definition: boolean;
293 context?: string;
294}
295
296/** Result of a find-references request. */
297export interface EditorReferencesResult {
298 symbol: string;
299 kind: ReferenceKind;
300 references: EditorReference[];
301}
302
303/**
304 * A parsed and validated ledger that caches the parse result.
305 * Use this class when you need to perform multiple operations on the same
306 * source without re-parsing each time.
307 */
308export class ParsedLedger {
309 constructor(source: string);
310 free(): void;
311
312 /** Check if the ledger is valid (no parse or validation errors). */
313 isValid(): boolean;
314
315 /** Get all errors (parse + validation). */
316 getErrors(): BeancountError[];
317
318 /** Get parse errors only. */
319 getParseErrors(): BeancountError[];
320
321 /** Get validation errors only. */
322 getValidationErrors(): BeancountError[];
323
324 /** Get the parsed directives. */
325 getDirectives(): Directive[];
326
327 /** Get the ledger options. */
328 getOptions(): LedgerOptions;
329
330 /** Get the number of directives. */
331 directiveCount(): number;
332
333 /** Run a BQL query on this ledger. */
334 query(queryStr: string): QueryResult;
335
336 /** Get account balances (shorthand for query("BALANCES")). */
337 balances(): QueryResult;
338
339 /** Format the ledger source. */
340 format(): FormatResult;
341
342 /** Expand pad directives. */
343 expandPads(): PadResult;
344
345 /** Run a native plugin on this ledger. */
346 runPlugin(pluginName: string): PluginResult;
347
348 // =========================================================================
349 // Editor Integration (LSP-like features)
350 // =========================================================================
351
352 /** Get completions at the given position. */
353 getCompletions(line: number, character: number): EditorCompletionResult;
354
355 /** Get hover information at the given position. */
356 getHoverInfo(line: number, character: number): EditorHoverInfo | null;
357
358 /** Get the definition location for the symbol at the given position. */
359 getDefinition(line: number, character: number): EditorLocation | null;
360
361 /** Get all document symbols for the outline view. */
362 getDocumentSymbols(): EditorDocumentSymbol[];
363
364 /** Find all references to the symbol at the given position. */
365 getReferences(line: number, character: number): EditorReferencesResult | null;
366}
367"#;
368
369#[wasm_bindgen(start)]
378pub fn init() {
379 console_error_panic_hook::set_once();
381}
382
383struct LoadResult {
389 directives: Vec<Directive>,
390 options: LedgerOptions,
391 errors: Vec<Error>,
392 lookup: LineLookup,
393 parse_result: ParserResult,
394}
395
396fn load_and_interpolate(source: &str) -> LoadResult {
400 let parse_result = parse_beancount(source);
401 let lookup = LineLookup::new(source);
402
403 let mut errors: Vec<Error> = parse_result
405 .errors
406 .iter()
407 .map(|e| Error::with_line(e.to_string(), lookup.byte_to_line(e.span().0)))
408 .collect();
409
410 let options = extract_options(&parse_result.options);
412
413 let mut directives: Vec<_> = parse_result
415 .directives
416 .iter()
417 .map(|s| s.value.clone())
418 .collect();
419
420 if errors.is_empty() {
422 for (i, directive) in directives.iter_mut().enumerate() {
423 if let Directive::Transaction(txn) = directive {
424 match interpolate(txn) {
425 Ok(result) => {
426 *txn = result.transaction;
427 }
428 Err(e) => {
429 let line = lookup.byte_to_line(parse_result.directives[i].span.start);
430 errors.push(Error::with_line(e.to_string(), line));
431 }
432 }
433 }
434 }
435 }
436
437 LoadResult {
438 directives,
439 options,
440 errors,
441 lookup,
442 parse_result,
443 }
444}
445
446fn run_validation(load: &LoadResult) -> Vec<Error> {
448 if !load.errors.is_empty() {
449 return Vec::new();
450 }
451
452 let mut date_to_line: HashMap<String, u32> = HashMap::new();
453 for spanned in &load.parse_result.directives {
454 let line = load.lookup.byte_to_line(spanned.span.start);
455 let date = spanned.value.date().to_string();
456 date_to_line.entry(date).or_insert(line);
457 }
458
459 validate_ledger(&load.directives)
460 .into_iter()
461 .map(|err| {
462 let line = date_to_line.get(&err.date.to_string()).copied();
463 Error {
464 message: err.message,
465 line,
466 column: None,
467 severity: Severity::Error,
468 }
469 })
470 .collect()
471}
472
473fn to_js<T: serde::Serialize>(value: &T) -> Result<JsValue, JsError> {
479 let serializer = serde_wasm_bindgen::Serializer::json_compatible();
480 value
481 .serialize(&serializer)
482 .map_err(|e| JsError::new(&e.to_string()))
483}
484
485#[wasm_bindgen]
493pub fn parse(source: &str) -> Result<JsValue, JsError> {
494 let result = parse_beancount(source);
495 let lookup = LineLookup::new(source);
496
497 let errors: Vec<Error> = result
498 .errors
499 .iter()
500 .map(|e| Error::with_line(e.to_string(), lookup.byte_to_line(e.span().0)))
501 .collect();
502
503 let options = extract_options(&result.options);
505
506 let ledger = Some(Ledger {
507 directives: result
508 .directives
509 .iter()
510 .map(|spanned| directive_to_json(&spanned.value))
511 .collect(),
512 options,
513 });
514
515 let parse_result = ParseResult { ledger, errors };
516 to_js(&parse_result)
517}
518
519fn extract_options(options: &[(String, String, rustledger_parser::Span)]) -> LedgerOptions {
521 let mut ledger_options = LedgerOptions::default();
522
523 for (key, value, _span) in options {
524 match key.as_str() {
525 "title" => ledger_options.title = Some(value.clone()),
526 "operating_currency" => {
527 ledger_options.operating_currencies.push(value.clone());
528 }
529 _ => {} }
531 }
532
533 ledger_options
534}
535
536#[wasm_bindgen(js_name = "validateSource")]
541pub fn validate_source(source: &str) -> Result<JsValue, JsError> {
542 let load = load_and_interpolate(source);
543 let validation_errors = run_validation(&load);
544 let mut errors = load.errors;
545 errors.extend(validation_errors);
546
547 let result = ValidationResult {
548 valid: errors.is_empty(),
549 errors,
550 };
551 to_js(&result)
552}
553
554#[wasm_bindgen]
559pub fn query(source: &str, query_str: &str) -> Result<JsValue, JsError> {
560 use rustledger_query::{Executor, parse as parse_query};
561
562 let load = load_and_interpolate(source);
563
564 if !load.errors.is_empty() {
566 let result = QueryResult {
567 columns: Vec::new(),
568 rows: Vec::new(),
569 errors: load.errors,
570 };
571 return to_js(&result);
572 }
573
574 let query = match parse_query(query_str) {
576 Ok(q) => q,
577 Err(e) => {
578 let result = QueryResult {
579 columns: Vec::new(),
580 rows: Vec::new(),
581 errors: vec![Error::new(format!("Query parse error: {e}"))],
582 };
583 return to_js(&result);
584 }
585 };
586
587 let mut executor = Executor::new(&load.directives);
588 match executor.execute(&query) {
589 Ok(result) => {
590 let rows: Vec<Vec<_>> = result
591 .rows
592 .iter()
593 .map(|row| row.iter().map(value_to_cell).collect())
594 .collect();
595
596 let query_result = QueryResult {
597 columns: result.columns,
598 rows,
599 errors: Vec::new(),
600 };
601 to_js(&query_result)
602 }
603 Err(e) => {
604 let result = QueryResult {
605 columns: Vec::new(),
606 rows: Vec::new(),
607 errors: vec![Error::new(format!("Query execution error: {e}"))],
608 };
609 to_js(&result)
610 }
611 }
612}
613
614#[wasm_bindgen]
618pub fn version() -> String {
619 env!("CARGO_PKG_VERSION").to_string()
620}
621
622#[wasm_bindgen]
627pub fn format(source: &str) -> Result<JsValue, JsError> {
628 use rustledger_core::{FormatConfig, format_directive};
629
630 let parse_result = parse_beancount(source);
631 let lookup = LineLookup::new(source);
632
633 if !parse_result.errors.is_empty() {
634 let result = FormatResult {
635 formatted: None,
636 errors: parse_result
637 .errors
638 .iter()
639 .map(|e| Error::with_line(e.to_string(), lookup.byte_to_line(e.span().0)))
640 .collect(),
641 };
642 return to_js(&result);
643 }
644
645 let config = FormatConfig::default();
646 let mut formatted = String::new();
647
648 for spanned in &parse_result.directives {
649 formatted.push_str(&format_directive(&spanned.value, &config));
650 formatted.push('\n');
651 }
652
653 let result = FormatResult {
654 formatted: Some(formatted),
655 errors: Vec::new(),
656 };
657 to_js(&result)
658}
659
660#[wasm_bindgen(js_name = "expandPads")]
664pub fn expand_pads(source: &str) -> Result<JsValue, JsError> {
665 use rustledger_booking::process_pads;
666
667 let load = load_and_interpolate(source);
668
669 if !load.errors.is_empty() {
671 let result = PadResult {
672 directives: Vec::new(),
673 padding_transactions: Vec::new(),
674 errors: load.errors,
675 };
676 return to_js(&result);
677 }
678
679 let pad_result = process_pads(&load.directives);
681
682 let result = PadResult {
683 directives: pad_result
684 .directives
685 .iter()
686 .map(directive_to_json)
687 .collect(),
688 padding_transactions: pad_result
689 .padding_transactions
690 .iter()
691 .map(|txn| directive_to_json(&Directive::Transaction(txn.clone())))
692 .collect(),
693 errors: pad_result
694 .errors
695 .iter()
696 .map(|e| Error::new(e.message.clone()))
697 .collect(),
698 };
699 to_js(&result)
700}
701
702#[cfg(feature = "plugins")]
706#[wasm_bindgen(js_name = "runPlugin")]
707pub fn run_plugin(source: &str, plugin_name: &str) -> Result<JsValue, JsError> {
708 use rustledger_plugin::{
709 NativePluginRegistry, PluginInput, PluginOptions, directives_to_wrappers,
710 wrappers_to_directives,
711 };
712
713 let load = load_and_interpolate(source);
714
715 if !load.errors.is_empty() {
717 let result = PluginResult {
718 directives: Vec::new(),
719 errors: load.errors,
720 };
721 return to_js(&result);
722 }
723
724 let registry = NativePluginRegistry::new();
726 let Some(plugin) = registry.find(plugin_name) else {
727 let result = PluginResult {
728 directives: Vec::new(),
729 errors: vec![Error::new(format!("Unknown plugin: {plugin_name}"))],
730 };
731 return to_js(&result);
732 };
733
734 let wrappers = directives_to_wrappers(&load.directives);
736 let input = PluginInput {
737 directives: wrappers,
738 options: PluginOptions::default(),
739 config: None,
740 };
741
742 let output = plugin.process(input);
743
744 let output_directives = match wrappers_to_directives(&output.directives) {
746 Ok(dirs) => dirs,
747 Err(e) => {
748 let result = PluginResult {
749 directives: Vec::new(),
750 errors: vec![Error::new(format!("Conversion error: {e}"))],
751 };
752 return to_js(&result);
753 }
754 };
755
756 let result = PluginResult {
757 directives: output_directives.iter().map(directive_to_json).collect(),
758 errors: output
759 .errors
760 .iter()
761 .map(|e| match e.severity {
762 rustledger_plugin::PluginErrorSeverity::Warning => {
763 Error::warning(e.message.clone())
764 }
765 rustledger_plugin::PluginErrorSeverity::Error => Error::new(e.message.clone()),
766 })
767 .collect(),
768 };
769 to_js(&result)
770}
771
772#[cfg(feature = "plugins")]
776#[wasm_bindgen(js_name = "listPlugins")]
777pub fn list_plugins() -> Result<JsValue, JsError> {
778 use rustledger_plugin::NativePluginRegistry;
779
780 let registry = NativePluginRegistry::new();
781 let plugins: Vec<PluginInfo> = registry
782 .list()
783 .iter()
784 .map(|p| PluginInfo {
785 name: p.name().to_string(),
786 description: p.description().to_string(),
787 })
788 .collect();
789
790 to_js(&plugins)
791}
792
793#[wasm_bindgen]
797pub fn balances(source: &str) -> Result<JsValue, JsError> {
798 query(source, "BALANCES")
799}
800
801#[cfg(feature = "completions")]
805#[wasm_bindgen(js_name = "bqlCompletions")]
806pub fn bql_completions(partial_query: &str, cursor_pos: usize) -> Result<JsValue, JsError> {
807 use rustledger_query::completions;
808
809 let result = completions::complete(partial_query, cursor_pos);
810
811 let json_result = CompletionResultJson {
812 completions: result
813 .completions
814 .into_iter()
815 .map(|c| CompletionJson {
816 text: c.text,
817 category: c.category.as_str().to_string(),
818 description: c.description,
819 })
820 .collect(),
821 context: format!("{:?}", result.context),
822 };
823
824 to_js(&json_result)
825}
826
827#[wasm_bindgen]
846pub struct ParsedLedger {
847 source: String,
849 parse_result: ParserResult,
851 directives: Vec<Directive>,
853 options: LedgerOptions,
855 parse_errors: Vec<Error>,
857 validation_errors: Vec<Error>,
859 editor_cache: editor::EditorCache,
861}
862
863#[wasm_bindgen]
864impl ParsedLedger {
865 #[wasm_bindgen(constructor)]
869 pub fn new(source: &str) -> Self {
870 let load = load_and_interpolate(source);
871 let validation_errors = run_validation(&load);
872
873 let editor_cache = editor::EditorCache::new(source, &load.parse_result);
875
876 Self {
877 source: source.to_string(),
878 parse_result: load.parse_result,
879 directives: load.directives,
880 options: load.options,
881 parse_errors: load.errors,
882 validation_errors,
883 editor_cache,
884 }
885 }
886
887 #[wasm_bindgen(js_name = "isValid")]
889 pub fn is_valid(&self) -> bool {
890 self.parse_errors.is_empty() && self.validation_errors.is_empty()
891 }
892
893 #[wasm_bindgen(js_name = "getErrors")]
895 pub fn get_errors(&self) -> Result<JsValue, JsError> {
896 let mut all_errors = self.parse_errors.clone();
897 all_errors.extend(self.validation_errors.clone());
898 to_js(&all_errors)
899 }
900
901 #[wasm_bindgen(js_name = "getParseErrors")]
903 pub fn get_parse_errors(&self) -> Result<JsValue, JsError> {
904 to_js(&self.parse_errors)
905 }
906
907 #[wasm_bindgen(js_name = "getValidationErrors")]
909 pub fn get_validation_errors(&self) -> Result<JsValue, JsError> {
910 to_js(&self.validation_errors)
911 }
912
913 #[wasm_bindgen(js_name = "getDirectives")]
915 pub fn get_directives(&self) -> Result<JsValue, JsError> {
916 let directives: Vec<_> = self.directives.iter().map(directive_to_json).collect();
917 to_js(&directives)
918 }
919
920 #[wasm_bindgen(js_name = "getOptions")]
922 pub fn get_options(&self) -> Result<JsValue, JsError> {
923 to_js(&self.options)
924 }
925
926 #[wasm_bindgen(js_name = "directiveCount")]
928 pub fn directive_count(&self) -> usize {
929 self.directives.len()
930 }
931
932 #[wasm_bindgen]
934 pub fn query(&self, query_str: &str) -> Result<JsValue, JsError> {
935 use rustledger_query::{Executor, parse as parse_query};
936
937 if !self.parse_errors.is_empty() {
938 let result = QueryResult {
939 columns: Vec::new(),
940 rows: Vec::new(),
941 errors: self.parse_errors.clone(),
942 };
943 return to_js(&result);
944 }
945
946 let query = match parse_query(query_str) {
947 Ok(q) => q,
948 Err(e) => {
949 let result = QueryResult {
950 columns: Vec::new(),
951 rows: Vec::new(),
952 errors: vec![Error::new(format!("Query parse error: {e}"))],
953 };
954 return to_js(&result);
955 }
956 };
957
958 let mut executor = Executor::new(&self.directives);
959 match executor.execute(&query) {
960 Ok(result) => {
961 let rows: Vec<Vec<_>> = result
962 .rows
963 .iter()
964 .map(|row| row.iter().map(value_to_cell).collect())
965 .collect();
966
967 let query_result = QueryResult {
968 columns: result.columns,
969 rows,
970 errors: Vec::new(),
971 };
972 to_js(&query_result)
973 }
974 Err(e) => {
975 let result = QueryResult {
976 columns: Vec::new(),
977 rows: Vec::new(),
978 errors: vec![Error::new(format!("Query execution error: {e}"))],
979 };
980 to_js(&result)
981 }
982 }
983 }
984
985 #[wasm_bindgen]
987 pub fn balances(&self) -> Result<JsValue, JsError> {
988 self.query("BALANCES")
989 }
990
991 #[wasm_bindgen]
993 pub fn format(&self) -> Result<JsValue, JsError> {
994 use rustledger_core::{FormatConfig, format_directive};
995
996 if !self.parse_errors.is_empty() {
997 let result = FormatResult {
998 formatted: None,
999 errors: self.parse_errors.clone(),
1000 };
1001 return to_js(&result);
1002 }
1003
1004 let config = FormatConfig::default();
1005 let mut formatted = String::new();
1006
1007 for directive in &self.directives {
1008 formatted.push_str(&format_directive(directive, &config));
1009 formatted.push('\n');
1010 }
1011
1012 let result = FormatResult {
1013 formatted: Some(formatted),
1014 errors: Vec::new(),
1015 };
1016 to_js(&result)
1017 }
1018
1019 #[wasm_bindgen(js_name = "expandPads")]
1021 pub fn expand_pads(&self) -> Result<JsValue, JsError> {
1022 use rustledger_booking::process_pads;
1023
1024 if !self.parse_errors.is_empty() {
1025 let result = PadResult {
1026 directives: Vec::new(),
1027 padding_transactions: Vec::new(),
1028 errors: self.parse_errors.clone(),
1029 };
1030 return to_js(&result);
1031 }
1032
1033 let pad_result = process_pads(&self.directives);
1034
1035 let result = PadResult {
1036 directives: pad_result
1037 .directives
1038 .iter()
1039 .map(directive_to_json)
1040 .collect(),
1041 padding_transactions: pad_result
1042 .padding_transactions
1043 .iter()
1044 .map(|txn| directive_to_json(&Directive::Transaction(txn.clone())))
1045 .collect(),
1046 errors: pad_result
1047 .errors
1048 .iter()
1049 .map(|e| Error::new(e.message.clone()))
1050 .collect(),
1051 };
1052 to_js(&result)
1053 }
1054
1055 #[cfg(feature = "plugins")]
1057 #[wasm_bindgen(js_name = "runPlugin")]
1058 pub fn run_plugin(&self, plugin_name: &str) -> Result<JsValue, JsError> {
1059 use rustledger_plugin::{
1060 NativePluginRegistry, PluginInput, PluginOptions, directives_to_wrappers,
1061 wrappers_to_directives,
1062 };
1063
1064 if !self.parse_errors.is_empty() {
1065 let result = PluginResult {
1066 directives: Vec::new(),
1067 errors: self.parse_errors.clone(),
1068 };
1069 return to_js(&result);
1070 }
1071
1072 let registry = NativePluginRegistry::new();
1073 let Some(plugin) = registry.find(plugin_name) else {
1074 let result = PluginResult {
1075 directives: Vec::new(),
1076 errors: vec![Error::new(format!("Unknown plugin: {plugin_name}"))],
1077 };
1078 return to_js(&result);
1079 };
1080
1081 let wrappers = directives_to_wrappers(&self.directives);
1082 let input = PluginInput {
1083 directives: wrappers,
1084 options: PluginOptions::default(),
1085 config: None,
1086 };
1087
1088 let output = plugin.process(input);
1089
1090 let output_directives = match wrappers_to_directives(&output.directives) {
1091 Ok(dirs) => dirs,
1092 Err(e) => {
1093 let result = PluginResult {
1094 directives: Vec::new(),
1095 errors: vec![Error::new(format!("Conversion error: {e}"))],
1096 };
1097 return to_js(&result);
1098 }
1099 };
1100
1101 let result = PluginResult {
1102 directives: output_directives.iter().map(directive_to_json).collect(),
1103 errors: output
1104 .errors
1105 .iter()
1106 .map(|e| match e.severity {
1107 rustledger_plugin::PluginErrorSeverity::Warning => {
1108 Error::warning(e.message.clone())
1109 }
1110 rustledger_plugin::PluginErrorSeverity::Error => Error::new(e.message.clone()),
1111 })
1112 .collect(),
1113 };
1114 to_js(&result)
1115 }
1116
1117 #[wasm_bindgen(js_name = "getCompletions")]
1126 pub fn get_completions(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
1127 let result =
1128 editor::get_completions_cached(&self.source, line, character, &self.editor_cache);
1129 to_js(&result)
1130 }
1131
1132 #[wasm_bindgen(js_name = "getHoverInfo")]
1136 pub fn get_hover_info(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
1137 let result = editor::get_hover_info_cached(
1138 &self.source,
1139 line,
1140 character,
1141 &self.parse_result,
1142 &self.editor_cache,
1143 );
1144 to_js(&result)
1145 }
1146
1147 #[wasm_bindgen(js_name = "getDefinition")]
1152 pub fn get_definition(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
1153 let result = editor::get_definition_cached(
1154 &self.source,
1155 line,
1156 character,
1157 &self.parse_result,
1158 &self.editor_cache,
1159 );
1160 to_js(&result)
1161 }
1162
1163 #[wasm_bindgen(js_name = "getDocumentSymbols")]
1168 pub fn get_document_symbols(&self) -> Result<JsValue, JsError> {
1169 let result = editor::get_document_symbols_cached(&self.parse_result, &self.editor_cache);
1170 to_js(&result)
1171 }
1172
1173 #[wasm_bindgen(js_name = "getReferences")]
1178 pub fn get_references(&self, line: u32, character: u32) -> Result<JsValue, JsError> {
1179 let result = editor::get_references_cached(
1180 &self.source,
1181 line,
1182 character,
1183 &self.parse_result,
1184 &self.editor_cache,
1185 );
1186 to_js(&result)
1187 }
1188}
1189
1190#[cfg(test)]
1195mod tests {
1196 use super::*;
1197
1198 #[test]
1199 fn test_parse_simple() {
1200 let source = r#"
12012024-01-01 open Assets:Bank USD
1202
12032024-01-15 * "Coffee Shop" "Morning coffee"
1204 Expenses:Food:Coffee 5.00 USD
1205 Assets:Bank -5.00 USD
1206"#;
1207
1208 let result = parse_beancount(source);
1209 assert!(result.errors.is_empty());
1210 assert_eq!(result.directives.len(), 2);
1211 }
1212
1213 #[test]
1214 fn test_version() {
1215 let v = version();
1216 assert!(!v.is_empty());
1217 }
1218
1219 #[test]
1220 fn test_load_and_interpolate() {
1221 let source = r#"
12232024-01-01 open Assets:Bank USD
12242024-01-01 open Expenses:Food USD
1225
12262024-01-15 * "Coffee"
1227 Expenses:Food 5.00 USD
1228 Assets:Bank -5.00 USD
1229"#;
1230 let load = load_and_interpolate(source);
1231 assert!(load.errors.is_empty());
1232 assert_eq!(load.directives.len(), 3);
1233
1234 let source = r#"
12362024-01-01 open Assets:Bank USD
1237
12382024-01-15 * "Coffee"
1239 Expenses:Food 5.00 USD
1240 Assets:Bank -5.00 USD
1241"#;
1242 let load = load_and_interpolate(source);
1243 assert!(load.errors.is_empty()); let validation_errors = validate_ledger(&load.directives);
1245 assert!(
1246 !validation_errors.is_empty(),
1247 "should detect Expenses:Food not opened"
1248 );
1249 }
1250}