hypen_engine/reactive/
binding.rs1use serde::{Deserialize, Serialize};
2
3#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
5pub enum BindingSource {
6 State,
8 Item,
10}
11
12#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
14pub struct Binding {
15 pub source: BindingSource,
17 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 pub fn state(path: Vec<String>) -> Self {
28 Self::new(BindingSource::State, path)
29 }
30
31 pub fn item(path: Vec<String>) -> Self {
33 Self::new(BindingSource::Item, path)
34 }
35
36 pub fn is_state(&self) -> bool {
38 matches!(self.source, BindingSource::State)
39 }
40
41 pub fn is_item(&self) -> bool {
43 matches!(self.source, BindingSource::Item)
44 }
45
46 pub fn root_key(&self) -> Option<&str> {
48 self.path.first().map(|s| s.as_str())
49 }
50
51 pub fn full_path(&self) -> String {
53 self.path.join(".")
54 }
55
56 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
70fn is_valid_path_segment(s: &str) -> bool {
75 if s.is_empty() {
76 return false;
77 }
78
79 if s.chars().all(|c| c.is_ascii_digit()) {
81 return true;
82 }
83
84 let mut chars = s.chars();
85 match chars.next() {
87 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
88 _ => return false,
89 }
90 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
92}
93
94pub fn parse_binding(s: &str) -> Option<Binding> {
96 let trimmed = s.trim();
97
98 if !trimmed.starts_with("${") || !trimmed.ends_with('}') {
100 return None;
101 }
102
103 let content = &trimmed[2..trimmed.len() - 1];
105
106 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 return Some(Binding::item(vec![]));
114 } else {
115 return None;
116 };
117
118 let path: Vec<String> = content[path_start..]
120 .split('.')
121 .map(|s| s.to_string())
122 .collect();
123
124 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}