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
203/// Check for sentence context immediately before a matched span.
204///
205/// Validates that the "before" context ends with a word token followed by whitespace,
206/// allowing flexible inspection of that word's properties (POS tags, etc.) via the predicate.
207/// The predicate can be used to confirm matches, suppress false positives, or apply conditional logic.
208///
209/// Returns `false` if context is `None`, missing tokens, or the structure is malformed.
210pub fn preceded_by_word(
211    context: Option<(&[Token], &[Token])>,
212    predicate: impl Fn(&Token) -> bool,
213) -> bool {
214    if let Some((before, _)) = context
215        && let [.., word, ws] = before
216        && ws.kind.is_whitespace()
217    {
218        return predicate(word);
219    }
220    false
221}
222
223/// Check for sentence context surrounding a matched span on both sides.
224///
225/// Validates that the "before" context ends with a word token followed by whitespace,
226/// and the "after" context starts with whitespace followed by a word token, allowing
227/// flexible inspection of both words' properties (POS tags, etc.) via the predicate.
228/// The predicate can be used to confirm matches, suppress false positives, or apply conditional logic.
229///
230/// Returns `false` if context is `None`, missing tokens, or the structure is malformed.
231pub fn surrounded_by_words(
232    context: Option<(&[Token], &[Token])>,
233    predicate: impl Fn(&Token, &Token) -> bool,
234) -> bool {
235    if let Some((before, after)) = context
236        && let [.., word_before, ws_before] = before
237        && let [ws_after, word_after, ..] = after
238        && ws_before.kind.is_whitespace()
239        && ws_after.kind.is_whitespace()
240    {
241        return predicate(word_before, word_after);
242    }
243    false
244}
245
246#[cfg(test)]
247mod tests_context {
248    use crate::expr::{Expr, FixedPhrase};
249    use crate::linting::expr_linter::{Chunk, Sentence};
250    use crate::linting::tests::assert_suggestion_result;
251    use crate::linting::{ExprLinter, Suggestion};
252    use crate::token_string_ext::TokenStringExt;
253    use crate::{Lint, Token};
254
255    pub struct TestSimpleLinter {
256        expr: Box<dyn Expr>,
257    }
258
259    impl Default for TestSimpleLinter {
260        fn default() -> Self {
261            Self {
262                expr: Box::new(FixedPhrase::from_phrase("two")),
263            }
264        }
265    }
266
267    impl ExprLinter for TestSimpleLinter {
268        type Unit = Chunk;
269
270        fn expr(&self) -> &dyn Expr {
271            &*self.expr
272        }
273
274        fn match_to_lint(&self, toks: &[Token], _src: &[char]) -> Option<Lint> {
275            Some(Lint {
276                span: toks.span()?,
277                message: "simple".to_owned(),
278                suggestions: vec![Suggestion::ReplaceWith(vec!['2'])],
279                ..Default::default()
280            })
281        }
282
283        fn description(&self) -> &str {
284            "test linter"
285        }
286    }
287
288    pub struct TestContextLinter {
289        expr: Box<dyn Expr>,
290    }
291
292    impl Default for TestContextLinter {
293        fn default() -> Self {
294            Self {
295                expr: Box::new(FixedPhrase::from_phrase("two")),
296            }
297        }
298    }
299
300    impl ExprLinter for TestContextLinter {
301        type Unit = Chunk;
302
303        fn expr(&self) -> &dyn Expr {
304            &*self.expr
305        }
306
307        fn match_to_lint_with_context(
308            &self,
309            toks: &[Token],
310            src: &[char],
311            context: Option<(&[Token], &[Token])>,
312        ) -> Option<Lint> {
313            if let Some((before, after)) = context {
314                let before = before.span()?.get_content_string(src);
315                let after = after.span()?.get_content_string(src);
316
317                let (message, suggestions) = if before.eq_ignore_ascii_case("one ")
318                    && after.eq_ignore_ascii_case(" three")
319                {
320                    (
321                        "ascending".to_owned(),
322                        vec![Suggestion::ReplaceWith(vec!['>'])],
323                    )
324                } else if before.eq_ignore_ascii_case("three ")
325                    && after.eq_ignore_ascii_case(" one")
326                {
327                    (
328                        "descending".to_owned(),
329                        vec![Suggestion::ReplaceWith(vec!['<'])],
330                    )
331                } else {
332                    ("dunno".to_owned(), vec![Suggestion::ReplaceWith(vec!['?'])])
333                };
334
335                return Some(Lint {
336                    span: toks.span()?,
337                    message,
338                    suggestions,
339                    ..Default::default()
340                });
341            } else {
342                None
343            }
344        }
345
346        fn description(&self) -> &str {
347            "context linter"
348        }
349    }
350
351    pub struct TestSentenceLinter {
352        expr: Box<dyn Expr>,
353    }
354
355    impl Default for TestSentenceLinter {
356        fn default() -> Self {
357            Self {
358                expr: Box::new(FixedPhrase::from_phrase("two, two")),
359            }
360        }
361    }
362
363    impl ExprLinter for TestSentenceLinter {
364        type Unit = Sentence;
365
366        fn expr(&self) -> &dyn Expr {
367            self.expr.as_ref()
368        }
369
370        fn match_to_lint(&self, toks: &[Token], _src: &[char]) -> Option<Lint> {
371            Some(Lint {
372                span: toks.span()?,
373                message: "sentence".to_owned(),
374                suggestions: vec![Suggestion::ReplaceWith(vec!['2', '&', '2'])],
375                ..Default::default()
376            })
377        }
378
379        fn description(&self) -> &str {
380            "sentence linter"
381        }
382    }
383
384    #[test]
385    fn simple_test_123() {
386        assert_suggestion_result("one two three", TestSimpleLinter::default(), "one 2 three");
387    }
388
389    #[test]
390    fn context_test_123() {
391        assert_suggestion_result("one two three", TestContextLinter::default(), "one > three");
392    }
393
394    #[test]
395    fn context_test_321() {
396        assert_suggestion_result("three two one", TestContextLinter::default(), "three < one");
397    }
398
399    #[test]
400    fn sentence_test_123() {
401        assert_suggestion_result(
402            "one, two, two, three",
403            TestSentenceLinter::default(),
404            "one, 2&2, three",
405        );
406    }
407}