Skip to main content

harper_core/patterns/
word.rs

1use super::SingleTokenPattern;
2
3use crate::{CharString, Token};
4
5/// Determines how case matching is performed for a word pattern.
6#[derive(Clone, Copy, PartialEq, Eq)]
7enum CaseMatchingMode {
8    /// Match regardless of case (case insensitive)
9    CaseInsensitive,
10    /// Match exact case only (case sensitive)
11    CaseSensitive,
12    /// Match only standard case patterns: all lowercase, all uppercase, or title case
13    StandardCase,
14}
15
16/// Matches a predefined word.
17#[derive(Clone)]
18pub struct Word {
19    /// The word to match.
20    word: CharString,
21    /// Determines how case matching is performed.
22    case_mode: CaseMatchingMode,
23}
24
25impl Word {
26    /// Matches the provided word, ignoring case.
27    pub fn new(word: &'static str) -> Self {
28        Self {
29            word: word.chars().collect(),
30            case_mode: CaseMatchingMode::CaseInsensitive,
31        }
32    }
33
34    /// Matches the provided word, ignoring case.
35    pub fn from_chars(word: &[char]) -> Self {
36        Self {
37            word: word.iter().copied().collect(),
38            case_mode: CaseMatchingMode::CaseInsensitive,
39        }
40    }
41
42    /// Matches the provided word, ignoring case.
43    pub fn from_char_string(word: CharString) -> Self {
44        Self {
45            word,
46            case_mode: CaseMatchingMode::CaseInsensitive,
47        }
48    }
49
50    /// Matches the provided word, case-sensitive.
51    pub fn new_exact(word: &'static str) -> Self {
52        Self {
53            word: word.chars().collect(),
54            case_mode: CaseMatchingMode::CaseSensitive,
55        }
56    }
57
58    /// Matches the provided word, but only if it follows standard case patterns:
59    /// all lowercase, all uppercase, or title case (first letter uppercase, rest lowercase).
60    /// This avoids matching trademarks, app names, and other mixed-case patterns.
61    pub fn new_standard_case(word: &'static str) -> Self {
62        Self {
63            word: word.chars().collect(),
64            case_mode: CaseMatchingMode::StandardCase,
65        }
66    }
67
68    /// Checks if the given characters follow a standard case pattern:
69    /// - All lowercase
70    /// - All uppercase  
71    /// - Title case (first letter uppercase, rest lowercase)
72    fn is_standard_case(chars: &[char]) -> bool {
73        match chars.len() {
74            0 => false,
75            1 => true,
76            _ => {
77                let (first, rest) = chars.split_at(1);
78                let c0 = first[0];
79                let c0_is_lowercase = c0.is_lowercase();
80
81                let (basis, rest) = if !c0_is_lowercase {
82                    (rest[0].is_lowercase(), &rest[1..])
83                } else {
84                    (c0_is_lowercase, rest)
85                };
86
87                rest.iter().all(|c| c.is_lowercase() == basis)
88            }
89        }
90    }
91}
92
93impl SingleTokenPattern for Word {
94    fn matches_token(&self, token: &Token, source: &[char]) -> bool {
95        if !token.kind.is_word() {
96            return false;
97        }
98        if token.span.len() != self.word.len() {
99            return false;
100        }
101
102        let chars = token.get_ch(source);
103
104        match self.case_mode {
105            CaseMatchingMode::CaseSensitive => chars == self.word.as_slice(),
106            _ => {
107                // Both CaseInsensitive and StandardCase need case-insensitive matching
108                // but StandardCase has an additional filter
109                if matches!(self.case_mode, CaseMatchingMode::StandardCase)
110                    && !Self::is_standard_case(chars)
111                {
112                    return false;
113                }
114                chars
115                    .iter()
116                    .zip(&self.word)
117                    .all(|(a, b)| a.eq_ignore_ascii_case(b))
118            }
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use crate::{Document, Span, linting::tests::SpanVecExt, patterns::DocPattern};
126
127    use super::Word;
128
129    #[test]
130    fn fruit() {
131        let doc = Document::new_plain_english_curated("I ate a banana and an apple today.");
132
133        assert_eq!(
134            Word::new("banana").find_all_matches_in_doc(&doc),
135            vec![Span::new(6, 7)]
136        );
137        assert_eq!(
138            Word::new_exact("banana").find_all_matches_in_doc(&doc),
139            vec![Span::new(6, 7)]
140        );
141    }
142
143    #[test]
144    fn fruit_whack_capitalization() {
145        let doc = Document::new_plain_english_curated("I Ate A bAnaNa And aN apPlE today.");
146
147        assert_eq!(
148            Word::new("banana").find_all_matches_in_doc(&doc),
149            vec![Span::new(6, 7)]
150        );
151        assert_eq!(
152            Word::new_exact("banana").find_all_matches_in_doc(&doc),
153            vec![]
154        );
155    }
156
157    #[test]
158    fn standard_case_basic_matches() {
159        let doc =
160            Document::new_plain_english_curated("I ate a banana and BANANA and Banana today.");
161
162        // Should match all three standard case variations
163        let matches = Word::new_standard_case("banana").find_all_matches_in_doc(&doc);
164        assert_eq!(matches.to_strings(&doc), vec!["banana", "BANANA", "Banana"]);
165    }
166
167    #[test]
168    fn standard_case_rejects_mixed_case() {
169        let doc = Document::new_plain_english_curated("I saw iPhone and iPad and YouTube today.");
170
171        // Should not match any mixed-case words
172        let iphone_matches: Vec<String> = Word::new_standard_case("iphone")
173            .find_all_matches_in_doc(&doc)
174            .to_strings(&doc);
175        let ipad_matches: Vec<String> = Word::new_standard_case("ipad")
176            .find_all_matches_in_doc(&doc)
177            .to_strings(&doc);
178        let youtube_matches: Vec<String> = Word::new_standard_case("youtube")
179            .find_all_matches_in_doc(&doc)
180            .to_strings(&doc);
181
182        assert_eq!(iphone_matches, Vec::<String>::new());
183        assert_eq!(ipad_matches, Vec::<String>::new());
184        assert_eq!(youtube_matches, Vec::<String>::new());
185    }
186
187    #[test]
188    fn standard_case_rejects_pascal_and_camel_case() {
189        let doc = Document::new_plain_english_curated(
190            "I saw BananaTree and bananaTree and BaNaNa today.",
191        );
192
193        // Should not match PascalCase or camelCase
194        let bananatree_matches: Vec<String> = Word::new_standard_case("bananatree")
195            .find_all_matches_in_doc(&doc)
196            .to_strings(&doc);
197        let banana_matches: Vec<String> = Word::new_standard_case("banana")
198            .find_all_matches_in_doc(&doc)
199            .to_strings(&doc);
200
201        assert_eq!(bananatree_matches, Vec::<String>::new());
202        assert_eq!(banana_matches, Vec::<String>::new());
203    }
204
205    #[test]
206    fn standard_case_single_letters() {
207        let doc = Document::new_plain_english_curated("A B C a b c I i");
208
209        // Should match single letters in both cases
210        let a_matches: Vec<String> = Word::new_standard_case("a")
211            .find_all_matches_in_doc(&doc)
212            .to_strings(&doc);
213        let b_matches: Vec<String> = Word::new_standard_case("b")
214            .find_all_matches_in_doc(&doc)
215            .to_strings(&doc);
216        let i_matches: Vec<String> = Word::new_standard_case("i")
217            .find_all_matches_in_doc(&doc)
218            .to_strings(&doc);
219
220        assert_eq!(a_matches, vec!["A", "a"]);
221        assert_eq!(b_matches, vec!["B", "b"]);
222        assert_eq!(i_matches, vec!["I", "i"]);
223    }
224
225    #[test]
226    fn standard_case_vs_exact_case() {
227        let doc = Document::new_plain_english_curated("I ate banana and BANANA and Banana.");
228
229        // new_exact should only match exact case
230        let exact_matches: Vec<String> = Word::new_exact("banana")
231            .find_all_matches_in_doc(&doc)
232            .to_strings(&doc);
233        assert_eq!(exact_matches, vec!["banana"]);
234
235        // new_standard_case should match all standard case variations
236        let standard_matches: Vec<String> = Word::new_standard_case("banana")
237            .find_all_matches_in_doc(&doc)
238            .to_strings(&doc);
239        assert_eq!(standard_matches, vec!["banana", "BANANA", "Banana"]);
240    }
241
242    #[test]
243    fn standard_case_edge_cases() {
244        let doc = Document::new_plain_english_curated("A a B b I i.");
245
246        // Test single characters and common pronouns
247        let a_matches: Vec<String> = Word::new_standard_case("a")
248            .find_all_matches_in_doc(&doc)
249            .to_strings(&doc);
250        let i_matches: Vec<String> = Word::new_standard_case("i")
251            .find_all_matches_in_doc(&doc)
252            .to_strings(&doc);
253
254        assert_eq!(a_matches, vec!["A", "a"]);
255        assert_eq!(i_matches, vec!["I", "i"]);
256    }
257
258    #[test]
259    fn standard_case_complex_examples() {
260        let doc = Document::new_plain_english_curated(
261            "The iPhone is made by Apple but the apple is fruit.",
262        );
263
264        // Should match both "Apple" and "apple" (both are standard case)
265        let apple_matches: Vec<String> = Word::new_standard_case("apple")
266            .find_all_matches_in_doc(&doc)
267            .to_strings(&doc);
268        assert_eq!(apple_matches, vec!["Apple", "apple"]);
269
270        // Should not match iPhone (not standard case)
271        let iphone_matches: Vec<String> = Word::new_standard_case("iphone")
272            .find_all_matches_in_doc(&doc)
273            .to_strings(&doc);
274        assert_eq!(iphone_matches, Vec::<String>::new());
275    }
276
277    #[test]
278    fn match_all_standard_3_letter_sets() {
279        let doc = Document::new_plain_english_curated("abc abC aBc aBC Abc AbC ABc ABC");
280
281        let matches: Vec<String> = Word::new_standard_case("abc")
282            .find_all_matches_in_doc(&doc)
283            .to_strings(&doc);
284
285        assert_eq!(matches, vec!["abc", "Abc", "ABC"]);
286    }
287}