Files
ottyl/src/filter_parser.rs
T
Hugh KaznowskiandClaude Sonnet 5 00c21c8032 Trim comments across Rust and webapp source to direct one-liners (ticket 030)
Pass over src/*.rs, webapp/src/**, and the in-app "?" docs prose:
condensed multi-paragraph doc comments to single lines that state the
non-obvious why, deleted comments that only restated adjacent code, and
tightened the Docs.tsx help-panel wording. No logic, structure, or code
formatting changed — verified via cargo test (143 passed) and
tsc -b && vite build.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XFUNZwCvz3kcod3352ZR2o
2026-09-05 17:02:33 +01:00

356 lines
11 KiB
Rust

//! FilterBar query language: grammar (see `src/filter_parser.pest`), AST and parser.
//! Evaluating the AST against real log data is left to `engine.rs`.
#![allow(dead_code)]
use pest::Parser;
use pest::iterators::Pair;
use pest_derive::Parser as PestParser;
use serde::{Deserialize, Serialize};
#[derive(PestParser)]
#[grammar = "filter_parser.pest"]
struct FilterGrammar;
/// The parsed AST for a FilterBar query.
#[derive(Debug, Clone, PartialEq)]
pub enum FilterExpr {
/// `regex("...")` — raw-text regex search over log lines.
Regex(String),
/// `metric[param] <op> value` — a single metric comparison.
Comparison {
metric: String,
param: Option<String>,
op: CompareOp,
value: FilterValue,
},
And(Box<FilterExpr>, Box<FilterExpr>),
Or(Box<FilterExpr>, Box<FilterExpr>),
}
/// Comparison operators for metric comparisons; also reused by `crate::alerts::AlertCondition`
/// for threshold/ratio comparisons (hence `Serialize`/`Deserialize`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CompareOp {
Lt,
Gt,
Le,
Ge,
Eq,
Ne,
}
/// The right-hand side of a comparison: values that parse as a number become
/// [`FilterValue::Number`], everything else is kept as an opaque [`FilterValue::Raw`] string.
#[derive(Debug, Clone, PartialEq)]
pub enum FilterValue {
Number(f64),
Raw(String),
}
/// A located, human-readable parse error.
#[derive(Debug, Clone, PartialEq)]
pub struct FilterParseError {
message: String,
}
impl FilterParseError {
fn new(message: impl Into<String>) -> Self {
FilterParseError {
message: message.into(),
}
}
}
impl std::fmt::Display for FilterParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for FilterParseError {}
impl From<pest::error::Error<Rule>> for FilterParseError {
fn from(err: pest::error::Error<Rule>) -> Self {
// pest's Display already renders a located message, so use it and add a short prefix.
FilterParseError::new(format!("failed to parse filter expression: {err}"))
}
}
/// Parse a FilterBar query string into a [`FilterExpr`] AST.
pub fn parse(input: &str) -> Result<FilterExpr, FilterParseError> {
let mut pairs = FilterGrammar::parse(Rule::file, input)?;
let file = pairs
.next()
.ok_or_else(|| FilterParseError::new("empty filter expression"))?;
let or_expr = file
.into_inner()
.find(|p| p.as_rule() == Rule::or_expr)
.ok_or_else(|| FilterParseError::new("malformed filter expression: missing body"))?;
build_or_expr(or_expr)
}
fn build_or_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
debug_assert_eq!(pair.as_rule(), Rule::or_expr);
// `or_kw` is atomic so it's not silent, showing up as a sibling pair between `and_expr` operands — skip it rather than assuming a fixed alternating structure.
let mut and_exprs = pair
.into_inner()
.filter(|p| p.as_rule() != Rule::or_kw);
let first = and_exprs
.next()
.ok_or_else(|| FilterParseError::new("malformed 'or' expression"))?;
let mut expr = build_and_expr(first)?;
for next in and_exprs {
let rhs = build_and_expr(next)?;
expr = FilterExpr::Or(Box::new(expr), Box::new(rhs));
}
Ok(expr)
}
fn build_and_expr(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
debug_assert_eq!(pair.as_rule(), Rule::and_expr);
// Same reasoning as `build_or_expr` for `and_kw`.
let mut terms = pair
.into_inner()
.filter(|p| p.as_rule() != Rule::and_kw);
let first = terms
.next()
.ok_or_else(|| FilterParseError::new("malformed 'and' expression"))?;
let mut expr = build_term(first)?;
for next in terms {
let rhs = build_term(next)?;
expr = FilterExpr::And(Box::new(expr), Box::new(rhs));
}
Ok(expr)
}
fn build_term(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
match pair.as_rule() {
Rule::regex_expr => build_regex(pair),
Rule::comparison => build_comparison(pair),
Rule::or_expr => build_or_expr(pair),
other => Err(FilterParseError::new(format!(
"unexpected grammar element: {other:?}"
))),
}
}
fn build_regex(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
let string_literal = pair
.into_inner()
.find(|p| p.as_rule() == Rule::string_literal)
.ok_or_else(|| FilterParseError::new("malformed regex(...) expression"))?;
let inner_str = string_literal
.into_inner()
.find(|p| p.as_rule() == Rule::inner_str)
.ok_or_else(|| FilterParseError::new("malformed string literal in regex(...)"))?;
Ok(FilterExpr::Regex(unescape(inner_str.as_str())))
}
fn build_comparison(pair: Pair<Rule>) -> Result<FilterExpr, FilterParseError> {
let mut inner = pair.into_inner();
let metric_pair = inner
.next()
.ok_or_else(|| FilterParseError::new("malformed comparison: missing metric name"))?;
let metric = metric_pair.as_str().to_string();
let mut next = inner
.next()
.ok_or_else(|| FilterParseError::new("malformed comparison: missing operator"))?;
let param = if next.as_rule() == Rule::param {
let p = next.as_str().to_string();
next = inner
.next()
.ok_or_else(|| FilterParseError::new("malformed comparison: missing operator"))?;
Some(p)
} else {
None
};
let op = parse_op(next.as_str())?;
let value_pair = inner
.next()
.ok_or_else(|| FilterParseError::new("malformed comparison: missing value"))?;
let value = parse_value(value_pair.as_str());
Ok(FilterExpr::Comparison {
metric,
param,
op,
value,
})
}
fn parse_op(s: &str) -> Result<CompareOp, FilterParseError> {
match s {
"<" => Ok(CompareOp::Lt),
">" => Ok(CompareOp::Gt),
"<=" => Ok(CompareOp::Le),
">=" => Ok(CompareOp::Ge),
"==" => Ok(CompareOp::Eq),
"!=" => Ok(CompareOp::Ne),
other => Err(FilterParseError::new(format!(
"unknown comparison operator: {other}"
))),
}
}
fn parse_value(s: &str) -> FilterValue {
match s.parse::<f64>() {
Ok(n) => FilterValue::Number(n),
Err(_) => FilterValue::Raw(s.to_string()),
}
}
/// Unescape a regex string literal's inner contents (`\"` -> `"`, `\\` -> `\`, `\x` -> `x`).
fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\\' {
if let Some(next) = chars.next() {
out.push(next);
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_bare_regex() {
let expr = parse(r#"regex("error")"#).expect("should parse");
assert_eq!(expr, FilterExpr::Regex("error".to_string()));
}
#[test]
fn parses_regex_with_escaped_quotes() {
let expr = parse(r#"regex("say \"hi\"")"#).expect("should parse");
assert_eq!(expr, FilterExpr::Regex(r#"say "hi""#.to_string()));
}
#[test]
fn parses_single_metric_comparison() {
let expr = parse("metric_name[param]<5").expect("should parse");
assert_eq!(
expr,
FilterExpr::Comparison {
metric: "metric_name".to_string(),
param: Some("param".to_string()),
op: CompareOp::Lt,
value: FilterValue::Number(5.0),
}
);
}
#[test]
fn parses_reserved_metric_sequence() {
let expr = parse("sequence<5").expect("should parse");
assert_eq!(
expr,
FilterExpr::Comparison {
metric: "sequence".to_string(),
param: None,
op: CompareOp::Lt,
value: FilterValue::Number(5.0),
}
);
}
#[test]
fn parses_reserved_metric_timestamp_as_raw_value() {
let expr = parse("timestamp>12:00").expect("should parse");
assert_eq!(
expr,
FilterExpr::Comparison {
metric: "timestamp".to_string(),
param: None,
op: CompareOp::Gt,
value: FilterValue::Raw("12:00".to_string()),
}
);
}
#[test]
fn parses_and_or_with_and_binding_tighter() {
let expr = parse(r#"regex("error") and sequence<5 or timestamp>12:00"#)
.expect("should parse");
let expected = FilterExpr::Or(
Box::new(FilterExpr::And(
Box::new(FilterExpr::Regex("error".to_string())),
Box::new(FilterExpr::Comparison {
metric: "sequence".to_string(),
param: None,
op: CompareOp::Lt,
value: FilterValue::Number(5.0),
}),
)),
Box::new(FilterExpr::Comparison {
metric: "timestamp".to_string(),
param: None,
op: CompareOp::Gt,
value: FilterValue::Raw("12:00".to_string()),
}),
);
assert_eq!(expr, expected);
}
#[test]
fn parenthesized_expression_overrides_precedence() {
let expr = parse(r#"regex("error") and (sequence<5 or timestamp>12:00)"#)
.expect("should parse");
let expected = FilterExpr::And(
Box::new(FilterExpr::Regex("error".to_string())),
Box::new(FilterExpr::Or(
Box::new(FilterExpr::Comparison {
metric: "sequence".to_string(),
param: None,
op: CompareOp::Lt,
value: FilterValue::Number(5.0),
}),
Box::new(FilterExpr::Comparison {
metric: "timestamp".to_string(),
param: None,
op: CompareOp::Gt,
value: FilterValue::Raw("12:00".to_string()),
}),
)),
);
assert_eq!(expr, expected);
}
#[test]
fn malformed_input_produces_located_error_not_panic() {
let result = parse("regex(\"unterminated and sequence<");
assert!(result.is_err());
let err = result.unwrap_err();
let msg = err.to_string();
assert!(!msg.is_empty());
}
#[test]
fn empty_input_is_an_error_not_a_panic() {
assert!(parse("").is_err());
}
#[test]
fn garbage_input_is_an_error_not_a_panic() {
assert!(parse("@@@ not a valid filter @@@").is_err());
}
}