hypen_engine/reactive/
binding.rs

1use serde::{Deserialize, Serialize};
2
3/// Represents a parsed binding expression like ${state.user.name}
4#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
5pub struct Binding {
6    /// The path in state, e.g., ["user", "name"]
7    pub path: Vec<String>,
8}
9
10impl Binding {
11    pub fn new(path: Vec<String>) -> Self {
12        Self { path }
13    }
14
15    /// Get the root key (first segment of path)
16    pub fn root_key(&self) -> Option<&str> {
17        self.path.first().map(|s| s.as_str())
18    }
19
20    /// Get the full path as a dot-separated string
21    pub fn full_path(&self) -> String {
22        self.path.join(".")
23    }
24}
25
26/// Parse a binding string like "${state.user.name}" into a Binding
27pub fn parse_binding(s: &str) -> Option<Binding> {
28    let trimmed = s.trim();
29
30    // Check for ${...} wrapper
31    if !trimmed.starts_with("${") || !trimmed.ends_with('}') {
32        return None;
33    }
34
35    // Extract content between ${ and }
36    let content = &trimmed[2..trimmed.len() - 1];
37
38    // Must start with "state."
39    if !content.starts_with("state.") {
40        return None;
41    }
42
43    // Split by dots and skip "state" prefix
44    let path: Vec<String> = content
45        .split('.')
46        .skip(1)
47        .map(|s| s.to_string())
48        .collect();
49
50    if path.is_empty() {
51        return None;
52    }
53
54    Some(Binding::new(path))
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn test_parse_simple_binding() {
63        let binding = parse_binding("${state.user}").unwrap();
64        assert_eq!(binding.path, vec!["user"]);
65        assert_eq!(binding.root_key(), Some("user"));
66    }
67
68    #[test]
69    fn test_parse_nested_binding() {
70        let binding = parse_binding("${state.user.name}").unwrap();
71        assert_eq!(binding.path, vec!["user", "name"]);
72        assert_eq!(binding.root_key(), Some("user"));
73        assert_eq!(binding.full_path(), "user.name");
74    }
75
76    #[test]
77    fn test_parse_invalid_binding() {
78        assert!(parse_binding("state.user").is_none());
79        assert!(parse_binding("${user}").is_none());
80        assert!(parse_binding("${state}").is_none());
81        assert!(parse_binding("${props.name}").is_none());
82    }
83}