Skip to main content

pamoja_zenoh/
keyexpr.rs

1//! The Zenoh key-expression language: validity, canonical form, and matching.
2//!
3//! A key expression is a `/`-joined list of non-empty chunks. A chunk is either a literal, the
4//! single-chunk wildcard `*` (one non-empty chunk), the multi-chunk wildcard `**` (zero or more
5//! chunks), or a literal carrying the sub-chunk wildcard `$*` (any run of characters, including
6//! none, within one chunk). A concrete key carries no wildcards. Leading, trailing, and doubled
7//! `/` are forbidden, as are the bare characters `*`, `$`, `?`, and `#` outside the wildcard forms.
8//!
9//! The rules follow the Zenoh key-expression specification, including its canonical-form rules:
10//! `**/**` collapses to `**`, `**/*` reorders to `*/**`, `$*$*` collapses to `$*`, and a chunk that
11//! is exactly `$*` becomes `*`.
12
13use alloc::string::{String, ToString};
14use alloc::vec::Vec;
15
16/// Returns whether a string is a well-formed key expression.
17///
18/// # Arguments
19///
20/// * `ke` - the candidate key expression.
21///
22/// # Returns
23///
24/// `true` if `ke` is `/`-joined non-empty chunks with no leading, trailing, or doubled `/`, where
25/// each chunk is `*`, `**`, or a literal in which `*` appears only as part of `$*` and `$` only
26/// before `*`, with no `?` or `#`.
27pub fn is_valid(ke: &str) -> bool {
28    if ke.is_empty() || ke.starts_with('/') || ke.ends_with('/') {
29        return false;
30    }
31    ke.split('/').all(chunk_valid)
32}
33
34/// Returns whether a key expression is valid and in canonical form.
35///
36/// # Arguments
37///
38/// * `ke` - the candidate key expression.
39///
40/// # Returns
41///
42/// `true` if `ke` equals its own [`canonize`] output, so two expressions selecting the same keys
43/// compare equal as strings.
44pub fn is_canon(ke: &str) -> bool {
45    canonize(ke).as_deref() == Some(ke)
46}
47
48/// Returns the canonical form of a key expression, or `None` if it is invalid.
49///
50/// # Arguments
51///
52/// * `ke` - the key expression to canonicalize.
53///
54/// # Returns
55///
56/// `Some(canonical)` for a valid `ke`, applying the canonical-form rules (`**/**` to `**`, `**/*`
57/// to `*/**`, `$*$*` to `$*`, and a `$*` chunk to `*`); `None` if `ke` is not a valid key
58/// expression.
59///
60/// # Examples
61///
62/// ```
63/// use pamoja_zenoh::keyexpr::canonize;
64///
65/// assert_eq!(canonize("robot/sensor/**/*").as_deref(), Some("robot/sensor/*/**"));
66/// assert_eq!(canonize("a/**/**/b").as_deref(), Some("a/**/b"));
67/// assert_eq!(canonize("a//b"), None); // a doubled slash is not a valid key expression
68/// ```
69pub fn canonize(ke: &str) -> Option<String> {
70    if !is_valid(ke) {
71        return None;
72    }
73    let canon_chunks: Vec<String> = ke.split('/').map(canon_chunk).collect();
74    let mut out: Vec<String> = Vec::new();
75    let mut i = 0;
76    while i < canon_chunks.len() {
77        if is_wildcard(canon_chunks[i].as_str()) {
78            let mut stars = 0;
79            let mut has_multi = false;
80            while i < canon_chunks.len() && is_wildcard(canon_chunks[i].as_str()) {
81                if canon_chunks[i].as_str() == "*" {
82                    stars += 1;
83                } else {
84                    has_multi = true;
85                }
86                i += 1;
87            }
88            out.extend((0..stars).map(|_| String::from("*")));
89            if has_multi {
90                out.push(String::from("**"));
91            }
92        } else {
93            out.push(canon_chunks[i].clone());
94            i += 1;
95        }
96    }
97    Some(out.join("/"))
98}
99
100/// Returns whether a concrete key is selected by a pattern key expression.
101///
102/// # Arguments
103///
104/// * `pattern` - the key expression to test against; it may contain wildcards.
105/// * `key` - the concrete key being routed; it must be valid and carry no wildcards.
106///
107/// # Returns
108///
109/// `true` if `key` is one of the keys `pattern` selects. Returns `false` if `pattern` is not a
110/// valid key expression, or if `key` is not a valid concrete key.
111///
112/// # Examples
113///
114/// ```
115/// use pamoja_zenoh::keyexpr::matches;
116///
117/// assert!(matches("room275/*/temperature", "room275/device1/temperature"));
118/// assert!(!matches("room275/*/temperature", "room275/temperature")); // `*` needs one chunk
119/// assert!(matches("organizationA/**/temperature", "organizationA/temperature")); // `**` allows none
120/// assert!(matches("thermometer$*/temperature", "thermometer1/temperature"));
121/// ```
122pub fn matches(pattern: &str, key: &str) -> bool {
123    if !is_valid(pattern) || !is_valid(key) || key.contains('*') {
124        return false;
125    }
126    let pattern_chunks: Vec<&str> = pattern.split('/').collect();
127    let key_chunks: Vec<&str> = key.split('/').collect();
128    match_chunks(&pattern_chunks, &key_chunks)
129}
130
131fn is_wildcard(chunk: &str) -> bool {
132    chunk == "*" || chunk == "**"
133}
134
135fn chunk_valid(chunk: &str) -> bool {
136    if chunk.is_empty() {
137        return false;
138    }
139    if is_wildcard(chunk) {
140        return true;
141    }
142    let bytes = chunk.as_bytes();
143    let mut i = 0;
144    while i < bytes.len() {
145        match bytes[i] {
146            b'?' | b'#' | b'/' => return false,
147            b'$' => {
148                if i + 1 >= bytes.len() || bytes[i + 1] != b'*' {
149                    return false;
150                }
151                i += 2;
152            }
153            b'*' => return false, // a `*` inside a chunk is legal only as part of `$*`
154            _ => i += 1,
155        }
156    }
157    true
158}
159
160fn canon_chunk(chunk: &str) -> String {
161    if is_wildcard(chunk) {
162        return chunk.to_string();
163    }
164    let mut s = chunk.to_string();
165    while s.contains("$*$*") {
166        s = s.replace("$*$*", "$*");
167    }
168    if s == "$*" {
169        return String::from("*");
170    }
171    s
172}
173
174fn match_chunks(pattern: &[&str], key: &[&str]) -> bool {
175    let Some((&head, rest)) = pattern.split_first() else {
176        return key.is_empty();
177    };
178    if head == "**" {
179        // `**` consumes zero or more key chunks; try every split.
180        (0..=key.len()).any(|skip| match_chunks(rest, &key[skip..]))
181    } else {
182        match key.split_first() {
183            Some((&first_key, rest_key)) => {
184                chunk_matches(head, first_key) && match_chunks(rest, rest_key)
185            }
186            None => false,
187        }
188    }
189}
190
191fn chunk_matches(pattern: &str, literal: &str) -> bool {
192    if pattern == "*" {
193        return true; // any single non-empty chunk; the literal is non-empty by validity
194    }
195    glob_match(pattern, literal)
196}
197
198// Matches a single chunk pattern (literals plus `$*` sub-chunk wildcards) against a literal chunk.
199fn glob_match(pattern: &str, s: &str) -> bool {
200    if !pattern.contains("$*") {
201        return pattern == s;
202    }
203    let parts: Vec<&str> = pattern.split("$*").collect();
204    let first = parts[0];
205    if !s.starts_with(first) {
206        return false;
207    }
208    let mut idx = first.len();
209    for part in &parts[1..parts.len() - 1] {
210        if part.is_empty() {
211            continue;
212        }
213        match s[idx..].find(part) {
214            Some(pos) => idx += pos + part.len(),
215            None => return false,
216        }
217    }
218    let last = parts[parts.len() - 1];
219    if last.is_empty() {
220        return true;
221    }
222    s.len() >= idx + last.len() && s[idx..].ends_with(last)
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn validity_follows_the_chunk_rules() {
231        assert!(is_valid("a/b/c"));
232        assert!(is_valid("a/*/c"));
233        assert!(is_valid("a/**/c"));
234        assert!(is_valid("thermometer$*/temperature"));
235        assert!(!is_valid("")); // empty
236        assert!(!is_valid("/a")); // leading slash
237        assert!(!is_valid("a/")); // trailing slash
238        assert!(!is_valid("a//b")); // doubled slash
239        assert!(!is_valid("a/b*")); // bare `*` inside a chunk
240        assert!(!is_valid("a/$x")); // `$` not before `*`
241        assert!(!is_valid("a/**b")); // `**` only as a whole chunk
242        assert!(!is_valid("a/b?")); // `?` is reserved
243    }
244
245    #[test]
246    fn matching_against_concrete_keys() {
247        // The single-chunk wildcard needs exactly one chunk.
248        assert!(matches(
249            "room275/*/temperature",
250            "room275/device1/temperature"
251        ));
252        assert!(!matches("room275/*/temperature", "room275/temperature"));
253        assert!(!matches("room275/*/temperature", "room275/a/b/temperature"));
254
255        // The multi-chunk wildcard spans zero or more chunks.
256        assert!(matches(
257            "organizationA/**/temperature",
258            "organizationA/temperature"
259        ));
260        assert!(matches(
261            "organizationA/**/temperature",
262            "organizationA/b8/r275/temperature"
263        ));
264
265        // A leading `**` selects everything below a root.
266        assert!(matches("**", "anything/at/all"));
267        assert!(matches("demo/**", "demo/a/b/c"));
268    }
269
270    #[test]
271    fn sub_chunk_wildcard_matches_within_a_chunk() {
272        assert!(matches(
273            "thermometer$*/temperature",
274            "thermometer1/temperature"
275        ));
276        assert!(matches(
277            "thermometer$*/temperature",
278            "thermometerA/temperature"
279        ));
280        assert!(matches(
281            "thermometer$*/temperature",
282            "thermometer/temperature"
283        )); // `$*` may be empty
284        assert!(!matches(
285            "thermometer$*/temperature",
286            "xthermometer1/temperature"
287        ));
288        assert!(matches("a$*b$*c", "aXXbYYc"));
289        assert!(!matches("a$*b$*c", "aXXc")); // the middle `b` is missing
290    }
291
292    #[test]
293    fn a_pattern_does_not_match_a_key_with_wildcards() {
294        // The right-hand side must be a concrete key.
295        assert!(!matches("a/*", "a/*"));
296        assert!(!matches("a/b", "a/*"));
297    }
298
299    #[test]
300    fn canonical_form_examples() {
301        // The published reordering example.
302        assert_eq!(
303            canonize("robot/sensor/**/*").as_deref(),
304            Some("robot/sensor/*/**")
305        );
306        // Consecutive `**` collapse.
307        assert_eq!(canonize("a/**/**/b").as_deref(), Some("a/**/b"));
308        // Consecutive `$*` collapse, and a lone `$*` chunk becomes `*`.
309        assert_eq!(canonize("a/x$*$*y/$*").as_deref(), Some("a/x$*y/*"));
310        // A mixed wildcard run sorts the single wildcards ahead of the multi.
311        assert_eq!(canonize("**/*/*").as_deref(), Some("*/*/**"));
312
313        assert!(is_canon("robot/sensor/*/**"));
314        assert!(!is_canon("robot/sensor/**/*"));
315        assert!(!is_canon("a//b")); // invalid is never canonical
316    }
317}