use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
pub const CLOSE: f64 = 0.3;
pub const VERY_CLOSE: f64 = 0.6;
pub const MAX_MATCHES: usize = 3;
pub const MAX_SHARED: usize = 4;
pub const MAX_UNITS: usize = 2000;
const STOP: &[&str] = &[
"a",
"about",
"above",
"after",
"again",
"against",
"all",
"also",
"am",
"an",
"and",
"any",
"are",
"as",
"at",
"be",
"because",
"been",
"before",
"being",
"below",
"between",
"both",
"but",
"by",
"can",
"cannot",
"could",
"did",
"do",
"does",
"doing",
"down",
"during",
"each",
"either",
"else",
"even",
"ever",
"every",
"few",
"for",
"from",
"further",
"get",
"gets",
"given",
"had",
"has",
"have",
"having",
"he",
"her",
"here",
"hers",
"him",
"his",
"how",
"if",
"in",
"into",
"is",
"it",
"its",
"itself",
"just",
"let",
"lets",
"may",
"me",
"might",
"more",
"most",
"much",
"my",
"neither",
"no",
"nor",
"not",
"now",
"of",
"off",
"on",
"once",
"one",
"only",
"or",
"other",
"our",
"ours",
"out",
"over",
"own",
"per",
"same",
"shall",
"she",
"so",
"some",
"such",
"than",
"that",
"the",
"their",
"theirs",
"them",
"then",
"there",
"these",
"they",
"this",
"those",
"through",
"to",
"too",
"under",
"until",
"up",
"upon",
"us",
"very",
"via",
"was",
"we",
"were",
"what",
"when",
"where",
"whether",
"which",
"while",
"who",
"whom",
"why",
"will",
"with",
"within",
"without",
"would",
"yet",
"you",
"your",
"yours",
"able",
"allow",
"allows",
"allowed",
"must",
"should",
"system",
"user",
"users",
"feature",
"requirement",
"new",
"want",
"wants",
"need",
"needs",
"make",
"makes",
"use",
"uses",
"using",
"way",
"ways",
"etc",
"e",
"g",
"i",
"ie",
"eg",
];
fn stem(word: &str) -> String {
let n = word.chars().count();
if n > 4
&& let Some(base) = word.strip_suffix("ies")
{
return format!("{base}y");
}
for (suffix, keep) in [("ing", 4), ("ed", 3), ("ly", 3), ("es", 3), ("s", 3)] {
if let Some(base) = word.strip_suffix(suffix)
&& base.chars().count() >= keep
&& !(suffix == "s" && base.ends_with('s'))
{
if suffix == "es"
&& !["s", "x", "z", "ch", "sh"]
.iter()
.any(|e| base.ends_with(e))
{
continue;
}
return base.to_owned();
}
}
word.to_owned()
}
#[must_use]
pub fn tokenize(text: &str) -> Vec<String> {
let normal: String = text.nfkc().collect::<String>().to_lowercase();
normal
.split(|c: char| !c.is_alphanumeric())
.filter(|w| w.chars().count() >= 2 && !STOP.contains(w))
.map(stem)
.filter(|w| w.chars().count() >= 2 && !STOP.contains(&w.as_str()))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Requirement {
pub unit: String,
pub id: String,
pub text: String,
pub at: Option<Place>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Place {
pub path: String,
pub line: u32,
pub quote: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SimilarLevel {
Close,
VeryClose,
}
impl SimilarLevel {
#[must_use]
pub const fn word(self) -> &'static str {
match self {
Self::Close => "close",
Self::VeryClose => "very close",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SimilarMatch {
pub unit: String,
pub requirement: String,
pub text: String,
pub score: f64,
pub level: SimilarLevel,
pub shared: Vec<String>,
pub at: Option<Place>,
}
fn unit_number(unit: &str) -> u64 {
unit.chars()
.take_while(char::is_ascii_digit)
.collect::<String>()
.parse()
.unwrap_or(u64::MAX)
}
type Vector = BTreeMap<String, f64>;
fn counts(terms: &[String]) -> BTreeMap<String, f64> {
let mut m = BTreeMap::new();
for t in terms {
*m.entry(t.clone()).or_insert(0.0) += 1.0;
}
m
}
fn norm(v: &Vector) -> f64 {
v.values().map(|w| w * w).sum::<f64>().sqrt()
}
#[must_use]
pub fn matches(query: &str, corpus: &[Requirement]) -> Vec<SimilarMatch> {
let q_terms = tokenize(query);
if q_terms.is_empty() || corpus.is_empty() {
return Vec::new();
}
let docs: Vec<BTreeMap<String, f64>> =
corpus.iter().map(|r| counts(&tokenize(&r.text))).collect();
let q_counts = counts(&q_terms);
let mut df: BTreeMap<&str, f64> = BTreeMap::new();
for d in docs.iter().chain(std::iter::once(&q_counts)) {
for t in d.keys() {
*df.entry(t.as_str()).or_insert(0.0) += 1.0;
}
}
#[allow(clippy::cast_precision_loss)]
let n = (docs.len() + 1) as f64;
let idf = |t: &str| ((1.0 + n) / (1.0 + df.get(t).copied().unwrap_or(0.0))).ln() + 1.0;
let weigh = |c: &BTreeMap<String, f64>| -> Vector {
c.iter().map(|(t, tf)| (t.clone(), tf * idf(t))).collect()
};
let q = weigh(&q_counts);
let qn = norm(&q);
let mut out: Vec<(f64, &Requirement, Vec<String>)> = Vec::new();
for (r, d) in corpus.iter().zip(&docs) {
let v = weigh(d);
let vn = norm(&v);
if vn == 0.0 || qn == 0.0 {
continue;
}
let mut shared: Vec<(f64, &str)> = q
.iter()
.filter_map(|(t, w)| v.get(t).map(|dw| (w * dw, t.as_str())))
.collect();
let dot: f64 = shared.iter().map(|(p, _)| p).sum();
let score = (dot / (qn * vn)).clamp(0.0, 1.0);
if score < CLOSE {
continue;
}
shared.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.1.cmp(b.1)));
let shared = shared
.into_iter()
.take(MAX_SHARED)
.map(|(_, t)| t.to_owned())
.collect();
out.push((score, r, shared));
}
out.sort_by(|a, b| {
b.0.total_cmp(&a.0)
.then_with(|| unit_number(&a.1.unit).cmp(&unit_number(&b.1.unit)))
.then_with(|| a.1.unit.cmp(&b.1.unit))
.then_with(|| a.1.id.cmp(&b.1.id))
});
out.into_iter()
.take(MAX_MATCHES)
.map(|(score, r, shared)| SimilarMatch {
unit: r.unit.clone(),
requirement: r.id.clone(),
text: r.text.clone(),
score: (score * 100.0).round() / 100.0,
level: if score >= VERY_CLOSE {
SimilarLevel::VeryClose
} else {
SimilarLevel::Close
},
shared,
at: r.at.clone(),
})
.collect()
}
#[must_use]
pub fn shared_terms(a: &str, b: &str) -> BTreeSet<String> {
let a: BTreeSet<String> = tokenize(a).into_iter().collect();
tokenize(b).into_iter().filter(|t| a.contains(t)).collect()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct SimilarReport {
pub query: String,
pub compared: usize,
pub limited: bool,
pub matches: Vec<SimilarMatch>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn stems_fold_the_usual_suffixes() {
assert_eq!(stem("reports"), "report");
assert_eq!(stem("exporting"), "export");
assert_eq!(stem("exported"), "export");
assert_eq!(stem("categories"), "category");
assert_eq!(stem("boxes"), "box");
assert_eq!(stem("class"), "class");
assert_eq!(stem("quickly"), "quick");
assert_eq!(stem("is"), "is");
}
#[test]
fn tokens_are_normalised_and_stop_words_dropped() {
assert_eq!(
tokenize("Users MUST be able to export Reports as CSV"),
vec!["export", "report", "csv"]
);
assert!(tokenize("the a of").is_empty());
}
}