harper_core/linting/
of_course.rs

1use crate::{
2    Token,
3    linting::{Lint, LintKind, PatternLinter, Suggestion},
4    patterns::{SequencePattern, WordSet},
5};
6
7pub struct OfCourse {
8    pattern: Box<dyn crate::patterns::Pattern>,
9}
10
11impl Default for OfCourse {
12    fn default() -> Self {
13        let wrong_forms = WordSet::new(&["curse", "corse"]);
14        let pattern = SequencePattern::default()
15            .t_aco("of")
16            .then_whitespace()
17            .then(wrong_forms);
18
19        Self {
20            pattern: Box::new(pattern),
21        }
22    }
23}
24
25impl PatternLinter for OfCourse {
26    fn pattern(&self) -> &dyn crate::patterns::Pattern {
27        self.pattern.as_ref()
28    }
29
30    fn match_to_lint(&self, matched: &[Token], source: &[char]) -> Option<Lint> {
31        // Skip if the word before “of” is “kind” or “sort” → “kind of curse” is valid.
32        if let Some(of_idx) = matched.first().map(|t| t.span.start) {
33            match source.get(..of_idx).map(|src| {
34                // Walk backwards over whitespace to find the preceding word token.
35                let mut i = of_idx.saturating_sub(1);
36                while i > 0 && src[i].is_whitespace() {
37                    i -= 1;
38                }
39                // Return slice ending with that char to build a small string.
40                let start = src[..=i]
41                    .iter()
42                    .rposition(|c| c.is_whitespace())
43                    .map(|p| p + 1)
44                    .unwrap_or(0);
45                src[start..=i].iter().collect::<String>()
46            }) {
47                Some(prev) => {
48                    let lower = prev.to_ascii_lowercase();
49                    if lower == "kind" || lower == "sort" {
50                        return None;
51                    }
52                }
53                _ => (),
54            }
55        }
56
57        let typo_span = matched.last()?.span;
58        let original = typo_span.get_content(source);
59
60        Some(Lint {
61            span: typo_span,
62            lint_kind: LintKind::WordChoice,
63            suggestions: vec![Suggestion::replace_with_match_case(
64                "course".chars().collect(),
65                original,
66            )],
67            message: "Did you mean “of **course**” (= clearly) instead of “of curse / corse”?"
68                .to_string(),
69            priority: 31,
70        })
71    }
72
73    fn description(&self) -> &str {
74        "Corrects the common eggcorn “of curse / corse” to “of course,” ignoring phrases like “kind of curse.”"
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::OfCourse;
81    use crate::linting::tests::{assert_lint_count, assert_suggestion_result};
82
83    #[test]
84    fn flags_of_curse() {
85        assert_suggestion_result("Yes, of curse!", OfCourse::default(), "Yes, of course!");
86    }
87
88    #[test]
89    fn flags_of_corse() {
90        assert_suggestion_result(
91            "Well, of corse we can.",
92            OfCourse::default(),
93            "Well, of course we can.",
94        );
95    }
96
97    #[test]
98    fn ignores_kind_of_curse() {
99        assert_lint_count("This kind of curse is dangerous.", OfCourse::default(), 0);
100    }
101
102    #[test]
103    fn ignores_sort_of_curse() {
104        assert_lint_count("It's a sort of curse that lingers.", OfCourse::default(), 0);
105    }
106
107    #[test]
108    fn ignores_curse_of_title() {
109        assert_lint_count(
110            "The Curse of Strahd is a famous module.",
111            OfCourse::default(),
112            0,
113        );
114    }
115}