Skip to main content

forma_ir/props/
value.rs

1use super::{compare_schema, field_path, PageProps, PropsError, PropsSource, SchemaBuilder};
2use crate::{
3    schema::{PropField, PropType},
4    IrModule,
5};
6use serde_json::Value;
7
8/// A closed-set value used by a derived props field. Unsupported Rust types do
9/// not implement this trait; derive emits bounds at each offending field.
10pub trait PropValue: Sized + 'static {
11    /// The actual FMIR type representation for this Rust value.
12    fn describe(builder: &mut SchemaBuilder) -> Result<PropType, PropsError>;
13    /// Project this value through the declared TypeScript type. None means an
14    /// omitted object field; an array caller converts it to a null placeholder.
15    fn encode_value(
16        &self,
17        context: &EncodeContext<'_>,
18        kind: &PropType,
19        path: &str,
20    ) -> Result<Option<Value>, PropsError>;
21    /// Root declaration; field declarations override this in value diagnostics.
22    fn source() -> PropsSource {
23        PropsSource::new(std::any::type_name::<Self>(), 0, 0)
24    }
25}
26
27/// Context passed by generated implementations; encoding always follows the
28/// parsed schema and never serializes an unrestricted Rust object.
29#[doc(hidden)]
30#[derive(Debug)]
31pub struct EncodeContext<'a> {
32    module: &'a IrModule,
33    route: &'a str,
34    typescript: Option<PropsSource>,
35    rust: Option<PropsSource>,
36    depth: usize,
37}
38impl<'a> EncodeContext<'a> {
39    /// Strip optional schema wrappers for a Rust field that always provides a value.
40    pub fn required<'b>(&self, mut kind: &'b PropType) -> &'b PropType {
41        while let PropType::Optional(inner) = kind {
42            kind = inner;
43        }
44        kind
45    }
46    /// A value violation with the active route and both declarations.
47    pub fn error(&self, code: &'static str, path: &str, message: impl Into<String>) -> PropsError {
48        PropsError {
49            code,
50            route: self.route.into(),
51            path: path.into(),
52            message: message.into(),
53            typescript: self.typescript.clone().map(Box::new),
54            rust: self.rust.clone().map(Box::new),
55        }
56    }
57    /// Fields of a declared object; used by a struct derive to project by name.
58    pub fn object(&self, kind: &PropType, path: &str) -> Result<&'a [PropField], PropsError> {
59        if let PropType::Object(index) = self.required(kind) {
60            return self
61                .module
62                .schema
63                .objects
64                .get(*index as usize)
65                .map(Vec::as_slice)
66                .ok_or_else(|| self.error("RENDER-404", path, "invalid object reference"));
67        }
68        Err(self.error("RENDER-404", path, "expected a declared object"))
69    }
70    /// Resolve a schema string without unchecked indexing.
71    pub fn name(&self, index: u32, path: &str) -> Result<&'a str, PropsError> {
72        self.module
73            .strings
74            .get(index)
75            .map_err(|e| self.error("RENDER-404", path, e.to_string()))
76    }
77    /// Enter a field, preserving the declaration metadata and limiting recursion.
78    pub fn field(
79        &self,
80        field: &PropField,
81        rust: PropsSource,
82        path: &str,
83    ) -> Result<Self, PropsError> {
84        self.descend(path).map(|mut child| {
85            child.typescript = self
86                .module
87                .strings
88                .get(field.file)
89                .ok()
90                .map(|file| PropsSource::new(file, field.line, field.column));
91            child.rust = Some(rust);
92            child
93        })
94    }
95    fn descend(&self, path: &str) -> Result<Self, PropsError> {
96        if self.depth >= crate::format::MAX_SCHEMA_ITEMS {
97            return Err(self.error(
98                "RENDER-404",
99                path,
100                "props value depth exceeds the schema limit",
101            ));
102        }
103        Ok(Self {
104            module: self.module,
105            route: self.route,
106            typescript: self.typescript.clone(),
107            rust: self.rust.clone(),
108            depth: self.depth + 1,
109        })
110    }
111    /// Encode a literal only if it belongs to the bound enum set.
112    pub fn enumeration(
113        &self,
114        kind: &PropType,
115        path: &str,
116        literal: &str,
117    ) -> Result<Option<Value>, PropsError> {
118        if let PropType::Enum(names) = self.required(kind) {
119            for index in names {
120                if self.name(*index, path)? == literal {
121                    return Ok(Some(Value::String(literal.into())));
122                }
123            }
124        }
125        Err(self.error(
126            "RENDER-404",
127            path,
128            "enum value is absent from the declared literal set",
129        ))
130    }
131}
132
133/// A props contract checked once against an immutable module. A development reload
134/// creates a new binding; an existing binding always projects through its own module.
135#[derive(Debug)]
136pub struct BoundProps<P: PageProps> {
137    module: std::sync::Arc<IrModule>,
138    route: String,
139    source: PropsSource,
140    warnings: Vec<super::PropsWarning>,
141    marker: std::marker::PhantomData<fn() -> P>,
142}
143impl<P: PageProps> BoundProps<P> {
144    /// Derive and compare metadata once, retaining the exact module used at bind.
145    pub fn bind(module: std::sync::Arc<IrModule>, route: &str) -> Result<Self, Vec<PropsError>> {
146        let rust = P::rust_type().map_err(|e| vec![e.with_route(route)])?;
147        let warnings = compare_schema(&module, &rust, route)?;
148        Ok(Self {
149            module,
150            route: route.into(),
151            source: rust.source,
152            warnings,
153            marker: std::marker::PhantomData,
154        })
155    }
156    /// The verified module, also used for value projection and rendering.
157    pub fn module(&self) -> &IrModule {
158        &self.module
159    }
160    /// Stable module identity for development reload detection.
161    pub fn module_arc(&self) -> &std::sync::Arc<IrModule> {
162        &self.module
163    }
164    /// Extra Rust fields reported once at bind.
165    pub fn warnings(&self) -> &[super::PropsWarning] {
166        &self.warnings
167    }
168    /// Check each value and encode only the already-bound TypeScript fields.
169    pub fn encode(&self, props: &P) -> Result<Value, PropsError> {
170        project(&self.module, props, &self.route, self.source.clone())
171    }
172}
173
174fn project<P: PageProps>(
175    module: &IrModule,
176    props: &P,
177    route: &str,
178    source: PropsSource,
179) -> Result<Value, PropsError> {
180    let context = EncodeContext {
181        module,
182        route,
183        typescript: None,
184        rust: Some(source),
185        depth: 0,
186    };
187    props
188        .encode_value(&context, &PropType::Object(0), "")?
189        .ok_or_else(|| context.error("RENDER-404", "", "page props cannot be absent"))
190}
191
192/// Encode only declared fields after checking assignability. A typed page handle
193/// may cache bind metadata, but value validation always uses its current module.
194pub fn encode_props<P: PageProps>(
195    module: &IrModule,
196    props: &P,
197    route: &str,
198) -> Result<Value, PropsError> {
199    let rust = P::rust_type().map_err(|e| e.with_route(route))?;
200    if let Err(errors) = compare_schema(module, &rust, route) {
201        if let Some(error) = errors.into_iter().next() {
202            return Err(error);
203        }
204    }
205    project(module, props, route, rust.source)
206}
207
208macro_rules! primitive {
209    ($ty:ty,$kind:ident,$convert:expr) => {
210        impl PropValue for $ty {
211            fn describe(_: &mut SchemaBuilder) -> Result<PropType, PropsError> {
212                Ok(PropType::$kind)
213            }
214            fn encode_value(
215                &self,
216                context: &EncodeContext<'_>,
217                kind: &PropType,
218                path: &str,
219            ) -> Result<Option<Value>, PropsError> {
220                if !matches!(context.required(kind), PropType::$kind) {
221                    return Err(context.error(
222                        "RENDER-404",
223                        path,
224                        "value does not match the declared type",
225                    ));
226                }
227                ($convert)(self, context, path).map(Some)
228            }
229        }
230    };
231}
232primitive!(bool, Boolean, |value: &bool,
233                           _: &EncodeContext<'_>,
234                           _: &str| Ok::<
235    Value,
236    PropsError,
237>(
238    Value::Bool(*value)
239));
240primitive!(String, String, |value: &String,
241                            _: &EncodeContext<'_>,
242                            _: &str| Ok::<
243    Value,
244    PropsError,
245>(
246    Value::String(value.clone())
247));
248const MAX_SAFE: i64 = 9_007_199_254_740_991;
249macro_rules! integers {
250    ($($ty:ty),*)=>{$(primitive!($ty,Number,|value:&$ty,context:&EncodeContext<'_>,path:&str|{
251        // Compare in the integer domain before any conversion can round the input.
252        let number=*value as i128;
253        if !(-(MAX_SAFE as i128)..=MAX_SAFE as i128).contains(&number){return Err(context.error("RENDER-401",path,"integer exceeds JavaScript's exact range ±(2^53 - 1)"));}
254        Ok(Value::Number(serde_json::Number::from(number as i64)))
255    });)*};
256}
257integers!(i8, i16, i32, i64, u8, u16, u32, u64, isize, usize);
258macro_rules! floats {($($ty:ty),*)=>{$(primitive!($ty,Number,|value:&$ty,context:&EncodeContext<'_>,path:&str|{
259    serde_json::Number::from_f64(*value as f64).map(Value::Number).ok_or_else(||context.error("RENDER-402",path,"number must be finite"))
260});)*};}
261floats!(f32, f64);
262
263fn date_milliseconds(negative: bool, duration: std::time::Duration) -> Option<i64> {
264    // JS Date precision is integer milliseconds. Truncate fractional milliseconds
265    // toward zero on both sides of the epoch, matching this documented wire rule.
266    let millis = duration.as_millis();
267    if millis > 8_640_000_000_000_000 || millis > MAX_SAFE as u128 {
268        return None;
269    }
270    Some(millis as i64 * if negative { -1 } else { 1 })
271}
272
273primitive!(
274    std::time::SystemTime,
275    Date,
276    |value: &std::time::SystemTime, context: &EncodeContext<'_>, path: &str| {
277        let (negative, duration) = match value.duration_since(std::time::UNIX_EPOCH) {
278            Ok(d) => (false, d),
279            Err(e) => (true, e.duration()),
280        };
281        date_milliseconds(negative, duration)
282            .map(|value| Value::Number(value.into()))
283            .ok_or_else(|| {
284                context.error(
285                    "RENDER-403",
286                    path,
287                    "date exceeds the valid JavaScript Date range ±8.64e15 milliseconds",
288                )
289            })
290    }
291);
292impl<T: PropValue> PropValue for Option<T> {
293    fn describe(builder: &mut SchemaBuilder) -> Result<PropType, PropsError> {
294        Ok(PropType::Optional(Box::new(builder.nested::<T>()?)))
295    }
296    fn encode_value(
297        &self,
298        context: &EncodeContext<'_>,
299        kind: &PropType,
300        path: &str,
301    ) -> Result<Option<Value>, PropsError> {
302        let PropType::Optional(inner) = kind else {
303            return Err(context.error(
304                "RENDER-404",
305                path,
306                "Option needs an optional declared type",
307            ));
308        };
309        match self {
310            Some(value) => value.encode_value(context, inner, path),
311            None => Ok(None),
312        }
313    }
314}
315impl<T: PropValue> PropValue for Vec<T> {
316    fn describe(builder: &mut SchemaBuilder) -> Result<PropType, PropsError> {
317        Ok(PropType::Array(Box::new(builder.nested::<T>()?)))
318    }
319    fn encode_value(
320        &self,
321        context: &EncodeContext<'_>,
322        kind: &PropType,
323        path: &str,
324    ) -> Result<Option<Value>, PropsError> {
325        let PropType::Array(inner) = context.required(kind) else {
326            return Err(context.error("RENDER-404", path, "Vec needs an array declared type"));
327        };
328        let context = context.descend(path)?;
329        let mut values = Vec::with_capacity(self.len());
330        for (index, value) in self.iter().enumerate() {
331            values.push(
332                value
333                    .encode_value(&context, inner, &format!("{path}[{index}]"))?
334                    .unwrap_or(Value::Null),
335            );
336        }
337        Ok(Some(Value::Array(values)))
338    }
339}
340
341// Used by generated derives without requiring consumers to depend on serde_json.
342#[doc(hidden)]
343pub use serde_json::{Map as JsonMap, Value as JsonValue};
344#[doc(hidden)]
345pub fn child_path(parent: &str, name: &str) -> String {
346    field_path(parent, name)
347}
348
349#[cfg(test)]
350mod tests {
351    use super::date_milliseconds;
352    use std::time::Duration;
353
354    #[test]
355    fn date_conversion_checks_both_signed_bounds_without_system_time_limits() {
356        // Some platforms cannot construct SystemTime values at the JS Date
357        // boundary. Exercise the actual conversion before its i64 cast directly.
358        let boundary = 8_640_000_000_000_000;
359        for negative in [false, true] {
360            let sign = if negative { -1 } else { 1 };
361            for (duration, expected) in [
362                (Duration::ZERO, 0),
363                (Duration::from_nanos(999_999), 0),
364                (Duration::from_nanos(1_999_999), 1),
365                (Duration::from_millis(boundary), boundary as i64),
366                (
367                    Duration::from_millis(boundary) + Duration::from_nanos(999_999),
368                    boundary as i64,
369                ),
370            ] {
371                assert_eq!(date_milliseconds(negative, duration), Some(expected * sign));
372            }
373            for duration in [
374                Duration::from_millis(boundary + 1),
375                Duration::from_millis(i64::MAX as u64 + 1),
376                Duration::from_secs(u64::MAX),
377            ] {
378                assert_eq!(date_milliseconds(negative, duration), None);
379            }
380        }
381    }
382}