diff --git a/src/lib.rs b/src/lib.rs index 0b95a1f..7d8770b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ //! The other thing you need is a random number generator from [rand][]: //! //! ```rust +//! # use petname::Generator; //! # #[cfg(feature = "default-rng")] //! let mut rng = rand::thread_rng(); //! # #[cfg(all(feature = "default-rng", feature = "default-words"))] @@ -18,6 +19,7 @@ //! It may be more convenient to use the default random number generator: //! //! ```rust +//! # use petname::Generator; //! # #[cfg(all(feature = "default-rng", feature = "default-words"))] //! let pname = petname::Petnames::default().generate_one(7, ":"); //! ``` @@ -33,6 +35,7 @@ //! [`iter`][`Petnames::iter`]: //! //! ```rust +//! # use petname::Generator; //! # #[cfg(feature = "default-rng")] //! let mut rng = rand::thread_rng(); //! # #[cfg(feature = "default-words")] @@ -46,6 +49,7 @@ //! the letter "b": //! //! ```rust +//! # use petname::Generator; //! # #[cfg(feature = "default-words")] //! let mut petnames = petname::Petnames::default(); //! # #[cfg(feature = "default-words")] @@ -59,23 +63,98 @@ extern crate alloc; use alloc::{ borrow::Cow, + boxed::Box, + collections::BTreeMap, string::{String, ToString}, + vec::Vec, }; use itertools::Itertools; -use rand::seq::SliceRandom; +use rand::seq::{IteratorRandom, SliceRandom}; /// Convenience function to generate a new petname from default word lists. #[allow(dead_code)] -#[cfg(feature = "default-rng")] -#[cfg(feature = "default-words")] -pub fn petname(words: u8, separator: &str) -> String { +#[cfg(all(feature = "default-rng", feature = "default-words"))] +pub fn petname(words: u8, separator: &str) -> Option { Petnames::default().generate_one(words, separator) } /// A word list. pub type Words<'a> = Cow<'a, [&'a str]>; +#[cfg(feature = "default-words")] +mod words { + include!(concat!(env!("OUT_DIR"), "/words.rs")); +} + +/// Trait that defines a generator of petnames. +/// +/// There are default implementations of `generate_one` and `iter`, i.e. only +/// `generate` needs to be implemented. +/// +pub trait Generator<'a> { + /// Generate a new petname. + /// + /// # Examples + /// + /// ```rust + /// # use petname::Generator; + /// # #[cfg(all(feature = "default-rng", feature = "default-words"))] + /// let mut rng = rand::thread_rng(); + /// # #[cfg(all(feature = "default-rng", feature = "default-words"))] + /// petname::Petnames::default().generate(&mut rng, 7, ":"); + /// ``` + /// + /// # Notes + /// + /// This may return fewer words than you request if one or more of the word + /// lists are empty. For example, if there are no adverbs, requesting 3 or + /// more words may still yield only "doubtful-salmon". + /// + fn generate(&self, rng: &mut RNG, words: u8, separator: &str) -> Option + where + RNG: rand::Rng; + + /// Generate a single new petname. + /// + /// This is like `generate` but uses `rand::thread_rng` as the random + /// source. For efficiency use `generate` when creating multiple names, or + /// when you want to use a custom source of randomness. + #[cfg(feature = "default-rng")] + fn generate_one(&self, words: u8, separator: &str) -> Option { + self.generate(&mut rand::thread_rng(), words, separator) + } + + /// Iterator yielding petnames. + /// + /// # Examples + /// + /// ```rust + /// # use petname::Generator; + /// # #[cfg(all(feature = "default-rng", feature = "default-words"))] + /// let mut rng = rand::thread_rng(); + /// # #[cfg(all(feature = "default-rng", feature = "default-words"))] + /// let petnames = petname::Petnames::default(); + /// # #[cfg(all(feature = "default-rng", feature = "default-words"))] + /// let mut iter = petnames.iter(&mut rng, 4, "_"); + /// # #[cfg(all(feature = "default-rng", feature = "default-words"))] + /// println!("name: {}", iter.next().unwrap()); + /// ``` + fn iter( + &'a self, + rng: &'a mut RNG, + words: u8, + separator: &str, + ) -> Box + 'a> + where + RNG: rand::Rng, + Self: Sized, + { + let names = Names { generator: self, rng, words, separator: separator.to_string() }; + Box::new(names) + } +} + /// Word lists and the logic to combine them into _petnames_. /// /// A _petname_ with `n` words will contain, in order: @@ -91,11 +170,6 @@ pub struct Petnames<'a> { pub nouns: Words<'a>, } -#[cfg(feature = "default-words")] -mod words { - include!(concat!(env!("OUT_DIR"), "/words.rs")); -} - impl<'a> Petnames<'a> { /// Constructs a new `Petnames` from the small word lists. #[cfg(feature = "default-words")] @@ -143,6 +217,7 @@ impl<'a> Petnames<'a> { /// # Examples /// /// ```rust + /// # use petname::Generator; /// # #[cfg(feature = "default-words")] /// let mut petnames = petname::Petnames::default(); /// # #[cfg(feature = "default-words")] @@ -167,8 +242,7 @@ impl<'a> Petnames<'a> { /// Calculate the cardinality of this `Petnames`. /// /// If this is low, names may be repeated by the generator with a higher - /// frequency than your use-case may allow. If it is 0 (zero) the generator - /// will panic (unless `words` is also zero). + /// frequency than your use-case may allow. /// /// This can saturate. If the total possible combinations of words exceeds /// `u128::MAX` then this will return `u128::MAX`. @@ -182,29 +256,14 @@ impl<'a> Petnames<'a> { .reduce(u128::saturating_mul) .unwrap_or(0u128) } +} - /// Generate a new petname. - /// - /// # Examples - /// - /// ```rust - /// # #[cfg(all(feature = "default-rng", feature = "default-words"))] - /// let mut rng = rand::thread_rng(); - /// # #[cfg(all(feature = "default-rng", feature = "default-words"))] - /// petname::Petnames::default().generate(&mut rng, 7, ":"); - /// ``` - /// - /// # Notes - /// - /// This may return fewer words than you request if one or more of the word - /// lists are empty. For example, if there are no adverbs, requesting 3 or - /// more words may still yield only "doubtful-salmon". - /// - pub fn generate(&self, rng: &mut RNG, words: u8, separator: &str) -> String +impl<'a> Generator<'a> for Petnames<'a> { + fn generate(&self, rng: &mut RNG, words: u8, separator: &str) -> Option where RNG: rand::Rng, { - Itertools::intersperse( + let name = Itertools::intersperse( Lists::new(words).filter_map(|list| match list { List::Adverb => self.adverbs.choose(rng).copied(), List::Adjective => self.adjectives.choose(rng).copied(), @@ -212,52 +271,119 @@ impl<'a> Petnames<'a> { }), separator, ) - .collect::() + .collect::(); + if name.is_empty() { + None + } else { + Some(name) + } } +} - /// Generate a single new petname. +#[cfg(feature = "default-words")] +impl<'a> Default for Petnames<'a> { + /// Constructs a new `Petnames` from the default (small) word lists. + fn default() -> Self { + Self::small() + } +} + +/// Word lists prepared for alliteration. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Alliterations<'a> { + pub groups: BTreeMap>, +} + +impl<'a> Alliterations<'a> { + /// Keep only those groups that match a predicate. + pub fn retain(&mut self, predicate: F) + where + F: FnMut(&char, &mut Petnames) -> bool, + { + self.groups.retain(predicate) + } + + /// Calculate the cardinality of this `Alliterations`. /// - /// This is like `generate` but uses `rand::thread_rng` as the random - /// source. For efficiency use `generate` when creating multiple names, or - /// when you want to use a custom source of randomness. - #[cfg(feature = "default-rng")] - pub fn generate_one(&self, words: u8, separator: &str) -> String { - self.generate(&mut rand::thread_rng(), words, separator) + /// This is the sum of the cardinality of all groups. + /// + /// This can saturate. If the total possible combinations of words exceeds + /// `u128::MAX` then this will return `u128::MAX`. + pub fn cardinality(&self, words: u8) -> u128 { + self.groups + .values() + .map(|petnames| petnames.cardinality(words)) + .reduce(u128::saturating_add) + .unwrap_or(0u128) } +} - /// Iterator yielding petnames. +impl<'a> From> for Alliterations<'a> { + fn from(petnames: Petnames<'a>) -> Self { + let mut adjectives: BTreeMap> = group_words_by_first_letter(petnames.adjectives); + let mut adverbs: BTreeMap> = group_words_by_first_letter(petnames.adverbs); + let nouns: BTreeMap> = group_words_by_first_letter(petnames.nouns); + // We find all adjectives and adverbs that start with the same letter as + // each group of nouns. We start from nouns because it's possible to + // have a petname with length of 1, i.e. a noun. This means that it's + // okay at this point for the adjectives and adverbs lists to be empty. + Alliterations { + groups: nouns.into_iter().fold(BTreeMap::default(), |mut acc, (first_letter, nouns)| { + acc.insert( + first_letter, + Petnames { + adjectives: adjectives.remove(&first_letter).unwrap_or_default().into(), + adverbs: adverbs.remove(&first_letter).unwrap_or_default().into(), + nouns: Cow::from(nouns), + }, + ); + acc + }), + } + } +} + +fn group_words_by_first_letter(words: Words) -> BTreeMap> { + words.iter().fold(BTreeMap::default(), |mut acc, s| match s.chars().next() { + Some(first_letter) => { + acc.entry(first_letter).or_default().push(s); + acc + } + None => acc, + }) +} + +impl<'a> Generator<'a> for Alliterations<'a> { + /// Generate a new petname. /// /// # Examples /// /// ```rust + /// # use petname::Generator; /// # #[cfg(all(feature = "default-rng", feature = "default-words"))] /// let mut rng = rand::thread_rng(); /// # #[cfg(all(feature = "default-rng", feature = "default-words"))] - /// let petnames = petname::Petnames::default(); - /// # #[cfg(all(feature = "default-rng", feature = "default-words"))] - /// let mut iter = petnames.iter(&mut rng, 4, "_"); - /// # #[cfg(all(feature = "default-rng", feature = "default-words"))] - /// println!("name: {}", iter.next().unwrap()); + /// petname::Petnames::default().generate(&mut rng, 7, ":"); /// ``` /// - pub fn iter( - &'a self, - rng: &'a mut RNG, - words: u8, - separator: &str, - ) -> impl Iterator + 'a + /// # Notes + /// + /// This may return fewer words than you request if one or more of the word + /// lists are empty. For example, if there are no adverbs, requesting 3 or + /// more words may still yield only "doubtful-salmon". + /// + fn generate(&self, rng: &mut RNG, words: u8, separator: &str) -> Option where RNG: rand::Rng, { - Names { petnames: self, rng, words, separator: separator.to_string() } + self.groups.values().choose(rng).and_then(|group| group.generate(rng, words, separator)) } } #[cfg(feature = "default-words")] -impl<'a> Default for Petnames<'a> { - /// Constructs a new `Petnames` from the default (small) word lists. +impl<'a> Default for Alliterations<'a> { fn default() -> Self { - Self::small() + Petnames::default().into() } } @@ -338,24 +464,26 @@ impl Iterator for Lists { } /// Iterator yielding petnames. -struct Names<'a, RNG> +struct Names<'a, RNG, GENERATOR> where RNG: rand::Rng, + GENERATOR: Generator<'a>, { - petnames: &'a Petnames<'a>, + generator: &'a GENERATOR, rng: &'a mut RNG, words: u8, separator: String, } -impl<'a, RNG> Iterator for Names<'a, RNG> +impl<'a, RNG, GENERATOR> Iterator for Names<'a, RNG, GENERATOR> where RNG: rand::Rng, + GENERATOR: Generator<'a>, { type Item = String; fn next(&mut self) -> Option { - Some(self.petnames.generate(self.rng, self.words, &self.separator)) + self.generator.generate(self.rng, self.words, &self.separator) } } diff --git a/src/main.rs b/src/main.rs index bc73d8a..fc9e1a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,9 @@ mod cli; use cli::Cli; -use petname::Petnames; +use petname::Alliterations; +use petname::{Generator, Petnames}; -use std::collections::HashSet; use std::fmt; use std::fs; use std::io; @@ -11,7 +11,7 @@ use std::path; use std::process; use clap::Parser; -use rand::{seq::IteratorRandom, SeedableRng}; +use rand::SeedableRng; fn main() { let cli = Cli::parse(); @@ -84,37 +84,6 @@ fn run(cli: Cli) -> Result<(), Error> { let mut rng = cli.seed.map(rand::rngs::StdRng::seed_from_u64).unwrap_or_else(rand::rngs::StdRng::from_entropy); - // Handle alliteration, either by eliminating a specified - // character, or using a random one. - let alliterate = cli.alliterate || cli.ubuntu || cli.alliterate_with.is_some(); - if alliterate { - // We choose the first letter from the intersection of the - // first letters of each word list in `petnames`. - let firsts = common_first_letters(&petnames.adjectives, &[&petnames.adverbs, &petnames.nouns]); - // if a specific character was requested for alliteration, - // attempt to use it. - if let Some(c) = cli.alliterate_with { - if firsts.contains(&c) { - petnames.retain(|s| s.starts_with(c)); - } else { - return Err(Error::Alliteration( - "no petnames begin with the chosen alliteration character".to_string(), - )); - } - } else { - // Otherwise choose the first letter at random; fails if - // there are no letters. - match firsts.iter().choose(&mut rng) { - Some(c) => petnames.retain(|s| s.starts_with(*c)), - None => { - return Err(Error::Alliteration( - "word lists have no initial letters in common".to_string(), - )) - } - }; - } - } - // Manage stdout. let stdout = io::stdout(); let mut writer = io::BufWriter::new(stdout.lock()); @@ -122,8 +91,28 @@ fn run(cli: Cli) -> Result<(), Error> { // Stream, or print a limited number of words? let count = if cli.stream { None } else { Some(cli.count) }; - // Get an iterator for the names we want to print out. - printer(&mut writer, petnames.iter(&mut rng, cli.words, &cli.separator), count) + // Get an iterator for the names we want to print out, handling alliteration. + if cli.alliterate || cli.ubuntu { + let mut alliterations: Alliterations = petnames.into(); + alliterations.retain(|_, group| group.cardinality(cli.words) > 0); + if alliterations.cardinality(cli.words) == 0 { + return Err(Error::Alliteration("word lists have no initial letters in common".to_string())); + } + printer(&mut writer, alliterations.iter(&mut rng, cli.words, &cli.separator), count) + } else if let Some(alliterate_with) = cli.alliterate_with { + let mut alliterations: Alliterations = petnames.into(); + alliterations.retain(|first_letter, group| { + *first_letter == alliterate_with && group.cardinality(cli.words) > 0 + }); + if alliterations.cardinality(cli.words) == 0 { + return Err(Error::Alliteration( + "no petnames begin with the chosen alliteration character".to_string(), + )); + } + printer(&mut writer, alliterations.iter(&mut rng, cli.words, &cli.separator), count) + } else { + printer(&mut writer, petnames.iter(&mut rng, cli.words, &cli.separator), count) + } } fn printer(writer: &mut OUT, names: NAMES, count: Option) -> Result<(), Error> @@ -147,17 +136,6 @@ where Ok(()) } -fn common_first_letters(init: &[&str], more: &[&[&str]]) -> HashSet { - let mut firsts = first_letters(init); - let firsts_other: Vec> = more.iter().map(|list| first_letters(list)).collect(); - firsts.retain(|c| firsts_other.iter().all(|fs| fs.contains(c))); - firsts -} - -fn first_letters(names: &[&str]) -> HashSet { - names.iter().filter_map(|s| s.chars().next()).collect() -} - enum Words { Custom(String, String, String), Builtin, diff --git a/tests/alliterations.rs b/tests/alliterations.rs new file mode 100644 index 0000000..3bf31f5 --- /dev/null +++ b/tests/alliterations.rs @@ -0,0 +1,73 @@ +use std::collections::HashSet; + +use rand::rngs::mock::StepRng; + +use petname::{Alliterations, Generator, Petnames}; + +#[test] +fn alliterations_from_petnames() { + let petnames = Petnames::new("able bold", "burly curly", "ant bee cow"); + let alliterations: Alliterations = petnames.into(); + let alliterations_expected = Alliterations { + groups: [ + ('a', Petnames::new("able", "", "ant")), + ('b', Petnames::new("bold", "burly", "bee")), + ('c', Petnames::new("", "curly", "cow")), + ] + .into(), + }; + assert_eq!(alliterations_expected, alliterations); +} + +#[test] +fn alliterations_retain_applies_given_predicate() { + let petnames = Petnames::new("able bold", "burly curly", "ant bee cow"); + let mut alliterations: Alliterations = petnames.into(); + alliterations.retain(|first_letter, _petnames| *first_letter != 'b'); + let alliterations_expected = Alliterations { + groups: [('a', Petnames::new("able", "", "ant")), ('c', Petnames::new("", "curly", "cow"))].into(), + }; + assert_eq!(alliterations_expected, alliterations); +} + +#[test] +#[cfg(feature = "default-words")] +fn alliterations_default_has_non_zero_cardinality() { + let alliterations = Alliterations::default(); + // This test will need to be adjusted when word lists change. + assert_eq!(0, alliterations.cardinality(0)); + assert_eq!(456, alliterations.cardinality(1)); + assert_eq!(11416, alliterations.cardinality(2)); + assert_eq!(198753, alliterations.cardinality(3)); + assert_eq!(4262775, alliterations.cardinality(4)); +} + +#[test] +fn alliterations_generate_uses_adverb_adjective_name() { + let petnames = Petnames::new("able bold", "burly curly", "ant bee cow"); + let alliterations: Alliterations = petnames.into(); + assert_eq!( + alliterations.generate(&mut StepRng::new(1234567890, 1), 3, "-"), + Some("burly-bold-bee".into()) + ); +} + +#[test] +fn alliterations_iter_yields_names() { + let mut rng = StepRng::new(1234567890, 1234567890); + let petnames = Petnames::new("able bold", "burly curly", "ant bee cow"); + let alliterations: Alliterations = petnames.into(); + let names = alliterations.iter(&mut rng, 3, " "); + let expected: HashSet = ["able ant", "burly bold bee", "curly cow"].map(String::from).into(); + let observed: HashSet = names.take(10).collect::>(); + assert_eq!(expected, observed); +} + +#[test] +fn alliterations_iter_yields_nothing_when_empty() { + let mut rng = StepRng::new(0, 1); + let alliteration = Alliterations { groups: Default::default() }; + assert_eq!(0, alliteration.cardinality(3)); + let mut names: Box> = alliteration.iter(&mut rng, 3, "."); + assert_eq!(None, names.next()); +} diff --git a/tests/petname.rs b/tests/petname.rs new file mode 100644 index 0000000..504b532 --- /dev/null +++ b/tests/petname.rs @@ -0,0 +1,14 @@ +#[cfg(all(feature = "default-rng", feature = "default-words"))] +use petname::petname; + +#[test] +#[cfg(all(feature = "default-rng", feature = "default-words"))] +fn petname_renders_desired_number_of_words() { + assert_eq!(petname(7, "-").unwrap().split('-').count(), 7); +} + +#[test] +#[cfg(all(feature = "default-rng", feature = "default-words"))] +fn petname_renders_with_desired_separator() { + assert_eq!(petname(7, "@").unwrap().split('@').count(), 7); +} diff --git a/tests/basic.rs b/tests/petnames.rs similarity index 54% rename from tests/basic.rs rename to tests/petnames.rs index d442e4b..64b4694 100644 --- a/tests/basic.rs +++ b/tests/petnames.rs @@ -1,33 +1,29 @@ -use std::borrow::Cow; - -#[cfg(all(feature = "default-rng", feature = "default-words"))] -use petname::petname; -use petname::Petnames; +use petname::{Generator, Petnames}; use rand::rngs::mock::StepRng; #[test] #[cfg(feature = "default-words")] -fn default_petnames_has_adjectives() { +fn petnames_default_has_adjectives() { let petnames = Petnames::default(); assert_ne!(petnames.adjectives.len(), 0); } #[test] #[cfg(feature = "default-words")] -fn default_petnames_has_adverbs() { +fn petnames_default_has_adverbs() { let petnames = Petnames::default(); assert_ne!(petnames.adverbs.len(), 0); } #[test] #[cfg(feature = "default-words")] -fn default_petnames_has_names() { +fn petnames_default_has_names() { let petnames = Petnames::default(); assert_ne!(petnames.nouns.len(), 0); } #[test] -fn retain_applies_given_predicate() { +fn petnames_retain_applies_given_predicate() { let petnames_expected = Petnames::new("bob", "bob", "bob jane"); let mut petnames = Petnames::new("alice bob carol", "alice bob", "bob carol jane"); petnames.retain(|word| word.len() < 5); @@ -36,7 +32,7 @@ fn retain_applies_given_predicate() { #[test] #[cfg(feature = "default-words")] -fn default_petnames_has_non_zero_cardinality() { +fn petnames_default_has_non_zero_cardinality() { let petnames = Petnames::default(); // This test will need to be adjusted when word lists change. assert_eq!(0, petnames.cardinality(0)); @@ -47,33 +43,28 @@ fn default_petnames_has_non_zero_cardinality() { } #[test] -fn generate_uses_adverb_adjective_name() { +fn petnames_generate_uses_adverb_adjective_name() { let petnames = Petnames { - adjectives: Cow::Owned(vec!["adjective"]), - adverbs: Cow::Owned(vec!["adverb"]), - nouns: Cow::Owned(vec!["noun"]), + adjectives: vec!["adjective"].into(), + adverbs: vec!["adverb"].into(), + nouns: vec!["noun"].into(), }; - assert_eq!(petnames.generate(&mut StepRng::new(0, 1), 3, "-"), "adverb-adjective-noun"); + assert_eq!(petnames.generate(&mut StepRng::new(0, 1), 3, "-"), Some("adverb-adjective-noun".into())); } #[test] -#[cfg(all(feature = "default-rng", feature = "default-words"))] -fn petname_renders_desired_number_of_words() { - assert_eq!(petname(7, "-").split('-').count(), 7); -} - -#[test] -#[cfg(all(feature = "default-rng", feature = "default-words"))] -fn petname_renders_with_desired_separator() { - assert_eq!(petname(7, "@").split('@').count(), 7); +fn petnames_iter_yields_names() { + let mut rng = StepRng::new(0, 1); + let petnames = Petnames::new("foo", "bar", "baz"); + let mut names: Box> = petnames.iter(&mut rng, 3, "."); + assert_eq!(Some("bar.foo.baz".to_string()), names.next()); } #[test] -fn petnames_iter_yields_names() { +fn petnames_iter_yields_nothing_when_empty() { let mut rng = StepRng::new(0, 1); - let petnames = Petnames::new("foo", "bar", "baz"); - let names = petnames.iter(&mut rng, 3, "."); - // Definitely an Iterator... - let mut iter: Box> = Box::new(names); - assert_eq!(Some("bar.foo.baz".to_string()), iter.next()); + let petnames = Petnames::new("", "", ""); + assert_eq!(0, petnames.cardinality(3)); + let mut names: Box> = petnames.iter(&mut rng, 3, "."); + assert_eq!(None, names.next()); }