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() {
137            return false;
138        } else if !match_single(&addr[ai], pat) {
139            return false;
140        }
141
142        ai += 1;
143        pi += 1;
144    }
145
146    ai == addr.len()
147}
148
149/// Match a single segment against a pattern segment
150fn match_single(segment: &str, pattern: &str) -> bool {
151    if pattern == "*" {
152        true
153    } else {
154        segment == pattern
155    }
156}
157
158/// A compiled pattern for efficient matching
159#[derive(Debug, Clone)]
160pub struct Pattern {
161    address: Address,
162    regex: Option<regex_lite::Regex>,
163}
164
165impl Pattern {
166    /// Compile a pattern from an address string
167    pub fn compile(s: &str) -> Result<Self> {
168        let address = Address::parse(s)?;
169
170        // Build regex for efficient matching (only used for complex patterns)
171        // Note: We prefer glob_match for actual matching as it handles all cases correctly
172        let regex = if address.is_pattern() {
173            // Build regex for potential use, but matches() uses glob_match directly
174            // ** matches zero or more path segments (including slashes)
175            // * matches zero or more characters within a segment
176            let regex_str = s
177                .replace("/**", "§§") // Temp placeholder for /**/
178                .replace("/**/", "§§/") // Handle mid-pattern /**/
179                .replace('*', "[^/]*") // * = zero or more non-slash chars
180                .replace("§§", "(/[^/]+)*"); // ** = zero or more /segment
181            let regex_str = format!("^{}$", regex_str);
182            Some(
183                regex_lite::Regex::new(&regex_str)
184                    .map_err(|e| Error::InvalidPattern(e.to_string()))?,
185            )
186        } else {
187            None
188        };
189
190        Ok(Self { address, regex })
191    }
192
193    /// Check if an address matches this pattern
194    /// Uses glob_match for consistent behavior with client-side matching
195    pub fn matches(&self, addr: &str) -> bool {
196        if self.address.is_pattern() {
197            // Use glob_match for pattern matching (consistent with client)
198            glob_match::glob_match(self.address.as_str(), addr)
199        } else {
200            // Exact match for non-patterns
201            addr == self.address.as_str()
202        }
203    }
204
205    /// Check if an Address matches this pattern
206    pub fn matches_address(&self, addr: &Address) -> bool {
207        self.matches(addr.as_str())
208    }
209
210    /// Get the underlying address
211    pub fn address(&self) -> &Address {
212        &self.address
213    }
214}
215
216// Use glob-match for simple cases
217pub fn glob_match(pattern: &str, address: &str) -> bool {
218    glob_match::glob_match(pattern, address)
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224
225    #[test]
226    fn test_parse_valid() {
227        let addr = Address::parse("/lumen/scene/0/layer/3/opacity").unwrap();
228        assert_eq!(addr.segments().len(), 6);
229        assert_eq!(addr.namespace(), Some("lumen"));
230        assert_eq!(addr.property(), Some("opacity"));
231    }
232
233    #[test]
234    fn test_parse_invalid() {
235        assert!(Address::parse("").is_err());
236        assert!(Address::parse("no/leading/slash").is_err());
237    }
238
239    #[test]
240    fn test_single_wildcard() {
241        let pattern = Pattern::compile("/lumen/scene/*/layer/*/opacity").unwrap();
242
243        assert!(pattern.matches("/lumen/scene/0/layer/3/opacity"));
244        assert!(pattern.matches("/lumen/scene/1/layer/0/opacity"));
245        assert!(!pattern.matches("/lumen/scene/0/layer/3/color"));
246        assert!(!pattern.matches("/lumen/scene/opacity"));
247    }
248
249    #[test]
250    fn test_double_wildcard() {
251        let pattern = Pattern::compile("/lumen/**/opacity").unwrap();
252
253        assert!(pattern.matches("/lumen/scene/0/opacity"));
254        assert!(pattern.matches("/lumen/scene/0/layer/3/opacity"));
255        assert!(pattern.matches("/lumen/opacity"));
256        assert!(!pattern.matches("/lumen/scene/0/color"));
257    }
258
259    #[test]
260    fn test_exact_match() {
261        let pattern = Pattern::compile("/lumen/scene/0/opacity").unwrap();
262
263        assert!(pattern.matches("/lumen/scene/0/opacity"));
264        assert!(!pattern.matches("/lumen/scene/1/opacity"));
265    }
266
267    #[test]
268    fn test_glob_match_fn() {
269        assert!(glob_match("/lumen/**", "/lumen/scene/0/opacity"));
270        assert!(glob_match("/lumen/*/opacity", "/lumen/scene/opacity"));
271        assert!(!glob_match("/lumen/*/opacity", "/lumen/scene/0/opacity"));
272    }
273}