aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/csl.rs112
-rw-r--r--src/fexpr/grammar.lalrpop171
-rw-r--r--src/fexpr/mod.rs66
-rw-r--r--src/filters.rs79
-rw-r--r--src/filters/log.rs130
-rw-r--r--src/filters/mod.rs229
-rw-r--r--src/filters/player.rs119
-rw-r--r--src/main.rs249
-rw-r--r--src/output/aggregators.rs2
-rw-r--r--src/output/formats.rs15
-rw-r--r--src/output/mod.rs2
11 files changed, 881 insertions, 293 deletions
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;