harper_core/linting/
oxford_comma.rs

1use crate::expr::Expr;
2use crate::expr::ExprExt;
3use crate::expr::OwnedExprExt;
4use crate::expr::SequenceExpr;
5use crate::{Lrc, Token, TokenStringExt, linting::Linter, patterns::WordSet};
6
7use super::{super::Lint, LintKind, Suggestion};
8
9pub struct OxfordComma {
10    expr: Box<dyn Expr>,
11}
12
13impl Default for OxfordComma {
14    fn default() -> Self {
15        let item = Lrc::new(
16            SequenceExpr::default()
17                .then_determiner()
18                .then_whitespace()
19                .then_nominal()
20                .or_longest(SequenceExpr::default().then_nominal()),
21        );
22
23        let item_chunk = SequenceExpr::default()
24            .then(item.clone())
25            .then_comma()
26            .then_whitespace();
27
28        let pattern = SequenceExpr::default()
29            .then_one_or_more(item_chunk)
30            .then(item.clone())
31            .then_whitespace()
32            .then(WordSet::new(&["and", "or", "nor"]))
33            .then_whitespace()
34            .then(item.clone());
35
36        Self {
37            expr: Box::new(pattern),
38        }
39    }
40}
41
42impl OxfordComma {
43    fn match_to_lint(&self, matched_toks: &[Token], _source: &[char]) -> Option<Lint> {
44        let conj_index = matched_toks.last_conjunction_index()?;
45        let offender = &matched_toks[conj_index - 2];
46
47        Some(Lint {
48            span: offender.span,
49            lint_kind: LintKind::Style,
50            suggestions: vec![Suggestion::InsertAfter(vec![','])],
51            message: "An Oxford comma is necessary here.".to_owned(),
52            priority: 31,
53        })
54    }
55}
56
57impl Linter for OxfordComma {
58    fn lint(&mut self, document: &crate::Document) -> Vec<crate::linting::Lint> {
59        let mut lints = Vec::new();
60        for sentence in document.iter_sentences() {
61            let mut skip = 0;
62
63            let mut words = sentence
64                .iter_words()
65                .filter_map(|v| v.kind.as_word())
66                .flatten();
67
68            if let (Some(first), Some(second)) = (words.next(), words.next())
69                && first.preposition
70                && second.is_likely_homograph()
71            {
72                skip = sentence
73                    .iter()
74                    .position(|t| t.kind.is_comma())
75                    .unwrap_or(sentence.iter().len())
76            }
77
78            let sentence = &sentence[skip..];
79
80            for match_span in self.expr.iter_matches(sentence, document.get_source()) {
81                let lint = self.match_to_lint(
82                    &sentence[match_span.start..match_span.end],
83                    document.get_source(),
84                );
85                lints.extend(lint);
86            }
87        }
88
89        lints
90    }
91
92    fn description(&self) -> &str {
93        "The Oxford comma is one of the more controversial rules in common use today. Enabling this lint checks that there is a comma before `and`, `or`, or `nor` when listing out more than two ideas."
94    }
95}
96
97#[cfg(test)]
98mod tests {
99    use crate::linting::tests::{assert_lint_count, assert_suggestion_result};
100
101    use super::OxfordComma;
102
103    #[test]
104    fn fruits() {
105        assert_lint_count(
106            "An apple, a banana and a pear walk into a bar.",
107            OxfordComma::default(),
108            1,
109        );
110    }
111
112    #[test]
113    fn people() {
114        assert_suggestion_result(
115            "Nancy, Steve and Carl are going to the coffee shop.",
116            OxfordComma::default(),
117            "Nancy, Steve, and Carl are going to the coffee shop.",
118        );
119    }
120
121    #[test]
122    fn places() {
123        assert_suggestion_result(
124            "I've always wanted to visit Paris, Tokyo and Rome.",
125            OxfordComma::default(),
126            "I've always wanted to visit Paris, Tokyo, and Rome.",
127        );
128    }
129
130    #[test]
131    fn foods() {
132        assert_suggestion_result(
133            "My favorite foods are pizza, sushi, tacos and burgers.",
134            OxfordComma::default(),
135            "My favorite foods are pizza, sushi, tacos, and burgers.",
136        );
137    }
138
139    #[test]
140    fn allows_clean_music() {
141        assert_lint_count(
142            "I enjoy listening to pop music, rock, hip-hop, electronic dance, and classical music.",
143            OxfordComma::default(),
144            0,
145        );
146    }
147
148    #[test]
149    fn allows_clean_nations() {
150        assert_lint_count(
151            "The team consists of players from different countries: France, Germany, Italy, and Spain.",
152            OxfordComma::default(),
153            0,
154        );
155    }
156
157    #[test]
158    fn or_writing() {
159        assert_suggestion_result(
160            "Harper can be a lifesaver when writing technical documents, emails or other formal forms of communication.",
161            OxfordComma::default(),
162            "Harper can be a lifesaver when writing technical documents, emails, or other formal forms of communication.",
163        );
164    }
165
166    #[test]
167    fn sports() {
168        assert_suggestion_result(
169            "They enjoy playing soccer, basketball or tennis.",
170            OxfordComma::default(),
171            "They enjoy playing soccer, basketball, or tennis.",
172        );
173    }
174
175    #[test]
176    fn nor_vegetables() {
177        assert_suggestion_result(
178            "I like carrots, kale nor broccoli.",
179            OxfordComma::default(),
180            "I like carrots, kale, nor broccoli.",
181        );
182    }
183
184    #[test]
185    fn allow_non_list_transportation() {
186        assert_lint_count(
187            "In transportation, autonomous vehicles and smart traffic management systems promise to reduce accidents and optimize travel routes.",
188            OxfordComma::default(),
189            0,
190        );
191    }
192
193    #[test]
194    fn allow_pill() {
195        assert_lint_count(
196            "Develop a pill that causes partial amnesia, affecting relationships and identity.",
197            OxfordComma::default(),
198            0,
199        );
200    }
201
202    #[test]
203    fn allow_at_first() {
204        assert_lint_count(
205            "In the heart of a bustling city, Sarah finds herself trapped in an endless cycle of the same day. Each morning, she awakens to find the date unchanged, her life on repeat. At first, confusion and frustration cloud her thoughts, but soon she notices something peculiar—each day has tiny differences, subtle changes that hint at a larger pattern.",
206            OxfordComma::default(),
207            0,
208        );
209    }
210
211    #[test]
212    fn allow_standoff() {
213        assert_lint_count(
214            "In a tense standoff, Alex and his reflection engage in a battle of wills.",
215            OxfordComma::default(),
216            0,
217        );
218    }
219}