Skip to main content

webui_parser/
condition_parser.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Parser for WebUI condition expressions.
5//!
6//! This module handles parsing condition expressions used in if directives.
7//! It uses a non-recursive approach for parsing expressions.
8
9use crate::{ParserError, Result};
10use webui_protocol::{ComparisonOperator, ConditionExpr, LogicalOperator};
11
12/// Parser for WebUI condition expressions.
13pub struct ConditionParser;
14
15impl ConditionParser {
16    /// Create a new condition parser.
17    pub fn new() -> Self {
18        Self
19    }
20
21    /// Parse a condition string into a ConditionExpr.
22    pub fn parse(&self, input: &str) -> Result<ConditionExpr> {
23        // Trim whitespace
24        let input = input.trim();
25
26        if input.is_empty() {
27            return Err(ParserError::Parse("Empty condition string".to_string()));
28        }
29
30        // Try to parse each type of expression, starting with compound expressions
31        if let Ok(expr) = self.parse_compound_expr(input) {
32            return Ok(expr);
33        }
34
35        if let Ok(expr) = self.parse_not_expr(input) {
36            return Ok(expr);
37        }
38
39        if let Ok(expr) = self.parse_predicate(input) {
40            return Ok(expr);
41        }
42
43        if let Ok(expr) = self.parse_identifier(input) {
44            return Ok(expr);
45        }
46
47        Err(ParserError::Parse(format!(
48            "Failed to parse condition: '{}'",
49            input
50        )))
51    }
52
53    /// Parse a simple identifier.
54    fn parse_identifier(&self, input: &str) -> Result<ConditionExpr> {
55        // Check if the input is a simple identifier (variable name)
56        if self.is_valid_identifier(input) {
57            return Ok(ConditionExpr::identifier(input));
58        }
59
60        Err(ParserError::Parse(format!(
61            "Invalid identifier value: '{}'",
62            input
63        )))
64    }
65
66    /// Check if a string is a valid identifier.
67    fn is_valid_identifier(&self, input: &str) -> bool {
68        if input.is_empty() {
69            return false;
70        }
71
72        // First character must be a letter or underscore
73        let first_char = match input.chars().next() {
74            Some(c) => c,
75            // This shouldn't happen since we check isEmpty above
76            None => return false,
77        };
78
79        if !first_char.is_alphabetic() && first_char != '_' {
80            return false;
81        }
82
83        // Rest can be alphanumeric, underscore, or dot (for dot notation).
84        input
85            .chars()
86            .skip(1)
87            .all(|c| c.is_alphanumeric() || c == '_' || c == '.')
88    }
89
90    /// Parse a negation expression (starting with !).
91    fn parse_not_expr(&self, input: &str) -> Result<ConditionExpr> {
92        let input = input.trim();
93
94        // Check if the input starts with a negation operator
95        if let Some(expr_str) = input.strip_prefix('!') {
96            let expr_str = expr_str.trim();
97
98            // Parse the negated expression
99            let expr = self.parse(expr_str)?;
100            return Ok(ConditionExpr::negated(expr));
101        }
102
103        Err(ParserError::Parse("Not a negation expression".to_string()))
104    }
105
106    /// Parse a predicate (comparison).
107    fn parse_predicate(&self, input: &str) -> Result<ConditionExpr> {
108        let input = input.trim();
109
110        // Check if the input contains a comparison operator.
111        for op_str in &[">=", "<=", "==", "!=", ">", "<"] {
112            if let Some(pos) = input.find(op_str) {
113                // Verify this is a standalone operator by checking surrounding characters
114                // If it's part of another sequence like << or >>, it's not a valid operator
115                let is_standalone = (pos == 0 || !input[..pos].ends_with(&op_str[0..1]))
116                    && (pos + op_str.len() == input.len()
117                        || !input[pos + op_str.len()..].starts_with(&op_str[0..1]));
118
119                if !is_standalone {
120                    continue;
121                }
122
123                // Split by operator
124                let left = input[..pos].trim();
125                let right = input[pos + op_str.len()..].trim();
126
127                // Check that both sides are valid
128                if left.is_empty() || right.is_empty() {
129                    continue;
130                }
131
132                // Convert operator string to enum variant.
133                let operator = match *op_str {
134                    ">=" => ComparisonOperator::GreaterThanOrEqual,
135                    "<=" => ComparisonOperator::LessThanOrEqual,
136                    "==" => ComparisonOperator::Equal,
137                    "!=" => ComparisonOperator::NotEqual,
138                    ">" => ComparisonOperator::GreaterThan,
139                    "<" => ComparisonOperator::LessThan,
140                    _ => unreachable!(),
141                };
142
143                let predicate = ConditionExpr::predicate(left, operator, right);
144
145                return Ok(predicate);
146            }
147        }
148
149        Err(ParserError::Parse("Not a predicate expression".to_string()))
150    }
151
152    /// Parse a compound expression (with logical operators AND/OR).
153    fn parse_compound_expr(&self, input: &str) -> Result<ConditionExpr> {
154        let input = input.trim();
155
156        // Look for AND operator
157        if let Some(parts) = self.split_by_logical_op(input, &["&&", "and"], LogicalOperator::And) {
158            return Ok(parts);
159        }
160
161        // Look for OR operator
162        if let Some(parts) = self.split_by_logical_op(input, &["||", "or"], LogicalOperator::Or) {
163            return Ok(parts);
164        }
165
166        Err(ParserError::Parse("Not a compound expression".to_string()))
167    }
168
169    /// Split a string by a logical operator and create a compound expression.
170    fn split_by_logical_op(
171        &self,
172        input: &str,
173        operators: &[&str],
174        op: LogicalOperator,
175    ) -> Option<ConditionExpr> {
176        // Track nesting level for quotes
177        let mut quote_char: Option<char> = None;
178        let chars: Vec<char> = input.chars().collect();
179
180        // Find operator indices
181        let mut operator_indices = Vec::new();
182        let mut i = 0;
183
184        while i < chars.len() {
185            match chars[i] {
186                '\'' | '"' => {
187                    if quote_char.is_none() {
188                        quote_char = Some(chars[i]);
189                    } else if quote_char == Some(chars[i]) {
190                        quote_char = None;
191                    }
192                }
193                _ => {
194                    // Only check for operators when not inside quotes
195                    if quote_char.is_none() {
196                        for op_str in operators {
197                            if i + op_str.len() <= chars.len() {
198                                let slice = chars[i..i + op_str.len()].iter().collect::<String>();
199
200                                if slice == *op_str {
201                                    // Check if it's an actual operator and not part of a word
202                                    let is_standalone = (i == 0 || !chars[i - 1].is_alphanumeric())
203                                        && (i + op_str.len() == chars.len()
204                                            || !chars[i + op_str.len()].is_alphanumeric());
205
206                                    if is_standalone {
207                                        operator_indices.push((i, op_str.len()));
208                                    }
209                                }
210                            }
211                        }
212                    }
213                }
214            }
215            i += 1;
216        }
217
218        // Process the last operator (assuming right-to-left associativity)
219        if let Some((op_start, op_len)) = operator_indices.first() {
220            // Extract left and right parts
221            let left_str = input[..(*op_start)].trim();
222            let right_str = input[(*op_start + *op_len)..].trim();
223
224            if !left_str.is_empty() && !right_str.is_empty() {
225                // Parse the two sides of the operator
226                if let Ok(left) = self.parse(left_str) {
227                    if let Ok(right) = self.parse(right_str) {
228                        return Some(ConditionExpr::compound(left, op, right));
229                    }
230                }
231            }
232        }
233
234        None
235    }
236}
237
238impl Default for ConditionParser {
239    fn default() -> Self {
240        Self::new()
241    }
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247    use webui_protocol::condition_expr::Expr;
248
249    #[test]
250    fn test_parse_identifier() {
251        let parser = ConditionParser::new();
252        let result = parser
253            .parse("isLoggedIn")
254            .expect("Failed to parse identifier");
255
256        assert!(matches!(&result.expr, Some(Expr::Identifier(id)) if id.value == "isLoggedIn"));
257
258        // Test dot notation
259        let result = parser
260            .parse("user.isAdmin")
261            .expect("Failed to parse dotted identifier");
262        assert!(matches!(&result.expr, Some(Expr::Identifier(id)) if id.value == "user.isAdmin"));
263    }
264
265    #[test]
266    fn test_parse_not() {
267        let parser = ConditionParser::new();
268        let result = parser
269            .parse("!isLoggedIn")
270            .expect("Failed to parse NOT expression");
271
272        // Check outer is Not
273        assert!(matches!(&result.expr, Some(Expr::Not(not)) if
274            matches!(not.condition.as_ref().and_then(|c| c.expr.as_ref()), Some(Expr::Identifier(id)) if id.value == "isLoggedIn")
275        ));
276
277        // Test with whitespace
278        let result = parser
279            .parse("! isLoggedIn")
280            .expect("Failed to parse NOT expression with space");
281        assert!(matches!(&result.expr, Some(Expr::Not(_))));
282    }
283
284    #[test]
285    fn test_parse_predicate() {
286        let parser = ConditionParser::new();
287
288        // Test each comparison operator
289        let test_cases = vec![
290            ("age > 18", ComparisonOperator::GreaterThan),
291            ("age < 65", ComparisonOperator::LessThan),
292            ("name == 'John'", ComparisonOperator::Equal),
293            ("status != 'inactive'", ComparisonOperator::NotEqual),
294            ("score >= 90", ComparisonOperator::GreaterThanOrEqual),
295            ("score <= 100", ComparisonOperator::LessThanOrEqual),
296        ];
297
298        for (input, expected_op) in test_cases {
299            let result = parser
300                .parse(input)
301                .expect("Failed to parse predicate expression");
302            assert!(
303                matches!(&result.expr, Some(Expr::Predicate(pred)) if ComparisonOperator::try_from(pred.operator) == Ok(expected_op))
304            );
305        }
306
307        // Test string values with quotes
308        let result = parser
309            .parse("name == \"John\"")
310            .expect("Failed to parse string comparison");
311        assert!(matches!(&result.expr, Some(Expr::Predicate(pred)) if
312            pred.left == "name" &&
313            ComparisonOperator::try_from(pred.operator) == Ok(ComparisonOperator::Equal) &&
314            pred.right == "\"John\""
315        ));
316    }
317
318    #[test]
319    fn test_parse_compound() {
320        let parser = ConditionParser::new();
321
322        // Test AND condition
323        let result = parser
324            .parse("isLoggedIn && age > 18")
325            .expect("Failed to parse compound AND expression");
326        assert!(matches!(&result.expr, Some(Expr::Compound(compound)) if
327            matches!(compound.left.as_ref().and_then(|l| l.expr.as_ref()), Some(Expr::Identifier(id)) if id.value == "isLoggedIn") &&
328            LogicalOperator::try_from(compound.op) == Ok(LogicalOperator::And) &&
329            matches!(compound.right.as_ref().and_then(|r| r.expr.as_ref()), Some(Expr::Predicate(_)))
330        ));
331
332        // Test OR condition with "or" keyword
333        let result = parser
334            .parse("status == 'premium' or isAdmin")
335            .expect("Failed to parse compound OR expression");
336        assert!(matches!(&result.expr, Some(Expr::Compound(compound)) if
337            matches!(compound.left.as_ref().and_then(|l| l.expr.as_ref()), Some(Expr::Predicate(_))) &&
338            LogicalOperator::try_from(compound.op) == Ok(LogicalOperator::Or) &&
339            matches!(compound.right.as_ref().and_then(|r| r.expr.as_ref()), Some(Expr::Identifier(id)) if id.value == "isAdmin")
340        ));
341    }
342
343    #[test]
344    fn test_complex_expressions() {
345        let parser = ConditionParser::new();
346
347        // Complex AND/OR expression
348        let result = parser
349            .parse("age > 18 && isVerified || isAdmin")
350            .expect("Failed to parse complex expression");
351        assert!(matches!(&result.expr, Some(Expr::Compound(_))));
352
353        // Negated expression
354        let result = parser
355            .parse("!isVerified")
356            .expect("Failed to parse NOT expression");
357        assert!(matches!(&result.expr, Some(Expr::Not(_))));
358    }
359
360    #[test]
361    fn test_invalid_expressions() {
362        let parser = ConditionParser::new();
363
364        // Empty input
365        assert!(parser.parse("").is_err());
366
367        // Invalid operators
368        assert!(parser.parse("age << 18").is_err());
369
370        // Incomplete expressions
371        assert!(parser.parse("age >").is_err());
372        assert!(parser.parse("&& isAdmin").is_err());
373    }
374
375    #[test]
376    fn test_tokenize_simple_identifier() {
377        let parser = ConditionParser::new();
378        let result = parser
379            .parse("isVisible")
380            .expect("Failed to parse simple identifier");
381
382        assert!(matches!(&result.expr, Some(Expr::Identifier(id)) if id.value == "isVisible"));
383    }
384
385    #[test]
386    fn test_tokenize_dotted_path() {
387        let parser = ConditionParser::new();
388        let result = parser
389            .parse("appearance")
390            .expect("Failed to parse identifier");
391
392        assert!(matches!(&result.expr, Some(Expr::Identifier(id)) if id.value == "appearance"));
393    }
394
395    #[test]
396    fn test_tokenize_binary_expression() {
397        let parser = ConditionParser::new();
398        let result = parser
399            .parse("x > 5")
400            .expect("Failed to parse binary expression");
401
402        assert!(matches!(&result.expr, Some(Expr::Predicate(pred)) if
403            pred.left == "x" &&
404            ComparisonOperator::try_from(pred.operator) == Ok(ComparisonOperator::GreaterThan) &&
405            pred.right == "5"
406        ));
407    }
408
409    #[test]
410    fn test_tokenize_complex_and_dotted() {
411        let parser = ConditionParser::new();
412        let result = parser
413            .parse("appearance == \"hub\" && actions.trailing")
414            .expect("Failed to parse complex and-dotted expression");
415
416        assert!(matches!(&result.expr, Some(Expr::Compound(compound)) if
417            matches!(compound.left.as_ref().and_then(|l| l.expr.as_ref()), Some(Expr::Predicate(pred)) if
418                pred.left == "appearance" &&
419                ComparisonOperator::try_from(pred.operator) == Ok(ComparisonOperator::Equal) &&
420                pred.right == "\"hub\""
421            ) &&
422            LogicalOperator::try_from(compound.op) == Ok(LogicalOperator::And) &&
423            matches!(compound.right.as_ref().and_then(|r| r.expr.as_ref()), Some(Expr::Identifier(id)) if id.value == "actions.trailing")
424        ));
425    }
426
427    #[test]
428    fn test_tokenize_unary_not() {
429        let parser = ConditionParser::new();
430        let result = parser
431            .parse("!disabled")
432            .expect("Failed to parse unary not expression");
433
434        assert!(matches!(&result.expr, Some(Expr::Not(not)) if
435            matches!(not.condition.as_ref().and_then(|c| c.expr.as_ref()), Some(Expr::Identifier(id)) if id.value == "disabled")
436        ));
437    }
438
439    #[test]
440    fn test_tokenize_undefined_comparison() {
441        let parser = ConditionParser::new();
442        let result = parser
443            .parse("foo == undefined")
444            .expect("Failed to parse undefined comparison");
445
446        assert!(matches!(&result.expr, Some(Expr::Predicate(pred)) if
447            pred.left == "foo" &&
448            ComparisonOperator::try_from(pred.operator) == Ok(ComparisonOperator::Equal) &&
449            pred.right == "undefined"
450        ));
451    }
452
453    #[test]
454    fn test_reject_mixed_and_or() {
455        // The Rust parser handles mixed && and || by nesting (unlike NodeJS which rejects them).
456        // "a && b || c" is parsed as Compound(a, And, Compound(b, Or, c)).
457        let parser = ConditionParser::new();
458        let result = parser
459            .parse("a && b || c")
460            .expect("Rust parser handles mixed operators via nesting");
461
462        assert!(matches!(&result.expr, Some(Expr::Compound(compound)) if
463            matches!(compound.left.as_ref().and_then(|l| l.expr.as_ref()), Some(Expr::Identifier(id)) if id.value == "a") &&
464            LogicalOperator::try_from(compound.op) == Ok(LogicalOperator::And) &&
465            matches!(compound.right.as_ref().and_then(|r| r.expr.as_ref()), Some(Expr::Compound(inner)) if
466                matches!(inner.left.as_ref().and_then(|l| l.expr.as_ref()), Some(Expr::Identifier(id)) if id.value == "b") &&
467                LogicalOperator::try_from(inner.op) == Ok(LogicalOperator::Or) &&
468                matches!(inner.right.as_ref().and_then(|r| r.expr.as_ref()), Some(Expr::Identifier(id)) if id.value == "c")
469            )
470        ));
471    }
472
473    #[test]
474    fn test_reject_too_many_tokens() {
475        // The Rust parser has no explicit complexity limit but handles long chains
476        // by deeply nesting Compound nodes. Verify a long chain parses correctly.
477        let parser = ConditionParser::new();
478        let result = parser
479            .parse("a && b && c && d && e && f && g && h && i && j")
480            .expect("Parser handles long chained expressions");
481
482        // The outermost should be a Compound with And
483        assert!(matches!(&result.expr, Some(Expr::Compound(compound)) if
484            matches!(compound.left.as_ref().and_then(|l| l.expr.as_ref()), Some(Expr::Identifier(id)) if id.value == "a") &&
485            LogicalOperator::try_from(compound.op) == Ok(LogicalOperator::And) &&
486            matches!(compound.right.as_ref().and_then(|r| r.expr.as_ref()), Some(Expr::Compound(_)))
487        ));
488    }
489}