hypen_engine/reactive/
binding.rs

1use serde::{Deserialize, Serialize};
2
3/// The source of a binding (state or item)
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} or @item.name
9    Item,
10}
11
12/// Represents a parsed binding expression like ${state.user.name} or ${item.name}
13#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct Binding {
15    /// The source of the binding (state or item)
16    pub source: BindingSource,
17    /// The path after the source, e.g., ["user", "name"]
18    pub path: Vec<String>,
19}
20
21impl Binding {
22    pub fn new(source: BindingSource, path: Vec<String>) -> Self {
23        Self { source, path }
24    }
25
26    /// Create a state binding
27    pub fn state(path: Vec<String>) -> Self {
28        Self::new(BindingSource::State, path)
29    }
30
31    /// Create an item binding
32    pub fn item(path: Vec<String>) -> Self {
33        Self::new(BindingSource::Item, path)
34    }
35
36    /// Check if this is a state binding
37    pub fn is_state(&self) -> bool {
38        matches!(self.source, BindingSource::State)
39    }
40
41    /// Check if this is an item binding
42    pub fn is_item(&self) -> bool {
43        matches!(self.source, BindingSource::Item)
44    }
45
46    /// Get the root key (first segment of path)
47    pub fn root_key(&self) -> Option<&str> {
48        self.path.first().map(|s| s.as_str())
49    }
50
51    /// Get the full path as a dot-separated string (without source prefix)
52    pub fn full_path(&self) -> String {
53        self.path.join(".")
54    }
55
56    /// Get the full path including source prefix (e.g., "state.user.name" or "item.name")
57    pub fn full_path_with_source(&self) -> String {
58        let prefix = match self.source {
59            BindingSource::State => "state",
60            BindingSource::Item => "item",
61        };
62        if self.path.is_empty() {
63            prefix.to_string()
64        } else {
65            format!("{}.{}", prefix, self.path.join("."))
66        }
67    }
68}
69
70/// Check if a string is a valid path segment (identifier or numeric index)
71/// Valid segments:
72/// - Identifiers: starts with letter/underscore, followed by alphanumerics/underscores
73/// - Numeric indices: all digits (for array access like items.0)
74fn is_valid_path_segment(s: &str) -> bool {
75    if s.is_empty() {
76        return false;
77    }
78
79    // Allow pure numeric strings for array indexing (e.g., "0", "123")
80    if s.chars().all(|c| c.is_ascii_digit()) {
81        return true;
82    }
83
84    let mut chars = s.chars();
85    // First char must be letter or underscore
86    match chars.next() {
87        Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
88        _ => return false,
89    }
90    // Rest can be alphanumeric or underscore
91    chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
92}
93
94/// Parse a binding string like "${state.user.name}" or "${item.name}" into a Binding
95pub fn parse_binding(s: &str) -> Option<Binding> {
96    let trimmed = s.trim();
97
98    // Check for ${...} wrapper
99    if !trimmed.starts_with("${") || !trimmed.ends_with('}') {
100        return None;
101    }
102
103    // Extract content between ${ and }
104    let content = &trimmed[2..trimmed.len() - 1];
105
106    // Determine source and extract path
107    let (source, path_start) = if content.starts_with("state.") {
108        (BindingSource::State, "state.".len())
109    } else if content.starts_with("item.") {
110        (BindingSource::Item, "item.".len())
111    } else if content == "item" {
112        // Handle bare ${item} - represents the whole item
113        return Some(Binding::item(vec![]));
114    } else {
115        return None;
116    };
117
118    // Split by dots after the prefix
119    let path: Vec<String> = content[path_start..]
120        .split('.')
121        .map(|s| s.to_string())
122        .collect();
123
124    // Validate that all path segments are valid identifiers
125    // This prevents expressions like "active ? 'a' : 'b'" from being parsed as bindings
126    for segment in &path {
127        if !is_valid_path_segment(segment) {
128            return None;
129        }
130    }
131
132    if path.is_empty() || (path.len() == 1 && path[0].is_empty()) {
133        return None;
134    }
135
136    Some(Binding::new(source, path))
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn test_parse_simple_state_binding() {
145        let binding = parse_binding("${state.user}").unwrap();
146        assert!(binding.is_state());
147        assert_eq!(binding.path, vec!["user"]);
148        assert_eq!(binding.root_key(), Some("user"));
149    }
150
151    #[test]
152    fn test_parse_nested_state_binding() {
153        let binding = parse_binding("${state.user.name}").unwrap();
154        assert!(binding.is_state());
155        assert_eq!(binding.path, vec!["user", "name"]);
156        assert_eq!(binding.root_key(), Some("user"));
157        assert_eq!(binding.full_path(), "user.name");
158        assert_eq!(binding.full_path_with_source(), "state.user.name");
159    }
160
161    #[test]
162    fn test_parse_simple_item_binding() {
163        let binding = parse_binding("${item.name}").unwrap();
164        assert!(binding.is_item());
165        assert_eq!(binding.path, vec!["name"]);
166        assert_eq!(binding.root_key(), Some("name"));
167        assert_eq!(binding.full_path(), "name");
168        assert_eq!(binding.full_path_with_source(), "item.name");
169    }
170
171    #[test]
172    fn test_parse_nested_item_binding() {
173        let binding = parse_binding("${item.user.profile.avatar}").unwrap();
174        assert!(binding.is_item());
175        assert_eq!(binding.path, vec!["user", "profile", "avatar"]);
176        assert_eq!(binding.full_path(), "user.profile.avatar");
177    }
178
179    #[test]
180    fn test_parse_bare_item_binding() {
181        let binding = parse_binding("${item}").unwrap();
182        assert!(binding.is_item());
183        assert_eq!(binding.path, Vec::<String>::new());
184        assert_eq!(binding.full_path(), "");
185        assert_eq!(binding.full_path_with_source(), "item");
186    }
187
188    #[test]
189    fn test_parse_invalid_binding() {
190        assert!(parse_binding("state.user").is_none());
191        assert!(parse_binding("${user}").is_none());
192        assert!(parse_binding("${state}").is_none());
193        assert!(parse_binding("${props.name}").is_none());
194    }
195}