Skip to main content

expectation_model/
text.rs

1//! Text helpers shared by the parser, diagnostics and docs: matching keys, slugs, numbers.
2
3/// Lowercase, collapse whitespace and drop a trailing full stop, for matching step text.
4pub fn normalize(s: &str) -> String {
5    let s = s.trim().trim_end_matches('.');
6    s.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase()
7}
8
9/// The matching key of a phrase: normalised, without a leading article.
10pub 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
20/// A file-name and anchor friendly form: "Turn it up!" -> "turn-it-up".
21pub 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
36/// Description lines as paragraphs: lines joined with spaces, an empty line between paragraphs.
37pub 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        // A list item (`1. text` or `- text`) is its own paragraph, so a list stays a list.
42        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
61/// "a", "a and b", "a, b and c".
62pub 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
70/// Numbers as people write them: 55 rather than 55.0, 77.5 rather than 77.500.
71pub 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
80/// Levenshtein distance, for "did you mean" suggestions.
81pub 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
96/// The candidate closest to `word`, if it's close enough to be a plausible typo.
97pub 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}