Skip to main content

hypen_engine/reactive/
binding.rs

1use serde::{Deserialize, Serialize};
2
3/// The source of a binding (state, item, or data source)
4#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
5pub enum BindingSource {
6    /// Binding to module state: ${state.user.name}
7    State,
8    /// Binding to iteration item: ${item.name}
9    Item,
10    /// Binding to a data source plugin: $spacetime.messages, $firebase.user.name
11    /// The String is the provider name (e.g., "spacetime", "firebase")
12    DataSource(String),
13}
14
15/// Represents a parsed binding expression like ${state.user.name} or ${item.name}
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
17pub struct Binding {
18    /// The source of the binding (state or item)
19    pub source: BindingSource,
20    /// The path after the source, e.g., ["user", "name"]
21    pub path: Vec<String>,
22}
23
24impl Binding {
25    pub fn new(source: BindingSource, path: Vec<String>) -> Self {
26        Self { source, path }
27    }
28
29    /// Create a state binding
30    pub fn state(path: Vec<String>) -> Self {
31        Self::new(BindingSource::State, path)
32    }
33
34    /// Create an item binding
35    pub fn item(path: Vec<String>) -> Self {
36        Self::new(BindingSource::Item, path)
37    }
38
39    /// Create a data source binding
40    pub fn data_source(provider: impl Into<String>, path: Vec<String>) -> Self {
41        Self::new(BindingSource::DataSource(provider.into()), path)
42    }
43
44    /// Check if this is a state binding
45    pub fn is_state(&self) -> bool {
46        matches!(self.source, BindingSource::State)
47    }
48
49    /// Check if this is an item binding
50    pub fn is_item(&self) -> bool {
51        matches!(self.source, BindingSource::Item)
52    }
53
54    /// Check if this is a data source binding
55    pub fn is_data_source(&self) -> bool {
56        matches!(self.source, BindingSource::DataSource(_))
57    }
58
59    /// Get the data source provider name (e.g., "spacetime", "firebase")
60    pub fn provider(&self) -> Option<&str> {
61        match &self.source {
62            BindingSource::DataSource(name) => Some(name.as_str()),
63            _ => None,
64        }
65    }
66
67    /// Get the root key (first segment of path)
68    pub fn root_key(&self) -> Option<&str> {
69        self.path.first().map(|s| s.as_str())
70    }
71
72    /// Get the full path as a dot-separated string (without source prefix)
73    pub fn full_path(&self) -> String {
74        self.path.join(".")
75    }
76
77    /// Get the full path including source prefix (e.g., "state.user.name" or "item.name")
78    pub fn full_path_with_source(&self) -> String {
79        let prefix = match &self.source {
80            BindingSource::State => "state".to_string(),
81            BindingSource::Item => "item".to_string(),
82            BindingSource::DataSource(provider) => provider.clone(),
83        };
84        if self.path.is_empty() {
85            prefix
86        } else {
87            format!("{}.{}", prefix, self.path.join("."))
88        }
89    }
90}
91
92/// Check if a string is a valid path segment (identifier or numeric index)
93/// Valid segments:
94/// - Identifiers: starts with letter/underscore, followed by alphanumerics/underscores
95/// - Numeric indices: all digits (for array access like items.0)
96fn is_valid_path_segment(s: &str) -> bool {
97    if s.is_empty() {
98        return false;
99    }
100
101    // Allow pure numeric strings for array indexing (e.g., "0", "123")
102    if s.chars().all(|c| c.is_ascii_digit()) {
103        return true;
104    }
105
106    let mut chars = s.chars();
107    // First char must be letter or underscore
108    match chars.next() {
109        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
110        _ => return false,
111    }
112    // Rest can be alphanumeric or underscore
113    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
114}
115
116/// Parse a binding string like "${state.user.name}" or "${item.name}" into a Binding.
117///
118/// Only recognizes `state.*` and `item.*` bindings. Data source bindings
119/// (e.g., `$spacetime.messages`) come from the parser's explicit
120/// `$provider.path` syntax, NOT from the `${...}` template syntax.
121/// This prevents `${foo.bar}` from silently becoming a data source binding
122/// when `foo` is just a typo or an expression variable.
123pub fn parse_binding(s: &str) -> Option<Binding> {
124    let trimmed = s.trim();
125
126    // Check for ${...} wrapper
127    if !trimmed.starts_with("${") || !trimmed.ends_with('}') {
128        return None;
129    }
130
131    // Extract content between ${ and }
132    let content = &trimmed[2..trimmed.len() - 1];
133
134    // Only recognize state.* and item.* — nothing else
135    let (source, path_start) = if content.starts_with("state.") {
136        (BindingSource::State, "state.".len())
137    } else if content.starts_with("item.") {
138        (BindingSource::Item, "item.".len())
139    } else if content == "item" {
140        // Handle bare ${item} - represents the whole item
141        return Some(Binding::item(vec![]));
142    } else {
143        // Not a recognized binding prefix — return None.
144        // Data source bindings use the parser's $provider.path syntax,
145        // not the ${provider.path} template syntax.
146        return None;
147    };
148
149    // Split by dots after the prefix
150    let path: Vec<String> = content[path_start..]
151        .split('.')
152        .map(|s| s.to_string())
153        .collect();
154
155    // Validate that all path segments are valid identifiers
156    // This prevents expressions like "active ? 'a' : 'b'" from being parsed as bindings
157    for segment in &path {
158        if !is_valid_path_segment(segment) {
159            return None;
160        }
161    }
162
163    if path.is_empty() || (path.len() == 1 && path[0].is_empty()) {
164        return None;
165    }
166
167    Some(Binding::new(source, path))
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn test_parse_simple_state_binding() {
176        let binding = parse_binding("${state.user}").unwrap();
177        assert!(binding.is_state());
178        assert_eq!(binding.path, vec!["user"]);
179        assert_eq!(binding.root_key(), Some("user"));
180    }
181
182    #[test]
183    fn test_parse_nested_state_binding() {
184        let binding = parse_binding("${state.user.name}").unwrap();
185        assert!(binding.is_state());
186        assert_eq!(binding.path, vec!["user", "name"]);
187        assert_eq!(binding.root_key(), Some("user"));
188        assert_eq!(binding.full_path(), "user.name");
189        assert_eq!(binding.full_path_with_source(), "state.user.name");
190    }
191
192    #[test]
193    fn test_parse_simple_item_binding() {
194        let binding = parse_binding("${item.name}").unwrap();
195        assert!(binding.is_item());
196        assert_eq!(binding.path, vec!["name"]);
197        assert_eq!(binding.root_key(), Some("name"));
198        assert_eq!(binding.full_path(), "name");
199        assert_eq!(binding.full_path_with_source(), "item.name");
200    }
201
202    #[test]
203    fn test_parse_nested_item_binding() {
204        let binding = parse_binding("${item.user.profile.avatar}").unwrap();
205        assert!(binding.is_item());
206        assert_eq!(binding.path, vec!["user", "profile", "avatar"]);
207        assert_eq!(binding.full_path(), "user.profile.avatar");
208    }
209
210    #[test]
211    fn test_parse_bare_item_binding() {
212        let binding = parse_binding("${item}").unwrap();
213        assert!(binding.is_item());
214        assert_eq!(binding.path, Vec::<String>::new());
215        assert_eq!(binding.full_path(), "");
216        assert_eq!(binding.full_path_with_source(), "item");
217    }
218
219    #[test]
220    fn test_parse_invalid_binding() {
221        assert!(parse_binding("state.user").is_none());
222        assert!(parse_binding("${user}").is_none());
223        assert!(parse_binding("${state}").is_none());
224    }
225
226    #[test]
227    fn test_parse_binding_rejects_unknown_prefix() {
228        // ${spacetime.messages} should NOT parse as a binding —
229        // data source bindings come from the parser's $provider.path syntax,
230        // not from ${...} template syntax
231        assert!(parse_binding("${spacetime.messages}").is_none());
232        assert!(parse_binding("${firebase.user.profile.name}").is_none());
233        assert!(parse_binding("${spacetime}").is_none());
234        assert!(parse_binding("${foo.bar}").is_none());
235    }
236
237    #[test]
238    fn test_data_source_binding_helpers() {
239        let binding = Binding::data_source("convex", vec!["tasks".to_string()]);
240        assert!(binding.is_data_source());
241        assert!(!binding.is_state());
242        assert!(!binding.is_item());
243        assert_eq!(binding.provider(), Some("convex"));
244    }
245}