From 0daa3e47c7a062ebcf0dd7b26ee758112ccfc67d Mon Sep 17 00:00:00 2001 From: Pavel Ivanov Date: Sun, 17 Dec 2023 00:09:30 +0100 Subject: [PATCH] new: added query option --- Cargo.lock | 4 +- Cargo.toml | 4 +- README.md | 85 +++++++-- benches/parse-and-format.rs | 13 +- src/app.rs | 65 +++++-- src/error.rs | 13 +- src/formatting.rs | 1 + src/level.rs | 38 +++- src/lib.rs | 5 +- src/main.rs | 12 +- src/model.rs | 256 +++++++++++++++++++++------ src/query.pest | 111 ++++++++++++ src/query.rs | 335 ++++++++++++++++++++++++++++++++++++ 13 files changed, 842 insertions(+), 100 deletions(-) create mode 100644 src/query.pest create mode 100644 src/query.rs diff --git a/Cargo.lock b/Cargo.lock index c321578b..55ec6b3c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -718,7 +718,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hl" -version = "0.23.2" +version = "0.24.0-beta.1" dependencies = [ "atoi", "bincode", @@ -752,6 +752,8 @@ dependencies = [ "nu-ansi-term", "num_cpus", "once_cell", + "pest", + "pest_derive", "platform-dirs", "regex", "rust-embed", diff --git a/Cargo.toml b/Cargo.toml index fca87dec..a7d7f297 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ categories = ["command-line-utilities"] description = "Utility for viewing json-formatted log files." keywords = ["cli", "human", "log"] name = "hl" -version = "0.23.2" +version = "0.24.0-beta.1" edition = "2021" build = "build.rs" @@ -45,6 +45,8 @@ itoa = { version = "1", default-features = false } notify = { version = "6", features = ["macos_kqueue"] } num_cpus = "1" once_cell = "1" +pest = "2" +pest_derive = "2" platform-dirs = "0" regex = "1" rust-embed = "6" diff --git a/README.md b/README.md index af78826b..cbeb7824 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Log viewer which translates JSON logs into pretty human-readable representation. $ hl -l e ``` - Shows only messages with error log level. + Displays only error log level messages. - Errors and warnings @@ -108,7 +108,7 @@ Log viewer which translates JSON logs into pretty human-readable representation. ``` $ hl -l w ``` - Shows only messages with warning and error log level. + Displays only warning and error log level messages. - Errors, warnings and informational @@ -117,7 +117,7 @@ Log viewer which translates JSON logs into pretty human-readable representation. ``` $ hl -l i ``` - Shows all log messages except debug level messages. + Displays all log messages except debug level messages. ### Using live log streaming @@ -126,7 +126,7 @@ Log viewer which translates JSON logs into pretty human-readable representation. ``` $ tail -f example.log | hl -P ``` - Follows changes in example.log file and displays them immediately. + Tracks changes in the example.log file and displays them immediately. Flag `-P` disables automatic using of pager in this case. @@ -135,23 +135,71 @@ Log viewer which translates JSON logs into pretty human-readable representation. - Command ``` - $ hl example.log -f component=tsdb + $ hl example.log --filter component=tsdb ``` - Shows only messages with field `component` having value `tsdb`. + Displays only messages where the `component` field has the value `tsdb`. - Command ``` $ hl example.log -f component!=tsdb -f component!=uninteresting ``` - Shows only messages with field `component` having value other than `tsdb` or `uninteresting`. + Displays only messages where the `component` field has a value other than `tsdb` or `uninteresting`. - Command ``` $ hl example.log -f provider~=string ``` - Shows only messages with field `provider` containing sub-string `string`. + Displays only messages where the `provider` field contains the `string` sub-string. + + +### Performing complex queries + +- Command + + ``` + $ hl my-service.log --query 'level > info or status-code >= 400 or duration > 0.5' + ``` + Displays messages that either have a level higher than info (i.e. warning or error) or have a status code field with a numeric value >= 400 or a duration field with a numeric value >= 0.5. + +- Command + + ``` + $ hl my-service.log -q '(request in (95c72499d9ec, 9697f7aa134f, bc3451d0ad60)) or (method != GET)' + ``` + Displays all messages that have the 'request' field with one of these values, or the 'method' field with a value other than 'GET'. + +- Complete set of supported operators + + * Logical operators + * Logical conjunction - `and`, `&&` + * Logical disjunction - `or`, `||` + * Logical negation - `not`, `!` + * Comparison operators + * Equal - `eq`, `=` + * Not equal - `ne`, `!=` + * Greater than - `gt`, `>` + * Greater or equal - `ge`, `>=` + * Less than - `lt`, `<` + * Less or equal - `le`, `<=` + * String matching operators + * Sub-string check - (`contain`, `~=`), (`not contain`, `!~=`) + * Wildcard match - (`like`), (`not like`) + * Wildcard characters are: `*` for zero or more characters and `?` for a single character + * Regular expression match - (`match`, `~~=`), (`not match`, `!~~=`) + * Operators with sets + * Test if value is one of the values in a set - `in (v1, v2)`, `not in (v1, v2)` + +- Notes + + * Special field names that are reserved for filtering by predefined fields regardless of the actual JSON field names used to load the corresponding value: `level`, `message`, `caller` and `logger`. + * To address a JSON field with one of these names instead of predefined fields, add a period before its name, i.e., `.level` will perform a match against the "level" JSON field. + * To address a JSON field by its exact name, use a JSON-formatted string, i.e. `-q '".level" = info'`. + * To specify special characters in field values, also use a JSON-formatted string, i.e. + ``` + $ hl my-service.log -q 'message contain "Error:\nSomething unexpected happened"' + ``` ### Filtering by time range @@ -161,21 +209,21 @@ Log viewer which translates JSON logs into pretty human-readable representation. ``` $ hl example.log --since 'Jun 19 11:22:33' --until yesterday ``` - Shows only messages occurred after Jun 19 11:22:33 UTC of the current year (or of the previous one if current date is less than Jun 19 11:22:33) and until yesterday midnight. + Displays only messages that occurred after Jun 19 11:22:33 UTC of the current year (or the previous year if the current date is less than Jun 19 11:22:33) and before yesterday midnight. - Command ``` $ hl example.log --since -3d ``` - Shows only messages for the last 72 hours. + Displays only messages from the past 72 hours. - Command ``` $ hl example.log --until '2021-06-01 18:00:00' --local ``` - Shows only messages occurred before 6 PM on 1st Jun 2021 in local time as well as show timestamps in local time. + Displays only messages that occurred before 6 PM local time on June 1, 2021, and shows timestamps in local time. ### Hiding or showing selected fields @@ -211,7 +259,7 @@ Log viewer which translates JSON logs into pretty human-readable representation. ``` $ hl -s *.log ``` - Shows log messages from all log files in current directory sorted in chronological order. + Displays log messages from all log files in the current directory sorted in chronological order. - Command @@ -219,12 +267,12 @@ Log viewer which translates JSON logs into pretty human-readable representation. ``` $ hl -F <(kubectl logs -l app=my-app-1 -f) <(kubectl logs -l app=my-app-2 -f) ``` - Runs without pager in follow mode by merging messages from outputs of these 2 commands and sorting them chronologically within default interval of 100ms. + Runs without a pager in follow mode by merging messages from the outputs of these 2 commands and sorting them chronologically within a default interval of 100ms. ### Configuration files -- Configuration file is loaded automatically if found at predefined platform-specific location. +- Configuration file is automatically loaded if found in a predefined platform-specific location. | OS | Location | | ------- | --------------------------------------------- | @@ -232,7 +280,7 @@ Log viewer which translates JSON logs into pretty human-readable representation. | Linux | ~/.config/hl/config.yaml | | Windows | %USERPROFILE%\AppData\Roaming\hl\config.yaml | -- Any parameters in the configuration file are optional and may be omitted. In this case default values will be used. +- All parameters in the configuration file are optional and can be omitted. In this case, default values are used. #### Default configuration file @@ -241,7 +289,7 @@ Log viewer which translates JSON logs into pretty human-readable representation. ### Environment variables -- Many parameters which are defined in command-line arguments and configuration files may be specified by envrionment variables also. +- Many parameters that are defined in command line arguments and configuration files can also be specified by environment variables. #### Precedence of configuration sources * Configuration file @@ -266,7 +314,7 @@ Log viewer which translates JSON logs into pretty human-readable representation. * Using command-line argument, i.e. `--theme classic`, overrides all other values. #### Custom themes -- Custom themes are loaded automatically if found at predefined platform-specific location. +- Custom themes are automatically loaded when found in a predefined platform-specific location. | OS | Location | | ------- | ---------------------------------------------- | @@ -361,7 +409,8 @@ Options: --buffer-size Buffer size [env: HL_BUFFER_SIZE=] [default: "256 KiB"] --max-message-size Maximum message size [env: HL_MAX_MESSAGE_SIZE=] [default: "64 MiB"] -C, --concurrency Number of processing threads [env: HL_CONCURRENCY=] - -f, --filter Filtering by field values in one of forms [=, ~=, ~~=, !=, !~=, !~~=] where ~ denotes substring match and ~~ denotes regular expression match + -f, --filter Filtering by field values in one of forms [k=v, k~=v, k~~=v, 'k!=v', 'k!~=v', 'k!~~=v'] where ~ does substring match and ~~ does regular expression match + -q, --query Custom query, accepts expressions from --filter and supports '(', ')', 'and', 'or', 'not', 'in', 'contain', 'like', '<', '>', '<=', '>=', etc -h, --hide Hide or unhide fields with the specified keys, prefix with ! to unhide, specify !* to unhide all -l, --level Filtering by level [env: HL_LEVEL=] --since Filtering by timestamp >= the value (--time-zone and --local options are honored) diff --git a/benches/parse-and-format.rs b/benches/parse-and-format.rs index 990b900e..ef7a90ed 100644 --- a/benches/parse-and-format.rs +++ b/benches/parse-and-format.rs @@ -8,9 +8,11 @@ use criterion::{criterion_group, criterion_main, Criterion}; // local imports use hl::{ - app::RecordIgnorer, app::SegmentProcess, settings, timezone::Tz, DateTimeFormatter, Filter, - IncludeExcludeKeyFilter, LinuxDateFormat, Parser, ParserSettings, RecordFormatter, SegmentProcessor, Settings, - Theme, + app::{RecordIgnorer, SegmentProcess, SegmentProcessorOptions}, + settings, + timezone::Tz, + DateTimeFormatter, Filter, IncludeExcludeKeyFilter, LinuxDateFormat, Parser, ParserSettings, RecordFormatter, + SegmentProcessor, Settings, Theme, }; // --- @@ -33,10 +35,11 @@ fn benchmark(c: &mut Criterion) { settings::Formatting::default(), ); let filter = Filter::default(); - let mut processor = SegmentProcessor::new(&parser, &formatter, &filter, false); + let mut processor = + SegmentProcessor::new(&parser, &formatter, &filter, SegmentProcessorOptions::default()); let mut buf = Vec::new(); b.iter(|| { - processor.run(record, &mut buf, "", &mut RecordIgnorer {}); + processor.process(record, &mut buf, "", &mut RecordIgnorer {}); buf.clear(); }); }); diff --git a/src/app.rs b/src/app.rs index e535fcc5..decd1eb1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -27,13 +27,13 @@ use std::num::{NonZeroU32, NonZeroUsize}; // local imports use crate::datefmt::{DateTimeFormat, DateTimeFormatter}; -use crate::error::*; +use crate::{error::*, QueryNone}; use crate::fmtx::aligned_left; use crate::fsmon::{self, EventKind}; use crate::formatting::{RecordFormatter, RecordWithSourceFormatter, RawRecordFormatter}; use crate::index::{Indexer, Timestamp}; use crate::input::{BlockLine, InputHolder, InputReference, Input}; -use crate::model::{Filter, Parser, ParserSettings, RawRecord, Record, RecordWithSourceConstructor}; +use crate::model::{Filter, Parser, ParserSettings, RawRecord, Record, RecordFilter, RecordWithSourceConstructor}; use crate::scanning::{BufFactory, Scanner, Segment, SegmentBufFactory}; use crate::serdex::StreamDeserializerWithOffsets; use crate::settings::{Fields, Formatting}; @@ -55,6 +55,7 @@ pub struct Options { pub max_message_size: NonZeroUsize, pub concurrency: usize, pub filter: Filter, + pub query: Option, pub fields: FieldOptions, pub formatting: Formatting, pub time_zone: Tz, @@ -67,6 +68,19 @@ pub struct Options { pub app_dirs: Option, } +impl Options { + fn filter_and_query<'a>(&'a self) -> Box { + match (self.filter.is_empty(), &self.query) { + (true, None) => return Box::new(QueryNone{}), + (false, None) => return Box::new(&self.filter), + (false, Some(query)) => return Box::new(query), + (true, Some(query)) => return Box::new((&self.filter).and(query)), + } + } +} + +type Query = Box; + pub struct FieldOptions { pub filter: Arc, pub settings: Fields, @@ -140,7 +154,7 @@ impl App { match segment { Segment::Complete(segment) => { let mut buf = bfo.new_buf(); - processor.run(segment.data(), &mut buf, prefix, &mut RecordIgnorer{}); + processor.process(segment.data(), &mut buf, prefix, &mut RecordIgnorer{}); sfi.recycle(segment); if let Err(_) = txo.send((i, buf)) { break; @@ -274,7 +288,7 @@ impl App { if line.len() == 0 { continue; } - processor.run(line.bytes(), &mut buf, "", &mut |record: &Record, location: Range|{ + processor.process(line.bytes(), &mut buf, "", &mut |record: &Record, location: Range|{ if let Some(ts) = &record.ts { if let Some(unix_ts) = ts.unix_utc() { items.push((unix_ts.into(), location)); @@ -448,7 +462,7 @@ impl App { Segment::Complete(segment) => { let mut buf = bfo.new_buf(); let mut index_builder = TimestampIndexBuilder{result: TimestampIndex::new(j)}; - processor.run(segment.data(), &mut buf, prefix, &mut index_builder); + processor.process(segment.data(), &mut buf, prefix, &mut index_builder); sfi.recycle(segment); if txo.send((i, buf, index_builder.result)).is_err() { return; @@ -673,42 +687,55 @@ impl App { } fn new_segment_processor<'a>(&'a self, parser: &'a Parser) -> impl SegmentProcess+'a { - SegmentProcessor::new(parser, self.formatter(), &self.options.filter, self.options.allow_prefix) + let options = SegmentProcessorOptions{ + allow_prefix: self.options.allow_prefix, + allow_unparsed_data: self.options.filter.is_empty() && self.options.query.is_none(), + }; + + SegmentProcessor::new(parser, self.formatter(), self.options.filter_and_query(), options) } } // --- pub trait SegmentProcess { - fn run(&mut self, data: &[u8], buf: &mut Vec, prefix: &str, observer: &mut O); + fn process(&mut self, data: &[u8], buf: &mut Vec, prefix: &str, observer: &mut O); +} + +// --- + +#[derive(Default)] +pub struct SegmentProcessorOptions { + pub allow_prefix: bool, + pub allow_unparsed_data: bool, } // --- -pub struct SegmentProcessor<'a, F> { +pub struct SegmentProcessor<'a, Formatter, Filter> { parser: &'a Parser, - formatter: F, - filter: &'a Filter, - allow_prefix: bool, + formatter: Formatter, + filter: Filter, + options: SegmentProcessorOptions, } -impl<'a, F: RecordWithSourceFormatter> SegmentProcessor<'a, F> { - pub fn new(parser: &'a Parser, formatter: F, filter: &'a Filter, allow_prefix: bool) -> Self { +impl<'a, Formatter: RecordWithSourceFormatter, Filter: RecordFilter> SegmentProcessor<'a, Formatter, Filter> { + pub fn new(parser: &'a Parser, formatter: Formatter, filter: Filter, options: SegmentProcessorOptions) -> Self { Self { parser, formatter, filter, - allow_prefix, + options, } } fn show_unparsed(&self) -> bool { - self.filter.is_empty() + self.options.allow_unparsed_data } } -impl<'a, F: RecordWithSourceFormatter> SegmentProcess for SegmentProcessor<'a, F> { - fn run(&mut self, data: &[u8], buf: &mut Vec, prefix: &str, observer: &mut O) +impl<'a, Formatter: RecordWithSourceFormatter, Filter: RecordFilter> SegmentProcess for SegmentProcessor<'a, Formatter, Filter> { + fn process(&mut self, data: &[u8], buf: &mut Vec, prefix: &str, observer: &mut O) where O: RecordObserver, { @@ -717,7 +744,7 @@ impl<'a, F: RecordWithSourceFormatter> SegmentProcess for SegmentProcessor<'a, F continue; } - let extra_prefix = if self.allow_prefix { + let extra_prefix = if self.options.allow_prefix { data.split(|c|*c==b'{').next().unwrap() } else { b"" @@ -735,7 +762,7 @@ impl<'a, F: RecordWithSourceFormatter> SegmentProcess for SegmentProcessor<'a, F } parsed_some = true; let record = self.parser.parse(record).with_prefix(extra_prefix); - if record.matches(self.filter) { + if record.matches(&self.filter) { let begin = buf.len(); buf.extend(prefix.as_bytes()); self.formatter.format_record(buf, record.with_source(&data[offsets.start..xn+offsets.end])); diff --git a/src/error.rs b/src/error.rs index 1d70e191..aab6de4b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -1,7 +1,7 @@ // std imports use std::boxed::Box; use std::io; -use std::num::{ParseIntError, TryFromIntError}; +use std::num::{ParseFloatError, ParseIntError, TryFromIntError}; use std::path::PathBuf; use std::sync::mpsc; @@ -10,6 +10,9 @@ use config::ConfigError; use nu_ansi_term::Color; use thiserror::Error; +// local imports +use crate::level; + /// Error is an error which may occur in the application. #[derive(Error, Debug)] pub enum Error { @@ -43,7 +46,7 @@ pub enum Error { FromUtf8Error(#[from] std::string::FromUtf8Error), #[error("failed to parse yaml: {0}")] YamlError(#[from] serde_yaml::Error), - #[error("wrong field filter format: {0}")] + #[error("failed to parse json: {0}")] WrongFieldFilter(String), #[error("wrong regular expression: {0}")] WrongRegularExpression(#[from] regex::Error), @@ -82,6 +85,12 @@ pub enum Error { #[source] source: mpsc::RecvTimeoutError, }, + #[error(transparent)] + QueryParseError(#[from] pest::error::Error), + #[error(transparent)] + LevelParseError(#[from] level::ParseError), + #[error(transparent)] + ParseFloatError(#[from] ParseFloatError), } /// SizeParseError is an error which may occur when parsing size. diff --git a/src/formatting.rs b/src/formatting.rs index 6c7b52b1..56e95db5 100644 --- a/src/formatting.rs +++ b/src/formatting.rs @@ -514,6 +514,7 @@ mod tests { ("ka", RawValue::from_string(r#"{"va":{"kb":42}}"#.into()).unwrap().as_ref()), ]).unwrap(), extrax: Vec::default(), + predefined: heapless::Vec::default(), }).unwrap(), String::from("\u{1b}[0;2;3m00-01-02 03:04:05.123 \u{1b}[0;36m|\u{1b}[0;95mDBG\u{1b}[0;36m|\u{1b}[0;2;3m \u{1b}[0;2;4mtl:\u{1b}[0;2;3m \u{1b}[0;1;39mtm \u{1b}[0;32mka\u{1b}[0;2m:\u{1b}[0;33m{ \u{1b}[0;32mva\u{1b}[0;2m:\u{1b}[0;33m{ \u{1b}[0;32mkb\u{1b}[0;2m:\u{1b}[0;94m42\u{1b}[0;33m } }\u{1b}[0;2;3m @ tc\u{1b}[0m"), ); diff --git a/src/level.rs b/src/level.rs index e09aec5f..eba40b70 100644 --- a/src/level.rs +++ b/src/level.rs @@ -1,5 +1,6 @@ // std imports use std::cmp::Ord; +use std::fmt; use std::ops::Deref; use std::result::Result; @@ -24,6 +25,23 @@ pub enum Level { // --- +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseError; + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "failed to parse level") + } +} + +impl std::error::Error for ParseError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + None + } +} + +// --- + #[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, Hash, Ord, PartialEq, PartialOrd, Enum)] pub struct RelaxedLevel(Level); @@ -48,6 +66,18 @@ impl ValueParserFactory for RelaxedLevel { } } +impl TryFrom<&str> for RelaxedLevel { + type Error = ParseError; + + fn try_from(value: &str) -> Result { + LevelValueParser::alternate_values() + .iter() + .find(|(_, values)| values.iter().cloned().any(|x| value.eq_ignore_ascii_case(x))) + .map(|(level, _)| RelaxedLevel(*level)) + .ok_or(ParseError) + } +} + // --- #[derive(Clone, Debug)] @@ -77,10 +107,10 @@ impl TypedValueParser for LevelValueParser { impl LevelValueParser { fn alternate_values<'a>() -> &'a [(Level, &'a [&'a str])] { &[ - (Level::Error, &["err", "e"]), - (Level::Warning, &["warn", "wrn", "w"]), - (Level::Info, &["inf", "i"]), - (Level::Debug, &["dbg", "d"]), + (Level::Error, &["error", "err", "e"]), + (Level::Warning, &["warning", "warn", "wrn", "w"]), + (Level::Info, &["info", "inf", "i"]), + (Level::Debug, &["debug", "dbg", "d"]), ] } } diff --git a/src/lib.rs b/src/lib.rs index a64953ae..29e6dd68 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ pub mod input; pub mod iox; pub mod level; pub mod output; +pub mod query; pub mod settings; pub mod theme; pub mod themecfg; @@ -40,7 +41,8 @@ pub use app::{App, FieldOptions, Options, SegmentProcessor}; pub use datefmt::{DateTimeFormatter, LinuxDateFormat}; pub use filtering::DefaultNormalizing; pub use formatting::RecordFormatter; -pub use model::{FieldFilterSet, Filter, Level, Parser, ParserSettings}; +pub use model::{FieldFilterSet, Filter, Level, Parser, ParserSettings, RecordFilter}; +pub use query::Query; pub use settings::Settings; pub use theme::Theme; @@ -50,3 +52,4 @@ pub use console::enable_ansi_support; // public type aliases pub type IncludeExcludeKeyFilter = filtering::IncludeExcludeKeyFilter; pub type KeyMatchOptions = filtering::MatchOptions; +pub type QueryNone = model::RecordFilterNone; diff --git a/src/main.rs b/src/main.rs index 7293f61a..12bd2809 100644 --- a/src/main.rs +++ b/src/main.rs @@ -100,10 +100,14 @@ struct Opt { #[arg(long, short = 'C', env = "HL_CONCURRENCY", overrides_with = "concurrency")] concurrency: Option, // - /// Filtering by field values in one of forms [=, ~=, ~~=, !=, !~=, !~~=] where ~ denotes substring match and ~~ denotes regular expression match. + /// Filtering by field values in one of forms [k=v, k~=v, k~~=v, 'k!=v', 'k!~=v', 'k!~~=v'] where ~ does substring match and ~~ does regular expression match. #[arg(short, long, number_of_values = 1)] filter: Vec, // + /// Custom query, accepts expressions from --filter and supports '(', ')', 'and', 'or', 'not', 'in', 'contain', 'like', '<', '>', '<=', '>=', etc. + #[arg(short, long, number_of_values = 1)] + query: Vec, + // /// Hide or unhide fields with the specified keys, prefix with ! to unhide, specify !* to unhide all. #[arg(long, short = 'h', number_of_values = 1)] hide: Vec, @@ -362,6 +366,11 @@ fn run() -> Result<()> { let max_message_size = opt.max_message_size; let buffer_size = std::cmp::min(max_message_size, opt.buffer_size); + let mut query = None; + for q in opt.query { + query = Some(hl::query::parse(&q)?); + } + // Create app. let app = hl::App::new(hl::Options { theme: Arc::new(theme), @@ -373,6 +382,7 @@ fn run() -> Result<()> { max_message_size, concurrency, filter, + query, fields: hl::FieldOptions { settings: settings.fields, filter: Arc::new(fields), diff --git a/src/model.rs b/src/model.rs index 5966d6f0..52e8b066 100644 --- a/src/model.rs +++ b/src/model.rs @@ -34,6 +34,7 @@ pub struct Record<'a> { pub caller: Option>, pub(crate) extra: heapless::Vec<(&'a str, &'a RawValue), RECORD_EXTRA_CAPACITY>, pub(crate) extrax: Vec<(&'a str, &'a RawValue)>, + pub(crate) predefined: heapless::Vec<(&'a str, &'a RawValue), MAX_PREDEFINED_FIELDS>, } impl<'a> Record<'a> { @@ -41,7 +42,11 @@ impl<'a> Record<'a> { self.extra.iter().chain(self.extrax.iter()) } - pub fn matches(&self, filter: &F) -> bool { + pub fn fields_for_search(&self) -> impl Iterator { + self.fields().chain(self.predefined.iter()) + } + + pub fn matches(&self, filter: F) -> bool { filter.apply(self) } @@ -65,6 +70,7 @@ impl<'a> Record<'a> { } else { Vec::new() }, + predefined: heapless::Vec::new(), } } } @@ -105,6 +111,80 @@ impl RecordWithSourceConstructor for Record<'_> { pub trait RecordFilter { fn apply<'a>(&self, record: &'a Record<'a>) -> bool; + + fn and(self, rhs: F) -> RecordFilterAnd + where + Self: Sized, + F: RecordFilter, + { + RecordFilterAnd { lhs: self, rhs } + } + + fn or(self, rhs: F) -> RecordFilterOr + where + Self: Sized, + F: RecordFilter, + { + RecordFilterOr { lhs: self, rhs } + } +} + +impl RecordFilter for Box { + fn apply<'a>(&self, record: &'a Record<'a>) -> bool { + (**self).apply(record) + } +} + +impl RecordFilter for &T { + fn apply<'a>(&self, record: &'a Record<'a>) -> bool { + (**self).apply(record) + } +} + +impl RecordFilter for Option { + fn apply<'a>(&self, record: &'a Record<'a>) -> bool { + if let Some(filter) = self { + filter.apply(record) + } else { + true + } + } +} + +// --- + +pub struct RecordFilterAnd { + lhs: L, + rhs: R, +} + +impl RecordFilter for RecordFilterAnd { + fn apply<'a>(&self, record: &'a Record<'a>) -> bool { + self.lhs.apply(record) && self.rhs.apply(record) + } +} + +// --- + +pub struct RecordFilterOr { + lhs: L, + rhs: R, +} + +impl RecordFilter for RecordFilterOr { + fn apply<'a>(&self, record: &'a Record<'a>) -> bool { + self.lhs.apply(record) || self.rhs.apply(record) + } +} + +// --- + +pub struct RecordFilterNone; + +impl RecordFilter for RecordFilterNone { + fn apply<'a>(&self, _: &'a Record<'a>) -> bool { + true + } } // --- @@ -253,6 +333,9 @@ impl ParserSettingsBlock { } None => false, }; + if is_root && done { + to.predefined.push((key, value)).ok(); + } if done || !is_root { return; } @@ -552,11 +635,27 @@ impl<'a> KeyMatcher<'a> { // --- +#[derive(Debug)] +pub enum NumericOp { + Eq(f64), + Ne(f64), + Gt(f64), + Ge(f64), + Lt(f64), + Le(f64), + In(Vec), +} + +// --- + #[derive(Debug)] pub enum ValueMatchPolicy { Exact(String), SubString(String), RegularExpression(Regex), + In(Vec), + WildCard(WildMatch), + Numerically(NumericOp), } impl ValueMatchPolicy { @@ -565,6 +664,23 @@ impl ValueMatchPolicy { Self::Exact(pattern) => subject == pattern, Self::SubString(pattern) => subject.contains(pattern), Self::RegularExpression(pattern) => pattern.is_match(subject), + Self::In(patterns) => patterns.iter().any(|pattern| subject == pattern), + Self::WildCard(pattern) => pattern.matches(subject), + Self::Numerically(op) => { + if let Some(value) = subject.parse::().ok() { + match op { + NumericOp::Eq(pattern) => value == *pattern, + NumericOp::Ne(pattern) => value != *pattern, + NumericOp::Gt(pattern) => value > *pattern, + NumericOp::Ge(pattern) => value >= *pattern, + NumericOp::Lt(pattern) => value < *pattern, + NumericOp::Le(pattern) => value <= *pattern, + NumericOp::In(patterns) => patterns.iter().any(|pattern| value == *pattern), + } + } else { + false + } + } } } } @@ -572,7 +688,7 @@ impl ValueMatchPolicy { // --- #[derive(Copy, Clone, Debug)] -enum UnaryBoolOp { +pub(crate) enum UnaryBoolOp { None, Negate, } @@ -595,25 +711,57 @@ impl Default for UnaryBoolOp { // --- +#[derive(Debug)] +pub enum FieldFilterKey { + Predefined(FieldKind), + Custom(S), +} + +impl FieldFilterKey { + pub fn borrowed(&self) -> FieldFilterKey<&str> { + match self { + FieldFilterKey::Predefined(kind) => FieldFilterKey::Predefined(*kind), + FieldFilterKey::Custom(key) => FieldFilterKey::Custom(key.as_str()), + } + } +} + +// --- + #[derive(Debug)] pub struct FieldFilter { - key: String, + key: FieldFilterKey, match_policy: ValueMatchPolicy, op: UnaryBoolOp, flat_key: bool, } impl FieldFilter { - fn parse(text: &str) -> Result { + pub(crate) fn new(key: FieldFilterKey<&str>, match_policy: ValueMatchPolicy, op: UnaryBoolOp) -> Self { + Self { + key: match key { + FieldFilterKey::Predefined(kind) => FieldFilterKey::Predefined(kind), + FieldFilterKey::Custom(key) => FieldFilterKey::Custom(key.chars().map(KeyMatcher::norm).collect()), + }, + match_policy, + op, + flat_key: match key { + FieldFilterKey::Predefined(_) => true, + FieldFilterKey::Custom(key) => !key.contains('.'), + }, + } + } + + pub(crate) fn parse(text: &str) -> Result { let parse = |key, value| { let (key, match_policy, op) = Self::parse_mp_op(key, value)?; - let flat_key = key.as_bytes().iter().position(|&x| x == b'.').is_none(); - Ok(Self { - key: key.chars().map(KeyMatcher::norm).collect(), - match_policy, - op, - flat_key, - }) + let key = match key { + "message" | "msg" => FieldFilterKey::Predefined(FieldKind::Message), + "caller" => FieldFilterKey::Predefined(FieldKind::Caller), + "logger" => FieldFilterKey::Predefined(FieldKind::Logger), + _ => FieldFilterKey::Custom(key.trim_start_matches('.')), + }; + Ok(Self::new(key, match_policy, op)) }; if let Some(index) = text.find('=') { @@ -649,12 +797,16 @@ impl FieldFilter { }) } - fn match_key<'a>(&'a self, key: &str) -> Option> { - if self.flat_key && self.key.len() != key.len() { - return None; - } + fn match_custom_key<'a>(&'a self, key: &str) -> Option> { + if let FieldFilterKey::Custom(k) = &self.key { + if self.flat_key && k.len() != key.len() { + return None; + } - KeyMatcher::new(&self.key).match_key(key) + KeyMatcher::new(k).match_key(key) + } else { + None + } } fn match_value(&self, value: Option<&str>, escaped: bool) -> bool { @@ -702,47 +854,58 @@ impl FieldFilter { impl RecordFilter for FieldFilter { fn apply<'a>(&self, record: &'a Record<'a>) -> bool { - match &self.key[..] { - "msg" | "message" => { - if !self.match_value(record.message.map(|x| x.get()), true) { - return false; + match &self.key { + FieldFilterKey::Predefined(kind) => match kind { + FieldKind::Time => { + if let Some(ts) = &record.ts { + self.match_value(Some(ts.raw()), false) + } else { + false + } } - } - "logger" => { - if !self.match_value(record.logger, false) { - return false; + FieldKind::Message => { + if let Some(message) = record.message { + self.match_value(Some(message.get()), true) + } else { + false + } } - } - "caller" => { - if let Some(Caller::Text(caller)) = record.caller { - if !self.match_value(Some(caller), false) { - return false; + FieldKind::Logger => { + if let Some(logger) = record.logger { + self.match_value(Some(logger), false) + } else { + false } - } else { - return false; } - } - _ => { - let mut matched = false; - for (k, v) in record.fields() { - match self.match_key(*k) { + FieldKind::Caller => { + if let Some(Caller::Text(caller)) = record.caller { + self.match_value(Some(caller), false) + } else { + false + } + } + _ => true, + }, + FieldFilterKey::Custom(_) => { + for (k, v) in record.fields_for_search() { + match self.match_custom_key(*k) { None => {} Some(KeyMatch::Full) => { let escaped = v.get().starts_with('"'); - matched |= self.match_value(Some(v.get()), escaped); + if self.match_value(Some(v.get()), escaped) { + return true; + } } Some(KeyMatch::Partial(subkey)) => { - matched |= self.match_value_partial(subkey, *v); + if self.match_value_partial(subkey, *v) { + return true; + } } } } - if !matched { - return false; - } + false } } - - true } } @@ -785,10 +948,6 @@ impl Filter { impl RecordFilter for Filter { fn apply<'a>(&self, record: &'a Record<'a>) -> bool { - if self.is_empty() { - return true; - } - if self.since.is_some() || self.until.is_some() { if let Some(ts) = record.ts.as_ref().and_then(|ts| ts.parse()) { if let Some(since) = self.since { @@ -914,4 +1073,5 @@ impl<'de: 'a, 'a, const N: usize> Deserialize<'de> for Array<'a, N> { // --- const RECORD_EXTRA_CAPACITY: usize = 32; -const RAW_RECORD_FIELDS_CAPACITY: usize = RECORD_EXTRA_CAPACITY + 8; +const MAX_PREDEFINED_FIELDS: usize = 8; +const RAW_RECORD_FIELDS_CAPACITY: usize = RECORD_EXTRA_CAPACITY + MAX_PREDEFINED_FIELDS; diff --git a/src/query.pest b/src/query.pest new file mode 100644 index 00000000..7ab0c95a --- /dev/null +++ b/src/query.pest @@ -0,0 +1,111 @@ +input = _{ SOI ~ ws* ~ _expression ~ ws* ~ EOI } + +_expression = _{ _e_or } +expr_or = { _e_and ~ ws* ~ (_or ~ ws* ~ _e_and ~ ws*)+ } +expr_and = { _e_unary ~ ws* ~ (_and ~ ws* ~ _e_unary ~ ws*)+ } +expr_not = { _not ~ ws* ~ _e_unary } +_e_or = _{ expr_or | _e_and } +_e_and = _{ expr_and | _e_unary } +_e_unary = _{ expr_not | primary } +primary = { "(" ~ ws* ~ _expression ~ ws* ~ ")" | term } +term = { level_filter | field_filter } +level_filter = { ^"level" ~ ws* ~ _lvl_op ~ ws* ~ level } +field_filter = { field_name ~ ws* ~ (_ff_rhs_num_1 | _ff_rhs_num_n | _ff_rhs_str_1 | _ff_rhs_str_n) ~ ws* } +field_name = ${ _f_name_short | json_string } + +_ff_rhs_num_1 = _{ _ff_num_op_1 ~ ws* ~ number } +_ff_rhs_num_n = _{ _ff_num_op_n ~ ws* ~ number_set } +_ff_rhs_str_1 = _{ _ff_str_op_1 ~ ws* ~ string } +_ff_rhs_str_n = _{ _ff_str_op_n ~ ws* ~ string_set } +_f_name_short = @{ ("@" | "_" | "-" | "." | LETTER | NUMBER)+ } + +level = ${ + string ~ &punctuation +} + +_or = _{ ^"or" ~ &punctuation | "||" } +_and = _{ ^"and" ~ &punctuation | "&&" } +_not = _{ ^"not" ~ &punctuation | "!" } + +_ff_num_op_1 = _{ op_le | op_ge | op_lt | op_gt } +_ff_num_op_n = _{ op_in | op_not_in } +_ff_str_op_1 = _{ op_regex_match | op_not_regex_match | op_contain | op_not_contain | op_like | op_not_like | op_equal | op_not_equal } +_ff_str_op_n = _{ op_in | op_not_in } +_lvl_op = _{ op_le | op_ge | op_lt | op_gt | op_equal | op_not_equal } +string_set = ${ "(" ~ ws* ~ string ~ (ws* ~ "," ~ ws* ~ string)* ~ ws* ~ ")" } +number_set = ${ "(" ~ ws* ~ number ~ (ws* ~ "," ~ ws* ~ number)* ~ ws* ~ ")" } + +op_regex_match = @{ + "~~=" + | ^"match" ~ &punctuation +} +op_not_regex_match = @{ + "!~~=" + | ^"not" ~ ws+ ~ ^"match" ~ &punctuation +} +op_contain = @{ + "~=" + | ^"contain" ~ &punctuation +} +op_not_contain = @{ + "!~=" + | ^"not" ~ ws+ ~ ^"contain" ~ &punctuation +} +op_like = @{ + ^"like" ~ &punctuation +} +op_not_like = @{ + ^"not" ~ ws+ ~ ^"like" ~ &punctuation +} +op_equal = @{ + "=" + | ^"eq" ~ &punctuation +} +op_not_equal = @{ + "!=" + | ^"not" ~ ws+ ~ ^"eq" ~ &punctuation + | ^"ne" ~ &punctuation +} +op_in = @{ + ^"in" ~ &punctuation +} +op_not_in = @{ + ^"not" ~ ws+ ~ ^"in" ~ &punctuation +} +op_le = @{ + "<=" + | ^"le" ~ &punctuation +} +op_lt = @{ + "<" + | ^"lt" ~ &punctuation +} +op_ge = @{ + ">=" + | ^"ge" ~ &punctuation +} +op_gt = @{ + ">" + | ^"gt" ~ &punctuation +} + +punctuation = _{ "(" | ")" | ws | EOI } + +string = ${ json_string | simple_string } + +json_string = @{ "\"" ~ json_string_inner ~ "\"" } +json_string_inner = @{ json_char* } +json_char = { + !("\"" | "\\") ~ ANY + | "\\" ~ ("\"" | "\\" | "/" | "b" | "f" | "n" | "r" | "t") + | "\\" ~ ("u" ~ ASCII_HEX_DIGIT{4}) +} + +simple_string = @{ simple_char+ } +simple_char = @{ (LETTER | NUMBER | "@" | "." | "_" | "-" | ":" | "/" | "!" | "#" | "%" | "$" | "*" | "+" | "?") } + +number = @{ + "-"? ~ ("0" | ASCII_NONZERO_DIGIT ~ ASCII_DIGIT*) ~ ("." ~ ASCII_DIGIT*)? ~ (^"e" ~ ("+" | "-")? ~ ASCII_DIGIT+)? +} + +ws = _{ (" " | "\t" | "\r" | "\n") } diff --git a/src/query.rs b/src/query.rs new file mode 100644 index 00000000..5b08b4ac --- /dev/null +++ b/src/query.rs @@ -0,0 +1,335 @@ +// third-party imports +use closure::closure; +use pest::{iterators::Pair, Parser}; +use pest_derive::Parser; +use serde_json as json; +use wildmatch::WildMatch; + +// local imports +use crate::error::Result; +use crate::level::RelaxedLevel; +use crate::model::{ + FieldFilter, FieldFilterKey, Level, NumericOp, Record, RecordFilter, UnaryBoolOp, ValueMatchPolicy, +}; +use crate::types::FieldKind; + +// --- + +#[derive(Parser)] +#[grammar = "query.pest"] +pub struct QueryParser; + +pub type Query = Box; + +// --- + +pub fn parse(str: &str) -> Result { + let mut pairs = QueryParser::parse(Rule::input, str)?; + Ok(expression(pairs.next().unwrap())?) +} + +fn expression(pair: Pair) -> Result { + match pair.as_rule() { + Rule::expr_or => binary_op::(pair), + Rule::expr_and => binary_op::(pair), + Rule::expr_not => not(pair), + Rule::primary => primary(pair), + _ => unreachable!(), + } +} + +fn binary_op(pair: Pair) -> Result { + let mut inner = pair.into_inner(); + let mut result = expression(inner.next().unwrap())?; + for inner in inner { + result = Box::new(Op::new(result, expression(inner)?)); + } + Ok(result) +} + +fn not(pair: Pair) -> Result { + assert_eq!(pair.as_rule(), Rule::expr_not); + + Ok(Box::new(Not { + arg: expression(pair.into_inner().next().unwrap())?, + })) +} + +fn primary(pair: Pair) -> Result { + assert_eq!(pair.as_rule(), Rule::primary); + + let inner = pair.into_inner().next().unwrap(); + match inner.as_rule() { + Rule::term => term(inner), + _ => expression(inner), + } +} + +fn term(pair: Pair) -> Result { + assert_eq!(pair.as_rule(), Rule::term); + + let inner = pair.into_inner().next().unwrap(); + match inner.as_rule() { + Rule::field_filter => field_filter(inner), + Rule::level_filter => level_filter(inner), + _ => unreachable!(), + } +} + +fn field_filter(pair: Pair) -> Result { + assert_eq!(pair.as_rule(), Rule::field_filter); + + let mut inner = pair.into_inner(); + let lhs = inner.next().unwrap(); + let op = inner.next().unwrap().as_rule(); + let rhs = inner.next().unwrap(); + + let (match_policy, negated) = match (op, rhs.as_rule()) { + (Rule::op_in | Rule::op_not_in, Rule::string_set) => { + (ValueMatchPolicy::In(string_set(rhs)?), op == Rule::op_not_in) + } + (Rule::op_equal | Rule::op_not_equal, Rule::string) => { + (ValueMatchPolicy::Exact(string(rhs)?), op == Rule::op_not_equal) + } + (Rule::op_like | Rule::op_not_like, Rule::string) => ( + ValueMatchPolicy::WildCard(WildMatch::new(string(rhs)?.as_str())), + op == Rule::op_not_like, + ), + (Rule::op_contain | Rule::op_not_contain, Rule::string) => { + (ValueMatchPolicy::SubString(string(rhs)?), op == Rule::op_not_contain) + } + (Rule::op_regex_match | Rule::op_not_regex_match, Rule::string) => ( + ValueMatchPolicy::RegularExpression(string(rhs)?.parse()?), + op == Rule::op_not_regex_match, + ), + (Rule::op_in | Rule::op_not_in, Rule::number_set) => ( + ValueMatchPolicy::Numerically(NumericOp::In(number_set(rhs)?)), + op == Rule::op_not_in, + ), + (Rule::op_equal, Rule::number) => (ValueMatchPolicy::Numerically(NumericOp::Eq(number(rhs)?)), false), + (Rule::op_not_equal, Rule::number) => (ValueMatchPolicy::Numerically(NumericOp::Ne(number(rhs)?)), false), + (Rule::op_ge, Rule::number) => (ValueMatchPolicy::Numerically(NumericOp::Ge(number(rhs)?)), false), + (Rule::op_gt, Rule::number) => (ValueMatchPolicy::Numerically(NumericOp::Gt(number(rhs)?)), false), + (Rule::op_le, Rule::number) => (ValueMatchPolicy::Numerically(NumericOp::Le(number(rhs)?)), false), + (Rule::op_lt, Rule::number) => (ValueMatchPolicy::Numerically(NumericOp::Lt(number(rhs)?)), false), + _ => unreachable!(), + }; + + Ok(Box::new(FieldFilter::new( + field_name(lhs)?.borrowed(), + match_policy, + if negated { + UnaryBoolOp::Negate + } else { + UnaryBoolOp::None + }, + ))) +} + +fn level_filter(pair: Pair) -> Result { + assert_eq!(pair.as_rule(), Rule::level_filter); + + let mut inner = pair.into_inner(); + + let op = inner.next().unwrap().as_rule(); + let level = level(inner.next().unwrap())?; + Ok(match op { + Rule::op_equal => LevelFilter::query(closure!(clone level, | l | l == level)), + Rule::op_not_equal => LevelFilter::query(closure!(clone level, | l | l != level)), + Rule::op_lt => LevelFilter::query(closure!(clone level, | l | l > level)), + Rule::op_le => LevelFilter::query(closure!(clone level, | l | l >= level)), + Rule::op_gt => LevelFilter::query(closure!(clone level, | l | l < level)), + Rule::op_ge => LevelFilter::query(closure!(clone level, | l | l <= level)), + _ => unreachable!(), + }) +} + +fn string(pair: Pair) -> Result { + assert_eq!(pair.as_rule(), Rule::string); + + let inner = pair.into_inner().next().unwrap(); + Ok(match inner.as_rule() { + Rule::json_string => json::from_str(inner.as_str())?, + Rule::simple_string => inner.as_str().into(), + _ => unreachable!(), + }) +} + +fn string_set(pair: Pair) -> Result> { + assert_eq!(pair.as_rule(), Rule::string_set); + + let inner = pair.into_inner(); + inner.map(|p| string(p)).collect::>>() +} + +fn number(pair: Pair) -> Result { + assert_eq!(pair.as_rule(), Rule::number); + + let inner = pair.as_str(); + Ok(inner.parse()?) +} + +fn number_set(pair: Pair) -> Result> { + assert_eq!(pair.as_rule(), Rule::number_set); + + let inner = pair.into_inner(); + inner.map(|p| number(p)).collect::>>() +} + +fn level(pair: Pair) -> Result { + assert_eq!(pair.as_rule(), Rule::level); + + let mut inner = pair.into_inner(); + let level = string(inner.next().unwrap())?; + Ok(RelaxedLevel::try_from(level.as_str())?.into()) +} + +fn field_name(pair: Pair) -> Result> { + assert_eq!(pair.as_rule(), Rule::field_name); + + let inner = pair.into_inner().next().unwrap(); + Ok(match inner.as_rule() { + Rule::json_string => FieldFilterKey::Custom(json::from_str(inner.as_str())?), + _ => match inner.as_str() { + "message" => FieldFilterKey::Predefined(FieldKind::Message), + "logger" => FieldFilterKey::Predefined(FieldKind::Logger), + "caller" => FieldFilterKey::Predefined(FieldKind::Caller), + _ => FieldFilterKey::Custom(inner.as_str().trim_start_matches('.').to_owned()), + }, + }) +} + +// --- + +trait BinaryOp: RecordFilter { + fn new(lhs: Query, rhs: Query) -> Self; +} + +// --- + +struct Or { + lhs: Query, + rhs: Query, +} + +impl RecordFilter for Or { + fn apply<'a>(&self, record: &'a Record<'a>) -> bool { + self.lhs.apply(record) || self.rhs.apply(record) + } +} + +impl BinaryOp for Or { + fn new(lhs: Query, rhs: Query) -> Self { + Self { lhs, rhs } + } +} + +// --- + +struct And { + lhs: Query, + rhs: Query, +} + +impl RecordFilter for And { + fn apply<'a>(&self, record: &'a Record<'a>) -> bool { + self.lhs.apply(record) && self.rhs.apply(record) + } +} + +impl BinaryOp for And { + fn new(lhs: Query, rhs: Query) -> Self { + Self { lhs, rhs } + } +} + +// --- + +struct Not { + arg: Query, +} + +impl RecordFilter for Not { + fn apply<'a>(&self, record: &'a Record<'a>) -> bool { + !self.arg.apply(record) + } +} + +// --- + +struct LevelFilter { + f: F, +} + +impl bool + Send + Sync + 'static> LevelFilter { + fn new(f: F) -> Self { + Self { f } + } + + fn query(f: F) -> Query { + Box::new(Self::new(f)) + } +} + +impl bool> RecordFilter for LevelFilter { + fn apply<'a>(&self, record: &'a Record<'a>) -> bool { + record.level.map(&self.f).unwrap_or(false) + } +} + +// --- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_or_3() { + let mut pairs = QueryParser::parse(Rule::input, ".a=1 or .b=2 or .c=3").unwrap(); + let p1 = pairs.next().unwrap(); + assert_eq!(p1.as_rule(), Rule::expr_or); + let mut pi1 = p1.into_inner(); + assert_eq!(pi1.len(), 3); + assert_eq!(pi1.next().unwrap().as_rule(), Rule::primary); + assert_eq!(pi1.next().unwrap().as_rule(), Rule::primary); + assert_eq!(pi1.next().unwrap().as_rule(), Rule::primary); + assert_eq!(pi1.next(), None); + } + + #[test] + fn test_and_3() { + let mut pairs = QueryParser::parse(Rule::input, ".a=1 and .b=2 and .c=3").unwrap(); + let p1 = pairs.next().unwrap(); + assert_eq!(p1.as_rule(), Rule::expr_and); + let mut pi1 = p1.into_inner(); + assert_eq!(pi1.len(), 3); + assert_eq!(pi1.next().unwrap().as_rule(), Rule::primary); + assert_eq!(pi1.next().unwrap().as_rule(), Rule::primary); + assert_eq!(pi1.next().unwrap().as_rule(), Rule::primary); + assert_eq!(pi1.next(), None); + } + + #[test] + fn test_or_and() { + let mut pairs = QueryParser::parse(Rule::input, ".a=1 or .b=2 and .c=3").unwrap(); + let p1 = pairs.next().unwrap(); + assert_eq!(p1.as_rule(), Rule::expr_or); + let mut pi1 = p1.into_inner(); + assert_eq!(pi1.len(), 2); + assert_eq!(pi1.next().unwrap().as_rule(), Rule::primary); + assert_eq!(pi1.next().unwrap().as_rule(), Rule::expr_and); + assert_eq!(pi1.next(), None); + } + + #[test] + fn test_and_or() { + let mut pairs = QueryParser::parse(Rule::input, ".a=1 and .b=2 or .c=3").unwrap(); + let p1 = pairs.next().unwrap(); + assert_eq!(p1.as_rule(), Rule::expr_or); + let mut pi1 = p1.into_inner(); + assert_eq!(pi1.len(), 2); + assert_eq!(pi1.next().unwrap().as_rule(), Rule::expr_and); + assert_eq!(pi1.next().unwrap().as_rule(), Rule::primary); + assert_eq!(pi1.next(), None); + } +}