git.delta.rocks / jrsonnet / refs/commits / 9aabcd49c223

difftreelog

refactor(web) idiomatic js api

zwxrzuuxYaroslav Bolyukin2026-05-05parent: #6ac56ea.patch.diff
in: master

10 files changed

modifiedCargo.lockdiffbeforeafterboth
--- 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",
modifiedREADME.adocdiffbeforeafterboth
before · README.adoc

jrsonnet logo

release donate license

What is it

Jsonnet is a data templating language

This Rust crate implements both jsonnet library and an alternative jsonnet executable based on it. For more information see Bindings.

Install

NixOS

jrsonnet is packaged in nixpkgs and maintained by @CertainLach

nix-env -iA nixpkgs.jrsonnet

MacOS

jrsonnet is packaged to brew and maintained by @messense

brew install jrsonnet

Windows/other linux distributions

You can get latest build of jrsonnet in releases.

Build from sources

jrsonnet should build on latest stable Rust version (probably on the oldest, but there is no MSRV policy provided)

Debug build will work too, but it is much slower than release

cargo build --release

Why?

There already are multiple implementations of this standard implemented in different languages:

This implementation shows performance better than all existing implementations. For more information see benchmarks

Also, I wanted to experiment on new syntax features, and jrsonnet implements some of them. For more information see features

In the end, it’s always fun to implement something in Rust.

Bindings

Rust

crates.io docs.rs

Jrsonnet is written in rust itself, so just add it as dependency

Python

crates.io

Bindings are created and maintained by @messense

C/C++

Jrsonnet provides a standard libjsonnet.so shared library and should work as drop-in replacement for it

Other

WASM bingings are also available, Java bindings (Both JNI and WASM compiled to .class) are in progress

See bindings for more information.

modifiedbindings/jrsonnet-web/Cargo.tomldiffbeforeafterboth
--- 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
modifiedbindings/jrsonnet-web/deno.jsondiffbeforeafterboth
--- 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
 	}
 }
modifiedbindings/jrsonnet-web/fmt.test.tsdiffbeforeafterboth
--- 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");
 });
modifiedbindings/jrsonnet-web/fmt.tsdiffbeforeafterboth
--- a/bindings/jrsonnet-web/fmt.ts
+++ b/bindings/jrsonnet-web/fmt.ts
@@ -0,0 +1 @@
+export { format, FormatOptions } from "./lib/jsonnet_web.js";
modifiedbindings/jrsonnet-web/mod.test.tsdiffbeforeafterboth
--- 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<string, string> = {
+		"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);
 });
modifiedbindings/jrsonnet-web/mod.tsdiffbeforeafterboth
--- 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<string, URL>();
-  response = new Map<string, Response>();
+	constructor(message: string, frames: JrsonnetFrame[], cause?: unknown) {
+		super(message, cause !== undefined ? { cause } : undefined);
+		this.frames = frames;
+	}
+}
 
-  async resolveFrom(from: string | undefined, path: string): Promise<string> {
-    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<Uint8Array> {
-    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<string, string>();
+	#bytes = new Map<string, Uint8Array>();
+
+	async resolveFrom(from: string | undefined, path: string): Promise<string> {
+		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<Uint8Array> {
+		const bytes = this.#bytes.get(resolved);
+		assert(bytes, `not loaded: ${resolved}`);
+		return Promise.resolve(bytes);
+	}
 }
modifiedbindings/jrsonnet-web/scripts/build_npm.tsdiffbeforeafterboth
--- 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");
+	},
 });
modifiedbindings/jrsonnet-web/src/lib.rsdiffbeforeafterboth
--- 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<Option<js_sys::Function>> = 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::<js_sys::Error>().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<<WasmVal as RefFromWasmAbi>::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 { <WasmVal as RefFromWasmAbi>::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<ValType> for ValKind {
@@ -107,7 +136,7 @@
 	}
 }
 
-#[wasm_bindgen]
+#[wasm_bindgen(js_name = Val)]
 pub struct WasmVal {
 	val: Val,
 	state: Option<State>,
@@ -121,12 +150,6 @@
 		Self {
 			val,
 			state: Some(state),
-		}
-	}
-	fn child(&self, val: Val) -> Self {
-		Self {
-			val,
-			state: self.state.clone(),
 		}
 	}
 	fn run<R>(&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<String>,
 
-		#[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<bool> {
 		self.val.as_bool()
 	}
+	#[wasm_bindgen(js_name = asNum)]
 	pub fn as_num(&self) -> Option<f64> {
 		self.val.as_num()
 	}
+	#[wasm_bindgen(js_name = asString)]
 	pub fn as_string(&self) -> Option<String> {
 		self.val.as_str().map(|s| s.to_string())
-	}
-	pub fn arr_len(&self) -> Option<u32> {
-		self.val.as_arr().map(|a| a.len())
 	}
-	pub fn arr_at(&self, index: u32) -> Result<Option<WasmVal>, 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<WasmArrValue> {
+		self.val.as_arr().map(|arr| WasmArrValue {
+			arr,
+			state: self.state.clone(),
+		})
 	}
-	pub fn obj_keys(&self) -> Option<Vec<String>> {
-		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<WasmObjValue> {
+		self.val.as_obj().map(|obj| WasmObjValue {
+			obj,
+			state: self.state.clone(),
+		})
 	}
-	pub fn obj_get(&self, key: String) -> Result<Option<WasmVal>, 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<string, Val>")] args: &js_sys::Object,
+	) -> Result<WasmVal, JsValue> {
+		let mut map: FxHashMap<IStr, TlaArg> = 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<String, JsValue> {
 		self.manifest_with(JsonFormat::cli(indent as usize))
 	}
+	#[wasm_bindgen(js_name = manifestToString)]
 	pub fn manifest_to_string(&self) -> Result<String, JsValue> {
 		self.manifest_with(ToStringFormat)
 	}
+	#[wasm_bindgen(js_name = manifestString)]
 	pub fn manifest_string(&self) -> Result<String, JsValue> {
 		self.manifest_with(StringFormat)
 	}
+	#[wasm_bindgen(js_name = manifestYaml)]
 	pub fn manifest_yaml(&self, indent: u32, quote_keys: bool) -> Result<String, JsValue> {
 		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<String, JsValue> {
 		self.manifest_with(XmlJsonmlFormat::std_to_xml())
 	}
+	#[wasm_bindgen(js_name = manifestToml)]
 	pub fn manifest_toml(&self, indent: u32) -> Result<String, JsValue> {
 		self.manifest_with(TomlFormat::std_to_toml(" ".repeat(indent as usize)))
 	}
+	#[wasm_bindgen(js_name = manifestIni)]
 	pub fn manifest_ini(&self) -> Result<String, JsValue> {
 		self.manifest_with(IniFormat::std())
 	}
 }
 
+#[wasm_bindgen(js_name = ArrValue)]
+pub struct WasmArrValue {
+	arr: ArrValue,
+	state: Option<State>,
+}
+
+#[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<Option<WasmVal>, 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<Val>| {
+				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<State>,
+}
+
+#[wasm_bindgen(js_class = ObjValue)]
+impl WasmObjValue {
+	pub fn keys(&self) -> Vec<String> {
+		self.obj
+			.fields()
+			.into_iter()
+			.map(|s| s.to_string())
+			.collect()
+	}
+	pub fn get(&self, key: String) -> Result<Option<WasmVal>, 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<Val>| {
+				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<JsAsyncResolver>,
 }
-#[wasm_bindgen]
+#[wasm_bindgen(js_class = State)]
 impl WasmState {
 	#[wasm_bindgen(constructor)]
-	pub fn new(resolver: ImportResolverJs) -> Self {
+	pub fn new(resolver: Option<ImportResolverJs>) -> 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<WasmVal, JsValue> {
 		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<WasmVal, JsValue> {
-		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<WasmVal, JsValue> {
+		self.evaluate_file_from_impl(Some(from), path).await
+	}
+}
+
+impl WasmState {
+	async fn evaluate_file_from_impl(
+		&self,
+		from: Option<String>,
+		path: String,
+	) -> Result<WasmVal, JsValue> {
+		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<SourcePath, JsValue> {
 		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<Vec<u8>, 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::<js_sys::Uint8Array>()
 			.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()
 	}
 }