use std::ops::RangeInclusive;
use crate::base_stats::{self, StatKey};
use crate::i18n;
use crate::models::{PokemonEntry, RosterKind, RosterTerm};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct Query {
pub text: String,
pub text_dex: Option<u32>,
pub rosters: Vec<RosterTerm>,
pub generations: Vec<u8>,
pub dex: Vec<RangeInclusive<u32>>,
pub favourites: bool,
pub stats: Vec<StatTerm>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct StatTerm {
pub key: StatKey,
pub op: Comparison,
pub value: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Comparison {
Less,
LessOrEqual,
Equal,
GreaterOrEqual,
Greater,
}
impl Comparison {
fn holds(self, left: u16, right: u16) -> bool {
match self {
Comparison::Less => left < right,
Comparison::LessOrEqual => left <= right,
Comparison::Equal => left == right,
Comparison::GreaterOrEqual => left >= right,
Comparison::Greater => left > right,
}
}
}
impl StatTerm {
pub fn matches(&self, name: &str) -> bool {
base_stats::lookup(name).is_some_and(|stats| self.op.holds(stats.get(self.key), self.value))
}
}
impl Query {
pub fn parse(raw: &str) -> Self {
let mut query = Query::default();
let mut words: Vec<&str> = Vec::new();
for token in raw.split_whitespace() {
match parse_stat_term(token) {
StatToken::Term(term) => {
if !query.stats.contains(&term) {
query.stats.push(term);
}
continue;
}
StatToken::Unfinished => continue,
StatToken::NotStat => {}
}
match token.split_once(':') {
Some(("type" | "t", value)) => {
let slug = i18n::type_slug(value).unwrap_or(value);
query.push_roster(RosterKind::Type, slug);
}
Some(("ability" | "a", value)) => {
query.push_roster(RosterKind::Ability, value);
}
Some(("egg" | "e", value)) => {
query.push_roster(RosterKind::EggGroup, &egg_group_slug(value));
}
Some(("gen" | "g", value)) => {
if let Ok(gen) = value.parse::<u8>() {
query.generations.push(gen);
}
}
Some(("dex" | "d", value)) => {
if let Some(range) = parse_dex_range(value) {
query.dex.push(range);
}
}
Some(("fav" | "f", _)) => query.favourites = true,
_ => words.push(token),
}
}
query.text = words.join(" ").to_lowercase();
query.text_dex = query.text.parse().ok();
query
}
fn push_roster(&mut self, kind: RosterKind, value: &str) {
if value.is_empty() {
return;
}
let term = RosterTerm::new(kind, value.to_lowercase());
if !self.rosters.contains(&term) {
self.rosters.push(term);
}
}
pub fn matches_entry(&self, entry: &PokemonEntry) -> bool {
let dex = entry.dex_number();
if !self.text.is_empty() {
let by_name = entry.name.to_lowercase().contains(&self.text);
let by_number = self.text_dex.is_some_and(|number| dex == Some(number));
if !by_name && !by_number {
return false;
}
}
if !self.dex.is_empty() {
let Some(dex) = dex else {
return false;
};
if !self.dex.iter().any(|range| range.contains(&dex)) {
return false;
}
}
if !self.generations.is_empty() {
let Some(gen) = entry.generation() else {
return false;
};
if !self.generations.contains(&gen) {
return false;
}
}
self.stats.iter().all(|term| term.matches(&entry.name))
}
}
#[derive(Debug, PartialEq, Eq)]
enum StatToken {
Term(StatTerm),
Unfinished,
NotStat,
}
fn parse_stat_term(token: &str) -> StatToken {
let Some(at) = token.find(['<', '>', '=']) else {
return StatToken::NotStat;
};
let (key, rest) = token.split_at(at);
let Some(key) = StatKey::parse(key) else {
return StatToken::NotStat;
};
let (op, value) = if let Some(value) = rest.strip_prefix(">=") {
(Comparison::GreaterOrEqual, value)
} else if let Some(value) = rest.strip_prefix("<=") {
(Comparison::LessOrEqual, value)
} else if let Some(value) = rest.strip_prefix('>') {
(Comparison::Greater, value)
} else if let Some(value) = rest.strip_prefix('<') {
(Comparison::Less, value)
} else {
(Comparison::Equal, &rest[1..])
};
match value.parse() {
Ok(value) => StatToken::Term(StatTerm { key, op, value }),
Err(_) => StatToken::Unfinished,
}
}
fn egg_group_slug(value: &str) -> String {
let lowered = value.to_lowercase();
match lowered.as_str() {
"grass" => "plant".to_string(),
"field" => "ground".to_string(),
"human-like" | "humanlike" | "human" => "humanshape".to_string(),
"amorphous" => "indeterminate".to_string(),
"water-1" => "water1".to_string(),
"water-2" => "water2".to_string(),
"water-3" => "water3".to_string(),
_ => lowered,
}
}
fn parse_dex_range(value: &str) -> Option<RangeInclusive<u32>> {
match value.split_once('-') {
Some((start, end)) => {
let start = start.parse::<u32>().ok()?;
let end = end.parse::<u32>().ok()?;
Some(start.min(end)..=start.max(end))
}
None => {
let number = value.parse::<u32>().ok()?;
Some(number..=number)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn one_range(range: RangeInclusive<u32>) -> Vec<RangeInclusive<u32>> {
vec![range]
}
fn ghost() -> RosterTerm {
RosterTerm::new(RosterKind::Type, "ghost")
}
fn entry(name: &str, id: u32) -> PokemonEntry {
PokemonEntry {
name: name.to_string(),
id,
}
}
#[test]
fn plain_text_is_lowercased_and_joined() {
assert_eq!(Query::parse(" MR Mime ").text, "mr mime");
}
#[test]
fn terms_are_lifted_out_of_the_text() {
let query = Query::parse("gen:1 type:Ghost ga");
assert_eq!(query.text, "ga");
assert_eq!(query.rosters, [ghost()]);
assert_eq!(query.generations, [1]);
}
#[test]
fn short_forms_are_accepted() {
let query = Query::parse("t:fire g:2 d:25");
assert_eq!(query.rosters, [RosterTerm::new(RosterKind::Type, "fire")]);
assert_eq!(query.generations, [2]);
assert_eq!(query.dex, one_range(25..=25));
}
#[test]
fn repeated_terms_accumulate() {
let query = Query::parse("type:water type:flying gen:1 gen:2");
assert_eq!(
query.rosters,
[
RosterTerm::new(RosterKind::Type, "water"),
RosterTerm::new(RosterKind::Type, "flying"),
]
);
assert_eq!(query.generations, [1, 2]);
}
#[test]
fn unrecognised_terms_stay_searchable_text() {
let query = Query::parse("colour:red");
assert_eq!(query.text, "colour:red");
assert!(query.rosters.is_empty());
}
#[test]
fn abilities_and_egg_groups_are_terms_of_their_own() {
let query = Query::parse("ability:Levitate egg:dragon");
assert_eq!(
query.rosters,
[
RosterTerm::new(RosterKind::Ability, "levitate"),
RosterTerm::new(RosterKind::EggGroup, "dragon"),
]
);
assert!(query.text.is_empty());
}
#[test]
fn ability_and_egg_have_short_forms_too() {
let query = Query::parse("a:levitate e:dragon");
assert_eq!(
query.rosters,
[
RosterTerm::new(RosterKind::Ability, "levitate"),
RosterTerm::new(RosterKind::EggGroup, "dragon"),
]
);
}
#[test]
fn egg_groups_accept_their_in_game_names() {
let slug = |raw: &str| Query::parse(raw).rosters[0].value.clone();
assert_eq!(slug("egg:grass"), "plant");
assert_eq!(slug("egg:Field"), "ground");
assert_eq!(slug("egg:human-like"), "humanshape");
assert_eq!(slug("egg:amorphous"), "indeterminate");
assert_eq!(slug("egg:water-1"), "water1");
assert_eq!(slug("egg:monster"), "monster");
}
#[test]
fn a_type_answers_to_its_name_in_any_interface_language() {
let slug = |raw: &str| Query::parse(raw).rosters[0].value.clone();
assert_eq!(slug("type:geist"), "ghost");
assert_eq!(slug("t:Hayalet"), "ghost");
assert_eq!(slug("type:ténèbres"), "dark");
assert_eq!(slug("type:tenebres"), "dark");
assert_eq!(slug("type:stellar"), "stellar");
}
#[test]
fn a_type_named_two_ways_is_recorded_once() {
assert_eq!(Query::parse("type:ghost type:geist").rosters, [ghost()]);
}
#[test]
fn a_repeated_roster_term_is_recorded_once() {
assert_eq!(Query::parse("type:ghost t:Ghost").rosters, [ghost()]);
}
#[test]
fn a_half_typed_roster_term_filters_nothing_extra() {
assert_eq!(Query::parse("type:"), Query::default());
assert_eq!(Query::parse("ability:"), Query::default());
assert_eq!(Query::parse("egg:"), Query::default());
}
#[test]
fn a_half_typed_generation_filters_nothing_extra() {
assert_eq!(Query::parse("gen:"), Query::default());
}
#[test]
fn a_half_typed_dex_range_filters_nothing_extra() {
assert_eq!(Query::parse("dex:"), Query::default());
assert_eq!(Query::parse("dex:1-"), Query::default());
assert_eq!(Query::parse("dex:eevee"), Query::default());
}
#[test]
fn a_dex_range_is_read_in_either_direction() {
assert_eq!(Query::parse("dex:1-151").dex, one_range(1..=151));
assert_eq!(Query::parse("dex:151-1").dex, one_range(1..=151));
}
#[test]
fn an_empty_query_matches_everything() {
let query = Query::parse(" ");
assert_eq!(query, Query::default());
assert!(query.matches_entry(&entry("bulbasaur", 1)));
assert!(query.matches_entry(&entry("raichu-alola", 10100)));
}
#[test]
fn text_matches_anywhere_in_the_name() {
let query = Query::parse("saur");
assert!(query.matches_entry(&entry("bulbasaur", 1)));
assert!(!query.matches_entry(&entry("pikachu", 25)));
}
#[test]
fn generations_are_matched_as_alternatives() {
let query = Query::parse("gen:1 gen:3");
assert!(query.matches_entry(&entry("bulbasaur", 1)));
assert!(query.matches_entry(&entry("treecko", 252)));
assert!(!query.matches_entry(&entry("chikorita", 152)));
}
#[test]
fn a_generation_filter_excludes_alternate_forms() {
let query = Query::parse("gen:7");
assert!(!query.matches_entry(&entry("raichu-alola", 10100)));
}
#[test]
fn a_bare_number_matches_by_dex_number() {
let query = Query::parse("25");
assert!(query.matches_entry(&entry("pikachu", 25)));
assert!(!query.matches_entry(&entry("bulbasaur", 1)));
}
#[test]
fn a_bare_number_still_matches_a_name_that_contains_it() {
let query = Query::parse("2");
assert!(query.matches_entry(&entry("porygon2", 233)));
assert!(query.matches_entry(&entry("ivysaur", 2)));
assert!(!query.matches_entry(&entry("pikachu", 25)));
}
#[test]
fn a_dex_term_matches_by_number_alone() {
let query = Query::parse("dex:2");
assert!(query.matches_entry(&entry("ivysaur", 2)));
assert!(!query.matches_entry(&entry("porygon2", 233)));
}
#[test]
fn a_dex_range_matches_its_ends() {
let query = Query::parse("dex:1-151");
assert!(query.matches_entry(&entry("bulbasaur", 1)));
assert!(query.matches_entry(&entry("mew", 151)));
assert!(!query.matches_entry(&entry("chikorita", 152)));
}
#[test]
fn dex_terms_are_matched_as_alternatives() {
let query = Query::parse("dex:1-3 dex:25");
assert!(query.matches_entry(&entry("ivysaur", 2)));
assert!(query.matches_entry(&entry("pikachu", 25)));
assert!(!query.matches_entry(&entry("mew", 151)));
}
#[test]
fn a_dex_filter_excludes_alternate_forms() {
let query = Query::parse("dex:26");
assert!(!query.matches_entry(&entry("raichu-alola", 10100)));
}
#[test]
fn dex_and_name_terms_combine() {
let query = Query::parse("dex:1-151 saur");
assert!(query.matches_entry(&entry("bulbasaur", 1)));
assert!(!query.matches_entry(&entry("pikachu", 25)));
}
#[test]
fn a_fav_term_asks_for_favourites_whatever_follows_the_colon() {
for raw in ["fav:", "f:", "fav:yes", "FAV:"] {
let query = Query::parse(&raw.to_lowercase());
assert!(query.favourites, "{raw} should ask for favourites");
assert_eq!(query.text, "", "{raw} is not a name to search for");
}
assert!(!Query::parse("favourite").favourites, "no colon, no term");
}
#[test]
fn a_fav_term_leaves_the_other_terms_in_place() {
let query = Query::parse("fav: gen:1 type:ghost ga");
assert!(query.favourites);
assert_eq!(query.generations, [1]);
assert_eq!(query.rosters.len(), 1);
assert_eq!(query.text, "ga");
}
fn stat(key: StatKey, op: Comparison, value: u16) -> StatTerm {
StatTerm { key, op, value }
}
#[test]
fn every_comparison_operator_parses() {
use Comparison::*;
for (raw, op) in [
("spe<50", Less),
("spe<=50", LessOrEqual),
("spe=50", Equal),
("spe>=50", GreaterOrEqual),
("spe>50", Greater),
] {
let query = Query::parse(raw);
assert_eq!(query.stats, [stat(StatKey::Speed, op, 50)], "{raw}");
assert_eq!(query.text, "", "{raw} is not a name to search for");
}
}
#[test]
fn stat_terms_combine_with_each_other_and_with_the_rest() {
let query = Query::parse("hp>100 DEF>=100 type:water gen:3 wal");
assert_eq!(
query.stats,
[
stat(StatKey::Hp, Comparison::Greater, 100),
stat(StatKey::Defense, Comparison::GreaterOrEqual, 100),
]
);
assert_eq!(query.rosters.len(), 1);
assert_eq!(query.generations, [3]);
assert_eq!(query.text, "wal");
}
#[test]
fn a_half_typed_stat_term_is_dropped_rather_than_searched_for() {
for raw in ["spe>", "spe>=", "bst=", "hp>abc", "atk<-5"] {
let query = Query::parse(raw);
assert!(query.stats.is_empty(), "{raw}");
assert_eq!(query.text, "", "{raw}");
}
}
#[test]
fn a_stat_key_with_no_operator_is_a_name_fragment() {
let query = Query::parse("spe");
assert!(query.stats.is_empty());
assert_eq!(query.text, "spe");
assert_eq!(Query::parse("luck>5").text, "luck>5");
}
#[test]
fn a_repeated_stat_term_is_recorded_once() {
assert_eq!(Query::parse("spe>100 speed>100").stats.len(), 1);
}
#[test]
fn stat_terms_read_the_built_in_table() {
let query = Query::parse("spe>=150");
assert!(query.matches_entry(&entry("ninjask", 291)));
assert!(!query.matches_entry(&entry("shuckle", 213)));
let bulky = Query::parse("def>=200 spd>=200");
assert!(bulky.matches_entry(&entry("shuckle", 213)));
assert!(!bulky.matches_entry(&entry("blissey", 242)));
}
#[test]
fn stat_terms_reach_alternate_forms() {
let query = Query::parse("spa>=95");
assert!(query.matches_entry(&entry("raichu-alola", 10100)));
assert!(!query.matches_entry(&entry("raichu", 26)));
}
#[test]
fn an_entry_missing_from_the_table_matches_no_stat_term() {
assert!(!Query::parse("bst>0").matches_entry(&entry("missingno", 0)));
assert!(Query::parse("").matches_entry(&entry("missingno", 0)));
}
}