Skip to main content

prosaic_core/
synonyms.rs

1//! Synonym registry for elegant variation.
2//!
3//! Register groups of equivalent words (e.g. `["class", "type"]`, or
4//! `["consumer", "dependent", "caller"]`) and the `{word|syn}` template
5//! pipe will pick whichever variant was *least recently used* in the
6//! engine's word-frequency history. This cures the "feels robotic
7//! because it keeps saying the same word" effect that lingers even when
8//! templates themselves already vary.
9
10#[cfg(not(feature = "std"))]
11use alloc::string::{String, ToString};
12#[cfg(not(feature = "std"))]
13use alloc::vec::Vec;
14
15use crate::collections::HashMap;
16
17/// Registry of synonym groups. Each group is an ordered list; ties in
18/// recency are broken by registration order (first-registered wins).
19///
20/// An inverted index (`index`) maps each lowercased word to its group so
21/// that [`synonyms_for`](SynonymRegistry::synonyms_for) is O(1) rather
22/// than O(groups × group_size).
23#[derive(Debug, Clone, Default)]
24pub struct SynonymRegistry {
25    groups: Vec<Vec<String>>,
26    /// Lowercased word → index into `groups`. Populated on every
27    /// `register_group` call so lookups never scan linearly.
28    index: HashMap<String, usize>,
29}
30
31impl SynonymRegistry {
32    pub fn new() -> Self {
33        Self::default()
34    }
35
36    /// Register a new synonym group. Order of insertion is preserved and
37    /// used to break ties when multiple synonyms have equal recency.
38    pub fn register_group(&mut self, words: &[&str]) {
39        if words.is_empty() {
40            return;
41        }
42        let group_idx = self.groups.len();
43        let group: Vec<String> = words.iter().map(|w| w.to_string()).collect();
44        // Pre-lowercase into the index so lookups are allocation-free on
45        // the index side (one allocation on the input word remains).
46        for w in &group {
47            self.index.insert(w.to_lowercase(), group_idx);
48        }
49        self.groups.push(group);
50    }
51
52    /// Look up the synonym group a word belongs to.
53    ///
54    /// Matching is case-insensitive. Returns `None` when the word is not
55    /// registered in any group.
56    pub fn synonyms_for(&self, word: &str) -> Option<&[String]> {
57        let key = word.to_lowercase();
58        self.index.get(&key).map(|&i| self.groups[i].as_slice())
59    }
60
61    pub fn is_empty(&self) -> bool {
62        self.groups.is_empty()
63    }
64
65    pub fn len(&self) -> usize {
66        self.groups.len()
67    }
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn lookup_finds_registered_word() {
76        let mut r = SynonymRegistry::new();
77        r.register_group(&["class", "type"]);
78        let group = r.synonyms_for("class").unwrap();
79        assert_eq!(group, &["class".to_string(), "type".to_string()]);
80    }
81
82    #[test]
83    fn lookup_is_case_insensitive() {
84        let mut r = SynonymRegistry::new();
85        r.register_group(&["class", "type"]);
86        assert!(r.synonyms_for("Class").is_some());
87        assert!(r.synonyms_for("TYPE").is_some());
88    }
89
90    #[test]
91    fn unregistered_word_has_no_group() {
92        let r = SynonymRegistry::new();
93        assert!(r.synonyms_for("class").is_none());
94    }
95
96    #[test]
97    fn empty_group_is_ignored() {
98        let mut r = SynonymRegistry::new();
99        r.register_group(&[]);
100        assert!(r.is_empty());
101    }
102}