use std::collections::HashMap;
use std::path::Path;
use wasm_bindgen::prelude::*;
use rustledger_core::Directive;
use rustledger_loader::{FileSystem, LoadError, LoadResult};
use rustledger_parser::parse as parse_beancount;
use crate::convert::{directive_to_json, value_to_cell};
use crate::helpers::{extract_options, load_and_book, run_validation, to_js};
#[cfg(feature = "completions")]
use crate::types::{CompletionJson, CompletionResultJson};
use crate::types::{
Error, FormatResult, Ledger, PadResult, ParseResult, QueryResult, Severity, ValidationResult,
};
#[cfg(feature = "plugins")]
use crate::types::{PluginInfo, PluginResult};
use crate::utils::LineLookup;
fn load_errors_to_errors(load_result: &LoadResult) -> Vec<Error> {
let mut errors = Vec::new();
for load_error in &load_result.errors {
match load_error {
LoadError::ParseErrors {
path,
errors: parse_errors,
} => {
for parse_error in parse_errors {
let span = parse_error.span();
let line = load_result
.source_map
.get_by_path(path)
.map(|file| file.line_col(span.0).0 as u32);
let msg = format!("{}: {}", path.display(), parse_error);
if let Some(line_num) = line {
errors.push(Error::with_line(msg, line_num));
} else {
errors.push(Error::new(msg));
}
}
}
other => {
errors.push(Error::new(other.to_string()));
}
}
}
errors
}
#[wasm_bindgen]
pub fn parse(source: &str) -> Result<JsValue, JsError> {
let result = parse_beancount(source);
let lookup = LineLookup::new(source);
let errors: Vec<Error> = result
.errors
.iter()
.map(|e| Error::with_line(e.to_string(), lookup.byte_to_line(e.span().0)))
.collect();
let options = extract_options(&result.options);
let ledger = Some(Ledger {
directives: result
.directives
.iter()
.map(|spanned| directive_to_json(&spanned.value))
.collect(),
options,
});
let parse_result = ParseResult { ledger, errors };
to_js(&parse_result)
}
#[wasm_bindgen(js_name = "validateSource")]
pub fn validate_source(source: &str) -> Result<JsValue, JsError> {
let load = load_and_book(source);
let validation_errors = run_validation(&load);
let mut errors = load.errors;
errors.extend(validation_errors);
let result = ValidationResult {
valid: errors.is_empty(),
errors,
};
to_js(&result)
}
#[wasm_bindgen]
pub fn query(source: &str, query_str: &str) -> Result<JsValue, JsError> {
use rustledger_query::{Executor, parse as parse_query};
let load = load_and_book(source);
if !load.errors.is_empty() {
let result = QueryResult {
columns: Vec::new(),
rows: Vec::new(),
errors: load.errors,
};
return to_js(&result);
}
let query = match parse_query(query_str) {
Ok(q) => q,
Err(e) => {
let result = QueryResult {
columns: Vec::new(),
rows: Vec::new(),
errors: vec![Error::new(e.to_string())],
};
return to_js(&result);
}
};
let mut executor = Executor::new(&load.directives);
match executor.execute(&query) {
Ok(result) => {
let rows: Vec<Vec<_>> = result
.rows
.iter()
.map(|row| row.iter().map(value_to_cell).collect())
.collect();
let query_result = QueryResult {
columns: result.columns,
rows,
errors: Vec::new(),
};
to_js(&query_result)
}
Err(e) => {
let result = QueryResult {
columns: Vec::new(),
rows: Vec::new(),
errors: vec![Error::new(format!("Query execution error: {e}"))],
};
to_js(&result)
}
}
}
#[wasm_bindgen]
pub fn version() -> String {
env!("CARGO_PKG_VERSION").to_string()
}
#[wasm_bindgen]
pub fn format(source: &str) -> Result<JsValue, JsError> {
use rustledger_core::{FormatConfig, format_directive};
let parse_result = parse_beancount(source);
let lookup = LineLookup::new(source);
if !parse_result.errors.is_empty() {
let result = FormatResult {
formatted: None,
errors: parse_result
.errors
.iter()
.map(|e| Error::with_line(e.to_string(), lookup.byte_to_line(e.span().0)))
.collect(),
};
return to_js(&result);
}
let config = FormatConfig::default();
let mut formatted = String::new();
for spanned in &parse_result.directives {
formatted.push_str(&format_directive(&spanned.value, &config));
formatted.push('\n');
}
let result = FormatResult {
formatted: Some(formatted),
errors: Vec::new(),
};
to_js(&result)
}
#[wasm_bindgen(js_name = "expandPads")]
pub fn expand_pads(source: &str) -> Result<JsValue, JsError> {
use rustledger_booking::process_pads;
let load = load_and_book(source);
if !load.errors.is_empty() {
let result = PadResult {
directives: Vec::new(),
padding_transactions: Vec::new(),
errors: load.errors,
};
return to_js(&result);
}
let pad_result = process_pads(&load.directives);
let result = PadResult {
directives: pad_result
.directives
.iter()
.map(directive_to_json)
.collect(),
padding_transactions: pad_result
.padding_transactions
.iter()
.map(|txn| directive_to_json(&Directive::Transaction(txn.clone())))
.collect(),
errors: pad_result
.errors
.iter()
.map(|e| Error::new(e.message.clone()))
.collect(),
};
to_js(&result)
}
#[cfg(feature = "plugins")]
#[wasm_bindgen(js_name = "runPlugin")]
pub fn run_plugin(source: &str, plugin_name: &str) -> Result<JsValue, JsError> {
use rustledger_plugin::{
NativePluginRegistry, PluginInput, PluginOptions, directives_to_wrappers,
wrappers_to_directives,
};
let load = load_and_book(source);
if !load.errors.is_empty() {
let result = PluginResult {
directives: Vec::new(),
errors: load.errors,
};
return to_js(&result);
}
let registry = NativePluginRegistry::new();
let Some(plugin) = registry.find(plugin_name) else {
let result = PluginResult {
directives: Vec::new(),
errors: vec![Error::new(format!("Unknown plugin: {plugin_name}"))],
};
return to_js(&result);
};
let wrappers = directives_to_wrappers(&load.directives);
let input = PluginInput {
directives: wrappers,
options: PluginOptions::default(),
config: None,
};
let output = plugin.process(input);
let output_directives = match wrappers_to_directives(&output.directives) {
Ok(dirs) => dirs,
Err(e) => {
let result = PluginResult {
directives: Vec::new(),
errors: vec![Error::new(format!("Conversion error: {e}"))],
};
return to_js(&result);
}
};
let result = PluginResult {
directives: output_directives.iter().map(directive_to_json).collect(),
errors: output
.errors
.iter()
.map(|e| match e.severity {
rustledger_plugin::PluginErrorSeverity::Warning => {
Error::warning(e.message.clone())
}
rustledger_plugin::PluginErrorSeverity::Error => Error::new(e.message.clone()),
})
.collect(),
};
to_js(&result)
}
#[cfg(feature = "plugins")]
#[wasm_bindgen(js_name = "listPlugins")]
pub fn list_plugins() -> Result<JsValue, JsError> {
use rustledger_plugin::NativePluginRegistry;
let registry = NativePluginRegistry::new();
let plugins: Vec<PluginInfo> = registry
.list()
.iter()
.map(|p| PluginInfo {
name: p.name().to_string(),
description: p.description().to_string(),
})
.collect();
to_js(&plugins)
}
#[wasm_bindgen]
pub fn balances(source: &str) -> Result<JsValue, JsError> {
query(source, "BALANCES")
}
#[cfg(feature = "completions")]
#[wasm_bindgen(js_name = "bqlCompletions")]
pub fn bql_completions(partial_query: &str, cursor_pos: usize) -> Result<JsValue, JsError> {
use rustledger_query::completions;
let result = completions::complete(partial_query, cursor_pos);
let json_result = CompletionResultJson {
completions: result
.completions
.into_iter()
.map(|c| CompletionJson {
text: c.text,
category: c.category.as_str().to_string(),
description: c.description,
})
.collect(),
context: format!("{:?}", result.context),
};
to_js(&json_result)
}
#[wasm_bindgen(js_name = "parseMultiFile")]
pub fn parse_multi_file(files: JsValue, entry_point: &str) -> Result<JsValue, JsError> {
use rustledger_booking::interpolate;
use rustledger_loader::{Loader, VirtualFileSystem};
let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
.map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
if file_map.is_empty() {
return Err(JsError::new("Files map cannot be empty"));
}
let vfs = VirtualFileSystem::from_files(file_map);
if !vfs.exists(Path::new(entry_point)) {
return Err(JsError::new(&format!(
"Entry point '{entry_point}' not found in files map"
)));
}
let mut loader = Loader::new().with_filesystem(Box::new(vfs));
let load_result = match loader.load(Path::new(entry_point)) {
Ok(result) => result,
Err(e) => {
let result = ParseResult {
ledger: None,
errors: vec![Error::new(format!("Load error: {e}"))],
};
return to_js(&result);
}
};
let mut errors = load_errors_to_errors(&load_result);
let options = crate::types::LedgerOptions {
title: load_result.options.title.clone(),
operating_currencies: load_result.options.operating_currency.clone(),
};
let mut directives: Vec<Directive> = load_result
.directives
.into_iter()
.map(|s| s.value)
.collect();
if errors.is_empty() {
for directive in &mut directives {
if let Directive::Transaction(txn) = directive {
match interpolate(txn) {
Ok(result) => {
*txn = result.transaction;
}
Err(e) => {
errors.push(Error::new(e.to_string()));
}
}
}
}
}
let ledger = Some(Ledger {
directives: directives.iter().map(directive_to_json).collect(),
options,
});
let result = ParseResult { ledger, errors };
to_js(&result)
}
#[wasm_bindgen(js_name = "validateMultiFile")]
pub fn validate_multi_file(files: JsValue, entry_point: &str) -> Result<JsValue, JsError> {
use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
.map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
if file_map.is_empty() {
return Err(JsError::new("Files map cannot be empty"));
}
let vfs = VirtualFileSystem::from_files(file_map);
if !vfs.exists(Path::new(entry_point)) {
return Err(JsError::new(&format!(
"Entry point '{entry_point}' not found in files map"
)));
}
let mut loader = Loader::new().with_filesystem(Box::new(vfs));
let load_result = match loader.load(Path::new(entry_point)) {
Ok(result) => result,
Err(e) => {
let result = ValidationResult {
valid: false,
errors: vec![Error::new(format!("Load error: {e}"))],
};
return to_js(&result);
}
};
let parse_errors = load_errors_to_errors(&load_result);
if !parse_errors.is_empty() {
let result = ValidationResult {
valid: false,
errors: parse_errors,
};
return to_js(&result);
}
let options = LoadOptions {
validate: true,
..Default::default()
};
let ledger = match process(load_result, &options) {
Ok(ledger) => ledger,
Err(e) => {
let result = ValidationResult {
valid: false,
errors: vec![Error::new(format!("Processing error: {e}"))],
};
return to_js(&result);
}
};
let errors: Vec<Error> = ledger.errors.into_iter().map(Error::from).collect();
let result = ValidationResult {
valid: errors.is_empty(),
errors,
};
to_js(&result)
}
#[wasm_bindgen(js_name = "queryMultiFile")]
pub fn query_multi_file(
files: JsValue,
entry_point: &str,
query_str: &str,
) -> Result<JsValue, JsError> {
use rustledger_booking::expand_pads;
use rustledger_loader::{LoadOptions, Loader, VirtualFileSystem, process};
use rustledger_query::{Executor, parse as parse_query};
let file_map: HashMap<String, String> = serde_wasm_bindgen::from_value(files)
.map_err(|e| JsError::new(&format!("Invalid files object: {e}")))?;
if file_map.is_empty() {
return Err(JsError::new("Files map cannot be empty"));
}
let vfs = VirtualFileSystem::from_files(file_map);
if !vfs.exists(Path::new(entry_point)) {
return Err(JsError::new(&format!(
"Entry point '{entry_point}' not found in files map"
)));
}
let mut loader = Loader::new().with_filesystem(Box::new(vfs));
let load_result = match loader.load(Path::new(entry_point)) {
Ok(result) => result,
Err(e) => {
let result = QueryResult {
columns: Vec::new(),
rows: Vec::new(),
errors: vec![Error::new(format!("Load error: {e}"))],
};
return to_js(&result);
}
};
let parse_errors = load_errors_to_errors(&load_result);
if !parse_errors.is_empty() {
let result = QueryResult {
columns: Vec::new(),
rows: Vec::new(),
errors: parse_errors,
};
return to_js(&result);
}
let options = LoadOptions {
validate: false,
..Default::default()
};
let ledger = match process(load_result, &options) {
Ok(ledger) => ledger,
Err(e) => {
let result = QueryResult {
columns: Vec::new(),
rows: Vec::new(),
errors: vec![Error::new(format!("Processing error: {e}"))],
};
return to_js(&result);
}
};
let errors: Vec<Error> = ledger.errors.into_iter().map(Error::from).collect();
let has_errors = errors.iter().any(|e| e.severity == Severity::Error);
if has_errors {
let result = QueryResult {
columns: Vec::new(),
rows: Vec::new(),
errors,
};
return to_js(&result);
}
let booked_directives: Vec<_> = ledger.directives.into_iter().map(|s| s.value).collect();
let directives = expand_pads(&booked_directives);
let query = match parse_query(query_str) {
Ok(q) => q,
Err(e) => {
let result = QueryResult {
columns: Vec::new(),
rows: Vec::new(),
errors: vec![Error::new(e.to_string())],
};
return to_js(&result);
}
};
let mut executor = Executor::new(&directives);
match executor.execute(&query) {
Ok(result) => {
let rows: Vec<Vec<_>> = result
.rows
.iter()
.map(|row| row.iter().map(value_to_cell).collect())
.collect();
let query_result = QueryResult {
columns: result.columns,
rows,
errors: Vec::new(),
};
to_js(&query_result)
}
Err(e) => {
let result = QueryResult {
columns: Vec::new(),
rows: Vec::new(),
errors: vec![Error::new(format!("Query execution error: {e}"))],
};
to_js(&result)
}
}
}
#[wasm_bindgen(js_name = "hashSources")]
#[allow(clippy::needless_pass_by_value)] pub fn hash_sources(sources: Vec<String>) -> String {
let refs: Vec<&str> = sources.iter().map(String::as_str).collect();
crate::cache::hash_sources(&refs)
}