diff options
| -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; | 
