Skip to main content

harper_core/linting/
expr_linter.rs

1use crate::expr::{Expr, ExprExt};
2use blanket::blanket;
3
4use crate::{Document, LSend, Token, TokenStringExt};
5
6use super::{Lint, Linter};
7
8pub trait DocumentIterator {
9    type Unit;
10
11    fn iter_units<'a>(document: &'a Document) -> Box<dyn Iterator<Item = &'a [Token]> + 'a>;
12}
13
14/// Process text in chunks (clauses between commas)
15pub struct Chunk;
16/// Process text in full sentences
17pub struct Sentence;
18
19impl DocumentIterator for Chunk {
20    type Unit = Chunk;
21
22    fn iter_units<'a>(document: &'a Document) -> Box<dyn Iterator<Item = &'a [Token]> + 'a> {
23        Box::new(document.iter_chunks())
24    }
25}
26
27impl DocumentIterator for Sentence {
28    type Unit = Sentence;
29
30    fn iter_units<'a>(document: &'a Document) -> Box<dyn Iterator<Item = &'a [Token]> + 'a> {
31        Box::new(document.iter_sentences())
32    }
33}
34
35/// A trait that searches for tokens that fulfil [`Expr`]s in a [`Document`].
36///
37/// Makes use of [`TokenStringExt::iter_chunks`] by default, or [`TokenStringExt::iter_sentences`] to process either
38/// a chunk (clause) or a sentence at a time.
39#[blanket(derive(Box))]
40pub trait ExprLinter: LSend {
41    type Unit: DocumentIterator;
42
43    /// A simple getter for the expression you want Harper to search for.
44    fn expr(&self) -> &dyn Expr;
45    /// If any portions of a [`Document`] match [`Self::expr`], they are passed through [`ExprLinter::match_to_lint`]
46    /// or [`ExprLinter::match_to_lint_with_context`] to be transformed into a [`Lint`] for editor consumption.
47    ///
48    /// Transform matched tokens into a [`Lint`] for editor consumption.
49    ///
50    /// This is the simple version that only sees the matched tokens. For context-aware linting,
51    /// implement `match_to_lint_with_context` instead.
52    ///
53    /// Return `None` to skip producing a lint for this match.
54    fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option<Lint> {
55        self.match_to_lint_with_context(matched_tokens, source, None)
56    }
57
58    /// Transform matched tokens into a [`Lint`] with access to surrounding context.
59    ///
60    /// The context provides access to tokens before and after the match. When implementing
61    /// this method, you can call `self.match_to_lint()` as a fallback if the context isn't needed.
62    ///
63    /// Return `None` to skip producing a lint for this match.
64    fn match_to_lint_with_context(
65        &self,
66        matched_tokens: &[Token],
67        source: &[char],
68        _context: Option<(&[Token], &[Token])>,
69    ) -> Option<Lint> {
70        // Default implementation falls back to the simple version
71        self.match_to_lint(matched_tokens, source)
72    }
73    /// A user-facing description of what kinds of grammatical errors this rule looks for.
74    /// It is usually shown in settings menus.
75    fn description(&self) -> &str;
76}
77
78/// Helper function to find the only occurrence of a token matching a predicate
79///
80/// Returns `Some(token)` if exactly one token matches the predicate, `None` otherwise.
81/// TODO: This can be used in the [`ThenThan`] linter when #1819 is merged.
82pub fn find_the_only_token_matching<'a, F>(
83    tokens: &'a [Token],
84    source: &[char],
85    predicate: F,
86) -> Option<&'a Token>
87where
88    F: Fn(&Token, &[char]) -> bool,
89{
90    find_the_only_token_index_matching(tokens, source, predicate).map(|idx| &tokens[idx])
91}
92
93/// Helper function to find the index of the only occurrence of a token matching a predicate.
94///
95/// Returns `Some(index)` if exactly one token matches the predicate, `None` otherwise.
96pub fn find_the_only_token_index_matching<F>(
97    tokens: &[Token],
98    source: &[char],
99    predicate: F,
100) -> Option<usize>
101where
102    F: Fn(&Token, &[char]) -> bool,
103{
104    let mut matches = tokens
105        .iter()
106        .enumerate()
107        .filter(|&(_, tok)| predicate(tok, source));
108
109    match (matches.next(), matches.next()) {
110        (Some((idx, _)), None) => Some(idx),
111        _ => None,
112    }
113}
114
115impl<L, U> Linter for L
116where
117    L: ExprLinter<Unit = U>,
118    U: DocumentIterator,
119{
120    fn lint(&mut self, document: &Document) -> Vec<Lint> {
121        let mut lints = Vec::new();
122        let source = document.get_source();
123
124        for unit in U::iter_units(document) {
125            lints.extend(run_on_chunk(self, unit, source));
126        }
127
128        lints
129    }
130
131    fn description(&self) -> &str {
132        self.description()
133    }
134}
135
136pub fn run_on_chunk<'a>(
137    linter: &'a impl ExprLinter,
138    unit: &'a [Token],
139    source: &'a [char],
140) -> impl Iterator<Item = Lint> + 'a {
141    linter
142        .expr()
143        .iter_matches(unit, source)
144        .filter_map(|match_span| {
145            linter.match_to_lint_with_context(
146                &unit[match_span.start..match_span.end],
147                source,
148                Some((&unit[..match_span.start], &unit[match_span.end..])),
149            )
150        })
151}
152
153/// Check for sentence continuation after a matched span.
154///
155/// Validates that the "after" context starts with whitespace followed by a word token,
156/// allowing flexible inspection of that word's properties (POS tags, etc.) via the predicate.
157/// The predicate can be used to confirm matches, suppress false positives, or apply conditional logic.
158///
159/// Returns `false` if context is `None`, missing tokens, or the structure is malformed.
160pub fn followed_by_word(
161    context: Option<(&[Token], &[Token])>,
162    predicate: impl Fn(&Token) -> bool,
163) -> bool {
164    if let Some((_, after)) = context
165        && let [ws, word, ..] = after
166        && ws.kind.is_whitespace()
167    {
168        return predicate(word);
169    }
170    false
171}
172
173/// Check for a specific token type after a matched span.
174///
175/// Validates that the "after" context starts with a token that matches the predicate.
176/// This is useful for checking for specific punctuation or other token types.
177///
178/// Returns `false` if context is `None`, missing tokens, or the structure is malformed.
179pub fn followed_by_token(
180    context: Option<(&[Token], &[Token])>,
181    predicate: impl Fn(&Token) -> bool,
182) -> bool {
183    context
184        .and_then(|(_, after)| after.first())
185        .is_some_and(predicate)
186}
187
188pub fn followed_by_hyphen(context: Option<(&[Token], &[Token])>) -> bool {
189    followed_by_token(context, |hy| hy.kind.is_hyphen())
190}
191
192/// Counterintuitively, a sentence includes the whitespace after
193/// the sentence-final punctuation.
194pub fn at_start_of_sentence(context: Option<(&[Token], &[Token])>) -> bool {
195    if let Some((before, _)) = context
196        && (before.is_empty() || (before.len() == 1 && before[0].kind.is_whitespace()))
197    {
198        return true;
199    }
200    false
201}
202
203pub fn preceded_by_word(
204    context: Option<(&[Token], &[Token])>,
205    predicate: impl Fn(&Token) -> bool,
206) -> bool {
207    if let Some((before, _)) = context
208        && let [.., word, ws] = before
209        && ws.kind.is_whitespace()
210    {
211        return predicate(word);
212    }
213    false
214}
215
216#[cfg(test)]
217mod tests_context {
218    use crate::expr::{Expr, FixedPhrase};
219    use crate::linting::expr_linter::{Chunk, Sentence};
220    use crate::linting::tests::assert_suggestion_result;
221    use crate::linting::{ExprLinter, Suggestion};
222    use crate::token_string_ext::TokenStringExt;
223    use crate::{Lint, Token};
224
225    pub struct TestSimpleLinter {
226        expr: Box<dyn Expr>,
227    }
228
229    impl Default for TestSimpleLinter {
230        fn default() -> Self {
231            Self {
232                expr: Box::new(FixedPhrase::from_phrase("two")),
233            }
234        }
235    }
236
237    impl ExprLinter for TestSimpleLinter {
238        type Unit = Chunk;
239
240        fn expr(&self) -> &dyn Expr {
241            &*self.expr
242        }
243
244        fn match_to_lint(&self, toks: &[Token], _src: &[char]) -> Option<Lint> {
245            Some(Lint {
246                span: toks.span()?,
247                message: "simple".to_owned(),
248                suggestions: vec![Suggestion::ReplaceWith(vec!['2'])],
249                ..Default::default()
250            })
251        }
252
253        fn description(&self) -> &str {
254            "test linter"
255        }
256    }
257
258    pub struct TestContextLinter {
259        expr: Box<dyn Expr>,
260    }
261
262    impl Default for TestContextLinter {
263        fn default() -> Self {
264            Self {
265                expr: Box::new(FixedPhrase::from_phrase("two")),
266            }
267        }
268    }
269
270    impl ExprLinter for TestContextLinter {
271        type Unit = Chunk;
272
273        fn expr(&self) -> &dyn Expr {
274            &*self.expr
275        }
276
277        fn match_to_lint_with_context(
278            &self,
279            toks: &[Token],
280            src: &[char],
281            context: Option<(&[Token], &[Token])>,
282        ) -> Option<Lint> {
283            if let Some((before, after)) = context {
284                let before = before.span()?.get_content_string(src);
285                let after = after.span()?.get_content_string(src);
286
287                let (message, suggestions) = if before.eq_ignore_ascii_case("one ")
288                    && after.eq_ignore_ascii_case(" three")
289                {
290                    (
291                        "ascending".to_owned(),
292                        vec![Suggestion::ReplaceWith(vec!['>'])],
293                    )
294                } else if before.eq_ignore_ascii_case("three ")
295                    && after.eq_ignore_ascii_case(" one")
296                {
297                    (
298                        "descending".to_owned(),
299                        vec![Suggestion::ReplaceWith(vec!['<'])],
300                    )
301                } else {
302                    ("dunno".to_owned(), vec![Suggestion::ReplaceWith(vec!['?'])])
303                };
304
305                return Some(Lint {
306                    span: toks.span()?,
307                    message,
308                    suggestions,
309                    ..Default::default()
310                });
311            } else {
312                None
313            }
314        }
315
316        fn description(&self) -> &str {
317            "context linter"
318        }
319    }
320
321    pub struct TestSentenceLinter {
322        expr: Box<dyn Expr>,
323    }
324
325    impl Default for TestSentenceLinter {
326        fn default() -> Self {
327            Self {
328                expr: Box::new(FixedPhrase::from_phrase("two, two")),
329            }
330        }
331    }
332
333    impl ExprLinter for TestSentenceLinter {
334        type Unit = Sentence;
335
336        fn expr(&self) -> &dyn Expr {
337            self.expr.as_ref()
338        }
339
340        fn match_to_lint(&self, toks: &[Token], _src: &[char]) -> Option<Lint> {
341            Some(Lint {
342                span: toks.span()?,
343                message: "sentence".to_owned(),
344                suggestions: vec![Suggestion::ReplaceWith(vec!['2', '&', '2'])],
345                ..Default::default()
346            })
347        }
348
349        fn description(&self) -> &str {
350            "sentence linter"
351        }
352    }
353
354    #[test]
355    fn simple_test_123() {
356        assert_suggestion_result("one two three", TestSimpleLinter::default(), "one 2 three");
357    }
358
359    #[test]
360    fn context_test_123() {
361        assert_suggestion_result("one two three", TestContextLinter::default(), "one > three");
362    }
363
364    #[test]
365    fn context_test_321() {
366        assert_suggestion_result("three two one", TestContextLinter::default(), "three < one");
367    }
368
369    #[test]
370    fn sentence_test_123() {
371        assert_suggestion_result(
372            "one, two, two, three",
373            TestSentenceLinter::default(),
374            "one, 2&2, three",
375        );
376    }
377}