Skip to main content

forma_ir/props/
mod.rs

1//! Declared page props: derived Rust metadata compared with FMIR's actual schema,
2//! followed by projection and checked encoding of only the declared fields.
3mod compare;
4mod value;
5use crate::schema::{PropField, PropType, PropsSchema};
6pub use compare::compare_schema;
7use std::any::TypeId;
8use std::collections::HashMap;
9#[doc(hidden)]
10pub use value::{child_path, JsonMap, JsonValue};
11pub use value::{encode_props, BoundProps, EncodeContext, PropValue};
12
13/// A source declaration on either side of a binding diagnostic.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct PropsSource {
16    /// Source file reported by the compiler or derive expansion.
17    pub file: String,
18    /// One-based declaration line.
19    pub line: u32,
20    /// One-based declaration column (UTF-16 for TypeScript).
21    pub column: u32,
22}
23impl PropsSource {
24    /// Construct declaration metadata; generated derives use the field's span.
25    pub fn new(file: &str, line: u32, column: u32) -> Self {
26        Self {
27            file: file.into(),
28            line,
29            column,
30        }
31    }
32}
33
34/// A bind or checked-value refusal. Diagnostics identify the route, declared path
35/// and both declarations whenever both sides have a field at that path.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct PropsError {
38    /// Stable BIND or RENDER code.
39    pub code: &'static str,
40    /// Route being bound or rendered.
41    pub route: String,
42    /// Declared property path, including array indices at render.
43    pub path: String,
44    /// Specific incompatibility or value violation.
45    pub message: String,
46    /// TypeScript declaration, if present.
47    pub typescript: Option<Box<PropsSource>>,
48    /// Rust declaration, if present.
49    pub rust: Option<Box<PropsSource>>,
50}
51impl PropsError {
52    /// Stable diagnostic code.
53    pub fn code(&self) -> &'static str {
54        self.code
55    }
56    /// Route associated with the refusal.
57    pub fn route(&self) -> &str {
58        &self.route
59    }
60    /// Full property path.
61    pub fn path(&self) -> &str {
62        &self.path
63    }
64    /// Attach the route to a derive-schema error discovered before binding.
65    pub fn with_route(mut self, route: &str) -> Self {
66        self.route = route.into();
67        self
68    }
69}
70impl std::fmt::Display for PropsError {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        write!(
73            f,
74            "{} route {} props {}: {}",
75            self.code,
76            self.route,
77            if self.path.is_empty() {
78                "<root>"
79            } else {
80                &self.path
81            },
82            self.message
83        )?;
84        for (label, source) in [("TypeScript", &self.typescript), ("Rust", &self.rust)] {
85            if let Some(source) = source {
86                write!(
87                    f,
88                    "; {label} {}:{}:{}",
89                    source.file, source.line, source.column
90                )?;
91            }
92        }
93        Ok(())
94    }
95}
96impl std::error::Error for PropsError {}
97
98/// An extra Rust field is visible at bind as a warning, and never encoded.
99#[derive(Debug, Clone, PartialEq, Eq)]
100pub struct PropsWarning {
101    /// Stable warning code (BIND-201).
102    pub code: &'static str,
103    /// Bound route.
104    pub route: String,
105    /// Extra field's path.
106    pub path: String,
107    /// Explanation of the ignored Rust field.
108    pub message: String,
109    /// Closest TypeScript declaration, when available.
110    pub typescript: Option<PropsSource>,
111    /// Extra Rust field's declaration.
112    pub rust: Option<PropsSource>,
113}
114impl std::fmt::Display for PropsWarning {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        PropsError {
117            code: self.code,
118            route: self.route.clone(),
119            path: self.path.clone(),
120            message: self.message.clone(),
121            typescript: self.typescript.clone().map(Box::new),
122            rust: self.rust.clone().map(Box::new),
123        }
124        .fmt(f)
125    }
126}
127
128/// Rust's derived schema uses the same closed type representation as parsed FMIR.
129#[derive(Debug, Clone)]
130pub struct RustType {
131    /// The actual FMIR object/field/type representation, with Rust source positions.
132    pub schema: PropsSchema,
133    /// Names, enum literals and source files referenced by that schema.
134    pub strings: Vec<String>,
135    /// Root kind; a page binding requires an object.
136    pub root: PropType,
137    /// Root Rust declaration, useful when a TypeScript field is missing in Rust.
138    pub source: PropsSource,
139}
140
141/// A named struct or unit enum declared with `#[derive(PageProps)]`.
142/// The trait and derive share their public name in Rust's separate namespaces.
143pub trait PageProps: PropValue {
144    /// Build the schema once at startup. Recursive Rust types return BIND-105.
145    fn rust_type() -> Result<RustType, PropsError> {
146        let mut builder = SchemaBuilder::default();
147        let root = Self::describe(&mut builder)?;
148        Ok(RustType {
149            schema: PropsSchema {
150                objects: builder.objects,
151            },
152            strings: builder.strings,
153            root,
154            source: Self::source(),
155        })
156    }
157}
158
159/// Derive support for constructing indexed FMIR metadata; no alternate schema.
160#[doc(hidden)]
161#[derive(Debug, Default)]
162pub struct SchemaBuilder {
163    strings: Vec<String>,
164    names: HashMap<String, u32>,
165    objects: Vec<Vec<PropField>>,
166    types: HashMap<TypeId, u16>,
167    active: Vec<TypeId>,
168    wrappers: usize,
169}
170impl SchemaBuilder {
171    fn failure(message: &str) -> PropsError {
172        PropsError {
173            code: "BIND-105",
174            route: String::new(),
175            path: String::new(),
176            message: message.into(),
177            typescript: None,
178            rust: None,
179        }
180    }
181    /// Intern a name or source path using the same bounded indices as FMIR.
182    pub fn intern(&mut self, name: &str) -> Result<u32, PropsError> {
183        if let Some(index) = self.names.get(name) {
184            return Ok(*index);
185        }
186        if name.len() > u16::MAX as usize || self.strings.len() >= crate::format::MAX_STRINGS {
187            return Err(Self::failure("Rust schema string exceeds the FMIR limit"));
188        }
189        let index = self.strings.len() as u32;
190        self.strings.push(name.into());
191        self.names.insert(name.into(), index);
192        Ok(index)
193    }
194    /// Describe an object once, rejecting recursive shapes before recursing again.
195    pub fn object<T: 'static>(
196        &mut self,
197        fields: impl FnOnce(&mut Self) -> Result<Vec<PropField>, PropsError>,
198    ) -> Result<PropType, PropsError> {
199        let ty = TypeId::of::<T>();
200        if self.active.contains(&ty) {
201            return Err(Self::failure("recursive Rust props are not supported"));
202        }
203        if let Some(index) = self.types.get(&ty) {
204            return Ok(PropType::Object(*index));
205        }
206        if self.objects.len() >= crate::format::MAX_SCHEMA_ITEMS {
207            return Err(Self::failure("too many Rust schema objects"));
208        }
209        let index = self.objects.len() as u16;
210        self.objects.push(Vec::new());
211        self.types.insert(ty, index);
212        self.active.push(ty);
213        let wrappers = std::mem::replace(&mut self.wrappers, 0);
214        let result = fields(self);
215        self.wrappers = wrappers;
216        self.active.pop();
217        let fields = result?;
218        if fields.len() > crate::format::MAX_SCHEMA_ITEMS {
219            return Err(Self::failure("too many Rust schema fields"));
220        }
221        let mut names = std::collections::HashSet::new();
222        if fields.iter().any(|f| !names.insert(f.name)) {
223            return Err(Self::failure("duplicate Rust props field names"));
224        }
225        self.objects[index as usize] = fields;
226        Ok(PropType::Object(index))
227    }
228    /// Make a field with its source declaration.
229    pub fn field(
230        &mut self,
231        name: &str,
232        source: PropsSource,
233        kind: PropType,
234    ) -> Result<PropField, PropsError> {
235        Ok(PropField {
236            name: self.intern(name)?,
237            file: self.intern(&source.file)?,
238            line: source.line,
239            column: source.column,
240            kind,
241        })
242    }
243    /// Build one bounded array/optional wrapper.
244    pub fn nested<T: PropValue>(&mut self) -> Result<PropType, PropsError> {
245        if self.wrappers >= crate::format::MAX_TYPE_DEPTH {
246            return Err(Self::failure(
247                "Rust props type nesting exceeds the FMIR limit",
248            ));
249        }
250        self.wrappers += 1;
251        let result = T::describe(self);
252        self.wrappers -= 1;
253        result
254    }
255    /// Unit enum literal names.
256    pub fn enumeration(&mut self, names: &[&str]) -> Result<PropType, PropsError> {
257        if names.is_empty() || names.len() > crate::format::MAX_SCHEMA_ITEMS {
258            return Err(Self::failure(
259                "Rust enum must contain a bounded nonempty set of literals",
260            ));
261        }
262        let names = names
263            .iter()
264            .map(|name| self.intern(name))
265            .collect::<Result<Vec<_>, _>>()?;
266        Ok(PropType::Enum(names))
267    }
268}
269
270/// Unambiguous readable field paths, including quoted unusual property names.
271#[doc(hidden)]
272pub fn field_path(parent: &str, name: &str) -> String {
273    let simple = !name.is_empty()
274        && name.chars().enumerate().all(|(i, c)| {
275            c == '_' || c == '$' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit())
276        });
277    if simple {
278        if parent.is_empty() {
279            name.into()
280        } else {
281            format!("{parent}.{name}")
282        }
283    } else {
284        format!("{parent}[{}]", serde_json::Value::String(name.into()))
285    }
286}