expectation_model/
text.rs1pub fn normalize(s: &str) -> String {
5 let s = s.trim().trim_end_matches('.');
6 s.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
7}
8
9pub fn key(s: &str) -> String {
11 let n = normalize(s);
12 for article in ["the ", "a ", "an "] {
13 if let Some(rest) = n.strip_prefix(article) {
14 return rest.to_string();
15 }
16 }
17 n
18}
19
20pub fn slug(s: &str) -> String {
22 let mut out = String::new();
23 for c in s.chars() {
24 if c.is_alphanumeric() {
25 out.extend(c.to_lowercase());
26 } else if !out.is_empty() && !out.ends_with('-') {
27 out.push('-');
28 }
29 }
30 while out.ends_with('-') {
31 out.pop();
32 }
33 if out.is_empty() { "untitled".into() } else { out }
34}
35
36pub fn paragraphs(lines: &[String]) -> Vec<String> {
38 let mut out: Vec<String> = vec![];
39 for block in lines.split(|l| l.is_empty()) {
40 let mut para: Vec<&str> = vec![];
41 for line in block {
43 if is_list_item(line) && !para.is_empty() {
44 out.push(para.join(" "));
45 para.clear();
46 }
47 para.push(line);
48 }
49 if !para.is_empty() {
50 out.push(para.join(" "));
51 }
52 }
53 out
54}
55
56fn is_list_item(line: &str) -> bool {
57 let digits = line.chars().take_while(char::is_ascii_digit).count();
58 line.starts_with("- ") || (digits > 0 && line[digits..].starts_with(". "))
59}
60
61pub fn join_and(items: &[String]) -> String {
63 match items {
64 [] => String::new(),
65 [one] => one.clone(),
66 [init @ .., last] => format!("{} and {last}", init.join(", ")),
67 }
68}
69
70pub fn number(v: f64) -> String {
72 if v.fract() == 0.0 && v.abs() < 1e15 {
73 format!("{}", v as i64)
74 } else {
75 let s = format!("{v:.3}");
76 s.trim_end_matches('0').trim_end_matches('.').to_string()
77 }
78}
79
80pub fn edit_distance(a: &str, b: &str) -> usize {
82 let b: Vec<char> = b.chars().collect();
83 let mut row: Vec<usize> = (0..=b.len()).collect();
84 for (i, ca) in a.chars().enumerate() {
85 let mut prev = row[0];
86 row[0] = i + 1;
87 for (j, cb) in b.iter().enumerate() {
88 let cur = row[j + 1];
89 row[j + 1] = if ca == *cb { prev } else { 1 + prev.min(row[j]).min(row[j + 1]) };
90 prev = cur;
91 }
92 }
93 row[b.len()]
94}
95
96pub fn closest<'a>(word: &str, candidates: impl IntoIterator<Item = &'a str>) -> Option<&'a str> {
98 candidates
99 .into_iter()
100 .map(|c| (edit_distance(word, c), c))
101 .filter(|(d, c)| *d <= (c.len().max(word.len()) / 3).max(2))
102 .min_by_key(|(d, _)| *d)
103 .map(|(_, c)| c)
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109
110 #[test]
111 fn list_items_stay_apart() {
112 let lines: Vec<String> = ["Pick one.", "", "1. First", " more", "2. Second", "", "After."]
113 .iter()
114 .map(|s| s.to_string())
115 .collect();
116 assert_eq!(paragraphs(&lines), ["Pick one.", "1. First more", "2. Second", "After."]);
117 }
118
119 #[test]
120 fn keys_ignore_case_spacing_and_articles() {
121 assert_eq!(key(" The Kettle is Off. "), "kettle is off");
122 assert_eq!(key("an event"), "event");
123 }
124
125 #[test]
126 fn slugs() {
127 assert_eq!(slug("Turning the dial up starts the kettle!"), "turning-the-dial-up-starts-the-kettle");
128 assert_eq!(slug("--"), "untitled");
129 }
130
131 #[test]
132 fn numbers_and_lists() {
133 assert_eq!(number(55.0), "55");
134 assert_eq!(number(77.5), "77.5");
135 assert_eq!(join_and(&["a".into(), "b".into(), "c".into()]), "a, b and c");
136 }
137
138 #[test]
139 fn suggestions() {
140 assert_eq!(closest("kettel", ["kettle", "dial"]), Some("kettle"));
141 assert_eq!(closest("zebra", ["kettle", "dial"]), None);
142 }
143}