Skip to main content

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}