Skip to main content

clasp_core/
address.rs

1//! Address parsing and pattern matching
2//!
3//! Clasp addresses follow this format:
4//! ```text
5//! /namespace/category/instance/property
6//! /lumen/scene/0/layer/3/opacity
7//! /midi/launchpad/cc/74
8//! ```
9//!
10//! Wildcards (for subscriptions):
11//! - `*` matches one segment
12//! - `**` matches any number of segments
13
14use crate::{Error, Result};
15
16/// A parsed Clasp address
17#[derive(Debug, Clone, PartialEq, Eq, Hash)]
18pub struct Address {
19    raw: String,
20    segments: Vec<String>,
21}
22
23impl Address {
24    /// Parse an address string
25    pub fn parse(s: &str) -> Result<Self> {
26        if s.is_empty() {
27            return Err(Error::InvalidAddress("empty address".to_string()));
28        }
29
30        if !s.starts_with('/') {
31            return Err(Error::InvalidAddress(format!(
32                "address must start with '/': {}",
33                s
34            )));
35        }
36
37        let segments: Vec<String> = s[1..].split('/').map(|s| s.to_string()).collect();
38
39        // Validate segments
40        for (i, seg) in segments.iter().enumerate() {
41            if seg.is_empty() && i < segments.len() - 1 {
42                return Err(Error::InvalidAddress(format!(
43                    "empty segment in address: {}",
44                    s
45                )));
46            }
47        }
48
49        Ok(Self {
50            raw: s.to_string(),
51            segments,
52        })
53    }
54
55    /// Get the raw address string
56    pub fn as_str(&self) -> &str {
57        &self.raw
58    }
59
60    /// Get the address segments
61    pub fn segments(&self) -> &[String] {
62        &self.segments
63    }
64
65    /// Get the namespace (first segment)
66    pub fn namespace(&self) -> Option<&str> {
67        self.segments.first().map(|s| s.as_str())
68    }
69
70    /// Get the last segment (usually the property name)
71    pub fn property(&self) -> Option<&str> {
72        self.segments.last().map(|s| s.as_str())
73    }
74
75    /// Check if this address contains wildcards
76    /// Detects: standalone `*` or `**`, and embedded wildcards like `zone5*`
77    pub fn is_pattern(&self) -> bool {
78        self.segments.iter().any(|s| s.contains('*'))
79    }
80
81    /// Check if this address matches a pattern
82    pub fn matches(&self, pattern: &Address) -> bool {
83        match_segments(&self.segments, &pattern.segments)
84    }
85}
86
87impl std::fmt::Display for Address {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        write!(f, "{}", self.raw)
90    }
91}
92
93impl TryFrom<&str> for Address {
94    type Error = Error;
95
96    fn try_from(s: &str) -> Result<Self> {
97        Address::parse(s)
98    }
99}
100
101impl TryFrom<String> for Address {
102    type Error = Error;
103
104    fn try_from(s: String) -> Result<Self> {
105        Address::parse(&s)
106    }
107}
108
109/// Match address segments against pattern segments
110fn match_segments(addr: &[String], pattern: &[String]) -> bool {
111    let mut ai = 0;
112    let mut pi = 0;
113
114    while pi < pattern.len() {
115        let pat = &pattern[pi];
116
117        if pat == "**" {
118            // ** matches zero or more segments
119            if pi == pattern.len() - 1 {
120                // ** at end matches everything
121                return true;
122            }
123
124            // Try to match remaining pattern after **
125            let next_pat = &pattern[pi + 1];
126            while ai < addr.len() {
127                if match_single(&addr[ai], next_pat) {
128                    // Try matching rest of pattern
129                    if match_segments(&addr[ai..], &pattern[pi + 1..]) {
130                        return true;
131                    }
132                }
133                ai += 1;
134            }
135            return false;
136        } else if ai >= addr.len() || !match_single(&addr[ai], pat) {
137            return false;
138        }
139
140        ai += 1;
141        pi += 1;
142    }
143
144    ai == addr.len()
145}
146
147/// Match a single segment against a pattern segment
148fn match_single(segment: &str, pattern: &str) -> bool {
149    if pattern == "*" {
150        true
151    } else {
152        segment == pattern
153    }
154}
155
156/// A compiled pattern for efficient matching
157#[derive(Debug, Clone)]
158pub struct Pattern {
159    address: Address,
160    _regex: Option<regex_lite::Regex>,
161}
162
163impl Pattern {
164    /// Compile a pattern from an address string
165    pub fn compile(s: &str) -> Result<Self> {
166        let address = Address::parse(s)?;
167
168        // Build regex for efficient matching (only used for complex patterns)
169        // Note: We prefer glob_match for actual matching as it handles all cases correctly
170        let regex = if address.is_pattern() {
171            // Build regex for potential use, but matches() uses glob_match directly
172            // ** matches zero or more path segments (including slashes)
173            // * matches zero or more characters within a segment
174            let regex_str = s
175                .replace("/**", "§§") // Temp placeholder for /**/
176                .replace("/**/", "§§/") // Handle mid-pattern /**/
177                .replace('*', "[^/]*") // * = zero or more non-slash chars
178                .replace("§§", "(/[^/]+)*"); // ** = zero or more /segment
179            let regex_str = format!("^{}$", regex_str);
180            Some(
181                regex_lite::Regex::new(&regex_str)
182                    .map_err(|e| Error::InvalidPattern(e.to_string()))?,
183            )
184        } else {
185            None
186        };
187
188        Ok(Self {
189            address,
190            _regex: regex,
191        })
192    }
193
194    /// Check if an address matches this pattern
195    /// Uses glob_match for consistent behavior with client-side matching
196    pub fn matches(&self, addr: &str) -> bool {
197        if self.address.is_pattern() {
198            // Use glob_match for pattern matching (consistent with client)
199            glob_match::glob_match(self.address.as_str(), addr)
200        } else {
201            // Exact match for non-patterns
202            addr == self.address.as_str()
203        }
204    }
205
206    /// Check if an Address matches this pattern
207    pub fn matches_address(&self, addr: &Address) -> bool {
208        self.matches(addr.as_str())
209    }
210
211    /// Get the underlying address
212    pub fn address(&self) -> &Address {
213        &self.address
214    }
215}
216
217// Use glob-match for simple cases
218pub fn glob_match(pattern: &str, address: &str) -> bool {
219    glob_match::glob_match(pattern, address)
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn test_parse_valid() {
228        let addr = Address::parse("/lumen/scene/0/layer/3/opacity").unwrap();
229        assert_eq!(addr.segments().len(), 6);
230        assert_eq!(addr.namespace(), Some("lumen"));
231        assert_eq!(addr.property(), Some("opacity"));
232    }
233
234    #[test]
235    fn test_parse_invalid() {
236        assert!(Address::parse("").is_err());
237        assert!(Address::parse("no/leading/slash").is_err());
238    }
239
240    #[test]
241    fn test_single_wildcard() {
242        let pattern = Pattern::compile("/lumen/scene/*/layer/*/opacity").unwrap();
243
244        assert!(pattern.matches("/lumen/scene/0/layer/3/opacity"));
245        assert!(pattern.matches("/lumen/scene/1/layer/0/opacity"));
246        assert!(!pattern.matches("/lumen/scene/0/layer/3/color"));
247        assert!(!pattern.matches("/lumen/scene/opacity"));
248    }
249
250    #[test]
251    fn test_double_wildcard() {
252        let pattern = Pattern::compile("/lumen/**/opacity").unwrap();
253
254        assert!(pattern.matches("/lumen/scene/0/opacity"));
255        assert!(pattern.matches("/lumen/scene/0/layer/3/opacity"));
256        assert!(pattern.matches("/lumen/opacity"));
257        assert!(!pattern.matches("/lumen/scene/0/color"));
258    }
259
260    #[test]
261    fn test_exact_match() {
262        let pattern = Pattern::compile("/lumen/scene/0/opacity").unwrap();
263
264        assert!(pattern.matches("/lumen/scene/0/opacity"));
265        assert!(!pattern.matches("/lumen/scene/1/opacity"));
266    }
267
268    #[test]
269    fn test_glob_match_fn() {
270        assert!(glob_match("/lumen/**", "/lumen/scene/0/opacity"));
271        assert!(glob_match("/lumen/*/opacity", "/lumen/scene/opacity"));
272        assert!(!glob_match("/lumen/*/opacity", "/lumen/scene/0/opacity"));
273    }
274}