From 9aabcd49c22306e64fc079b9421000235f261ede Mon Sep 17 00:00:00 2001 From: Yaroslav Bolyukin Date: Tue, 05 May 2026 17:34:58 +0000 Subject: [PATCH] refactor(web): idiomatic js api --- --- a/Cargo.lock +++ b/Cargo.lock @@ -1119,6 +1119,7 @@ "jrsonnet-stdlib", "jrsonnet-types", "js-sys", + "rustc-hash 2.1.2", "url", "wasm-bindgen", "wasm-bindgen-futures", --- a/README.adoc +++ b/README.adoc @@ -88,6 +88,21 @@ Jrsonnet is written in rust itself, so just add it as dependency +=== JavaScript/TypeScript (WASM) + +// image:https://img.shields.io/npm/v/jrsonnet[alt=npm, link=https://www.npmjs.com/package/jrsonnet] +image:https://jsr.io/badges/@jrsonnet/jrsonnet[alt=JSR, link=https://jsr.io/@jrsonnet/jrsonnet] +image:https://jsr.io/badges/@jrsonnet/jrsonnet/score[alt=JSR score, link=https://jsr.io/@jrsonnet/jrsonnet] + +WASM bindings are published to JSR as `@jrsonnet/jrsonnet`. + +[source,sh] +---- +deno add jsr:@jrsonnet/jrsonnet +---- + +// npm package (`jrsonnet`) is not yet published. + === Python image:https://img.shields.io/pypi/v/rjsonnet[alt=crates.io, link=https://pypi.org/project/rjsonnet/] @@ -100,6 +115,6 @@ === Other -WASM bingings are also available, Java bindings (Both JNI and WASM compiled to `.class`) are in progress +Java bindings (Both JNI and WASM compiled to `.class`) are in progress See link:./bindings/[bindings] for more information. --- a/bindings/jrsonnet-web/Cargo.toml +++ b/bindings/jrsonnet-web/Cargo.toml @@ -1,6 +1,8 @@ [package] name = "jrsonnet-web" -description = "WASM JS bindings for jrsonnet" +description = "WebAssembly JavaScript bindings for jrsonnet" +keywords = ["jsonnet", "wasm", "web"] +categories = ["wasm"] authors.workspace = true edition.workspace = true license.workspace = true @@ -17,6 +19,7 @@ jrsonnet-stdlib.workspace = true jrsonnet-types.workspace = true js-sys.workspace = true +rustc-hash.workspace = true url.workspace = true wasm-bindgen.workspace = true wasm-bindgen-futures.workspace = true --- a/bindings/jrsonnet-web/deno.json +++ b/bindings/jrsonnet-web/deno.json @@ -1,7 +1,7 @@ { "name": "@jrsonnet/jrsonnet", "license": "MIT", - "version": "0.0.1", + "version": "0.0.2", "tasks": { "wasmbuild": "deno run -A @deno/wasmbuild -p jrsonnet-web --skip-opt" }, @@ -11,11 +11,15 @@ "@std/assert": "jsr:@std/assert@^1.0.19" }, "exports": { - ".": "./mod.ts" + ".": "./mod.ts", + "./fmt": "./fmt.ts" }, "publish": { "exclude": [ "!lib" ] + }, + "fmt": { + "useTabs": true } } --- a/bindings/jrsonnet-web/fmt.test.ts +++ b/bindings/jrsonnet-web/fmt.test.ts @@ -1,7 +1,7 @@ import { assertEquals } from "@std/assert"; -import { format, FormatOptions } from "./mod.ts"; +import { format, FormatOptions } from "./fmt.ts"; Deno.test("format", () => { - const opts = new FormatOptions(); - assertEquals(format("{a:1+1}", opts), "{ a: 1 + 1 }\n"); + const opts = new FormatOptions(); + assertEquals(format("{a:1+1}", opts), "{ a: 1 + 1 }\n"); }); --- a/bindings/jrsonnet-web/fmt.ts +++ b/bindings/jrsonnet-web/fmt.ts @@ -0,0 +1 @@ +export { format, FormatOptions } from "./lib/jsonnet_web.js"; --- a/bindings/jrsonnet-web/mod.test.ts +++ b/bindings/jrsonnet-web/mod.test.ts @@ -1,8 +1,139 @@ -import { assertEquals } from "@std/assert"; -import { WasmState } from "./mod.ts"; +import { assertEquals, assertRejects, assertThrows } from "@std/assert"; +import { type ImportResolver, JrsonnetError, State, ValKind } from "./mod.ts"; + +Deno.test("evaluateSnippet returns numbers", () => { + const state = new State(); + const v = state.evaluateSnippet("test.jsonnet", "1 + 2"); + assertEquals(v.kind, ValKind.Num); + assertEquals(v.asNum(), 3); +}); + +Deno.test("evaluateSnippet returns booleans", () => { + const state = new State(); + const v = state.evaluateSnippet("test.jsonnet", "true && !false"); + assertEquals(v.kind, ValKind.Bool); + assertEquals(v.asBool(), true); +}); + +Deno.test("evaluateSnippet returns strings", () => { + const state = new State(); + const v = state.evaluateSnippet("test.jsonnet", "'hello ' + 'world'"); + assertEquals(v.kind, ValKind.Str); + assertEquals(v.asString(), "hello world"); +}); -Deno.test("basic", () => { - const state = new WasmState(); +Deno.test("evaluateSnippet returns null", () => { + const state = new State(); + const v = state.evaluateSnippet("test.jsonnet", "null"); + assertEquals(v.kind, ValKind.Null); + assertEquals(v.asNum(), undefined); +}); - assertEquals(state.evaluate_snippet("test.jsonnet", "1 + 2").as_num(), 3); +Deno.test("Val.asArr exposes ArrValue", () => { + const state = new State(); + const arr = state.evaluateSnippet("test.jsonnet", "[10, 20, 30]").asArr(); + if (!arr) throw new Error("expected array"); + assertEquals(arr.length, 3); + assertEquals(arr.at(1)?.asNum(), 20); + assertEquals(arr.at(99), undefined); +}); + +Deno.test("Val.asObj exposes ObjValue", () => { + const state = new State(); + const obj = state.evaluateSnippet("test.jsonnet", "{a: 1, b: 'two'}").asObj(); + if (!obj) throw new Error("expected object"); + assertEquals(obj.keys().sort(), ["a", "b"]); + assertEquals(obj.get("a")?.asNum(), 1); + assertEquals(obj.get("b")?.asString(), "two"); + assertEquals(obj.get("missing"), undefined); +}); + +Deno.test("evaluateSnippet manifests JSON", () => { + const state = new State(); + const v = state.evaluateSnippet("test.jsonnet", "{a: 1, b: [2, 3]}"); + assertEquals(v.manifestJson(0), '{"a":1,"b":[2,3]}'); +}); + +Deno.test("evaluateSnippet propagates jsonnet errors", () => { + const state = new State(); + assertThrows(() => state.evaluateSnippet("test.jsonnet", "error 'boom'")); +}); + +Deno.test("evaluateFile without resolver rejects", async () => { + const state = new State(); + await assertRejects(() => state.evaluateFile("anything.jsonnet")); +}); + +Deno.test("resolver errors become JrsonnetError with cause", async () => { + const original = new Error("disk on fire"); + const resolver: ImportResolver = { + resolveFrom(_from, path) { + return Promise.resolve(`memory:///${path}`); + }, + loadFileContents(_resolved) { + throw original; + }, + }; + const state = new State(resolver); + const err = await assertRejects( + () => state.evaluateFile("anything.jsonnet"), + JrsonnetError, + "loadFileContents", + ); + assertEquals(err.cause, original); + assertEquals(err.frames[0]?.desc, "loadFileContents"); + // The wrapped error's own stack must not mention internal wasm frames. + assertEquals((err.stack ?? "").includes(".wasm"), false); +}); + +Deno.test("Val.applyTla calls function with named args", () => { + const state = new State(); + const fn = state.evaluateSnippet( + "test.jsonnet", + "function(x, y) x + y", + ); + const result = fn.applyTla({ + x: state.evaluateSnippet("x.jsonnet", "10"), + y: state.evaluateSnippet("y.jsonnet", "32"), + }); + assertEquals(result.asNum(), 42); +}); + +Deno.test("Val.applyTla borrows args without consuming them", () => { + const state = new State(); + const fn = state.evaluateSnippet("test.jsonnet", "function(x) x * 2"); + const x = state.evaluateSnippet("x.jsonnet", "21"); + assertEquals(fn.applyTla({ x }).asNum(), 42); + assertEquals(x.asNum(), 21); + assertEquals(fn.applyTla({ x }).asNum(), 42); +}); + +Deno.test("Val.applyTla on non-function returns the value unchanged", () => { + const state = new State(); + const v = state.evaluateSnippet("test.jsonnet", "123"); + assertEquals(v.applyTla({}).asNum(), 123); +}); + +Deno.test("evaluateFileFrom resolves relative paths", async () => { + const files: Record = { + "memory:///root/main.jsonnet": "import 'lib.jsonnet'", + "memory:///root/lib.jsonnet": "{ answer: 42 }", + }; + const resolver: ImportResolver = { + resolveFrom(from, path) { + const base = from ?? "memory:///root/"; + return Promise.resolve(new URL(path, base).toString()); + }, + loadFileContents(resolved) { + const code = files[resolved]; + if (code === undefined) throw new Error(`missing ${resolved}`); + return Promise.resolve(new TextEncoder().encode(code)); + }, + }; + const state = new State(resolver); + const v = await state.evaluateFileFrom( + "memory:///root/main.jsonnet", + "./lib.jsonnet", + ); + assertEquals(v.asObj()?.get("answer")?.asNum(), 42); }); --- a/bindings/jrsonnet-web/mod.ts +++ b/bindings/jrsonnet-web/mod.ts @@ -1,60 +1,71 @@ import { assert } from "@std/assert"; import { - format as formatRaw, - type ImportResolver, - WasmFormatOptions, - WasmState, - WasmVal, + ArrValue, + type ImportResolver, + ObjValue, + setErrorFactory, + State, + Val, + ValKind, } from "./lib/jsonnet_web.js"; -export { type ImportResolver, WasmFormatOptions, WasmState, WasmVal }; +export interface JrsonnetFrame { + desc: string; + path?: string; + line?: number; + column?: number; +} -class FetchImportResolver implements ImportResolver { - constructor(public base: string) {} +export class JrsonnetError extends Error { + override name = "JrsonnetError" as const; + frames: JrsonnetFrame[]; - resolution = new Map(); - response = new Map(); + constructor(message: string, frames: JrsonnetFrame[], cause?: unknown) { + super(message, cause !== undefined ? { cause } : undefined); + this.frames = frames; + } +} - async resolveFrom(from: string | undefined, path: string): Promise { - let resolved: URL; - if (from) { - resolved = new URL(path, from); - } else { - resolved = new URL(path, this.base); - } - const resolvingStr = resolved.toString(); - resolved = this.resolution.get(resolvingStr) ?? resolved; +setErrorFactory( + (message: string, frames: JrsonnetFrame[], cause: unknown) => + new JrsonnetError(message, frames, cause), +); + +export { ArrValue, type ImportResolver, ObjValue, State, Val, ValKind }; - const resolvedStr = resolved.toString(); - if (!this.response.has(resolvedStr)) { - console.log(resolved); - const v = await fetch(resolved); - this.response.set(resolvedStr, v); - resolved = new URL(v.url); - this.resolution.set(resolvingStr, resolved); - } - return resolved.toString(); - } - loadFileContents(resolved: string): Promise { - console.log(resolved); - const v = this.response.get(resolved); - assert(v, "should be resolved"); - return v.bytes(); - } -} +export class FetchImportResolver implements ImportResolver { + constructor(base: URL | string) { + this.#base = new URL(base); + } -// -// try { -// console.log("eval file"); -// await state.evaluate_file("example.jsonnet"); -// console.log("eval file done"); -// } catch (e) { -// console.log(e); -// } -// -export function format( - code: string, - opts: WasmFormatOptions = new WasmFormatOptions(), -): string { - return formatRaw(code, opts); + #base: URL; + #resolution = new Map(); + #bytes = new Map(); + + async resolveFrom(from: string | undefined, path: string): Promise { + const base = from !== undefined ? from : this.#base; + const requestStr = new URL(path, base).toString(); + + const cached = this.#resolution.get(requestStr); + if (cached !== undefined) return cached; + + const resp = await fetch(requestStr); + if (!resp.ok) { + throw new Error( + `fetch ${requestStr}: HTTP ${resp.status} ${resp.statusText}`, + ); + } + const canonical = resp.url; + if (!this.#bytes.has(canonical)) { + this.#bytes.set(canonical, await resp.bytes()); + } + this.#resolution.set(requestStr, canonical); + return canonical; + } + + loadFileContents(resolved: string): Promise { + const bytes = this.#bytes.get(resolved); + assert(bytes, `not loaded: ${resolved}`); + return Promise.resolve(bytes); + } } --- a/bindings/jrsonnet-web/scripts/build_npm.ts +++ b/bindings/jrsonnet-web/scripts/build_npm.ts @@ -3,27 +3,27 @@ await emptyDir("./npm"); await build({ - entryPoints: ["./mod.ts"], - outDir: "./npm", - shims: { - // see JS docs for overview and more options - deno: true, - }, - package: { - // package.json properties - name: "jrsonnet", - version: Deno.args[0], - description: "Jrsonnet.", - license: "MIT", - repository: { - type: "git", - url: "git+https://github.com/CertainLach/jrsonnet.git", - }, - bugs: { - url: "https://github.com/CertainLach/jrsonnet/issues", - }, - }, - postBuild() { - Deno.copyFileSync("../../LICENSE", "npm/LICENSE"); - }, + entryPoints: ["./mod.ts"], + outDir: "./npm", + shims: { + // see JS docs for overview and more options + deno: true, + }, + package: { + // package.json properties + name: "jrsonnet", + version: Deno.args[0], + description: "Jrsonnet.", + license: "MIT", + repository: { + type: "git", + url: "git+https://github.com/CertainLach/jrsonnet.git", + }, + bugs: { + url: "https://github.com/CertainLach/jrsonnet/issues", + }, + }, + postBuild() { + Deno.copyFileSync("../../LICENSE", "npm/LICENSE"); + }, }); --- a/bindings/jrsonnet-web/src/lib.rs +++ b/bindings/jrsonnet-web/src/lib.rs @@ -1,19 +1,25 @@ -use std::result::Result; +#![allow(clippy::future_not_send, reason = "we work with js promises anyway")] +use std::{cell::RefCell, result::Result}; + use jrsonnet_evaluator::{ - NumValue, Result as JrResult, SourcePath, SourceUrl, State, StateBuilder, Val, + IStr, NumValue, ObjValue, Result as JrResult, SourcePath, SourceUrl, State, StateBuilder, Val, async_import::{ResolvedImportResolver, async_import}, error, function::builtin::{NativeCallback, NativeCallbackHandler}, manifest::{JsonFormat, ManifestFormat, StringFormat, ToStringFormat, YamlStreamFormat}, - trace::{JsFormat, PathResolver, TraceFormat}, + tla::{TlaArg, apply_tla}, + trace::PathResolver, + val::ArrValue, with_state, }; use jrsonnet_formatter::FormatOptions; use jrsonnet_gcmodule::Trace; use jrsonnet_stdlib::{IniFormat, TomlFormat, XmlJsonmlFormat, YamlFormat}; use jrsonnet_types::ValType; -use wasm_bindgen::prelude::*; +use js_sys::Reflect::get; +use rustc_hash::FxHashMap; +use wasm_bindgen::{convert::RefFromWasmAbi, prelude::*}; #[wasm_bindgen] #[derive(Clone, Copy)] @@ -27,37 +33,60 @@ Func, } -#[wasm_bindgen(inline_js = r" -export class JrsonnetError extends Error { - constructor(message, frames) { - super(message); - this.name = 'JrsonnetError'; - this.frames = frames; - } +thread_local! { + static ERR_FACTORY: RefCell> = const { RefCell::new(None) }; } -export function makeJrsonnetError(message, frames) { - return new JrsonnetError(message, frames); +#[wasm_bindgen(js_name = setErrorFactory)] +pub fn set_error_factory(f: js_sys::Function) { + ERR_FACTORY.with(|c| *c.borrow_mut() = Some(f)); } -")] -extern "C" { - #[wasm_bindgen(js_name = makeJrsonnetError)] - fn make_jrsonnet_error(message: &str, frames: js_sys::Array) -> JsValue; +fn make_jrsonnet_error(message: &str, frames: js_sys::Array, cause: &JsValue) -> JsValue { + ERR_FACTORY.with(|c| { + c.borrow().as_ref().map_or_else( + || js_sys::Error::new(message).into(), + |f| { + let args = js_sys::Array::new(); + args.push(&JsValue::from_str(message)); + args.push(&frames); + args.push(cause); + f.apply(&JsValue::NULL, &args) + .unwrap_or_else(|e| js_sys::Error::new(&format!("{e:?}")).into()) + }, + ) + }) } -#[wasm_bindgen(typescript_custom_section)] -const TS_JRSONNET_ERROR: &'static str = r" -export interface JrsonnetFrame { - desc: string; - path?: string; - line?: number; - column?: number; +fn js_error_message(e: &JsValue) -> String { + e.dyn_ref::().map_or_else( + || e.as_string().unwrap_or_else(|| format!("{e:?}")), + |err| String::from(err.message()), + ) } -export class JrsonnetError extends Error { - name: 'JrsonnetError'; - frames: JrsonnetFrame[]; + +fn unwrap_val_ref(value: &JsValue) -> Result<::Anchor, JsValue> { + let ptr = get(value, &JsValue::from_str("__wbg_ptr")) + .ok() + .and_then(|v| v.as_f64()) + .ok_or_else(|| JsValue::from_str("expected a Val instance"))? as u32; + if ptr == 0 { + return Err(JsValue::from_str("Val has been freed")); + } + Ok(unsafe { ::ref_from_abi(ptr) }) } -"; +fn js_resolver_error(prefix: &str, e: JsValue) -> JsValue { + let msg = format!("{prefix}: {}", js_error_message(&e)); + let frames = js_sys::Array::new(); + let frame = js_sys::Object::new(); + let _ = js_sys::Reflect::set( + &frame, + &JsValue::from_str("desc"), + &JsValue::from_str(prefix), + ); + frames.push(&frame); + make_jrsonnet_error(&msg, frames, &e) +} + fn jrsonnet_js_error(e: &jrsonnet_evaluator::Error) -> JsValue { let msg = e.error().to_string(); // let msg = format.format(e).unwrap_or_else(|_| e.to_string()); @@ -90,7 +119,7 @@ } frames.push(&frame); } - make_jrsonnet_error(&msg, frames) + make_jrsonnet_error(&msg, frames, &JsValue::UNDEFINED) } impl From for ValKind { @@ -107,7 +136,7 @@ } } -#[wasm_bindgen] +#[wasm_bindgen(js_name = Val)] pub struct WasmVal { val: Val, state: Option, @@ -121,12 +150,6 @@ Self { val, state: Some(state), - } - } - fn child(&self, val: Val) -> Self { - Self { - val, - state: self.state.clone(), } } fn run(&self, f: impl FnOnce(&Val) -> R) -> R { @@ -143,7 +166,7 @@ } } -#[wasm_bindgen] +#[wasm_bindgen(js_class = Val)] impl WasmVal { pub fn null() -> Self { Self::new(Val::Null) @@ -167,7 +190,7 @@ pub fn func( params: Vec, - #[wasm_bindgen(unchecked_param_type = "(...args: WasmVal[]) => WasmVal")] + #[wasm_bindgen(unchecked_param_type = "(...args: Val[]) => Val")] callback: js_sys::Function, ) -> Self { #[allow(deprecated)] @@ -181,52 +204,76 @@ pub fn kind(&self) -> ValKind { self.val.value_type().into() } + #[wasm_bindgen(js_name = asBool)] pub fn as_bool(&self) -> Option { self.val.as_bool() } + #[wasm_bindgen(js_name = asNum)] pub fn as_num(&self) -> Option { self.val.as_num() } + #[wasm_bindgen(js_name = asString)] pub fn as_string(&self) -> Option { self.val.as_str().map(|s| s.to_string()) - } - pub fn arr_len(&self) -> Option { - self.val.as_arr().map(|a| a.len()) } - pub fn arr_at(&self, index: u32) -> Result, JsValue> { - let Some(a) = self.val.as_arr() else { - return Ok(None); - }; - self.run(|_| a.get(index)) - .map(|opt| opt.map(|v| self.child(v))) - .map_err(|e| jrsonnet_js_error(&e)) + #[wasm_bindgen(js_name = asArr)] + pub fn as_arr(&self) -> Option { + self.val.as_arr().map(|arr| WasmArrValue { + arr, + state: self.state.clone(), + }) } - pub fn obj_keys(&self) -> Option> { - self.val - .as_obj() - .map(|o| o.fields().into_iter().map(|s| s.to_string()).collect()) + #[wasm_bindgen(js_name = asObj)] + pub fn as_obj(&self) -> Option { + self.val.as_obj().map(|obj| WasmObjValue { + obj, + state: self.state.clone(), + }) } - pub fn obj_get(&self, key: String) -> Result, JsValue> { - let Some(o) = self.val.as_obj() else { - return Ok(None); - }; - self.run(|_| o.get(key.into())) - .map(|opt| opt.map(|v| self.child(v))) + + #[wasm_bindgen(js_name = applyTla)] + pub fn apply_tla( + &self, + #[wasm_bindgen(unchecked_param_type = "Record")] args: &js_sys::Object, + ) -> Result { + let mut map: FxHashMap = FxHashMap::default(); + for entry in js_sys::Object::entries(args).iter() { + let pair: js_sys::Array = entry + .dyn_into() + .map_err(|_| JsValue::from_str("expected [key, value] entry"))?; + let key = pair + .get(0) + .as_string() + .ok_or_else(|| JsValue::from_str("TLA arg key must be a string"))?; + let value = unwrap_val_ref(&pair.get(1))?; + map.insert(key.into(), TlaArg::Val(value.val.clone())); + } + let val = self.val.clone(); + self.run(|_| apply_tla(&map, val)) + .map(|v| WasmVal { + val: v, + state: self.state.clone(), + }) .map_err(|e| jrsonnet_js_error(&e)) } + #[wasm_bindgen(js_name = manifestJson)] pub fn manifest_json(&self, indent: u32) -> Result { self.manifest_with(JsonFormat::cli(indent as usize)) } + #[wasm_bindgen(js_name = manifestToString)] pub fn manifest_to_string(&self) -> Result { self.manifest_with(ToStringFormat) } + #[wasm_bindgen(js_name = manifestString)] pub fn manifest_string(&self) -> Result { self.manifest_with(StringFormat) } + #[wasm_bindgen(js_name = manifestYaml)] pub fn manifest_yaml(&self, indent: u32, quote_keys: bool) -> Result { self.manifest_with(YamlFormat::std_to_yaml(indent != 0, quote_keys)) } + #[wasm_bindgen(js_name = manifestYamlStream)] pub fn manifest_yaml_stream( &self, indent: u32, @@ -238,17 +285,84 @@ c_document_end, )) } + #[wasm_bindgen(js_name = manifestXmlJsonml)] pub fn manifest_xml_jsonml(&self) -> Result { self.manifest_with(XmlJsonmlFormat::std_to_xml()) } + #[wasm_bindgen(js_name = manifestToml)] pub fn manifest_toml(&self, indent: u32) -> Result { self.manifest_with(TomlFormat::std_to_toml(" ".repeat(indent as usize))) } + #[wasm_bindgen(js_name = manifestIni)] pub fn manifest_ini(&self) -> Result { self.manifest_with(IniFormat::std()) } } +#[wasm_bindgen(js_name = ArrValue)] +pub struct WasmArrValue { + arr: ArrValue, + state: Option, +} + +#[wasm_bindgen(js_class = ArrValue)] +impl WasmArrValue { + #[wasm_bindgen(getter)] + pub fn length(&self) -> u32 { + self.arr.len() + } + pub fn at(&self, index: u32) -> Result, JsValue> { + let result = self.state.as_ref().map_or_else( + || self.arr.get(index), + |state| { + let _guard = state.try_enter(); + self.arr.get(index) + }, + ); + result + .map(|opt: Option| { + opt.map(|v| WasmVal { + val: v, + state: self.state.clone(), + }) + }) + .map_err(|e| jrsonnet_js_error(&e)) + } +} + +#[wasm_bindgen(js_name = ObjValue)] +pub struct WasmObjValue { + obj: ObjValue, + state: Option, +} + +#[wasm_bindgen(js_class = ObjValue)] +impl WasmObjValue { + pub fn keys(&self) -> Vec { + self.obj + .fields() + .into_iter() + .map(|s| s.to_string()) + .collect() + } + pub fn get(&self, key: String) -> Result, JsValue> { + let result = if let Some(state) = &self.state { + let _guard = state.try_enter(); + self.obj.get(key.into()) + } else { + self.obj.get(key.into()) + }; + result + .map(|opt: Option| { + opt.map(|v| WasmVal { + val: v, + state: self.state.clone(), + }) + }) + .map_err(|e| jrsonnet_js_error(&e)) + } +} + #[derive(Trace)] struct JsHandler { #[trace(skip)] @@ -292,15 +406,15 @@ } } -#[wasm_bindgen] +#[wasm_bindgen(js_name = State)] pub struct WasmState { state: State, - resolver: JsAsyncResolver, + resolver: Option, } -#[wasm_bindgen] +#[wasm_bindgen(js_class = State)] impl WasmState { #[wasm_bindgen(constructor)] - pub fn new(resolver: ImportResolverJs) -> Self { + pub fn new(resolver: Option) -> Self { console_error_panic_hook::set_once(); let mut state = StateBuilder::default(); state.import_resolver(ResolvedImportResolver::new()); @@ -309,11 +423,11 @@ let state = state.build(); Self { state, - resolver: JsAsyncResolver { js: resolver }, + resolver: resolver.map(|js| JsAsyncResolver { js }), } } - #[wasm_bindgen] + #[wasm_bindgen(js_name = evaluateSnippet)] pub fn evaluate_snippet(&self, name: &str, snippet: &str) -> Result { let _guard = self.state.enter(); self.state @@ -322,8 +436,35 @@ .map_err(|e| jrsonnet_js_error(&e)) } + #[wasm_bindgen(js_name = evaluateFile)] pub async fn evaluate_file(&self, path: String) -> Result { - let path = async_import(self.state.clone(), self.resolver.clone(), &path.as_str()).await?; + self.evaluate_file_from_impl(None, path).await + } + + #[wasm_bindgen(js_name = evaluateFileFrom)] + pub async fn evaluate_file_from(&self, from: String, path: String) -> Result { + self.evaluate_file_from_impl(Some(from), path).await + } +} + +impl WasmState { + async fn evaluate_file_from_impl( + &self, + from: Option, + path: String, + ) -> Result { + let resolver = self + .resolver + .clone() + .ok_or_else(|| JsValue::from_str("file evaluation requires an ImportResolver"))?; + let from = match from { + Some(s) => { + let url = url::Url::parse(&s).map_err(|e| JsValue::from_str(&e.to_string()))?; + SourcePath::new(SourceUrl::new(url)) + } + None => SourcePath::default(), + }; + let path = async_import(self.state.clone(), resolver, &from, &path.as_str()).await?; let _guard = self.state.enter(); self.state .import_resolved(path) @@ -375,8 +516,13 @@ ) -> Result { let from_js = (!from.is_default()).then(|| from.to_string()); let path_str = path.as_path().as_ref().to_string_lossy().into_owned(); - let promise = self.js.resolve_from(from_js, &path_str)?; - let resolved_js = wasm_bindgen_futures::JsFuture::from(promise).await?; + let promise = self + .js + .resolve_from(from_js, &path_str) + .map_err(|e| js_resolver_error("resolveFrom", e))?; + let resolved_js = wasm_bindgen_futures::JsFuture::from(promise) + .await + .map_err(|e| js_resolver_error("resolveFrom", e))?; let resolved_str = resolved_js .as_string() .ok_or_else(|| JsValue::from_str("resolveFrom must return string"))?; @@ -386,8 +532,13 @@ async fn load_file_contents(&self, resolved: &SourcePath) -> Result, JsValue> { let resolved_str = resolved.to_string(); - let promise = self.js.load_file_contents(&resolved_str)?; - let bytes_js = wasm_bindgen_futures::JsFuture::from(promise).await?; + let promise = self + .js + .load_file_contents(&resolved_str) + .map_err(|e| js_resolver_error("loadFileContents", e))?; + let bytes_js = wasm_bindgen_futures::JsFuture::from(promise) + .await + .map_err(|e| js_resolver_error("loadFileContents", e))?; let arr = bytes_js .dyn_into::() .map_err(|_| JsValue::from_str("loadFileContents must return Uint8Array"))?; @@ -395,17 +546,27 @@ } } -#[wasm_bindgen] -pub struct WasmFormatOptions {} -#[wasm_bindgen] +#[wasm_bindgen(js_name = FormatOptions)] +pub struct WasmFormatOptions { + indent: u8, +} +#[wasm_bindgen(js_class = FormatOptions)] impl WasmFormatOptions { #[wasm_bindgen(constructor)] pub fn new() -> Self { - Self {} + Self { indent: 0 } } fn build(&self) -> FormatOptions { - FormatOptions { indent: 0 } + FormatOptions { + indent: self.indent, + } + } +} + +impl Default for WasmFormatOptions { + fn default() -> Self { + Self::new() } } -- gitstuff