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
8/// A trait that searches for tokens that fulfil [`Expr`]s in a [`Document`].
9///
10/// Makes use of [`TokenStringExt::iter_chunks`] to avoid matching across sentence or clause
11/// boundaries.
12#[blanket(derive(Box))]
13pub trait ExprLinter: LSend {
14    /// A simple getter for the expression you want Harper to search for.
15    fn expr(&self) -> &dyn Expr;
16    /// If any portions of a [`Document`] match [`Self::expr`], they are passed through [`ExprLinter::match_to_lint`]
17    /// or [`ExprLinter::match_to_lint_with_context`] to be transformed into a [`Lint`] for editor consumption.
18    ///
19    /// Transform matched tokens into a [`Lint`] for editor consumption.
20    ///
21    /// This is the simple version that only sees the matched tokens. For context-aware linting,
22    /// implement `match_to_lint_with_context` instead.
23    ///
24    /// Return `None` to skip producing a lint for this match.
25    fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option<Lint> {
26        self.match_to_lint_with_context(matched_tokens, source, None)
27    }
28
29    /// Transform matched tokens into a [`Lint`] with access to surrounding context.
30    ///
31    /// The context provides access to tokens before and after the match. When implementing
32    /// this method, you can call `self.match_to_lint()` as a fallback if the context isn't needed.
33    ///
34    /// Return `None` to skip producing a lint for this match.
35    fn match_to_lint_with_context(
36        &self,
37        matched_tokens: &[Token],
38        source: &[char],
39        _context: Option<(&[Token], &[Token])>,
40    ) -> Option<Lint> {
41        // Default implementation falls back to the simple version
42        self.match_to_lint(matched_tokens, source)
43    }
44    /// A user-facing description of what kinds of grammatical errors this rule looks for.
45    /// It is usually shown in settings menus.
46    fn description(&self) -> &str;
47}
48
49/// Helper function to find the only occurrence of a token matching a predicate
50///
51/// Returns `Some(token)` if exactly one token matches the predicate, `None` otherwise.
52/// TODO: This can be used in the [`ThenThan`] linter when #1819 is merged.
53pub fn find_the_only_token_matching<'a, F>(
54    tokens: &'a [Token],
55    source: &[char],
56    predicate: F,
57) -> Option<&'a Token>
58where
59    F: Fn(&Token, &[char]) -> bool,
60{
61    let mut matches = tokens.iter().filter(|&tok| predicate(tok, source));
62    match (matches.next(), matches.next()) {
63        (Some(tok), None) => Some(tok),
64        _ => None,
65    }
66}
67
68impl<L> Linter for L
69where
70    L: ExprLinter,
71{
72    fn lint(&mut self, document: &Document) -> Vec<Lint> {
73        let mut lints = Vec::new();
74        let source = document.get_source();
75
76        for chunk in document.iter_chunks() {
77            lints.extend(run_on_chunk(self, chunk, source));
78        }
79
80        lints
81    }
82
83    fn description(&self) -> &str {
84        self.description()
85    }
86}
87
88pub fn run_on_chunk<'a>(
89    linter: &'a impl ExprLinter,
90    chunk: &'a [Token],
91    source: &'a [char],
92) -> impl Iterator<Item = Lint> + 'a {
93    linter
94        .expr()
95        .iter_matches(chunk, source)
96        .filter_map(|match_span| {
97            linter.match_to_lint_with_context(
98                &chunk[match_span.start..match_span.end],
99                source,
100                Some((&chunk[..match_span.start], &chunk[match_span.end..])),
101            )
102        })
103}
104
105#[cfg(test)]
106mod tests {
107    use crate::expr::{Expr, FixedPhrase};
108    use crate::linting::tests::assert_suggestion_result;
109    use crate::linting::{ExprLinter, Suggestion};
110    use crate::token_string_ext::TokenStringExt;
111    use crate::{Lint, Token};
112
113    pub struct TestSimpleLinter {
114        expr: Box<dyn Expr>,
115    }
116
117    impl Default for TestSimpleLinter {
118        fn default() -> Self {
119            Self {
120                expr: Box::new(FixedPhrase::from_phrase("two")),
121            }
122        }
123    }
124
125    impl ExprLinter for TestSimpleLinter {
126        fn expr(&self) -> &dyn Expr {
127            &*self.expr
128        }
129
130        fn match_to_lint(&self, toks: &[Token], _src: &[char]) -> Option<Lint> {
131            Some(Lint {
132                span: toks.span()?,
133                message: "simple".to_string(),
134                suggestions: vec![Suggestion::ReplaceWith(vec!['2'])],
135                ..Default::default()
136            })
137        }
138
139        fn description(&self) -> &str {
140            "test linter"
141        }
142    }
143
144    pub struct TestContextLinter {
145        expr: Box<dyn Expr>,
146    }
147
148    impl Default for TestContextLinter {
149        fn default() -> Self {
150            Self {
151                expr: Box::new(FixedPhrase::from_phrase("two")),
152            }
153        }
154    }
155
156    impl ExprLinter for TestContextLinter {
157        fn expr(&self) -> &dyn Expr {
158            &*self.expr
159        }
160
161        fn match_to_lint_with_context(
162            &self,
163            toks: &[Token],
164            src: &[char],
165            context: Option<(&[Token], &[Token])>,
166        ) -> Option<Lint> {
167            if let Some((before, after)) = context {
168                let before = before.span()?.get_content_string(src);
169                let after = after.span()?.get_content_string(src);
170
171                let (message, suggestions) = if before.eq_ignore_ascii_case("one ")
172                    && after.eq_ignore_ascii_case(" three")
173                {
174                    (
175                        "ascending".to_string(),
176                        vec![Suggestion::ReplaceWith(vec!['>'])],
177                    )
178                } else if before.eq_ignore_ascii_case("three ")
179                    && after.eq_ignore_ascii_case(" one")
180                {
181                    (
182                        "descending".to_string(),
183                        vec![Suggestion::ReplaceWith(vec!['<'])],
184                    )
185                } else {
186                    (
187                        "dunno".to_string(),
188                        vec![Suggestion::ReplaceWith(vec!['?'])],
189                    )
190                };
191
192                return Some(Lint {
193                    span: toks.span()?,
194                    message,
195                    suggestions,
196                    ..Default::default()
197                });
198            } else {
199                None
200            }
201        }
202
203        fn description(&self) -> &str {
204            "context linter"
205        }
206    }
207
208    #[test]
209    fn simple_test_123() {
210        assert_suggestion_result("one two three", TestSimpleLinter::default(), "one 2 three");
211    }
212
213    #[test]
214    fn context_test_123() {
215        assert_suggestion_result("one two three", TestContextLinter::default(), "one > three");
216    }
217
218    #[test]
219    fn context_test_321() {
220        assert_suggestion_result("three two one", TestContextLinter::default(), "three < one");
221    }
222}