fc_core/lib.rs
1//! fc-core — the shared runtime for future-css WASM components.
2//!
3//! # Why this crate exists
4//!
5//! Every future-css WASM component needs the same three things: a panic hook, a
6//! version handshake with the JS side, and a structured error type. That is all
7//! this crate contains. It is the most expensive crate in the workspace per byte
8//! of functionality, because every consumer of every component links it.
9//!
10//! Two rules follow from that. No component-specific logic belongs here, however
11//! tempting — a helper only rich text uses belongs in fc-rich-text, duplicated
12//! if a second component later needs it. And no dependency on any sibling crate:
13//! the arrow points down only.
14//!
15//! # The versioning handshake
16//!
17//! A CSS framework and a WASM module are versioned together, but a consumer
18//! upgrades them independently and often loads the `.wasm` from a CDN or a
19//! service-worker cache. If the JS layer and the module drift out of sync the
20//! failure mode is a wrong-but-plausible result rather than a crash, which is
21//! much worse to debug. [`abi_version`] makes that mismatch fail loudly on the
22//! first call instead.
23//!
24//! CSS and WASM are versioned separately on purpose: the CSS must work with no
25//! WASM at all, so bumping one must never force a bump of the other. The
26//! handshake covers the seam between them.
27
28use serde::{Deserialize, Serialize};
29#[cfg(feature = "bindgen")]
30use wasm_bindgen::prelude::*;
31
32/// The ABI version of this core module.
33///
34/// Increment whenever a change alters the meaning or shape of anything crossing
35/// the JS boundary. The JS layer asserts equality against this value.
36pub const ABI_VERSION: u32 = 2;
37
38/// Reported to JavaScript on load. Cheap, and the first thing to check when
39/// diagnosing a misbehaving integration.
40#[cfg_attr(feature = "bindgen", wasm_bindgen(js_name = abiVersion))]
41pub fn abi_version() -> u32 {
42 ABI_VERSION
43}
44
45
46/// Installs a panic hook that prints a readable message to the console.
47///
48/// Without this, a Rust panic in a release build surfaces to JS as
49/// "unreachable", which tells a user nothing. With `panic = "abort"` and release
50/// optimization this is the only diagnostic that exists, so it is called
51/// unconditionally at init rather than left to the consumer.
52#[cfg(feature = "bindgen")]
53#[wasm_bindgen(js_name = initPanicHook)]
54pub fn init_panic_hook() {
55 console_error_panic_hook::set_once();
56}
57
58/// Normalizes a text offset pair into a half-open range `[start, end)`.
59///
60/// Half-open is the convention throughout the workspace: it composes with
61/// slicing, and `start == end` naturally expresses a caret rather than needing a
62/// separate "is this a selection" flag.
63///
64/// A backwards range is an error rather than a silent swap. A caller computing
65/// one has a bug, and quietly fixing it hides that bug while surfacing its
66/// symptom somewhere unrelated.
67pub fn normalize_range(start: usize, end: usize) -> Result<(usize, usize), FcError> {
68 if end < start {
69 return Err(FcError::InvalidInput {
70 message: format!("range end ({end}) precedes start ({start})"),
71 });
72 }
73 Ok((start, end))
74}
75
76#[cfg(test)]
77mod tests {
78 use super::*;
79
80 #[test]
81 fn abi_version_matches_the_exported_constant() {
82 // Guards against the constant and the export drifting apart, which would
83 // make the handshake check vacuous.
84 assert_eq!(abi_version(), ABI_VERSION);
85 }
86
87 #[test]
88 fn normalize_range_accepts_forward_and_empty_ranges() {
89 assert_eq!(normalize_range(0, 5).unwrap(), (0, 5));
90 // A caret is the empty range, not a special case.
91 assert_eq!(normalize_range(3, 3).unwrap(), (3, 3));
92 }
93
94 #[test]
95 fn normalize_range_rejects_backwards_ranges() {
96 let err = normalize_range(5, 2).unwrap_err();
97 assert_eq!(err.kind(), "invalidInput");
98 assert!(err.message().contains("precedes"));
99 }
100
101 #[test]
102 fn error_kinds_are_stable() {
103 // These strings cross into JS and into logs; changing one is an ABI break.
104 assert_eq!(FcError::InvalidInput { message: String::new() }.kind(), "invalidInput");
105 assert_eq!(FcError::Unsupported { message: String::new() }.kind(), "unsupported");
106 assert_eq!(FcError::OutOfBounds { message: String::new() }.kind(), "outOfBounds");
107 }
108}
109
110/// The structured error every crate in the workspace returns across the
111/// boundary.
112///
113/// A `String` across a WASM boundary loses the distinction between "the user
114/// typed something invalid" and "we have a bug", and those need different
115/// handling at each end. This is serialized rather than thrown so the JS side
116/// can branch on `kind` without string matching.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118#[serde(tag = "kind", rename_all = "camelCase")]
119pub enum FcError {
120 /// Well-formed but semantically invalid, e.g. a range with end before
121 /// start. Usually the caller's fault and worth surfacing to the user.
122 InvalidInput { message: String },
123 /// Cannot be satisfied in the current state, e.g. undo on an empty history.
124 /// Expected in normal operation.
125 Unsupported { message: String },
126 /// An index outside the bounds of a document or model. Indicates a bug, so
127 /// it is logged rather than displayed.
128 OutOfBounds { message: String },
129}
130
131impl FcError {
132 /// A short machine-readable tag, for callers that branch without parsing.
133 pub fn kind(&self) -> &'static str {
134 match self {
135 FcError::InvalidInput { .. } => "invalidInput",
136 FcError::Unsupported { .. } => "unsupported",
137 FcError::OutOfBounds { .. } => "outOfBounds",
138 }
139 }
140
141 /// The human-facing message, without the kind prefix.
142 pub fn message(&self) -> &str {
143 match self {
144 FcError::InvalidInput { message }
145 | FcError::Unsupported { message }
146 | FcError::OutOfBounds { message } => message,
147 }
148 }
149}
150
151#[cfg(feature = "bindgen")]
152impl From<FcError> for JsValue {
153 /// A tagged primitive retains classification without linking JS object APIs.
154 fn from(err: FcError) -> Self {
155 let mut message = String::from("fc:");
156 message.push_str(err.kind());
157 message.push(':');
158 message.push_str(err.message());
159 JsValue::from_str(&message)
160 }
161}