Skip to main content

zerodds_web/
sample_selector.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! DDS-WEB Sample Selector BNF Parser — Spec §7.4.8.
5//!
6//! Implementiert §7.4.8 voll.
7//!
8//! Spec-Quelle: OMG DDS-WEB 1.0 §7.4.8 (S. 51-53) — `DataReader::read`
9//! mit `SampleSelector`-BNF-Grammar:
10//!
11//! ```text
12//! SampleSelector  ::= FilterExpression ( ',' MetadataExpression )?
13//! FilterExpression ::= Term ( ('AND' | 'OR') Term )*
14//! Term            ::= Atom | '(' FilterExpression ')'
15//! Atom            ::= FieldRef CompOp Literal
16//! MetadataExpression ::= 'sample_state=' SampleState
17//!                      | 'view_state='   ViewState
18//!                      | 'instance_state='InstanceState
19//! ```
20//!
21//! Der Parser liefert ein AST [`SampleSelector`], das vom Caller in
22//! eine DDS [`QueryCondition`] uebersetzt werden kann
23//! (`crates/dcps/src/query_condition.rs`).
24
25use alloc::string::{String, ToString};
26use alloc::vec::Vec;
27use core::fmt;
28
29/// Top-Level-AST der Sample-Selector-Expression.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct SampleSelector {
32    /// Filter-Ausdruck (kann leer sein, wenn nur Metadata).
33    pub filter: Option<FilterExpression>,
34    /// Metadata-Ausdruecke (mehrere mit AND verknuepft moeglich).
35    pub metadata: Vec<MetadataExpression>,
36}
37
38/// Filter-Expression — boolescher Ausdruck ueber Field-Referenzen.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum FilterExpression {
41    /// Einzelner Vergleich.
42    Comparison {
43        /// Field-Pfad (z.B. `position.x`).
44        field: String,
45        /// Vergleichs-Operator.
46        op: CompareOp,
47        /// Literal-Wert (rechte Seite).
48        value: Literal,
49    },
50    /// Konjunktion / Disjunktion.
51    Boolean {
52        /// Boolescher Operator.
53        op: BoolOp,
54        /// Linke Sub-Expression.
55        lhs: alloc::boxed::Box<FilterExpression>,
56        /// Rechte Sub-Expression.
57        rhs: alloc::boxed::Box<FilterExpression>,
58    },
59}
60
61/// Vergleichs-Operator (Spec §7.4.8).
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum CompareOp {
64    /// `=`
65    Eq,
66    /// `!=`
67    NotEq,
68    /// `<`
69    Lt,
70    /// `<=`
71    Le,
72    /// `>`
73    Gt,
74    /// `>=`
75    Ge,
76}
77
78/// Boolescher Operator.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum BoolOp {
81    /// `AND`
82    And,
83    /// `OR`
84    Or,
85}
86
87/// Literal-Wert auf der rechten Seite eines Vergleichs.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub enum Literal {
90    /// Integer.
91    Integer(i64),
92    /// String (zwischen `'...'` oder `"..."`).
93    Str(String),
94    /// Boolean (`true` / `false`).
95    Bool(bool),
96}
97
98/// Metadata-Expression aus Spec §7.4.8.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum MetadataExpression {
101    /// `sample_state=read|not_read|any`
102    SampleState(SampleStateMatch),
103    /// `view_state=new|not_new|any`
104    ViewState(ViewStateMatch),
105    /// `instance_state=alive|not_alive_disposed|not_alive_no_writers|any`
106    InstanceState(InstanceStateMatch),
107}
108
109/// Spec §7.4.8 — Sample-State-Match (DDS DCPS §2.2.2.5).
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum SampleStateMatch {
112    /// Nur READ-Samples.
113    Read,
114    /// Nur NOT_READ-Samples.
115    NotRead,
116    /// Beide.
117    Any,
118}
119
120/// Spec §7.4.8 — View-State-Match.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum ViewStateMatch {
123    /// Nur NEW.
124    New,
125    /// Nur NOT_NEW.
126    NotNew,
127    /// Beide.
128    Any,
129}
130
131/// Spec §7.4.8 — Instance-State-Match.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub enum InstanceStateMatch {
134    /// Nur ALIVE.
135    Alive,
136    /// Nur NOT_ALIVE_DISPOSED.
137    NotAliveDisposed,
138    /// Nur NOT_ALIVE_NO_WRITERS.
139    NotAliveNoWriters,
140    /// Alle.
141    Any,
142}
143
144/// Parser-Fehler.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub enum ParseError {
147    /// Unexpected token at position.
148    Unexpected {
149        /// Char-position in source.
150        pos: usize,
151        /// Description what was found.
152        found: String,
153    },
154    /// Eof inmitten eines Ausdrucks.
155    UnexpectedEof,
156    /// Unbalanced parenthesis.
157    UnbalancedParen,
158    /// Numerisches Literal nicht parsbar.
159    InvalidNumber(String),
160    /// Unbekannter Metadata-Key (z.B. `xyz_state`).
161    UnknownMetadataKey(String),
162    /// Unbekannter Metadata-Wert.
163    UnknownMetadataValue(String),
164}
165
166impl fmt::Display for ParseError {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        match self {
169            Self::Unexpected { pos, found } => {
170                write!(f, "unexpected token '{found}' at pos {pos}")
171            }
172            Self::UnexpectedEof => f.write_str("unexpected end of input"),
173            Self::UnbalancedParen => f.write_str("unbalanced parenthesis"),
174            Self::InvalidNumber(s) => write!(f, "invalid number literal '{s}'"),
175            Self::UnknownMetadataKey(s) => write!(f, "unknown metadata key '{s}'"),
176            Self::UnknownMetadataValue(s) => write!(f, "unknown metadata value '{s}'"),
177        }
178    }
179}
180
181#[cfg(feature = "std")]
182impl std::error::Error for ParseError {}
183
184/// Parst einen Sample-Selector-String laut Spec §7.4.8 BNF.
185///
186/// # Errors
187/// `ParseError` bei BNF-Verletzung.
188pub fn parse_sample_selector(src: &str) -> Result<SampleSelector, ParseError> {
189    let mut p = Parser::new(src);
190    p.skip_whitespace();
191
192    // Spec §7.4.8: Selector kann starten mit FilterExpression oder mit
193    // einer (durch Komma optional separierten) Sequenz von
194    // MetadataExpressions.
195    let filter = if p.peek_metadata_key().is_some() {
196        None
197    } else {
198        Some(p.parse_filter_expression()?)
199    };
200
201    let mut metadata = Vec::new();
202    loop {
203        p.skip_whitespace();
204        if p.is_eof() {
205            break;
206        }
207        // Optionales `,` zwischen Filter und Metadata bzw. zwischen
208        // mehreren Metadata-Expressions.
209        let _ = p.consume_char(',');
210        p.skip_whitespace();
211        if p.peek_metadata_key().is_none() {
212            break;
213        }
214        metadata.push(p.parse_metadata_expression()?);
215    }
216
217    if !p.is_eof() {
218        return Err(ParseError::Unexpected {
219            pos: p.pos,
220            found: p.peek_token(),
221        });
222    }
223    Ok(SampleSelector { filter, metadata })
224}
225
226// ============================================================================
227//  Parser (recursive descent).
228// ============================================================================
229
230struct Parser<'a> {
231    src: &'a [u8],
232    pos: usize,
233}
234
235impl<'a> Parser<'a> {
236    fn new(src: &'a str) -> Self {
237        Self {
238            src: src.as_bytes(),
239            pos: 0,
240        }
241    }
242
243    fn is_eof(&self) -> bool {
244        self.pos >= self.src.len()
245    }
246
247    fn peek(&self) -> Option<u8> {
248        self.src.get(self.pos).copied()
249    }
250
251    fn skip_whitespace(&mut self) -> bool {
252        while let Some(b) = self.peek() {
253            if b.is_ascii_whitespace() {
254                self.pos += 1;
255            } else {
256                break;
257            }
258        }
259        !self.is_eof()
260    }
261
262    fn consume_char(&mut self, c: char) -> bool {
263        if self.peek() == Some(c as u8) {
264            self.pos += 1;
265            true
266        } else {
267            false
268        }
269    }
270
271    fn peek_token(&self) -> String {
272        let mut end = self.pos;
273        while end < self.src.len() && !self.src[end].is_ascii_whitespace() && self.src[end] != b','
274        {
275            end += 1;
276        }
277        if end > self.pos {
278            String::from_utf8_lossy(&self.src[self.pos..end]).into_owned()
279        } else {
280            "<eof>".to_string()
281        }
282    }
283
284    fn parse_filter_expression(&mut self) -> Result<FilterExpression, ParseError> {
285        let mut lhs = self.parse_term()?;
286        loop {
287            self.skip_whitespace();
288            let saved = self.pos;
289            let op = if self.consume_keyword("AND") {
290                BoolOp::And
291            } else if self.consume_keyword("OR") {
292                BoolOp::Or
293            } else {
294                self.pos = saved;
295                break;
296            };
297            self.skip_whitespace();
298            let rhs = self.parse_term()?;
299            lhs = FilterExpression::Boolean {
300                op,
301                lhs: alloc::boxed::Box::new(lhs),
302                rhs: alloc::boxed::Box::new(rhs),
303            };
304        }
305        Ok(lhs)
306    }
307
308    fn parse_term(&mut self) -> Result<FilterExpression, ParseError> {
309        self.skip_whitespace();
310        if self.consume_char('(') {
311            let inner = self.parse_filter_expression()?;
312            self.skip_whitespace();
313            if !self.consume_char(')') {
314                return Err(ParseError::UnbalancedParen);
315            }
316            return Ok(inner);
317        }
318        // Atom: FieldRef CompOp Literal
319        let field = self.parse_identifier_path()?;
320        self.skip_whitespace();
321        let op = self.parse_compare_op()?;
322        self.skip_whitespace();
323        let value = self.parse_literal()?;
324        Ok(FilterExpression::Comparison { field, op, value })
325    }
326
327    fn parse_identifier_path(&mut self) -> Result<String, ParseError> {
328        self.skip_whitespace();
329        let start = self.pos;
330        while let Some(b) = self.peek() {
331            if b.is_ascii_alphanumeric() || b == b'_' || b == b'.' {
332                self.pos += 1;
333            } else {
334                break;
335            }
336        }
337        if self.pos == start {
338            return Err(ParseError::Unexpected {
339                pos: self.pos,
340                found: self.peek_token(),
341            });
342        }
343        Ok(String::from_utf8_lossy(&self.src[start..self.pos]).into_owned())
344    }
345
346    fn parse_compare_op(&mut self) -> Result<CompareOp, ParseError> {
347        let op = match (self.peek(), self.src.get(self.pos + 1).copied()) {
348            (Some(b'='), _) => {
349                self.pos += 1;
350                CompareOp::Eq
351            }
352            (Some(b'!'), Some(b'=')) => {
353                self.pos += 2;
354                CompareOp::NotEq
355            }
356            (Some(b'<'), Some(b'=')) => {
357                self.pos += 2;
358                CompareOp::Le
359            }
360            (Some(b'<'), _) => {
361                self.pos += 1;
362                CompareOp::Lt
363            }
364            (Some(b'>'), Some(b'=')) => {
365                self.pos += 2;
366                CompareOp::Ge
367            }
368            (Some(b'>'), _) => {
369                self.pos += 1;
370                CompareOp::Gt
371            }
372            _ => {
373                return Err(ParseError::Unexpected {
374                    pos: self.pos,
375                    found: self.peek_token(),
376                });
377            }
378        };
379        Ok(op)
380    }
381
382    fn parse_literal(&mut self) -> Result<Literal, ParseError> {
383        match self.peek() {
384            Some(b'\'') | Some(b'"') => self.parse_string_literal(),
385            Some(b'-') | Some(b'0'..=b'9') => self.parse_number_literal(),
386            Some(b) if b.is_ascii_alphabetic() => {
387                // boolean
388                let saved = self.pos;
389                if self.consume_keyword("true") {
390                    return Ok(Literal::Bool(true));
391                }
392                if self.consume_keyword("false") {
393                    return Ok(Literal::Bool(false));
394                }
395                self.pos = saved;
396                Err(ParseError::Unexpected {
397                    pos: self.pos,
398                    found: self.peek_token(),
399                })
400            }
401            _ => Err(ParseError::Unexpected {
402                pos: self.pos,
403                found: self.peek_token(),
404            }),
405        }
406    }
407
408    fn parse_string_literal(&mut self) -> Result<Literal, ParseError> {
409        let quote = self.peek().ok_or(ParseError::UnexpectedEof)?;
410        self.pos += 1;
411        let start = self.pos;
412        while let Some(b) = self.peek() {
413            if b == quote {
414                let s = String::from_utf8_lossy(&self.src[start..self.pos]).into_owned();
415                self.pos += 1;
416                return Ok(Literal::Str(s));
417            }
418            self.pos += 1;
419        }
420        Err(ParseError::UnexpectedEof)
421    }
422
423    fn parse_number_literal(&mut self) -> Result<Literal, ParseError> {
424        let start = self.pos;
425        if self.peek() == Some(b'-') {
426            self.pos += 1;
427        }
428        while let Some(b) = self.peek() {
429            if b.is_ascii_digit() {
430                self.pos += 1;
431            } else {
432                break;
433            }
434        }
435        let raw = String::from_utf8_lossy(&self.src[start..self.pos]).into_owned();
436        raw.parse::<i64>()
437            .map(Literal::Integer)
438            .map_err(|_| ParseError::InvalidNumber(raw))
439    }
440
441    fn consume_keyword(&mut self, kw: &str) -> bool {
442        let bytes = kw.as_bytes();
443        if self.pos + bytes.len() > self.src.len() {
444            return false;
445        }
446        for (i, b) in bytes.iter().enumerate() {
447            let actual = self.src[self.pos + i];
448            // Match case-insensitive for AND/OR; case-sensitive for true/false
449            let matches = if kw.chars().all(|c| c.is_ascii_uppercase()) {
450                actual.eq_ignore_ascii_case(b)
451            } else {
452                actual == *b
453            };
454            if !matches {
455                return false;
456            }
457        }
458        // Boundary check: next char must not be alphanumeric.
459        if let Some(after) = self.src.get(self.pos + bytes.len()) {
460            if after.is_ascii_alphanumeric() || *after == b'_' {
461                return false;
462            }
463        }
464        self.pos += bytes.len();
465        true
466    }
467
468    fn peek_metadata_key(&self) -> Option<&'static str> {
469        for key in ["sample_state", "view_state", "instance_state"] {
470            let bytes = key.as_bytes();
471            if self.pos + bytes.len() <= self.src.len()
472                && &self.src[self.pos..self.pos + bytes.len()] == bytes
473            {
474                return Some(key);
475            }
476        }
477        None
478    }
479
480    fn parse_metadata_expression(&mut self) -> Result<MetadataExpression, ParseError> {
481        let key = self
482            .peek_metadata_key()
483            .ok_or_else(|| ParseError::UnknownMetadataKey(self.peek_token()))?;
484        self.pos += key.len();
485        self.skip_whitespace();
486        if !self.consume_char('=') {
487            return Err(ParseError::Unexpected {
488                pos: self.pos,
489                found: self.peek_token(),
490            });
491        }
492        self.skip_whitespace();
493        let val = self.parse_identifier_path()?;
494        match key {
495            "sample_state" => match val.as_str() {
496                "read" => Ok(MetadataExpression::SampleState(SampleStateMatch::Read)),
497                "not_read" => Ok(MetadataExpression::SampleState(SampleStateMatch::NotRead)),
498                "any" => Ok(MetadataExpression::SampleState(SampleStateMatch::Any)),
499                _ => Err(ParseError::UnknownMetadataValue(val)),
500            },
501            "view_state" => match val.as_str() {
502                "new" => Ok(MetadataExpression::ViewState(ViewStateMatch::New)),
503                "not_new" => Ok(MetadataExpression::ViewState(ViewStateMatch::NotNew)),
504                "any" => Ok(MetadataExpression::ViewState(ViewStateMatch::Any)),
505                _ => Err(ParseError::UnknownMetadataValue(val)),
506            },
507            "instance_state" => match val.as_str() {
508                "alive" => Ok(MetadataExpression::InstanceState(InstanceStateMatch::Alive)),
509                "not_alive_disposed" => Ok(MetadataExpression::InstanceState(
510                    InstanceStateMatch::NotAliveDisposed,
511                )),
512                "not_alive_no_writers" => Ok(MetadataExpression::InstanceState(
513                    InstanceStateMatch::NotAliveNoWriters,
514                )),
515                "any" => Ok(MetadataExpression::InstanceState(InstanceStateMatch::Any)),
516                _ => Err(ParseError::UnknownMetadataValue(val)),
517            },
518            _ => Err(ParseError::UnknownMetadataKey(key.to_string())),
519        }
520    }
521}
522
523#[cfg(test)]
524#[allow(
525    clippy::expect_used,
526    clippy::unwrap_used,
527    clippy::unreachable,
528    clippy::panic
529)]
530mod tests {
531    use super::*;
532
533    #[test]
534    fn parses_simple_equality_filter() {
535        let s = parse_sample_selector("speed = 42").expect("parse");
536        assert!(s.filter.is_some());
537        assert!(s.metadata.is_empty());
538        if let FilterExpression::Comparison { field, op, value } = s.filter.unwrap() {
539            assert_eq!(field, "speed");
540            assert_eq!(op, CompareOp::Eq);
541            assert_eq!(value, Literal::Integer(42));
542        } else {
543            unreachable!();
544        }
545    }
546
547    #[test]
548    fn parses_inequality_with_string_literal() {
549        let s = parse_sample_selector("name != 'sensor'").expect("parse");
550        if let FilterExpression::Comparison { field, op, value } = s.filter.unwrap() {
551            assert_eq!(field, "name");
552            assert_eq!(op, CompareOp::NotEq);
553            assert_eq!(value, Literal::Str("sensor".to_string()));
554        } else {
555            unreachable!();
556        }
557    }
558
559    #[test]
560    fn parses_dotted_field_path() {
561        let s = parse_sample_selector("position.x > 0").expect("parse");
562        if let FilterExpression::Comparison { field, .. } = s.filter.unwrap() {
563            assert_eq!(field, "position.x");
564        } else {
565            unreachable!();
566        }
567    }
568
569    #[test]
570    fn parses_and_conjunction() {
571        let s = parse_sample_selector("a > 1 AND b < 10").expect("parse");
572        if let FilterExpression::Boolean { op, .. } = s.filter.unwrap() {
573            assert_eq!(op, BoolOp::And);
574        } else {
575            unreachable!();
576        }
577    }
578
579    #[test]
580    fn parses_or_with_parenthesis() {
581        let s = parse_sample_selector("(a = 1) OR (b = 2)").expect("parse");
582        if let FilterExpression::Boolean { op, .. } = s.filter.unwrap() {
583            assert_eq!(op, BoolOp::Or);
584        } else {
585            unreachable!();
586        }
587    }
588
589    #[test]
590    fn parses_metadata_only_expression() {
591        let s = parse_sample_selector("sample_state=read").expect("parse");
592        assert!(s.filter.is_none());
593        assert_eq!(s.metadata.len(), 1);
594        assert_eq!(
595            s.metadata[0],
596            MetadataExpression::SampleState(SampleStateMatch::Read)
597        );
598    }
599
600    #[test]
601    fn parses_filter_plus_metadata() {
602        let s = parse_sample_selector("speed > 5, view_state=new").expect("parse");
603        assert!(s.filter.is_some());
604        assert_eq!(
605            s.metadata,
606            alloc::vec![MetadataExpression::ViewState(ViewStateMatch::New)]
607        );
608    }
609
610    #[test]
611    fn parses_all_three_metadata_kinds() {
612        let s = parse_sample_selector("sample_state=any, view_state=any, instance_state=alive")
613            .expect("parse");
614        assert_eq!(s.metadata.len(), 3);
615        assert!(matches!(
616            s.metadata[2],
617            MetadataExpression::InstanceState(InstanceStateMatch::Alive)
618        ));
619    }
620
621    #[test]
622    fn rejects_unknown_metadata_key() {
623        let err = parse_sample_selector("xyz_state=read").expect_err("error");
624        // "xyz_state" parses as identifier-path until '='; then comparison
625        // tries to read literal 'read' which is invalid - that's still an
626        // unexpected token at pos>0; either way it's an error.
627        assert!(matches!(
628            err,
629            ParseError::UnknownMetadataKey(_)
630                | ParseError::Unexpected { .. }
631                | ParseError::UnknownMetadataValue(_)
632        ));
633    }
634
635    #[test]
636    fn rejects_unknown_metadata_value() {
637        let err = parse_sample_selector("sample_state=xyz").expect_err("error");
638        assert!(matches!(err, ParseError::UnknownMetadataValue(_)));
639    }
640
641    #[test]
642    fn rejects_unbalanced_parenthesis() {
643        let err = parse_sample_selector("(a = 1").expect_err("error");
644        assert!(matches!(err, ParseError::UnbalancedParen));
645    }
646
647    #[test]
648    fn rejects_trailing_garbage() {
649        let err = parse_sample_selector("a = 1 garbage").expect_err("error");
650        assert!(matches!(err, ParseError::Unexpected { .. }));
651    }
652
653    #[test]
654    fn comparison_operators_full_coverage() {
655        for (src, expected) in [
656            ("a = 1", CompareOp::Eq),
657            ("a != 1", CompareOp::NotEq),
658            ("a < 1", CompareOp::Lt),
659            ("a <= 1", CompareOp::Le),
660            ("a > 1", CompareOp::Gt),
661            ("a >= 1", CompareOp::Ge),
662        ] {
663            let s = parse_sample_selector(src).expect("parse");
664            if let FilterExpression::Comparison { op, .. } = s.filter.unwrap() {
665                assert_eq!(op, expected, "for src={src}");
666            } else {
667                unreachable!("expected Comparison for src={src}");
668            }
669        }
670    }
671
672    #[test]
673    fn boolean_literal_supported() {
674        let s = parse_sample_selector("active = true").expect("parse");
675        if let FilterExpression::Comparison { value, .. } = s.filter.unwrap() {
676            assert_eq!(value, Literal::Bool(true));
677        } else {
678            unreachable!();
679        }
680    }
681}