diff options
author | Daniel <kingdread@gmx.de> | 2020-04-26 11:45:37 +0200 |
---|---|---|
committer | Daniel <kingdread@gmx.de> | 2020-04-26 11:45:37 +0200 |
commit | 13053073e3336b8e6ffefd6a056d159239550be7 (patch) | |
tree | 63544e8764b55563a48d3f9fd530b0381f42a277 | |
parent | 3c429432382dfad6d4ac97349c96e4a4eb292089 (diff) | |
parent | 9bbd5db2a6caae10f0ab2cf2625fbc34485a4ce9 (diff) | |
download | raidgrep-13053073e3336b8e6ffefd6a056d159239550be7.tar.gz raidgrep-13053073e3336b8e6ffefd6a056d159239550be7.tar.bz2 raidgrep-13053073e3336b8e6ffefd6a056d159239550be7.zip |
Merge branch 'new-filters'
The new filter system (includes both the internal rewrite and the
command line parsing) is now being included in master. This gives a lot
more flexibility.
-rw-r--r-- | Cargo.toml | 7 | ||||
-rw-r--r-- | build.rs | 5 | ||||
-rw-r--r-- | src/csl.rs | 112 | ||||
-rw-r--r-- | src/fexpr/grammar.lalrpop | 171 | ||||
-rw-r--r-- | src/fexpr/mod.rs | 66 | ||||
-rw-r--r-- | src/filters.rs | 79 | ||||
-rw-r--r-- | src/filters/log.rs | 130 | ||||
-rw-r--r-- | src/filters/mod.rs | 229 | ||||
-rw-r--r-- | src/filters/player.rs | 119 | ||||
-rw-r--r-- | src/main.rs | 249 | ||||
-rw-r--r-- | src/output/aggregators.rs | 2 | ||||
-rw-r--r-- | src/output/formats.rs | 15 | ||||
-rw-r--r-- | src/output/mod.rs | 2 |
13 files changed, 893 insertions, 293 deletions
@@ -14,6 +14,7 @@ colored = "1" chrono = "0.4" rayon = "1" num-traits = "0.2" +num-derive = "0.3" humantime = "2.0" zip = "0.5" anyhow = "1.0" @@ -23,3 +24,9 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" dirs = "2.0" log = { version = "0.4", features = ["std"] } +thiserror = "1.0" +lalrpop-util = "0.18" +rustyline = "6.1" + +[build-dependencies] +lalrpop = { version = "0.18", features = ["lexer"] } diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..23c7d3f --- /dev/null +++ b/build.rs @@ -0,0 +1,5 @@ +extern crate lalrpop; + +fn main() { + lalrpop::process_root().unwrap(); +} diff --git a/src/csl.rs b/src/csl.rs deleted file mode 100644 index e7d84f3..0000000 --- a/src/csl.rs +++ /dev/null @@ -1,112 +0,0 @@ -use std::collections::HashSet; -use std::hash::Hash; -use std::str::FromStr; -use std::fmt; - -use super::{SearchField, FightOutcome}; -use chrono::Weekday; -use evtclib::statistics::gamedata::Boss; - -pub trait Variants: Copy { - type Output: Iterator<Item=Self>; - fn variants() -> Self::Output; -} - -macro_rules! variants { - ($target:ident => $($var:ident),+) => { - impl Variants for $target { - type Output = ::std::iter::Cloned<::std::slice::Iter<'static, Self>>; - fn variants() -> Self::Output { - // Exhaustiveness check - #[allow(dead_code)] - fn exhaustiveness_check(value: $target) { - match value { - $($target :: $var => ()),+ - } - } - // Actual result - [ - $($target :: $var),+ - ].iter().cloned() - } - } - }; - ($target:ident => $($var:ident,)+) => { - variants!($target => $($var),+); - }; -} - -variants! { SearchField => Account, Character, Guild } -variants! { FightOutcome => Success, Wipe } -variants! { Weekday => Mon, Tue, Wed, Thu, Fri, Sat, Sun } -variants! { Boss => - ValeGuardian, Gorseval, Sabetha, - Slothasor, Matthias, - KeepConstruct, Xera, - Cairn, MursaatOverseer, Samarog, Deimos, - SoullessHorror, Dhuum, - ConjuredAmalgamate, LargosTwins, Qadim, - CardinalAdina, CardinalSabir, QadimThePeerless, - - IcebroodConstruct, VoiceOfTheFallen, FraenirOfJormag, Boneskinner, WhisperOfJormag, - - Skorvald, Artsariiv, Arkk, - MAMA, Siax, Ensolyss, -} - -/// The character that delimits items from each other. -const DELIMITER: char = ','; -/// The character that negates the result. -const NEGATOR: char = '!'; - -/// A list that is given as comma-separated values. -#[derive(Debug, Clone)] -pub struct CommaSeparatedList<T: Eq + Hash + fmt::Debug> { - values: HashSet<T>, -} - -#[derive(Debug, Clone)] -pub enum ParseError<E> { - Underlying(E), -} - -impl<E: fmt::Display> fmt::Display for ParseError<E> { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match *self { - ParseError::Underlying(ref e) => e.fmt(f), - } - } -} - -impl<T> FromStr for CommaSeparatedList<T> - where T: FromStr + Variants + Hash + Eq + fmt::Debug -{ - type Err = ParseError<T::Err>; - - fn from_str(input: &str) -> Result<Self, Self::Err> { - if input == "*" { - Ok(CommaSeparatedList { values: T::variants().collect() }) - } else if input.starts_with(NEGATOR) { - let no_csl = CommaSeparatedList::from_str(&input[1..])?; - let all_values = T::variants().collect::<HashSet<_>>(); - Ok(CommaSeparatedList { - values: all_values.difference(&no_csl.values).cloned().collect() - }) - } else { - let parts = input.split(DELIMITER); - let values = parts - .map(FromStr::from_str) - .collect::<Result<HashSet<_>, _>>() - .map_err(ParseError::Underlying)?; - Ok(CommaSeparatedList { values }) - } - } -} - -impl<T> CommaSeparatedList<T> - where T: Hash + Eq + fmt::Debug -{ - pub fn contains(&self, value: &T) -> bool { - self.values.contains(value) - } -} diff --git a/src/fexpr/grammar.lalrpop b/src/fexpr/grammar.lalrpop new file mode 100644 index 0000000..58ec052 --- /dev/null +++ b/src/fexpr/grammar.lalrpop @@ -0,0 +1,171 @@ +use super::{ + FError, + FErrorKind, + FightOutcome, + filters, + SearchField, + Weekday, +}; +use evtclib::statistics::gamedata::Boss; +use std::collections::HashSet; +use lalrpop_util::ParseError; + +use chrono::NaiveDateTime; +use regex::Regex; + +grammar; + +extern { + type Error = FError; +} + +pub LogFilter: Box<dyn filters::log::LogFilter> = { + Disjunction<LogPredicate>, +} + +PlayerFilter: Box<dyn filters::player::PlayerFilter> = { + Disjunction<PlayerPredicate>, +} + +Disjunction<T>: T = { + <a:Disjunction<T>> "or" <b:Conjunction<T>> => a | b, + Conjunction<T>, +} + +Conjunction<T>: T = { + <a:Conjunction<T>> "and"? <b:Negation<T>> => a & b, + Negation<T>, +} + +Negation<T>: T = { + "not" <Negation<T>> => ! <>, + "!" <Negation<T>> => ! <>, + T, +} + +LogPredicate: Box<dyn filters::log::LogFilter> = { + "-success" => filters::log::success(), + "-wipe" => filters::log::wipe(), + "-outcome" <Comma<FightOutcome>> => filters::log::outcome(<>), + + "-weekday" <Comma<Weekday>> => filters::log::weekday(<>), + "-before" <Date> => filters::log::before(<>), + "-after" <Date> => filters::log::after(<>), + + "-boss" <Comma<Boss>> => filters::log::boss(<>), + + "-include" => filters::constant(true), + "-exclude" => filters::constant(false), + + "-player" <Regex> => filters::player::any( + filters::player::character(<>.clone()) + | filters::player::account(<>) + ), + + "all" "(" "player" ":" <PlayerFilter> ")" => filters::player::all(<>), + "any" "(" "player" ":" <PlayerFilter> ")" => filters::player::any(<>), + "exists" "(" "player" ":" <PlayerFilter> ")" => filters::player::any(<>), + + "(" <LogFilter> ")", +} + +PlayerPredicate: Box<dyn filters::player::PlayerFilter> = { + "-character" <Regex> => filters::player::character(<>), + "-account" <Regex> => filters::player::account(<>), + "-name" <Regex> => + filters::player::account(<>.clone()) + | filters::player::character(<>), + + "(" <PlayerFilter> ")", +} + +Regex: Regex = { + <l:@L> <s:regex> =>? Regex::new(&s[1..s.len() - 1]).map_err(|error| ParseError::User { + error: FError { + location: l, + data: s.to_string(), + kind: error.into(), + } + }), + <l:@L> <s:word> =>? Regex::new(s).map_err(|error| ParseError::User { + error: FError { + location: l, + data: s.to_string(), + kind: error.into(), + } + }), +} + +FightOutcome: FightOutcome = { + <l:@L> <w:word> =>? w.parse().map_err(|_| ParseError::User { + error: FError { + location: l, + data: w.into(), + kind: FErrorKind::InvalidFightOutcome, + } + }), +} + +Weekday: Weekday = { + <l:@L> <w:word> =>? w.parse().map_err(|_| ParseError::User { + error: FError { + location: l, + data: w.into(), + kind: FErrorKind::InvalidWeekday, + } + }), +} + +Boss: Boss = { + <l:@L> <w:word> =>? w.parse().map_err(|_| ParseError::User { + error: FError { + location: l, + data: w.into(), + kind: FErrorKind::InvalidBoss, + } + }), +} + +Date: NaiveDateTime = { + <l:@L> <d:datetime> =>? NaiveDateTime::parse_from_str(d, "%Y-%m-%d %H:%M:%S") + .map_err(|error| ParseError::User { + error: FError { + location: l, + data: d.into(), + kind: error.into(), + } + }), + <l:@L> <d:date> =>? NaiveDateTime::parse_from_str(&format!("{} 00:00:00", d), "%Y-%m-%d %H:%M:%S") + .map_err(|error| ParseError::User { + error: FError { + location: l, + data: d.into(), + kind: error.into(), + } + }), +} + +Comma<T>: HashSet<T> = { + <v:(<T> ",")*> <e:T> => { + let mut result = v.into_iter().collect::<HashSet<_>>(); + result.insert(e); + result + }, +} + +match { + "player" => "player", + "not" => "not", + "or" => "or", + "and" => "and", + "any" => "any", + "all" => "all", + "exists" => "exists", + + r"\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d" => datetime, + r"\d\d\d\d-\d\d-\d\d" => date, + r"\w+" => word, + r#""[^"]*""# => regex, + + _ +} diff --git a/src/fexpr/mod.rs b/src/fexpr/mod.rs new file mode 100644 index 0000000..5610aba --- /dev/null +++ b/src/fexpr/mod.rs @@ -0,0 +1,66 @@ +//! Filter expression language. +//! +//! This module contains methods to parse a given string into an abstract filter tree, check its +//! type and convert it to a [`Filter`][super::filters::Filter]. +// Make it available in the grammar mod. +use super::{filters, FightOutcome, SearchField, Weekday}; + +use std::{error, fmt}; + +use lalrpop_util::{lalrpop_mod, lexer::Token, ParseError}; +use thiserror::Error; + +lalrpop_mod!(#[allow(clippy::all)] pub grammar, "/fexpr/grammar.rs"); + +#[derive(Debug)] +pub struct FError { + location: usize, + data: String, + kind: FErrorKind, +} + +impl fmt::Display for FError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{} (at {})", self.kind, self.location) + } +} + +impl error::Error for FError { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + Some(&self.kind) + } +} + +#[derive(Debug, Error)] +pub enum FErrorKind { + #[error("invalid regular expression: {0}")] + InvalidRegex(#[from] regex::Error), + #[error("invalid fight outcome")] + InvalidFightOutcome, + #[error("invalid weekday")] + InvalidWeekday, + #[error("invalid timestamp: {0}")] + InvalidTimestamp(#[from] chrono::format::ParseError), + #[error("invalid boss name")] + InvalidBoss, +} + +/// Shortcut to create a new parser and parse the given input. +pub fn parse_logfilter<'a>( + input: &'a str, +) -> Result<Box<dyn filters::log::LogFilter>, ParseError<usize, Token<'a>, FError>> { + grammar::LogFilterParser::new().parse(input) +} + +/// Extract the location from the given error. +pub fn location<T>(err: &ParseError<usize, T, FError>) -> usize { + match *err { + ParseError::InvalidToken { location } => location, + ParseError::UnrecognizedEOF { location, .. } => location, + ParseError::UnrecognizedToken { + token: (l, _, _), .. + } => l, + ParseError::ExtraToken { token: (l, _, _) } => l, + ParseError::User { ref error } => error.location, + } +} diff --git a/src/filters.rs b/src/filters.rs deleted file mode 100644 index cdd8f36..0000000 --- a/src/filters.rs +++ /dev/null @@ -1,79 +0,0 @@ -use evtclib::raw::parser::PartialEvtc; -use evtclib::statistics::gamedata::Boss; -use evtclib::{Agent, AgentName}; - -use num_traits::FromPrimitive; - -use super::{guilds, LogResult, Opt, SearchField}; - -use chrono::Datelike; - -/// Do filtering based on the character or account name. -pub fn filter_name(evtc: &PartialEvtc, opt: &Opt) -> bool { - for player in &evtc.agents { - let fancy = Agent::from_raw(player); - if let Ok(AgentName::Player { - ref account_name, - ref character_name, - .. - }) = fancy.as_ref().map(Agent::name) - { - if (opt.field.contains(&SearchField::Account) && opt.expression.is_match(account_name)) - || (opt.field.contains(&SearchField::Character) - && opt.expression.is_match(character_name)) - { - return true; - } - } - } - // Don't throw away the log yet if we are searching for guilds - opt.field.contains(&SearchField::Guild) -} - -/// Do filtering based on the boss ID. -pub fn filter_boss(evtc: &PartialEvtc, opt: &Opt) -> bool { - let boss = Boss::from_u16(evtc.header.combat_id); - boss.map(|b| opt.bosses.contains(&b)).unwrap_or(true) -} - -/// Do filtering based on the fight outcome. -pub fn filter_outcome(result: &LogResult, opt: &Opt) -> bool { - opt.outcome.contains(&result.outcome) -} - -/// Do filtering based on the weekday of the fight. -pub fn filter_weekday(result: &LogResult, opt: &Opt) -> bool { - opt.weekdays.contains(&result.time.weekday()) -} - -/// Do filtering based on encounter time. -pub fn filter_time(result: &LogResult, opt: &Opt) -> bool { - let after_ok = match opt.after { - Some(time) => time <= result.time, - None => true, - }; - let before_ok = match opt.before { - Some(time) => time >= result.time, - None => true, - }; - - after_ok && before_ok -} - -/// Do filtering based on the guilds. -pub fn filter_guilds(result: &LogResult, opt: &Opt) -> bool { - if !opt.guilds { - return true; - } - if !opt.field.contains(&SearchField::Guild) { - return true; - } - result.players.iter().any(|player| { - let guild = player.guild_id.as_ref().and_then(|id| guilds::lookup(id)); - if let Some(guild) = guild { - opt.expression.is_match(guild.tag()) || opt.expression.is_match(guild.name()) - } else { - false - } - }) -} diff --git a/src/filters/log.rs b/src/filters/log.rs new file mode 100644 index 0000000..8d4e0b5 --- /dev/null +++ b/src/filters/log.rs @@ -0,0 +1,130 @@ +//! This module contains specific filters that operate on log files. +//! +//! This is the "base unit", as each file corresponds to one log. Filters on other items (such as +//! players) have to be lifted into log filters first. +use super::{ + super::{FightOutcome, LogResult, Weekday}, + Filter, Inclusion, +}; + +use std::collections::HashSet; + +use evtclib::raw::parser::PartialEvtc; +use evtclib::statistics::gamedata::Boss; + +use chrono::{Datelike, NaiveDateTime}; +use num_traits::FromPrimitive as _; + +/// Filter trait used for filters that operate on complete logs. +pub trait LogFilter = Filter<PartialEvtc, LogResult>; + +#[derive(Debug, Clone)] +struct BossFilter(HashSet<Boss>); + +impl Filter<PartialEvtc, LogResult> for BossFilter { + fn filter_early(&self, partial_evtc: &PartialEvtc) -> Inclusion { + let boss = Boss::from_u16(partial_evtc.header.combat_id); + boss.map(|b| self.0.contains(&b).into()) + .unwrap_or(Inclusion::Include) + } + + fn filter(&self, log: &LogResult) -> bool { + let boss = Boss::from_u16(log.boss_id); + boss.map(|b| self.0.contains(&b)).unwrap_or(false) + } +} + +/// A `LogFilter` that only accepts logs with one of the given bosses. +pub fn boss(bosses: HashSet<Boss>) -> Box<dyn LogFilter> { + Box::new(BossFilter(bosses)) +} + + +#[derive(Debug, Clone)] +struct OutcomeFilter(HashSet<FightOutcome>); + +impl Filter<PartialEvtc, LogResult> for OutcomeFilter { + fn filter(&self, log: &LogResult) -> bool { + self.0.contains(&log.outcome) + } +} + +/// A `LogFilter` that only accepts logs with one of the given outcomes. +/// +/// See also [`success`][success] and [`wipe`][wipe]. +pub fn outcome(outcomes: HashSet<FightOutcome>) -> Box<dyn LogFilter> { + Box::new(OutcomeFilter(outcomes)) +} + +/// A `LogFilter` that only accepts successful logs. +/// +/// See also [`outcome`][outcome] and [`wipe`][wipe]. +pub fn success() -> Box<dyn LogFilter> { + let mut outcomes = HashSet::new(); + outcomes.insert(FightOutcome::Success); + outcome(outcomes) +} + +/// A `LogFilter` that only accepts failed logs. +/// +/// See also [`outcome`][outcome] and [`success`][wipe]. +pub fn wipe() -> Box<dyn LogFilter> { + let mut outcomes = HashSet::new(); + outcomes.insert(FightOutcome::Wipe); + outcome(outcomes) +} + +#[derive(Debug, Clone)] +struct WeekdayFilter(HashSet<Weekday>); + +impl Filter<PartialEvtc, LogResult> for WeekdayFilter { + fn filter(&self, log: &LogResult) -> bool { + self.0.contains(&log.time.weekday()) + } +} + +/// A `LogFilter` that only accepts logs if they were done on one of the given weekdays. +pub fn weekday(weekdays: HashSet<Weekday>) -> Box<dyn LogFilter> { + Box::new(WeekdayFilter(weekdays)) +} + + +#[derive(Debug, Clone)] +struct TimeFilter(Option<NaiveDateTime>, Option<NaiveDateTime>); + +impl Filter<PartialEvtc, LogResult> for TimeFilter { + fn filter(&self, log: &LogResult) -> bool { + let after_ok = match self.0 { + Some(time) => time <= log.time, + None => true, + }; + let before_ok = match self.1 { + Some(time) => time >= log.time, + None => true, + }; + + after_ok && before_ok + } +} + +/// A `LogFilter` that only accepts logs in the given time frame. +/// +/// If a bound is not given, -Infinity is assumed for the lower bound, and Infinity for the upper +/// bound. +pub fn time(lower: Option<NaiveDateTime>, upper: Option<NaiveDateTime>) -> Box<dyn LogFilter> { + Box::new(TimeFilter(lower, upper)) +} + +/// A `LogFilter` that only accepts logs after the given date. +/// +/// Also see [`time`][time] and [`before`][before]. +pub fn after(when: NaiveDateTime) -> Box<dyn LogFilter> { + time(Some(when), None) +} + +/// A `LogFilter` that only accepts logs before the given date. +/// +/// Also see [`time`][time] and [`after`][after]. +pub fn before(when: NaiveDateTime) -> Box<dyn LogFilter> { + time(None, Some(when)) +} diff --git a/src/filters/mod.rs b/src/filters/mod.rs new file mode 100644 index 0000000..162b6f8 --- /dev/null +++ b/src/filters/mod.rs @@ -0,0 +1,229 @@ +use std::{fmt, ops}; + +use num_derive::FromPrimitive; +use num_traits::FromPrimitive as _; + +pub mod log; +pub mod player; + +/// Early filtering result. +/// +/// This implements a [three-valued logic](https://en.wikipedia.org/wiki/Three-valued_logic), +/// similar to SQL's `NULL`. +#[derive(Debug, Copy, Clone, PartialEq, Eq, FromPrimitive)] +#[repr(i8)] +pub enum Inclusion { + /// The log should be included. + Include = 1, + /// The state is yet unknown. + Unknown = 0, + /// The log should be excluded. + Exclude = -1, +} + +impl ops::Not for Inclusion { + type Output = Self; + + fn not(self) -> Self::Output { + Inclusion::from_i8(-(self as i8)).unwrap() + } +} + +impl ops::BitAnd<Inclusion> for Inclusion { + type Output = Self; + + fn bitand(self, rhs: Inclusion) -> Self::Output { + Inclusion::from_i8((self as i8).min(rhs as i8)).unwrap() + } +} + +impl ops::BitOr<Inclusion> for Inclusion { + type Output = Self; + + fn bitor(self, rhs: Inclusion) -> Self::Output { + Inclusion::from_i8((self as i8).max(rhs as i8)).unwrap() + } +} + +impl From<bool> for Inclusion { + fn from(data: bool) -> Self { + if data { + Inclusion::Include + } else { + Inclusion::Exclude + } + } +} + +/// The main filter trait. +/// +/// Filters are usually handled as a `Box<dyn Filter>`. +pub trait Filter<Early, Late>: Send + Sync + fmt::Debug { + /// Determine early (before processing all events) whether the log stands a chance to be + /// included. + /// + /// Note that you can return [Inclusion::Unkown] if this filter cannot determine yet a definite + /// answer. + fn filter_early(&self, _: &Early) -> Inclusion { + Inclusion::Unknown + } + + /// Return whether the log should be included, according to this filter. + fn filter(&self, _: &Late) -> bool; +} + +#[derive(Debug, Clone, Copy)] +struct Const(pub bool); + +impl<E, L> Filter<E, L> for Const { + fn filter_early(&self, _: &E) -> Inclusion { + self.0.into() + } + + fn filter(&self, _: &L) -> bool { + self.0 + } +} + +/// Construct a `Filter` that always returns a fixed value. +pub fn constant<E, L>(output: bool) -> Box<dyn Filter<E, L>> { + Box::new(Const(output)) +} + +struct AndFilter<E, L>(Box<dyn Filter<E, L>>, Box<dyn Filter<E, L>>); + +impl<E, L> Filter<E, L> for AndFilter<E, L> { + fn filter_early(&self, partial_evtc: &E) -> Inclusion { + let lhs = self.0.filter_early(partial_evtc); + // Short circuit behaviour + if lhs == Inclusion::Exclude { + Inclusion::Exclude + } else { + lhs & self.1.filter_early(partial_evtc) + } + } + + fn filter(&self, log: &L) -> bool { + self.0.filter(log) && self.1.filter(log) + } +} + +impl<E: 'static, L: 'static> ops::BitAnd<Box<dyn Filter<E, L>>> for Box<dyn Filter<E, L>> { + type Output = Box<dyn Filter<E, L>>; + + fn bitand(self, rhs: Box<dyn Filter<E, L>>) -> Self::Output { + Box::new(AndFilter(self, rhs)) + } +} + +impl<E, L> fmt::Debug for AndFilter<E, L> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "({:?}) and ({:?})", self.0, self.1) + } +} + +struct OrFilter<E, L>(Box<dyn Filter<E, L>>, Box<dyn Filter<E, L>>); + +impl<E, L> Filter<E, L> for OrFilter<E, L> { + fn filter_early(&self, partial_evtc: &E) -> Inclusion { + let lhs = self.0.filter_early(partial_evtc); + // Short circuit behaviour + if lhs == Inclusion::Include { + Inclusion::Include + } else { + lhs | self.1.filter_early(partial_evtc) + } + } + + fn filter(&self, log: &L) -> bool { + self.0.filter(log) || self.1.filter(log) + } +} + +impl<E: 'static, L: 'static> ops::BitOr<Box<dyn Filter<E, L>>> for Box<dyn Filter<E, L>> { + type Output = Box<dyn Filter<E, L>>; + + fn bitor(self, rhs: Box<dyn Filter<E, L>>) -> Self::Output { + Box::new(OrFilter(self, rhs)) + } +} + +impl<E, L> fmt::Debug for OrFilter<E, L> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "({:?}) or ({:?})", self.0, self.1) + } +} + +struct NotFilter<E, L>(Box<dyn Filter<E, L>>); + +impl<E, L> Filter<E, L> for NotFilter<E, L> { + fn filter_early(&self, partial_evtc: &E) -> Inclusion { + !self.0.filter_early(partial_evtc) + } + + fn filter(&self, log: &L) -> bool { + !self.0.filter(log) + } +} + +impl<E: 'static, L: 'static> ops::Not for Box<dyn Filter<E, L>> { + type Output = Box<dyn Filter<E, L>>; + + fn not(self) -> Self::Output { + Box::new(NotFilter(self)) + } +} + +impl<E, L> fmt::Debug for NotFilter<E, L> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "not ({:?})", self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_inclusion_not() { + use Inclusion::*; + + assert_eq!(!Exclude, Include); + assert_eq!(!Include, Exclude); + assert_eq!(!Unknown, Unknown); + } + + #[test] + fn test_inclusion_and() { + use Inclusion::*; + + assert_eq!(Exclude & Exclude, Exclude); + assert_eq!(Exclude & Unknown, Exclude); + assert_eq!(Exclude & Include, Exclude); + + assert_eq!(Unknown & Exclude, Exclude); + assert_eq!(Unknown & Unknown, Unknown); + assert_eq!(Unknown & Include, Unknown); + + assert_eq!(Include & Exclude, Exclude); + assert_eq!(Include & Unknown, Unknown); + assert_eq!(Include & Include, Include); + } + + #[test] + fn test_inclusion_or() { + use Inclusion::*; + + assert_eq!(Exclude | Exclude, Exclude); + assert_eq!(Exclude | Unknown, Unknown); + assert_eq!(Exclude | Include, Include); + + assert_eq!(Unknown | Exclude, Unknown); + assert_eq!(Unknown | Unknown, Unknown); + assert_eq!(Unknown | Include, Include); + + assert_eq!(Include | Exclude, Include); + assert_eq!(Include | Unknown, Include); + assert_eq!(Include | Include, Include); + } +} diff --git a/src/filters/player.rs b/src/filters/player.rs new file mode 100644 index 0000000..4daeb22 --- /dev/null +++ b/src/filters/player.rs @@ -0,0 +1,119 @@ +//! This module contains filters pertaining to a single player. +//! +//! Additionally, it provides methods to lift a player filter to a log filter with [`any`][any] and +//! [`all`][all]. +use super::{ + super::{guilds, LogResult, Player, SearchField}, + log::LogFilter, + Filter, Inclusion, +}; + +use evtclib::raw::parser::PartialEvtc; +use evtclib::{Agent, AgentName}; + +use regex::Regex; + +/// Filter type for filters that operate on single players. +pub trait PlayerFilter = Filter<Agent, Player>; + +/// Struct that lifts a [`PlayerFilter`](traitalias.PlayerFilter.html) to a +/// [`LogFilter`](../log/traitalias.LogFilter.html) by requiring all players to match. +/// +/// This struct will short-circuit once the result is known. +#[derive(Debug)] +struct AllPlayers(Box<dyn PlayerFilter>); + +impl Filter<PartialEvtc, LogResult> for AllPlayers { + fn filter_early(&self, partial_evtc: &PartialEvtc) -> Inclusion { + let mut result = Inclusion::Include; + for agent in &partial_evtc.agents { + if !agent.is_player() { + continue; + } + + let agent = Agent::from_raw(agent); + if let Ok(agent) = agent { + result = result & self.0.filter_early(&agent); + } else { + result = result & Inclusion::Unknown; + } + // Short circuit + if result == Inclusion::Exclude { + return result; + } + } + result + } + + fn filter(&self, log: &LogResult) -> bool { + log.players.iter().all(|p| self.0.filter(p)) + } +} + +/// Construct a filter that requires the given `player_filter` to match for all players in a log. +pub fn all(player_filter: Box<dyn PlayerFilter>) -> Box<dyn LogFilter> { + Box::new(AllPlayers(player_filter)) +} + +/// Construct a filter that requires the given `player_filter` to match for any player in a log. +pub fn any(player_filter: Box<dyn PlayerFilter>) -> Box<dyn LogFilter> { + !all(!player_filter) +} + +/// Filter that filters players according to their name. +/// +/// The given SearchField determines in which field something should be searched. +#[derive(Debug, Clone)] +struct NameFilter(SearchField, Regex); + +impl Filter<Agent, Player> for NameFilter { + fn filter_early(&self, agent: &Agent) -> Inclusion { + if self.0 == SearchField::Guild { + return Inclusion::Unknown; + } + + if let AgentName::Player { + ref account_name, + ref character_name, + .. + } = agent.name() + { + let field = match self.0 { + SearchField::Account => account_name, + SearchField::Character => character_name, + _ => unreachable!("We already checked for Guild earlier"), + }; + self.1.is_match(field).into() + } else { + Inclusion::Unknown + } + } + + fn filter(&self, player: &Player) -> bool { + match self.0 { + SearchField::Account => self.1.is_match(&player.account_name), + SearchField::Character => self.1.is_match(&player.character_name), + SearchField::Guild => player + .guild_id + .as_ref() + .and_then(|id| guilds::lookup(id)) + .map(|guild| self.1.is_match(guild.tag()) || self.1.is_match(guild.name())) + .unwrap_or(false), + } + } +} + +/// Construct a `PlayerFilter` that searches the given `field` with the given `regex`. +pub fn name(field: SearchField, regex: Regex) -> Box<dyn PlayerFilter> { + Box::new(NameFilter(field, regex)) +} + +/// Construct a `PlayerFilter` that searches the character name with the given `regex`. +pub fn character(regex: Regex) -> Box<dyn PlayerFilter> { + name(SearchField::Character, regex) +} + +/// Construct a `PlayerFilter` that searches the account name with the given `regex`. +pub fn account(regex: Regex) -> Box<dyn PlayerFilter> { + name(SearchField::Account, regex) +} diff --git a/src/main.rs b/src/main.rs index 6b67875..231fbdc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,27 +1,31 @@ +#![feature(trait_alias)] use std::collections::HashMap; +use std::fmt; use std::fs::File; use std::io::{BufReader, Read, Seek}; use std::path::PathBuf; use std::str::FromStr; -use anyhow::{anyhow, Result}; -use chrono::{Duration, NaiveDateTime, Weekday}; +use anyhow::{anyhow, Error, Result}; +use chrono::{NaiveDateTime, Weekday}; +use colored::Colorize; +use itertools::Itertools; +use log::debug; use num_traits::cast::FromPrimitive; use regex::Regex; +use rustyline::Editor; use structopt::StructOpt; use walkdir::{DirEntry, WalkDir}; -use log::debug; use evtclib::{AgentKind, AgentName, EventKind, Log}; +mod fexpr; mod filters; +use filters::{log::LogFilter, Inclusion}; mod guilds; mod logger; mod output; -mod csl; -use csl::CommaSeparatedList; - macro_rules! unwrap { ($p:pat = $e:expr => { $r:expr} ) => { if let $p = $e { @@ -32,28 +36,36 @@ macro_rules! unwrap { }; } - -/// A program that allows you to search through all your evtc logs for specific -/// people. +/// A program that allows you to search through all your evtc logs for specific people. +/// +/// raidgrep supports different predicates that determine whether a log is included or not. +/// Predicates start with a - and optionally take an argument. Predicates can be combined with +/// "and", "or" and "not", and predicates that operate on single players (instead of logs) have to +/// be within an "all(player: ...)" or "any(player: ...)" construct. +/// +/// PREDICATES: +/// +/// -character REGEX True if the character name matches the regex. +/// -account REGEX True if the account name matches the regex. +/// -name REGEX True if either character or account name match. +/// +/// -success Only include successful logs. +/// -wipe Only include failed logs. +/// -outcome OUTCOMES Only include logs with the given outcomes. +/// -weekday WEEKDAYS Only include logs from the given weekdays. +/// -before DATE Only include logs from before the given date. +/// -after DATE Only include logs from after the given date. +/// -boss BOSSES Only include logs from the given bosses. +/// -player REGEX Shorthand to check if any player in the log has the given name. +/// -include Always evaluates to including the log. +/// -exclude Always evaluates to excluding the log. #[derive(StructOpt, Debug)] -#[structopt(name = "raidgrep")] +#[structopt(verbatim_doc_comment)] pub struct Opt { /// Path to the folder with logs. #[structopt(short = "d", long = "dir", default_value = ".", parse(from_os_str))] path: PathBuf, - /// The fields which should be searched. - #[structopt(short = "f", long = "fields", default_value = "account,character")] - field: CommaSeparatedList<SearchField>, - - /// Only display fights with the given outcome. - #[structopt(short = "o", long = "outcome", default_value = "*")] - outcome: CommaSeparatedList<FightOutcome>, - - /// Invert the regular expression (show fights that do not match) - #[structopt(short = "v", long = "invert-match")] - invert: bool, - /// Only show the name of matching files. #[structopt(short = "l", long = "files-with-matches")] file_name_only: bool, @@ -62,51 +74,31 @@ pub struct Opt { #[structopt(long = "no-color")] no_color: bool, - /// Only show logs that are younger than the given time. - #[structopt( - short = "a", - long = "younger", - parse(try_from_str = parse_time_arg) - )] - after: Option<NaiveDateTime>, - - /// Only show logs that are older than the given time. - #[structopt( - short = "b", - long = "older", - parse(try_from_str = parse_time_arg) - )] - before: Option<NaiveDateTime>, - - /// Only show logs from the given weekdays. - #[structopt( - short = "w", - long = "weekdays", - default_value = "*", - parse(try_from_str = try_from_str_simple_error) - )] - weekdays: CommaSeparatedList<Weekday>, - - /// Only show logs from the given encounters. - #[structopt(short = "e", long = "bosses", default_value = "*")] - bosses: CommaSeparatedList<evtclib::statistics::gamedata::Boss>, - /// Print more debugging information to stderr. #[structopt(long = "debug")] debug: bool, /// Load guild information from the API. + /// + /// Loading guild information requires network access and slows down the program considerably, + /// so this is disabled by default. #[structopt(long = "guilds")] guilds: bool, - /// The regular expression to search for. - #[structopt(name = "EXPR")] - expression: Regex, + /// Run the REPL. + /// + /// The REPL will allow you to keep entering queries which are being searched by raidgrep, + /// until you manually exit with Crtl+C. + #[structopt(long)] + repl: bool, + + /// The filter expression, see PREDICATES for more information. + expression: Vec<String>, } /// A flag indicating which fields should be searched. #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -enum SearchField { +pub enum SearchField { /// Only search the account name. Account, /// Only search the character name. @@ -135,6 +127,8 @@ pub struct LogResult { log_file: PathBuf, /// The time of the recording. time: NaiveDateTime, + /// The numeric ID of the boss. + boss_id: u16, /// The name of the boss. boss_name: String, /// A vector of all participating players. @@ -177,26 +171,6 @@ impl FromStr for FightOutcome { } } -fn parse_time_arg(input: &str) -> Result<NaiveDateTime> { - if let Ok(duration) = humantime::parse_duration(input) { - let now = chrono::Local::now().naive_local(); - let chrono_dur = Duration::from_std(duration).expect("Duration out of range!"); - return Ok(now - chrono_dur); - } - if let Ok(time) = humantime::parse_rfc3339_weak(input) { - let timestamp = time - .duration_since(std::time::SystemTime::UNIX_EPOCH) - .unwrap() - .as_secs(); - return Ok(NaiveDateTime::from_timestamp(timestamp as i64, 0)); - } - Err(anyhow!("unknown time format")) -} - -fn try_from_str_simple_error<T: FromStr>(input: &str) -> Result<T, String> { - T::from_str(input).map_err(|_| format!("'{}' is an invalid value", input)) -} - enum ZipWrapper<R: Read + Seek> { Raw(Option<R>), Zipped(zip::ZipArchive<R>), @@ -219,7 +193,42 @@ impl<R: Read + Seek> ZipWrapper<R> { } } +#[derive(Clone, Debug)] +struct InputError { + line: String, + location: usize, + msg: String, +} + +impl fmt::Display for InputError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let prefix = "Input:"; + writeln!(f, "{} {}", prefix.yellow(), self.line)?; + let prefix_len = prefix.len() + self.location; + writeln!(f, "{}{}", " ".repeat(prefix_len), " ^-".red())?; + write!(f, "{}: {}", "Error".red(), self.msg)?; + Ok(()) + } +} + +impl std::error::Error for InputError {} + fn main() { + let result = run(); + if let Err(err) = result { + display_error(&err); + } +} + +fn display_error(err: &Error) { + if let Some(err) = err.downcast_ref::<InputError>() { + eprintln!("{}", err); + } else { + eprintln!("{}: {}", "Error".red(), err); + } +} + +fn run() -> Result<()> { let opt = Opt::from_args(); if opt.no_color { @@ -236,17 +245,64 @@ fn main() { guilds::prepare_cache(); } - let result = grep(&opt); - match result { - Ok(_) => {} - Err(e) => { - eprintln!("Error: {}", e); - } + if !opt.repl { + single(&opt)?; + } else { + repl(&opt)?; } if opt.guilds { guilds::save_cache(); } + + Ok(()) +} + +fn single(opt: &Opt) -> Result<()> { + // As a shortcut, we allow only the regular expression to be given, to retain the behaviour + // before the filter changes. + if opt.expression.len() == 1 { + let line = &opt.expression[0]; + let maybe_filter = build_filter(line); + if maybe_filter.is_err() && !line.starts_with('-') { + let maybe_regex = Regex::new(line); + if let Ok(rgx) = maybe_regex { + let filter = filters::player::any( + filters::player::account(rgx.clone()) | filters::player::character(rgx), + ); + return grep(opt, &*filter); + } + } + return grep(opt, &*maybe_filter?); + } + + let expr_string = opt + .expression + .iter() + .map(|part| { + if part.contains(' ') { + format!(r#""{}""#, part) + } else { + part.into() + } + }) + .join(" "); + let filter = build_filter(&expr_string)?; + grep(&opt, &*filter)?; + Ok(()) +} + +fn repl(opt: &Opt) -> Result<()> { + let mut rl = Editor::<()>::new(); + loop { + let line = rl.readline("Query> ")?; + rl.add_history_entry(&line); + let parsed = build_filter(&line); + match parsed { + Ok(filter) => grep(&opt, &*filter)?, + Err(err) => display_error(&err), + } + } } /// Check if the given entry represents a log file, based on the file name. @@ -258,8 +314,22 @@ fn is_log_file(entry: &DirEntry) -> bool { .unwrap_or(false) } +/// Small wrapper around `fexpr::parse_logfilter` to convert the returned `Err` to be `'static'. +fn build_filter(expr_string: &str) -> Result<Box<dyn LogFilter>> { + if expr_string.trim().is_empty() { + return Err(anyhow!("Expected a filter to be given")); + } + Ok( + fexpr::parse_logfilter(expr_string).map_err(|error| InputError { + line: expr_string.to_string(), + location: fexpr::location(&error), + msg: error.to_string(), + })?, + ) +} + /// Run the grep search with the given options. -fn grep(opt: &Opt) -> Result<()> { +fn grep(opt: &Opt, filter: &dyn LogFilter) -> Result<()> { let pipeline = &output::build_pipeline(opt); rayon::scope(|s| { let walker = WalkDir::new(&opt.path); @@ -267,7 +337,7 @@ fn grep(opt: &Opt) -> Result<()> { let entry = entry?; s.spawn(move |_| { if is_log_file(&entry) { - let search = search_log(&entry, opt); + let search = search_log(&entry, filter); match search { Ok(None) => (), Ok(Some(result)) => pipeline.push_item(&result), @@ -287,7 +357,7 @@ fn grep(opt: &Opt) -> Result<()> { /// If the log matches, returns `Ok(Some(..))`. /// If the log doesn't match, returns `Ok(None)`. /// If there was a fatal error, returns `Err(..)`. -fn search_log(entry: &DirEntry, opt: &Opt) -> Result<Option<LogResult>> { +fn search_log(entry: &DirEntry, filter: &dyn LogFilter) -> Result<Option<LogResult>> { let file_stream = BufReader::new(File::open(entry.path())?); let is_zip = entry .file_name() @@ -302,10 +372,9 @@ fn search_log(entry: &DirEntry, opt: &Opt) -> Result<Option<LogResult>> { let mut stream = wrapper.get_stream(); let partial = evtclib::raw::parser::parse_partial_file(&mut stream)?; - let early_ok = - filters::filter_name(&partial, opt) != opt.invert && filters::filter_boss(&partial, opt); + let early_ok = filter.filter_early(&partial); - if !early_ok { + if early_ok == Inclusion::Exclude { return Ok(None); } @@ -320,10 +389,7 @@ fn search_log(entry: &DirEntry, opt: &Opt) -> Result<Option<LogResult>> { let info = extract_info(entry, &log); - let take_log = filters::filter_outcome(&info, opt) - && filters::filter_weekday(&info, opt) - && filters::filter_time(&info, opt) - && filters::filter_guilds(&info, opt); + let take_log = filter.filter(&info); if take_log { Ok(Some(info)) @@ -372,6 +438,7 @@ fn extract_info(entry: &DirEntry, log: &Log) -> LogResult { LogResult { log_file: entry.path().to_path_buf(), time: NaiveDateTime::from_timestamp(i64::from(get_start_timestamp(log)), 0), + boss_id: log.boss_id(), boss_name, players, outcome: get_fight_outcome(log), diff --git a/src/output/aggregators.rs b/src/output/aggregators.rs index 5d0429c..1b04af3 100644 --- a/src/output/aggregators.rs +++ b/src/output/aggregators.rs @@ -15,12 +15,10 @@ pub trait Aggregator: Sync { fn finish(self, format: &dyn Format, stream: &mut dyn Write); } - /// An aggregator that just pushes through each item to the output stream without any sorting or /// whatsoever. pub struct WriteThrough; - impl Aggregator for WriteThrough { fn push_item(&self, item: &LogResult, format: &dyn Format, stream: &mut dyn Write) { let text = format.format_result(item); diff --git a/src/output/formats.rs b/src/output/formats.rs index b697401..a608eab 100644 --- a/src/output/formats.rs +++ b/src/output/formats.rs @@ -1,8 +1,8 @@ //! A crate defining different output formats for search results. use std::fmt::Write; -use super::{LogResult, FightOutcome}; use super::super::guilds; +use super::{FightOutcome, LogResult}; /// An output format pub trait Format: Sync + Send { @@ -10,14 +10,12 @@ pub trait Format: Sync + Send { fn format_result(&self, item: &LogResult) -> String; } - /// The human readable, colored format. #[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)] pub struct HumanReadable { pub show_guilds: bool, } - impl Format for HumanReadable { fn format_result(&self, item: &LogResult) -> String { use colored::Colorize; @@ -36,7 +34,8 @@ impl Format for HumanReadable { "Boss".green(), item.boss_name, outcome, - ).unwrap(); + ) + .unwrap(); for player in &item.players { write!( result, @@ -45,7 +44,8 @@ impl Format for HumanReadable { player.account_name.yellow(), player.character_name.cyan(), player.profession, - ).unwrap(); + ) + .unwrap(); if self.show_guilds { let guild = player.guild_id.as_ref().and_then(|id| guilds::lookup(id)); if let Some(guild) = guild { @@ -54,7 +54,8 @@ impl Format for HumanReadable { " [{}] {}", guild.tag().magenta(), guild.name().magenta(), - ).unwrap(); + ) + .unwrap(); } } writeln!(result).unwrap(); @@ -63,12 +64,10 @@ impl Format for HumanReadable { } } - /// A format which outputs only the file-name #[derive(Copy, Clone, Debug, Hash, PartialEq, Eq)] pub struct FileOnly; - impl Format for FileOnly { fn format_result(&self, item: &LogResult) -> String { let filename = item.log_file.to_string_lossy(); diff --git a/src/output/mod.rs b/src/output/mod.rs index aadcbf9..0fd92d9 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -2,8 +2,8 @@ use super::{FightOutcome, LogResult, Opt}; use std::io; -pub mod formats; pub mod aggregators; +pub mod formats; pub mod pipeline; pub use self::pipeline::Pipeline; |