#![allow(missing_docs)]
use pest::Parser;
use pest_derive::Parser;
use super::ast::FilterExpr;
use super::error::{FilterError, MAX_INPUT_LENGTH, MAX_NESTING_DEPTH};
#[derive(Parser)]
#[grammar = "filter/filter.pest"]
#[allow(missing_docs)]
pub struct FilterParser;
pub fn parse(input: &str) -> Result<FilterExpr, FilterError> {
if input.len() > MAX_INPUT_LENGTH {
return Err(FilterError::InputTooLong {
max_length: MAX_INPUT_LENGTH,
actual_length: input.len(),
});
}
let pairs = FilterParser::parse(Rule::filter, input).map_err(|e| from_pest_error(&e, input))?;
let expr = build_ast(pairs)?;
let depth = expr.depth();
if depth > MAX_NESTING_DEPTH {
return Err(FilterError::NestingTooDeep {
max_depth: MAX_NESTING_DEPTH,
actual_depth: depth,
});
}
Ok(expr)
}
fn from_pest_error(e: &pest::error::Error<Rule>, input: &str) -> FilterError {
let (position, line, column) = match e.line_col {
pest::error::LineColLocation::Pos((line, col))
| pest::error::LineColLocation::Span((line, col), _) => {
let pos = line_col_to_position(input, line, col);
(pos, line, col)
}
};
let message = format!("{}", e.variant.message());
let suggestion = generate_suggestion(&message, input, position);
FilterError::SyntaxError {
position,
line,
column,
message,
suggestion,
}
}
fn line_col_to_position(input: &str, line: usize, col: usize) -> usize {
let mut pos = 0;
for (i, l) in input.lines().enumerate() {
if i + 1 == line {
return pos + col.saturating_sub(1);
}
pos += l.len() + 1; }
pos
}
fn generate_suggestion(message: &str, input: &str, position: usize) -> Option<String> {
if position >= input.len() || !input.is_char_boundary(position) {
return None;
}
let remaining = &input[position..];
let before = &input[..position];
if remaining.starts_with(':') {
return Some("Did you mean '=' instead of ':'? EdgeVec uses '=' for equality.".to_string());
}
if remaining.starts_with("==") {
return Some("Use single '=' for equality comparisons, not '=='.".to_string());
}
if remaining.starts_with("===") {
return Some(
"EdgeVec uses '=' for equality. JavaScript-style '===' is not supported.".to_string(),
);
}
if remaining.starts_with("<>") {
return Some("Use '!=' for not-equal comparisons, not '<>'.".to_string());
}
if remaining.starts_with("&&") || remaining.starts_with("||") {
if message.contains("expected") {
return Some(
"Both symbolic (&&, ||) and keyword (AND, OR) operators are supported.".to_string(),
);
}
}
if message.contains("expected") && !remaining.is_empty() {
let first_char = remaining.chars().next().unwrap_or(' ');
if first_char.is_alphabetic() && !is_keyword(remaining) {
if before.ends_with("= ") || before.ends_with('=') {
let word: String = remaining
.chars()
.take_while(|c| c.is_alphanumeric())
.collect();
if !word.is_empty() && !is_keyword(&word) {
return Some(format!(
"String values must be quoted. Try: = \"{word}\" instead of = {word}"
));
}
}
}
}
if remaining.to_uppercase().starts_with("WHERE ") {
return Some(
"EdgeVec filter expressions don't use 'WHERE'. Start directly with conditions."
.to_string(),
);
}
if message.contains("expected") {
let words: Vec<&str> = before.split_whitespace().collect();
if !words.is_empty() {
let last_word = *words.last().unwrap_or(&"");
if is_valid_field_name(last_word) && !is_keyword(last_word) {
if let Some(first_remaining_word) = remaining.split_whitespace().next() {
if !is_operator(first_remaining_word) && !is_keyword(first_remaining_word) {
return Some(format!(
"Missing operator between '{last_word}' and value. \
Expected: =, !=, <, <=, >, >=, CONTAINS, IN, etc."
));
}
}
}
}
}
if remaining.starts_with('(')
&& message.contains("expected")
&& (before.to_uppercase().ends_with(" IN") || before.to_uppercase().ends_with(" IN "))
{
return Some(
"Use square brackets [...] for arrays, not parentheses (...). \
Example: category IN [\"a\", \"b\"]"
.to_string(),
);
}
if before.to_uppercase().contains("BETWEEN")
&& !before.to_uppercase().contains(" AND ")
&& (remaining.to_uppercase().starts_with("TO ")
|| remaining.to_uppercase().starts_with("- "))
{
return Some(
"BETWEEN uses AND to separate values. Example: price BETWEEN 10 AND 100".to_string(),
);
}
None
}
fn is_keyword(s: &str) -> bool {
let upper = s.to_uppercase();
let word: String = upper.chars().take_while(|c| c.is_alphabetic()).collect();
matches!(
word.as_str(),
"AND"
| "OR"
| "NOT"
| "IN"
| "BETWEEN"
| "LIKE"
| "CONTAINS"
| "STARTS_WITH"
| "ENDS_WITH"
| "IS"
| "NULL"
| "TRUE"
| "FALSE"
| "ANY"
| "ALL"
| "NONE"
)
}
fn is_valid_field_name(s: &str) -> bool {
if s.is_empty() {
return false;
}
let first = s.chars().next().unwrap();
if !first.is_alphabetic() && first != '_' {
return false;
}
s.chars().all(|c| c.is_alphanumeric() || c == '_')
}
fn is_operator(s: &str) -> bool {
matches!(s, "=" | "!=" | "<" | "<=" | ">" | ">=" | "&&" | "||" | "!")
}
fn build_ast(pairs: pest::iterators::Pairs<Rule>) -> Result<FilterExpr, FilterError> {
for pair in pairs {
if pair.as_rule() == Rule::filter {
for inner in pair.into_inner() {
if inner.as_rule() == Rule::logical_expr {
return build_logical_expr(inner);
}
}
}
}
Err(FilterError::SyntaxError {
position: 0,
line: 1,
column: 1,
message: "Empty or invalid filter expression".to_string(),
suggestion: None,
})
}
fn build_logical_expr(pair: pest::iterators::Pair<Rule>) -> Result<FilterExpr, FilterError> {
let inner = pair.into_inner().next().unwrap();
build_or_expr(inner)
}
fn build_or_expr(pair: pest::iterators::Pair<Rule>) -> Result<FilterExpr, FilterError> {
let mut inner = pair.into_inner();
let first = inner.next().unwrap();
let mut left = build_and_expr(first)?;
for next in inner {
let right = build_and_expr(next)?;
left = FilterExpr::Or(Box::new(left), Box::new(right));
}
Ok(left)
}
fn build_and_expr(pair: pest::iterators::Pair<Rule>) -> Result<FilterExpr, FilterError> {
let mut inner = pair.into_inner();
let first = inner.next().unwrap();
let mut left = build_not_expr(first)?;
for next in inner {
let right = build_not_expr(next)?;
left = FilterExpr::And(Box::new(left), Box::new(right));
}
Ok(left)
}
fn build_not_expr(pair: pest::iterators::Pair<Rule>) -> Result<FilterExpr, FilterError> {
let inner: Vec<_> = pair.into_inner().collect();
if inner.is_empty() {
return Err(FilterError::SyntaxError {
position: 0,
line: 1,
column: 1,
message: "Empty not_expr".to_string(),
suggestion: None,
});
}
let first = inner.into_iter().next().unwrap();
match first.as_rule() {
Rule::not_expr => {
let operand = build_not_expr(first)?;
Ok(FilterExpr::Not(Box::new(operand)))
}
Rule::primary_expr => {
build_primary_expr(first)
}
_ => {
build_primary_expr(first)
}
}
}
fn build_primary_expr(pair: pest::iterators::Pair<Rule>) -> Result<FilterExpr, FilterError> {
let inner = pair.into_inner().next().unwrap();
match inner.as_rule() {
Rule::grouped_expr => build_grouped_expr(inner),
Rule::null_check => build_null_check(inner),
Rule::between_expr => build_between_expr(inner),
Rule::array_op_expr => build_array_op_expr(inner),
Rule::string_op_expr => build_string_op_expr(inner),
Rule::set_op_expr => build_set_op_expr(inner),
Rule::comparison_expr => build_comparison_expr(inner),
_ => Err(FilterError::SyntaxError {
position: 0,
line: 1,
column: 1,
message: format!("Unexpected rule: {:?}", inner.as_rule()),
suggestion: None,
}),
}
}
fn build_grouped_expr(pair: pest::iterators::Pair<Rule>) -> Result<FilterExpr, FilterError> {
for inner in pair.into_inner() {
if inner.as_rule() == Rule::logical_expr {
return build_logical_expr(inner);
}
}
Err(FilterError::SyntaxError {
position: 0,
line: 1,
column: 1,
message: "Empty grouped expression".to_string(),
suggestion: None,
})
}
fn build_null_check(pair: pest::iterators::Pair<Rule>) -> Result<FilterExpr, FilterError> {
let mut inner = pair.into_inner();
let field_pair = inner.next().unwrap();
let field_name = field_pair.as_str().to_string();
let field_expr = FilterExpr::Field(field_name);
let op_pair = inner.next().unwrap();
let op_inner = op_pair.into_inner().next().unwrap();
match op_inner.as_rule() {
Rule::is_not_null_op => Ok(FilterExpr::IsNotNull(Box::new(field_expr))),
Rule::is_null_only_op => Ok(FilterExpr::IsNull(Box::new(field_expr))),
_ => Err(FilterError::SyntaxError {
position: 0,
line: 1,
column: 1,
message: "Invalid null check operator".to_string(),
suggestion: None,
}),
}
}
fn build_between_expr(pair: pest::iterators::Pair<Rule>) -> Result<FilterExpr, FilterError> {
let mut inner = pair.into_inner();
let field_pair = inner.next().unwrap();
let field_expr = FilterExpr::Field(field_pair.as_str().to_string());
inner.next();
let low_pair = inner.next().unwrap();
let low_expr = build_value(low_pair)?;
inner.next();
let high_pair = inner.next().unwrap();
let high_expr = build_value(high_pair)?;
Ok(FilterExpr::Between(
Box::new(field_expr),
Box::new(low_expr),
Box::new(high_expr),
))
}
fn build_array_op_expr(pair: pest::iterators::Pair<Rule>) -> Result<FilterExpr, FilterError> {
let mut inner = pair.into_inner();
let field_pair = inner.next().unwrap();
let field_expr = FilterExpr::Field(field_pair.as_str().to_string());
let op_pair = inner.next().unwrap();
let op_str = op_pair.as_str().to_lowercase();
let array_pair = inner.next().unwrap();
let array_expr = build_array_literal(array_pair)?;
match op_str.as_str() {
"any" => Ok(FilterExpr::Any(Box::new(field_expr), Box::new(array_expr))),
"all" => Ok(FilterExpr::All(Box::new(field_expr), Box::new(array_expr))),
"none" => Ok(FilterExpr::None(Box::new(field_expr), Box::new(array_expr))),
_ => Err(FilterError::SyntaxError {
position: 0,
line: 1,
column: 1,
message: format!("Unknown array operator: {op_str}"),
suggestion: None,
}),
}
}
fn build_string_op_expr(pair: pest::iterators::Pair<Rule>) -> Result<FilterExpr, FilterError> {
let mut inner = pair.into_inner();
let field_pair = inner.next().unwrap();
let field_expr = FilterExpr::Field(field_pair.as_str().to_string());
let op_pair = inner.next().unwrap();
let op_str = op_pair.as_str().to_lowercase();
let string_pair = inner.next().unwrap();
let string_expr = build_string_literal(&string_pair)?;
match op_str.as_str() {
"contains" => Ok(FilterExpr::Contains(
Box::new(field_expr),
Box::new(string_expr),
)),
"starts_with" => Ok(FilterExpr::StartsWith(
Box::new(field_expr),
Box::new(string_expr),
)),
"ends_with" => Ok(FilterExpr::EndsWith(
Box::new(field_expr),
Box::new(string_expr),
)),
"like" => Ok(FilterExpr::Like(
Box::new(field_expr),
Box::new(string_expr),
)),
_ => Err(FilterError::SyntaxError {
position: 0,
line: 1,
column: 1,
message: format!("Unknown string operator: {op_str}"),
suggestion: None,
}),
}
}
fn build_set_op_expr(pair: pest::iterators::Pair<Rule>) -> Result<FilterExpr, FilterError> {
let mut inner = pair.into_inner();
let field_pair = inner.next().unwrap();
let field_expr = FilterExpr::Field(field_pair.as_str().to_string());
let op_pair = inner.next().unwrap();
let op_inner = op_pair.into_inner().next().unwrap();
let is_not_in = op_inner.as_rule() == Rule::not_in_op;
let array_pair = inner.next().unwrap();
let array_expr = build_array_literal(array_pair)?;
if is_not_in {
Ok(FilterExpr::NotIn(
Box::new(field_expr),
Box::new(array_expr),
))
} else {
Ok(FilterExpr::In(Box::new(field_expr), Box::new(array_expr)))
}
}
fn build_comparison_expr(pair: pest::iterators::Pair<Rule>) -> Result<FilterExpr, FilterError> {
let mut inner = pair.into_inner();
let field_pair = inner.next().unwrap();
let field_expr = FilterExpr::Field(field_pair.as_str().to_string());
let op_pair = inner.next().unwrap();
let op_str = op_pair.as_str();
let value_pair = inner.next().unwrap();
let value_expr = build_value(value_pair)?;
match op_str {
"=" => Ok(FilterExpr::Eq(Box::new(field_expr), Box::new(value_expr))),
"!=" => Ok(FilterExpr::Ne(Box::new(field_expr), Box::new(value_expr))),
"<" => Ok(FilterExpr::Lt(Box::new(field_expr), Box::new(value_expr))),
"<=" => Ok(FilterExpr::Le(Box::new(field_expr), Box::new(value_expr))),
">" => Ok(FilterExpr::Gt(Box::new(field_expr), Box::new(value_expr))),
">=" => Ok(FilterExpr::Ge(Box::new(field_expr), Box::new(value_expr))),
_ => Err(FilterError::SyntaxError {
position: 0,
line: 1,
column: 1,
message: format!("Unknown comparison operator: {op_str}"),
suggestion: None,
}),
}
}
fn build_value(pair: pest::iterators::Pair<Rule>) -> Result<FilterExpr, FilterError> {
let inner = pair.into_inner().next().unwrap();
match inner.as_rule() {
Rule::string_literal => build_string_literal(&inner),
Rule::number => build_number(&inner),
Rule::boolean => build_boolean(&inner),
Rule::field => Ok(FilterExpr::Field(inner.as_str().to_string())),
_ => Err(FilterError::SyntaxError {
position: 0,
line: 1,
column: 1,
message: format!("Unexpected value type: {:?}", inner.as_rule()),
suggestion: None,
}),
}
}
fn build_string_literal(pair: &pest::iterators::Pair<Rule>) -> Result<FilterExpr, FilterError> {
let raw = pair.as_str();
let content = &raw[1..raw.len() - 1];
let processed = process_escapes(content)?;
Ok(FilterExpr::LiteralString(processed))
}
fn process_escapes(s: &str) -> Result<String, FilterError> {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('"') => result.push('"'),
Some('\\') => result.push('\\'),
Some('n') => result.push('\n'),
Some('r') => result.push('\r'),
Some('t') => result.push('\t'),
Some(other) => {
return Err(FilterError::InvalidEscape {
char: other,
position: 0, });
}
None => {
return Err(FilterError::SyntaxError {
position: 0,
line: 1,
column: 1,
message: "Trailing backslash in string".to_string(),
suggestion: Some("Escape the backslash with \\\\".to_string()),
});
}
}
} else {
result.push(c);
}
}
Ok(result)
}
fn build_number(pair: &pest::iterators::Pair<Rule>) -> Result<FilterExpr, FilterError> {
let s = pair.as_str();
if !s.contains('.') {
if let Ok(i) = s.parse::<i64>() {
return Ok(FilterExpr::LiteralInt(i));
}
}
if let Ok(f) = s.parse::<f64>() {
return Ok(FilterExpr::LiteralFloat(f));
}
Err(FilterError::InvalidNumber {
value: s.to_string(),
position: 0,
})
}
fn build_boolean(pair: &pest::iterators::Pair<Rule>) -> Result<FilterExpr, FilterError> {
let s = pair.as_str().to_lowercase();
match s.as_str() {
"true" => Ok(FilterExpr::LiteralBool(true)),
"false" => Ok(FilterExpr::LiteralBool(false)),
_ => Err(FilterError::SyntaxError {
position: 0,
line: 1,
column: 1,
message: format!("Invalid boolean: {s}"),
suggestion: None,
}),
}
}
fn build_array_literal(pair: pest::iterators::Pair<Rule>) -> Result<FilterExpr, FilterError> {
let mut elements = Vec::new();
for inner in pair.into_inner() {
if inner.as_rule() == Rule::value {
elements.push(build_value(inner)?);
}
}
Ok(FilterExpr::LiteralArray(elements))
}
#[cfg(test)]
#[allow(clippy::redundant_closure_for_method_calls)] mod tests {
use super::*;
#[test]
fn test_parse_simple_eq() {
let expr = parse("category = \"gpu\"").unwrap();
assert!(matches!(expr, FilterExpr::Eq(_, _)));
if let FilterExpr::Eq(left, right) = expr {
assert_eq!(left.as_field(), Some("category"));
assert_eq!(right.as_string(), Some("gpu"));
}
}
#[test]
fn test_parse_simple_ne() {
let expr = parse("status != \"deleted\"").unwrap();
assert!(matches!(expr, FilterExpr::Ne(_, _)));
}
#[test]
fn test_parse_simple_lt() {
let expr = parse("price < 500").unwrap();
assert!(matches!(expr, FilterExpr::Lt(_, _)));
if let FilterExpr::Lt(left, right) = expr {
assert_eq!(left.as_field(), Some("price"));
assert_eq!(right.as_int(), Some(500));
}
}
#[test]
fn test_parse_simple_le() {
let expr = parse("rating <= 4.5").unwrap();
assert!(matches!(expr, FilterExpr::Le(_, _)));
}
#[test]
fn test_parse_simple_gt() {
let expr = parse("count > 0").unwrap();
assert!(matches!(expr, FilterExpr::Gt(_, _)));
}
#[test]
fn test_parse_simple_ge() {
let expr = parse("score >= 90").unwrap();
assert!(matches!(expr, FilterExpr::Ge(_, _)));
}
#[test]
fn test_parse_and() {
let expr = parse("a = 1 AND b = 2").unwrap();
assert!(matches!(expr, FilterExpr::And(_, _)));
}
#[test]
fn test_parse_or() {
let expr = parse("a = 1 OR b = 2").unwrap();
assert!(matches!(expr, FilterExpr::Or(_, _)));
}
#[test]
fn test_parse_not() {
let expr = parse("NOT active = true").unwrap();
assert!(matches!(expr, FilterExpr::Not(_)));
}
#[test]
fn test_parse_symbolic_and() {
let expr = parse("a = 1 && b = 2").unwrap();
assert!(matches!(expr, FilterExpr::And(_, _)));
}
#[test]
fn test_parse_symbolic_or() {
let expr = parse("a = 1 || b = 2").unwrap();
assert!(matches!(expr, FilterExpr::Or(_, _)));
}
#[test]
fn test_parse_symbolic_not() {
let expr = parse("!active = true").unwrap();
assert!(matches!(expr, FilterExpr::Not(_)));
}
#[test]
fn test_precedence_and_binds_tighter_than_or() {
let expr = parse("a = 1 OR b = 2 AND c = 3").unwrap();
if let FilterExpr::Or(left, right) = expr {
assert!(left.as_field().is_none()); assert!(matches!(*right, FilterExpr::And(_, _)));
} else {
panic!("Expected Or at top level");
}
}
#[test]
fn test_precedence_parentheses() {
let expr = parse("(a = 1 OR b = 2) AND c = 3").unwrap();
assert!(matches!(expr, FilterExpr::And(_, _)));
if let FilterExpr::And(left, _) = expr {
assert!(matches!(*left, FilterExpr::Or(_, _)));
}
}
#[test]
fn test_parse_contains() {
let expr = parse("description CONTAINS \"fast\"").unwrap();
assert!(matches!(expr, FilterExpr::Contains(_, _)));
}
#[test]
fn test_parse_starts_with() {
let expr = parse("name STARTS_WITH \"GPU\"").unwrap();
assert!(matches!(expr, FilterExpr::StartsWith(_, _)));
}
#[test]
fn test_parse_ends_with() {
let expr = parse("filename ENDS_WITH \".pdf\"").unwrap();
assert!(matches!(expr, FilterExpr::EndsWith(_, _)));
}
#[test]
fn test_parse_like() {
let expr = parse("name LIKE \"GPU_%\"").unwrap();
assert!(matches!(expr, FilterExpr::Like(_, _)));
}
#[test]
fn test_parse_in() {
let expr = parse("category IN [\"gpu\", \"cpu\"]").unwrap();
assert!(matches!(expr, FilterExpr::In(_, _)));
}
#[test]
fn test_parse_not_in() {
let expr = parse("status NOT IN [\"deleted\", \"archived\"]").unwrap();
assert!(matches!(expr, FilterExpr::NotIn(_, _)));
}
#[test]
fn test_parse_any() {
let expr = parse("tags ANY [\"rust\", \"wasm\"]").unwrap();
assert!(matches!(expr, FilterExpr::Any(_, _)));
}
#[test]
fn test_parse_all() {
let expr = parse("tags ALL [\"rust\", \"wasm\"]").unwrap();
assert!(matches!(expr, FilterExpr::All(_, _)));
}
#[test]
fn test_parse_none() {
let expr = parse("tags NONE [\"deprecated\"]").unwrap();
assert!(matches!(expr, FilterExpr::None(_, _)));
}
#[test]
fn test_parse_between() {
let expr = parse("price BETWEEN 100 AND 500").unwrap();
assert!(matches!(expr, FilterExpr::Between(_, _, _)));
if let FilterExpr::Between(field, low, high) = expr {
assert_eq!(field.as_field(), Some("price"));
assert_eq!(low.as_int(), Some(100));
assert_eq!(high.as_int(), Some(500));
}
}
#[test]
fn test_parse_is_null() {
let expr = parse("description IS NULL").unwrap();
assert!(matches!(expr, FilterExpr::IsNull(_)));
}
#[test]
fn test_parse_is_not_null() {
let expr = parse("description IS NOT NULL").unwrap();
assert!(matches!(expr, FilterExpr::IsNotNull(_)));
}
#[test]
fn test_case_insensitive_and() {
assert!(parse("a = 1 and b = 2").is_ok());
assert!(parse("a = 1 AND b = 2").is_ok());
assert!(parse("a = 1 And b = 2").is_ok());
}
#[test]
fn test_case_insensitive_or() {
assert!(parse("a = 1 or b = 2").is_ok());
assert!(parse("a = 1 OR b = 2").is_ok());
}
#[test]
fn test_case_insensitive_contains() {
assert!(parse("name contains \"test\"").is_ok());
assert!(parse("name CONTAINS \"test\"").is_ok());
}
#[test]
fn test_case_insensitive_boolean() {
assert!(parse("active = TRUE").is_ok());
assert!(parse("active = true").is_ok());
assert!(parse("active = True").is_ok());
}
#[test]
fn test_parse_integer() {
let expr = parse("count = 42").unwrap();
if let FilterExpr::Eq(_, right) = expr {
assert_eq!(right.as_int(), Some(42));
}
}
#[test]
fn test_parse_negative_integer() {
let expr = parse("temp = -10").unwrap();
if let FilterExpr::Eq(_, right) = expr {
assert_eq!(right.as_int(), Some(-10));
}
}
#[test]
fn test_parse_float() {
let expr = parse("rating = 4.5").unwrap();
if let FilterExpr::Eq(_, right) = expr {
assert_eq!(right.as_float(), Some(4.5));
}
}
#[test]
fn test_parse_negative_float() {
let expr = parse("temp = -2.5").unwrap();
if let FilterExpr::Eq(_, right) = expr {
assert_eq!(right.as_float(), Some(-2.5));
}
}
#[test]
fn test_parse_string_escapes() {
let expr = parse(r#"msg = "hello \"world\"""#).unwrap();
if let FilterExpr::Eq(_, right) = expr {
assert_eq!(right.as_string(), Some("hello \"world\""));
}
}
#[test]
fn test_parse_string_escape_newline() {
let expr = parse(r#"msg = "line1\nline2""#).unwrap();
if let FilterExpr::Eq(_, right) = expr {
assert_eq!(right.as_string(), Some("line1\nline2"));
}
}
#[test]
fn test_parse_empty_array() {
let expr = parse("tags IN []").unwrap();
if let FilterExpr::In(_, right) = expr {
assert_eq!(right.as_array().map(|a| a.len()), Some(0));
}
}
#[test]
fn test_parse_complex_expression() {
let input = "category = \"gpu\" AND (price < 500 OR rating >= 4.5)";
let expr = parse(input).unwrap();
assert!(matches!(expr, FilterExpr::And(_, _)));
}
#[test]
fn test_parse_deeply_nested() {
let input = "((a = 1) AND (b = 2)) OR ((c = 3) AND (d = 4))";
let expr = parse(input).unwrap();
assert!(matches!(expr, FilterExpr::Or(_, _)));
}
#[test]
fn test_parse_error_empty() {
let result = parse("");
assert!(result.is_err());
}
#[test]
fn test_parse_error_unclosed_string() {
let result = parse("name = \"unclosed");
assert!(result.is_err());
}
#[test]
fn test_parse_error_unclosed_paren() {
let result = parse("(a = 1");
assert!(result.is_err());
}
#[test]
fn test_parse_error_invalid_operator() {
let result = parse("a == 1");
assert!(result.is_err());
}
#[test]
fn test_parse_error_input_too_long() {
let long_input = "a".repeat(MAX_INPUT_LENGTH + 1);
let result = parse(&long_input);
assert!(matches!(result, Err(FilterError::InputTooLong { .. })));
}
#[test]
fn test_whitespace_handling() {
assert!(parse(" a = 1 ").is_ok());
assert!(parse("a=1").is_ok());
assert!(parse("a = 1 AND b = 2").is_ok());
assert!(parse("a=1AND b=2").is_ok());
}
#[test]
fn test_newline_handling() {
let input = "a = 1\nAND\nb = 2";
assert!(parse(input).is_ok());
}
#[test]
fn test_fuzz_regression_non_char_boundary() {
let crash_input = "v=\"ss\u{07C3}\u{0083}\"|\"";
let result = parse(crash_input);
assert!(result.is_err());
}
#[test]
fn test_fuzz_regression_raw_bytes() {
let bytes: &[u8] = &[
0x76, 0x3d, 0x22, 0x73, 0x73, 0xde, 0x83, 0xc2, 0x83, 0x22, 0x7c, 0x22,
];
let input = String::from_utf8_lossy(bytes);
let _ = parse(&input);
}
#[test]
fn test_multibyte_utf8_handling() {
let inputs = [
"x = \"日本語\"", "x = \"émoji: 🦀\"", "field_名前 = 1", "x = \"Москва\"", "x = \"🦀\"", ];
for input in inputs {
let _ = parse(input);
}
}
#[test]
fn test_error_position_multibyte() {
let result = parse("名前 : 1"); assert!(result.is_err());
}
#[test]
fn test_suggestion_colon_instead_of_equals() {
let result = parse("category : \"gpu\"");
assert!(result.is_err());
if let Err(FilterError::SyntaxError { suggestion, .. }) = result {
assert!(
suggestion.is_some(),
"Should suggest using '=' instead of ':'"
);
let s = suggestion.unwrap();
assert!(s.contains('='), "Suggestion should mention '='");
}
}
#[test]
fn test_suggestion_double_equals() {
let result = parse("a == 1");
assert!(result.is_err(), "== should not be valid syntax");
}
#[test]
fn test_suggestion_sql_not_equal() {
let result = parse("a <> 1");
assert!(result.is_err(), "<> should not be valid syntax");
}
#[test]
fn test_suggestion_where_keyword() {
let result = parse("WHERE a = 1");
assert!(result.is_err(), "WHERE keyword should not be valid");
}
#[test]
fn test_suggestion_parentheses_for_array() {
let result = parse("category IN (\"a\", \"b\")");
assert!(result.is_err());
if let Err(FilterError::SyntaxError { suggestion, .. }) = result {
assert!(
suggestion.is_some(),
"Should suggest using [...] instead of (...)"
);
let s = suggestion.unwrap();
assert!(
s.contains('[') || s.contains("square brackets"),
"Suggestion should mention square brackets"
);
}
}
#[test]
fn test_helper_is_keyword() {
assert!(is_keyword("AND"));
assert!(is_keyword("and"));
assert!(is_keyword("Or"));
assert!(is_keyword("NOT"));
assert!(is_keyword("IN"));
assert!(is_keyword("CONTAINS"));
assert!(!is_keyword("category"));
assert!(!is_keyword("price"));
}
#[test]
fn test_helper_is_valid_field_name() {
assert!(is_valid_field_name("category"));
assert!(is_valid_field_name("price_usd"));
assert!(is_valid_field_name("_private"));
assert!(is_valid_field_name("field123"));
assert!(!is_valid_field_name("123field"));
assert!(!is_valid_field_name(""));
assert!(!is_valid_field_name("field-name"));
}
#[test]
fn test_helper_is_operator() {
assert!(is_operator("="));
assert!(is_operator("!="));
assert!(is_operator("<"));
assert!(is_operator("<="));
assert!(is_operator(">"));
assert!(is_operator(">="));
assert!(is_operator("&&"));
assert!(is_operator("||"));
assert!(!is_operator("AND"));
assert!(!is_operator("category"));
}
}