noyalib_wasm/lib.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! noyalib WASM bindings.
5//!
6//! Exposes YAML parse/serialize and the lossless Document API to JavaScript
7//! via wasm-bindgen.
8//!
9//! The pure-Rust logic lives in [`core`] so it is reachable from
10//! `cargo test` on the rlib side; the bindings in this module are
11//! the thin JsValue conversion shells.
12//!
13//! # Cargo features
14//!
15//! - **`wasm-opt`** *(optional, off by default)* — enables a
16//! post-build pass that re-runs Binaryen's `wasm-opt -O3 -s`
17//! on the produced `.wasm` to shrink the bundle (~17% smaller
18//! on `noyalib-wasm`). Pulls in no extra Rust dependencies; it
19//! is a build-script feature only. Disable for fastest dev
20//! builds.
21//!
22//! Optional `noyalib` features pulled in by a downstream wasm-pack
23//! consumer (`schema`, `parallel` …) compile against the
24//! `wasm32-unknown-unknown` target only when their dep tree is
25//! WASM-clean — `parallel` (rayon) requires
26//! `--target wasm32-wasip1-threads`, not the default browser
27//! target. The canonical `noyalib` feature matrix lives in
28//! [`crates/noyalib/src/lib.rs`](https://docs.rs/noyalib).
29//!
30//! # MSRV
31//!
32//! **Rust 1.85.0** stable. The `wasm-bindgen` 0.2 ecosystem
33//! pulls helpers floored at 1.85; the core `noyalib` library
34//! still builds on **1.75**. See workspace
35//! [`POLICIES.md`](https://github.com/sebastienrousseau/noyalib/blob/main/doc/POLICIES.md#1-msrv-minimum-supported-rust-version).
36//!
37//! # Panics
38//!
39//! Public bindings do not panic on well-formed input. Rust-side
40//! parse / serialise errors are converted to `JsError` values
41//! that JavaScript receives as a thrown `Error`; the bindings
42//! never `unwrap` user-supplied data. The remaining sources of
43//! panic on the WebAssembly instance are environmental, not
44//! logic, and are documented here for completeness:
45//!
46//! - **WASM linear-memory exhaustion (OOM).** Allocating a Vec
47//! or String larger than the host's WASM memory limit aborts
48//! the instance. Browsers default to a 4 GiB cap on
49//! `wasm32-unknown-unknown`. Bound `noyalib` resource use
50//! ahead of time via `ParserConfig::strict()` or explicit
51//! `max_*` budgets to fail with a structured `Error::Budget`
52//! instead of an OOM abort.
53//! - **Stack overflow.** Pathologically deep YAML (>4096 nested
54//! nodes by default) is rejected with `Error::RecursionLimitExceeded`
55//! long before the WASM stack overflows; deliberately
56//! misconfigured `max_depth` may overflow the host stack.
57//! - **Host abort on `panic = abort`.** Every release build
58//! uses `panic = abort` (per `Cargo.toml`), so any logic-level
59//! panic terminates the WebAssembly instance with no chance
60//! of `catch_unwind`. `console_error_panic_hook` is wired in
61//! `examples/` so debug builds surface a JS-readable trace
62//! before the abort.
63//!
64//! # Errors
65//!
66//! Every fallible binding returns `Result<JsValue, JsError>`;
67//! the `JsError` carries the Rust-side `Display` text from
68//! [`noyalib::Error`]. JavaScript callers handle these as
69//! standard thrown errors — `try { … } catch (e) { e.message }`.
70//!
71//! # Concurrency
72//!
73//! WebAssembly is single-threaded on browsers (without
74//! shared-memory + `--enable-experimental-features`). The
75//! bindings hold no internal state outside the `WasmDocument`
76//! handle; multi-document workloads simply construct multiple
77//! handles. Web Workers hosting independent WASM instances are
78//! the recommended way to parallelise on the browser.
79//!
80//! # Platform support
81//!
82//! Builds against every wasm-pack target: `bundler` (Webpack /
83//! Rollup / esbuild / Vite), `web` (native ES module), `nodejs`
84//! (CommonJS), `deno`, `no-modules` (plain global). CI verifies
85//! the `wasm32-unknown-unknown` target each PR via
86//! `wasm-pack test --node`. Cloudflare Workers, Deno, and Bun
87//! consume the `bundler` target via their own packaging step;
88//! see [`crates/noyalib-wasm/doc/bundling.md`](https://github.com/sebastienrousseau/noyalib/blob/main/crates/noyalib-wasm/doc/bundling.md).
89//!
90//! # Performance
91//!
92//! Release bundle size: ~338 KB raw, ~140 KB gzip. With the
93//! optional `wasm-opt` post-build pass (`--features wasm-opt`):
94//! ~280 KB raw, ~115 KB gzip. Tree-shaking-friendly: the
95//! `WasmDocument` API and the plain `parse` / `stringify` API
96//! are independent modules; bundlers drop whichever your code
97//! does not import.
98//!
99//! # Security
100//!
101//! `#![forbid(unsafe_code)]`. No `unsafe` outside `wasm-bindgen`'s
102//! own bridge. No network I/O. No filesystem access (browsers
103//! sandbox WASM by default; Node and Deno hosts can grant fs
104//! access to the host process, but this crate's bindings do
105//! not expose it). Resource-limit gates are inherited from
106//! `noyalib`'s `ParserConfig` defaults. Full posture:
107//! [`SECURITY.md`](https://github.com/sebastienrousseau/noyalib/blob/main/SECURITY.md).
108//!
109//! # API stability and SemVer
110//!
111//! Pre-1.0 (`0.0.x`): the JavaScript-facing API (`parse`,
112//! `stringify`, `WasmDocument` shape, JSON shape of returned
113//! values) is **stable** within a 0.0.x line — bug fixes only.
114//! Adding a new method or option is allowed within a 0.0.x
115//! bump; removing or renaming an exported binding, or changing
116//! the JSON shape of a returned object, is held to a 0.x bump
117//! (e.g. 0.0.x → 0.1.0). The Rust library surface (`WasmDocument`,
118//! `core::*`) is covered by the workspace SemVer policy in
119//! [`POLICIES.md`](https://github.com/sebastienrousseau/noyalib/blob/main/doc/POLICIES.md#2-semver--api-stability).
120//! `cargo-semver-checks` runs in CI on every PR.
121//!
122//! # Documentation
123//!
124//! - **Engineering policies** — workspace
125//! [`POLICIES.md`](https://github.com/sebastienrousseau/noyalib/blob/main/doc/POLICIES.md).
126//! - **JS API reference**:
127//! [`doc/js-api.md`](https://github.com/sebastienrousseau/noyalib/blob/main/crates/noyalib-wasm/doc/js-api.md).
128//! - **Bundling guide** (Vite, Webpack, Next.js, Cloudflare,
129//! Deno, Bun):
130//! [`doc/bundling.md`](https://github.com/sebastienrousseau/noyalib/blob/main/crates/noyalib-wasm/doc/bundling.md).
131//! - **Browser / Node demos**:
132//! [`examples/`](https://github.com/sebastienrousseau/noyalib/tree/main/crates/noyalib-wasm/examples).
133
134#![forbid(unsafe_code)]
135
136pub mod core;
137
138use noyalib::cst::{Document, parse_document};
139use serde::Serialize;
140use wasm_bindgen::prelude::*;
141
142/// Convert a Rust value to a `JsValue` using the JS-friendly
143/// serializer config:
144///
145/// - **Mappings** become plain JS Objects (`{ name: 'foo' }`),
146/// not the `Map` instance `serde_wasm_bindgen`'s default
147/// produces. End users overwhelmingly expect dot-property
148/// access (`value.name`) rather than `.get('name')`.
149/// - Other defaults are preserved.
150fn to_js<T: Serialize + ?Sized>(value: &T) -> Result<JsValue, serde_wasm_bindgen::Error> {
151 let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true);
152 value.serialize(&serializer)
153}
154
155#[derive(Serialize)]
156struct WasmSpan {
157 start: usize,
158 end: usize,
159}
160
161/// A YAML document with byte-faithful source preservation and path-targeted edits.
162#[wasm_bindgen]
163pub struct WasmDocument {
164 inner: Document,
165}
166
167#[wasm_bindgen]
168impl WasmDocument {
169 /// Parse a YAML string into a lossless Document.
170 #[wasm_bindgen(constructor)]
171 pub fn new(yaml: &str) -> Result<WasmDocument, JsError> {
172 let doc = parse_document(yaml).map_err(|e| JsError::new(&e.to_string()))?;
173 Ok(WasmDocument { inner: doc })
174 }
175
176 /// Re-emit the document as a string. Byte-identical to original if no edits.
177 ///
178 /// Bound on the JS side as `toString()` (the conventional camelCase
179 /// name) so callers can write `doc.toString()` and override the
180 /// default `Object.prototype.toString` rather than coexist with it.
181 #[allow(clippy::inherent_to_string)]
182 #[wasm_bindgen(js_name = toString)]
183 pub fn to_string(&self) -> String {
184 self.inner.to_string()
185 }
186
187 /// Replace the bytes at `start..end` with `replacement`.
188 ///
189 /// Bound on the JS side as `replaceSpan(start, end, replacement)`
190 /// per JS naming conventions.
191 #[wasm_bindgen(js_name = replaceSpan)]
192 pub fn replace_span(
193 &mut self,
194 start: usize,
195 end: usize,
196 replacement: &str,
197 ) -> Result<(), JsError> {
198 self.inner
199 .replace_span(start, end, replacement)
200 .map_err(|e| JsError::new(&e.to_string()))
201 }
202
203 /// Get the parsed value at a dotted path.
204 pub fn get(&self, path: &str) -> Result<JsValue, JsError> {
205 match core::document_get_value(&self.inner, path) {
206 Some(v) => to_js(&v).map_err(|e| JsError::new(&e.to_string())),
207 None => Ok(JsValue::NULL),
208 }
209 }
210
211 /// Get the raw source fragment at a dotted path.
212 ///
213 /// Bound on the JS side as `getSource(path)` per JS naming
214 /// conventions.
215 #[wasm_bindgen(js_name = getSource)]
216 pub fn get_source(&self, path: &str) -> JsValue {
217 match core::document_get_source(&self.inner, path) {
218 Some(s) => JsValue::from_str(s),
219 None => JsValue::NULL,
220 }
221 }
222
223 /// Get the byte range `{ start, end }` for the value at a dotted path.
224 ///
225 /// Bound on the JS side as `spanAt(path)` per JS naming
226 /// conventions.
227 #[wasm_bindgen(js_name = spanAt)]
228 pub fn span_at(&self, path: &str) -> Result<JsValue, JsError> {
229 match core::document_span_at(&self.inner, path) {
230 Some((start, end)) => {
231 to_js(&WasmSpan { start, end }).map_err(|e| JsError::new(&e.to_string()))
232 }
233 None => Ok(JsValue::NULL),
234 }
235 }
236
237 /// Set a value at a dotted path using a JS object.
238 ///
239 /// Bound on the JS side as `setValue(path, value)` per JS naming
240 /// conventions.
241 #[wasm_bindgen(js_name = setValue)]
242 pub fn set_value(&mut self, path: &str, value: JsValue) -> Result<(), JsError> {
243 let v: noyalib::Value =
244 serde_wasm_bindgen::from_value(value).map_err(|e| JsError::new(&e.to_string()))?;
245 self.inner
246 .set_value(path, &v)
247 .map_err(|e| JsError::new(&e.to_string()))
248 }
249
250 /// Set a value at a dotted path using a YAML fragment string.
251 pub fn set(&mut self, path: &str, fragment: &str) -> Result<(), JsError> {
252 self.inner
253 .set(path, fragment)
254 .map_err(|e| JsError::new(&e.to_string()))
255 }
256
257 /// Read the YAML comments associated with the node at `path`.
258 /// Returns `{ before: string[], inline: string | null }` so the
259 /// caller can surface human-authored doc-comments alongside
260 /// values — the demo that motivates the entire CST architecture.
261 ///
262 /// Bound on the JS side as `commentsAt(path)` per JS naming
263 /// conventions.
264 #[wasm_bindgen(js_name = commentsAt)]
265 pub fn comments_at(&self, path: &str) -> Result<JsValue, JsError> {
266 let (before, inline) = core::document_comments_at(&self.inner, path);
267 #[derive(Serialize)]
268 struct Bundle {
269 before: Vec<String>,
270 inline: Option<String>,
271 }
272 to_js(&Bundle { before, inline }).map_err(|e| JsError::new(&e.to_string()))
273 }
274}
275
276impl WasmDocument {
277 /// Native (rlib) accessor for the inner [`Document`]. Lets
278 /// `cargo test` exercise the underlying state transitions
279 /// without going through a JS shell.
280 pub fn as_document(&self) -> &Document {
281 &self.inner
282 }
283}
284
285// ── Legacy / Simple API ──────────────────────────────────────────────────────
286
287/// Parse a YAML string and return a JS object.
288#[wasm_bindgen]
289pub fn parse(yaml: &str) -> Result<JsValue, JsError> {
290 let value = core::parse_yaml_to_value(yaml).map_err(|e| JsError::new(&e.to_string()))?;
291 to_js(&value).map_err(|e| JsError::new(&e.to_string()))
292}
293
294/// Serialize a JS object to a YAML string.
295#[wasm_bindgen]
296pub fn stringify(value: JsValue) -> Result<String, JsError> {
297 let v: noyalib::Value =
298 serde_wasm_bindgen::from_value(value).map_err(|e| JsError::new(&e.to_string()))?;
299 core::value_to_yaml(&v).map_err(|e| JsError::new(&e.to_string()))
300}
301
302/// Validate YAML against the JSON-compatible schema. Bound on the
303/// JS side as `validateJson(yaml)` per JS naming conventions.
304#[wasm_bindgen(js_name = validateJson)]
305pub fn validate_json(yaml: &str) -> Result<bool, JsError> {
306 core::validate_yaml_json(yaml).map_err(|e| JsError::new(&e.to_string()))
307}
308
309/// Get a value at a dotted path from a YAML string. Bound on the
310/// JS side as `getPath(yaml, path)` per JS naming conventions.
311#[wasm_bindgen(js_name = getPath)]
312pub fn get_path(yaml: &str, path: &str) -> Result<JsValue, JsError> {
313 match core::yaml_get_path(yaml, path).map_err(|e| JsError::new(&e.to_string()))? {
314 Some(v) => to_js(&v).map_err(|e| JsError::new(&e.to_string())),
315 None => Ok(JsValue::NULL),
316 }
317}
318
319/// Merge two YAML documents.
320#[wasm_bindgen]
321pub fn merge(base_yaml: &str, override_yaml: &str) -> Result<String, JsError> {
322 core::merge_yaml(base_yaml, override_yaml).map_err(|e| JsError::new(&e.to_string()))
323}