Skip to main content

harper_core/linting/
mod.rs

1//! Frameworks and rules that locate errors in text.
2//!
3//! See the [`Linter`] trait and the [documentation for authoring a rule](https://writewithharper.com/docs/contributors/author-a-rule) for more information.
4
5mod a_part;
6mod a_some_time;
7mod a_ways_to_go;
8mod a_while;
9mod addicting;
10mod adjective_double_degree;
11mod adjective_of_a;
12mod after_later;
13mod all_hell_break_loose;
14mod all_intents_and_purposes;
15mod allow_to;
16mod am_in_the_morning;
17mod amounts_for;
18mod an_a;
19mod analog_acoustic_bike;
20mod and_the_like;
21mod another_thing_coming;
22mod another_think_coming;
23mod apart_from;
24mod arrive_to;
25mod as_how;
26mod as_to_interrogative;
27mod ask_no_preposition;
28mod aspire_to;
29mod avoid_contractions;
30mod avoid_curses;
31mod back_in_the_day;
32mod barely_un;
33mod be_adjective_confusions;
34mod be_allowed;
35mod behind_the_scenes;
36mod best_of_all_time;
37mod boring_words;
38mod bought;
39mod brand_brandish;
40mod by_accident;
41mod by_ones_own;
42mod by_the_book;
43mod call_it_quits;
44mod call_them;
45mod cant;
46mod capitalize_personal_pronouns;
47mod catch_22;
48mod cautionary_tale;
49mod change_tack;
50mod chock_full;
51mod close_tight_knit;
52mod closed_compounds;
53mod code_in_write_in;
54mod comma_fixes;
55mod complain_as_noun;
56mod compound_nouns;
57mod compound_subject_i;
58mod confident;
59mod convenient_store;
60mod correct_number_suffix;
61mod crave_for;
62mod criteria_phenomena;
63mod cure_for;
64mod currency_placement;
65mod damages;
66mod dashes;
67mod day_and_age;
68mod despite_it_is;
69mod despite_of;
70mod determiner_without_noun;
71mod did_past;
72mod didnt;
73mod discourse_markers;
74mod disjoint_prefixes;
75mod do_mistake;
76mod dot_initialisms;
77mod double_click;
78mod double_modal;
79mod ellipsis_length;
80mod else_possessive;
81mod ever_every;
82mod everyday;
83mod except_of;
84mod expand_memory_shorthands;
85mod expand_people;
86mod expand_time_shorthands;
87mod expr_linter;
88mod fall_below;
89mod far_be_it;
90mod fascinated_by;
91mod fed_up_with;
92mod feel_fell;
93mod fellow_co_redundancy;
94mod few_units_of_time_ago;
95mod filler_words;
96mod find_fine;
97mod first_aid_kit;
98mod fish_nor_fowl;
99mod flesh_out_vs_full_fledged;
100mod foot_inch_minute_second_symbols;
101mod for_free_of_charge;
102mod for_noun;
103mod for_the_nth_time;
104mod free_predicate;
105mod friend_of_me;
106mod full_to_the_brim;
107mod go_so_far_as_to;
108mod go_to_war;
109mod good_at;
110mod handful;
111mod handful_of_more;
112mod have_a_hard_time;
113mod have_pronoun;
114mod have_take_a_look;
115mod hedging;
116mod hello_greeting;
117mod helped_past;
118mod hereby;
119mod hop_hope;
120mod hope_youre;
121mod how_does_compared;
122mod how_to;
123mod hyphenate_number_day;
124mod i_am_agreement;
125mod if_wouldve;
126mod in_demand_in_depth;
127mod in_favour_of_doing;
128mod in_on_the_cards;
129mod in_stock;
130mod in_time_from_now;
131mod inflected_verb_after_to;
132mod informal_laughter;
133mod initialism_linter;
134mod initialisms;
135mod interested_in;
136mod it_is;
137mod it_looks_like_that;
138mod it_would_be;
139mod its_contraction;
140mod its_possessive;
141mod jealous_of;
142mod johns_hopkins;
143mod jump_the_gun;
144mod lead_rise_to;
145mod leaving_in_droves;
146mod left_right_hand;
147mod less_worse;
148mod let_to_do;
149mod lets_confusion;
150mod likewise;
151mod lint;
152mod lint_group;
153mod lint_kind;
154mod little_known;
155mod long_sentences;
156mod long_time_ago;
157mod look_down_ones_nose;
158mod looking_forward_to;
159mod map_phrase_linter;
160mod map_phrase_set_linter;
161mod mass_nouns;
162mod means_a_lot_to;
163mod merge_linters;
164mod merge_words;
165mod missing_preposition;
166mod missing_space;
167mod missing_to;
168mod misspell;
169mod mixed_bag;
170mod modal_be_adjective;
171mod modal_of;
172mod modal_seem;
173mod months;
174mod more_adjective;
175mod more_better;
176mod most_number;
177mod most_of_the_times;
178mod multiple_frequency_adverbs;
179mod multiple_sequential_pronouns;
180mod nail_on_the_head;
181mod naked_eye;
182mod need_to_noun;
183mod no_french_spaces;
184mod no_harm_no_foul;
185mod no_longer;
186mod no_longer_pronoun;
187mod no_match_for;
188mod no_oxford_comma;
189mod nobody;
190mod nominal_wants;
191mod nor_modal_pronoun;
192mod not_only_inversion;
193mod noun_verb_confusion;
194mod number_suffix_capitalization;
195mod numeric_range_en_dash;
196mod obsess_preposition;
197mod of_course;
198mod oldest_in_the_book;
199mod on_floor;
200mod once_or_twice;
201mod one_and_the_same;
202mod one_of_the_singular;
203mod ones_own_accord;
204mod open_compounds;
205mod open_the_light;
206mod orthographic_consistency;
207mod ought_to_be;
208mod out_of_date;
209mod out_of_the_window;
210mod over_plus;
211mod oxford_comma;
212mod oxymorons;
213mod pale_by_comparison;
214mod passionate_about;
215mod pay_for_price;
216mod phrasal_verb_as_compound_noun;
217mod phrase_set_corrections;
218mod pique_interest;
219mod plural_decades;
220mod plural_wrong_word_of_phrase;
221mod possessive_noun;
222mod possessive_your;
223mod progressive_needs_be;
224mod pronoun_are;
225mod pronoun_contraction;
226mod pronoun_inflection_be;
227mod pronoun_knew;
228mod pronoun_verb_agreement;
229mod proper_noun_capitalization_linters;
230mod quantifier_needs_of;
231mod quantifier_numeral_conflict;
232mod quite_quiet;
233mod quote_spacing;
234mod reason_for_doing;
235mod redundant_acronyms;
236mod redundant_additive_adverbs;
237mod redundant_firsts;
238mod redundant_progressive_comparative;
239mod redundant_self;
240mod regimen_regiment;
241mod regionalisms;
242mod regular_irregulars;
243mod repeated_words;
244mod respond;
245mod right_click;
246mod rise_the_ranks;
247mod roller_skated;
248mod run_into_problems_or_trouble;
249mod safe_to_save;
250mod save_to_safe;
251mod sentence_capitalization;
252mod shoot_oneself_in_the_foot;
253mod simple_past_to_past_participle;
254mod since_duration;
255mod single_be;
256mod sneaked_snuck;
257mod some_without_article;
258mod something_is;
259mod somewhat_something;
260mod soon_to_be;
261mod sought_after;
262mod spaces;
263mod spell_check;
264mod spelled_numbers;
265mod split_words;
266mod subject_pronoun;
267mod suggestion;
268mod take_a_look_to;
269mod take_medicine;
270mod take_serious;
271mod that_than;
272mod that_which;
273mod the_how_why;
274mod the_last_days;
275mod the_my;
276mod the_point_for;
277mod the_proper_noun_possessive;
278mod the_the_to_that_the;
279mod then_than;
280mod there_is_agreement;
281mod there_own;
282mod theres;
283mod theses_these;
284mod theyre_confusions;
285mod thing_think;
286mod this_type_of_thing;
287mod though_thought;
288mod thrive_on;
289mod throw_away;
290mod throw_baby_with_bathwater;
291mod throw_rubbish;
292mod till_date;
293mod to_adverb;
294mod to_two_too;
295mod touristic;
296mod transposed_space;
297mod try_ones_hand_at;
298mod try_ones_luck;
299mod unclosed_quotes;
300mod update_place_names;
301mod use_ellipsis_character;
302mod use_title_case;
303mod verb_to_adjective;
304mod very_unique;
305mod vice_versa;
306mod vicious_loop;
307mod was_aloud;
308mod way_too_adjective;
309mod web_scraping;
310mod weir_rules;
311mod well_educated;
312mod were_where;
313mod whereas;
314mod whom_subject_of_verb;
315mod widely_accepted;
316mod will_non_lemma;
317mod win_prize;
318mod wish_could;
319mod wordpress_dotcom;
320mod worth_to_do;
321mod would_never_have;
322mod wrong_apostrophe;
323
324pub use expr_linter::{Chunk, ExprLinter, Sentence};
325pub use initialism_linter::InitialismLinter;
326pub use lint::Lint;
327pub use lint_group::{
328    FlatConfig, HumanReadableSetting, HumanReadableStructuredConfig, LintGroup, StructuredConfig,
329};
330pub use lint_kind::LintKind;
331pub use map_phrase_linter::MapPhraseLinter;
332pub use map_phrase_set_linter::MapPhraseSetLinter;
333pub use suggestion::{Suggestion, SuggestionCollectionExt};
334
335use crate::{Document, LSend, render_markdown};
336
337/// Maximum number of sequential lint suggestions explored when searching for a
338/// transformation path (for example in Weir rule tests or linting test helpers).
339///
340/// This is a compile-time limit that prevents unbounded search when suggestion
341/// application cycles or deep multi-step fixes are involved.
342pub const MAX_SUGGESTION_TRANSFORMATION_DEPTH: usize = 100;
343
344/// A __stateless__ rule that searches documents for grammatical errors.
345///
346/// Commonly implemented via [`ExprLinter`].
347///
348/// See also: [`LintGroup`].
349pub trait Linter: LSend {
350    /// Analyzes a document and produces zero or more [`Lint`]s.
351    /// We pass `self` mutably for caching purposes.
352    fn lint(&mut self, document: &Document) -> Vec<Lint>;
353    /// A user-facing description of what kinds of grammatical errors this rule looks for.
354    /// It is usually shown in settings menus.
355    fn description(&self) -> &str;
356}
357
358/// A blanket-implemented trait that renders the Markdown description field of a linter to HTML.
359pub trait HtmlDescriptionLinter {
360    fn description_html(&self) -> String;
361}
362
363impl<L: ?Sized> HtmlDescriptionLinter for L
364where
365    L: Linter,
366{
367    fn description_html(&self) -> String {
368        let desc = self.description();
369        render_markdown(desc)
370    }
371}
372
373pub mod debug {
374    use crate::Token;
375
376    /// Formats a lint match with surrounding context for debug output.
377    ///
378    /// The function takes the same `matched_tokens` and `source`, and `context` parameters
379    /// passed to `[match_to_lint_with_context]`.
380    ///
381    /// # Arguments
382    /// * `log` - `matched_tokens`
383    /// * `ctx` - `context`, or `None` if calling from `[match_to_lint]`
384    /// * `src` - `source` from `[match_to_lint]` / `[match_to_lint_with_context]`
385    ///
386    /// # Returns
387    /// A string with ANSI escape codes where:
388    /// - Context tokens are dimmed before and after the matched tokens in normal weight.
389    /// - Markup and formatting text hidden in whitespace tokens is filtered out.
390    pub fn format_lint_match(
391        log: &[Token],
392        ctx: Option<(&[Token], &[Token])>,
393        src: &[char],
394    ) -> String {
395        let fmt = |tokens: &[Token]| {
396            tokens
397                .iter()
398                .filter(|t| !t.kind.is_unlintable())
399                .map(|t| t.get_str(src))
400                .collect::<String>()
401        };
402
403        if let Some((pro, epi)) = ctx {
404            format!(
405                "\x1b[2m{}\x1b[0m{}\x1b[2m{}\x1b[0m",
406                fmt(pro),
407                fmt(log),
408                fmt(epi)
409            )
410        } else {
411            fmt(log)
412        }
413    }
414}
415
416#[cfg(test)]
417pub mod tests {
418    use crate::{Document, Span, Token, linting::Linter};
419    use hashbrown::HashSet;
420
421    /// Extension trait for converting spans of tokens back to their original text
422    pub trait SpanVecExt {
423        fn to_strings(&self, doc: &Document) -> Vec<String>;
424    }
425
426    impl SpanVecExt for Vec<Span<Token>> {
427        fn to_strings(&self, doc: &Document) -> Vec<String> {
428            self.iter()
429                .map(|sp| {
430                    doc.get_tokens()[sp.start..sp.end]
431                        .iter()
432                        .map(|tok| doc.get_span_content_str(&tok.span))
433                        .collect::<String>()
434                })
435                .collect()
436        }
437    }
438
439    // Special Linter just for testing
440    use crate::{
441        CharStringExt, Lint, TokenStringExt,
442        linting::{LintKind, Suggestion},
443    };
444
445    /// Type alias for many:many error-to-fix mappings used in testing
446    /// Each error pattern can map to multiple possible fixes
447    pub type TestLinterMap<'a> = &'a [(&'a [&'a str], &'a [&'a str])];
448
449    #[derive(Clone)]
450    pub struct TestLinter<'a> {
451        map: TestLinterMap<'a>,
452    }
453    impl<'a> TestLinter<'a> {
454        pub fn new(map: TestLinterMap<'a>) -> Self {
455            Self { map }
456        }
457    }
458    impl<'a> Linter for TestLinter<'a> {
459        fn lint(&mut self, doc: &Document) -> Vec<Lint> {
460            let mut corr: Vec<(Span<char>, &[char], &[&str])> = Vec::new();
461            for wordtok in doc.iter_words() {
462                let wordspan = wordtok.span;
463                let word_chars = wordspan.get_content(doc.get_source());
464                // Check if word matches any of the patterns in the map
465                for (errors, fixes) in self.map {
466                    // if any of the errors match, add all of the corrections
467                    if errors.iter().any(|&e| word_chars.eq_str(e)) {
468                        corr.push((wordspan, word_chars, fixes))
469                    }
470                }
471            }
472            corr.iter()
473                .map(|(ws, wch, cstr)| {
474                    // Create suggestions for all possible fixes
475                    let suggestions: Vec<Suggestion> = cstr
476                        .iter()
477                        .map(|&suggestion_str| {
478                            Suggestion::replace_with_match_case(
479                                suggestion_str.chars().collect(),
480                                wch.to_owned(),
481                            )
482                        })
483                        .collect();
484
485                    Lint {
486                        span: *ws,
487                        lint_kind: LintKind::Spelling,
488                        suggestions,
489                        message: "Test linter for 'linting assertion' tests".to_owned(),
490                        ..Default::default()
491                    }
492                })
493                .collect()
494        }
495        fn description(&self) -> &str {
496            "Test linter for 'linting assertion' tests"
497        }
498    }
499
500    // Before the asserts, let's test that the test linter itself has the behaviours we intend
501    mod linter_tests {
502        use super::{TestLinter, assert_suggestion_result};
503
504        #[test]
505        fn test_1_to_1_error_to_fix() {
506            assert_suggestion_result("bad", TestLinter::new(&[(&["bad"], &["good"])]), "good");
507        }
508
509        #[test]
510        fn test_1_to_2_error_to_fixes() {
511            let linter = TestLinter::new(&[(&["bad"], &["good1", "good2"])]);
512            assert_suggestion_result("bad", linter.clone(), "good1");
513            assert_suggestion_result("bad", linter, "good2");
514        }
515
516        #[test]
517        fn test_2_to_1_errors_to_fix() {
518            let linter = TestLinter::new(&[(&["bad1", "bad2"], &["good"])]);
519            assert_suggestion_result("bad1", linter.clone(), "good");
520            assert_suggestion_result("bad2", linter, "good");
521        }
522
523        #[test]
524        fn test_2_to_2_errors_to_fixes() {
525            let linter = TestLinter::new(&[(&["bad1", "bad2"], &["good1", "good2"])]);
526            assert_suggestion_result("bad1", linter.clone(), "good1");
527            assert_suggestion_result("bad2", linter.clone(), "good2");
528            assert_suggestion_result("bad1", linter.clone(), "good2");
529            assert_suggestion_result("bad2", linter, "good1");
530        }
531    }
532
533    #[track_caller]
534    pub fn assert_no_lints(text: &str, linter: impl Linter) {
535        assert_lint_count(text, linter, 0);
536    }
537
538    #[test]
539    fn verify_no_lints() {
540        assert_no_lints("hello world", TestLinter::new(&[]));
541    }
542
543    #[track_caller]
544    pub fn assert_lint_count(text: &str, mut linter: impl Linter, count: usize) {
545        let test = Document::new_plain_english_curated(text);
546        let lints = linter.lint(&test);
547        // dbg!(&lints);
548        if lints.len() != count {
549            panic!(
550                "Expected \"{text}\" to create {count} lints, but it created {}.",
551                lints.len()
552            );
553        }
554    }
555
556    #[test]
557    fn verify_1_lint() {
558        assert_lint_count(
559            "heloo world",
560            TestLinter::new(&[(&["heloo"], &["hello"])]),
561            1,
562        );
563    }
564
565    #[test]
566    fn verify_2_lints() {
567        assert_lint_count(
568            "heloo wolrd",
569            TestLinter::new(&[(&["heloo"], &["hello"]), (&["wolrd"], &["world"])]),
570            2,
571        );
572    }
573
574    /// Assert the total number of suggestions produced by a [`Linter`], spread across all produced
575    /// [`Lint`]s.
576    #[track_caller]
577    pub fn assert_suggestion_count(text: &str, mut linter: impl Linter, count: usize) {
578        let test = Document::new_plain_english_curated(text);
579        let lints = linter.lint(&test);
580        eprintln!(
581            "{}",
582            lints
583                .iter()
584                .map(|l| l
585                    .suggestions
586                    .iter()
587                    .map(|s| s.to_string())
588                    .collect::<Vec<_>>()
589                    .join(", "))
590                .collect::<Vec<_>>()
591                .join("\n")
592        );
593        assert_eq!(
594            lints.iter().map(|l| l.suggestions.len()).sum::<usize>(),
595            count
596        );
597    }
598
599    #[test]
600    fn verify_no_suggestions() {
601        assert_suggestion_count("afjehwkf", TestLinter::new(&[]), 0);
602    }
603
604    #[test]
605    fn verify_1_suggestion() {
606        assert_suggestion_count(
607            "dictionery",
608            TestLinter::new(&[(&["dictionery"], &["dictionary"])]),
609            1,
610        );
611    }
612
613    /// Document types for suggestion search testing
614    #[derive(Debug, Clone, Copy)]
615    enum DocumentType {
616        PlainEnglish,
617        Markdown,
618    }
619
620    /// Creates a document of the specified type from character data
621    fn create_document(chars: &[char], doc_type: DocumentType) -> Document {
622        match doc_type {
623            DocumentType::PlainEnglish => Document::new_plain_english_curated_chars(chars),
624            DocumentType::Markdown => Document::new_markdown_default_curated_chars(chars),
625        }
626    }
627
628    /// Applies suggestions iteratively until any combination produces the expected result.
629    ///
630    /// Explores all possible suggestion branches (depth-first search) until finding a path
631    /// that produces the expected result. Stops after
632    /// [`MAX_SUGGESTION_TRANSFORMATION_DEPTH`] iterations to prevent infinite loops.
633    ///
634    /// Use this when you want to verify that *some* suggestion sequence produces the
635    /// expected result, without caring which specific suggestions are used.
636    ///
637    /// See issue #950: https://github.com/Automattic/harper/issues/950
638    #[track_caller]
639    pub fn assert_suggestion_result(text: &str, mut linter: impl Linter, needle: &str) {
640        if search_for_suggestion(DocumentType::PlainEnglish, text, &mut linter, needle, 0) {
641            return;
642        }
643
644        panic!(
645            "No suggestion sequence produced the expected result.\n\
646            Expected: \"{needle}\""
647        );
648    }
649
650    /// DFS implementation using markdown instead of plain English
651    #[track_caller]
652    pub fn assert_markdown_suggestion_result(text: &str, mut linter: impl Linter, needle: &str) {
653        if !search_for_suggestion(DocumentType::Markdown, text, &mut linter, needle, 0) {
654            panic!("No suggestion sequence produced the expected result.\nExpected: {needle}");
655        }
656    }
657
658    /// Recursively searches all suggestion combinations using depth-first search.
659    /// Returns true if any path reaches the expected result, false otherwise.
660    fn search_for_suggestion(
661        doc_type: DocumentType,
662        text: &str,
663        linter: &mut impl Linter,
664        needle: &str,
665        depth: usize,
666    ) -> bool {
667        // Prevent infinite recursion (e.g. cycles in suggestions)
668        if depth > super::MAX_SUGGESTION_TRANSFORMATION_DEPTH {
669            eprintln!(
670                "⚠️  Reached depth limit ({})",
671                super::MAX_SUGGESTION_TRANSFORMATION_DEPTH
672            );
673            return false;
674        }
675
676        // Check if we've reached the expected result
677        if text == needle {
678            return true;
679        }
680
681        // Lint current text and try each suggestion branch
682        let chars: Vec<char> = text.chars().collect();
683        let document = create_document(&chars, doc_type);
684        let mut lints = linter.lint(&document);
685        lints.sort_by_key(|l| l.priority);
686
687        if let Some(lint) = lints.first() {
688            for sug in lint.suggestions.iter() {
689                let mut chars_copy = chars.clone();
690                sug.apply(lint.span, &mut chars_copy);
691                let next: String = chars_copy.iter().collect();
692
693                // Recursively search this branch
694                if search_for_suggestion(doc_type, &next, linter, needle, depth + 1) {
695                    return true;
696                }
697            }
698        }
699
700        false
701    }
702
703    #[test]
704    fn verify_fix_one_lint() {
705        assert_suggestion_result(
706            "find the misstake and fix it",
707            TestLinter::new(&[(&["misstake"], &["mistake"])]),
708            "find the mistake and fix it",
709        );
710    }
711
712    #[test]
713    #[should_panic]
714    fn verify_unable_to_fix_one_spanish_lint() {
715        assert_suggestion_result("Hay una orrrer", TestLinter::new(&[]), "Hay una error");
716    }
717
718    #[test]
719    fn verify_fix_two_lints() {
720        assert_suggestion_result(
721            "find two misstakes and fix theem",
722            TestLinter::new(&[(&["misstakes"], &["mistakes"]), (&["theem"], &["them"])]),
723            "find two mistakes and fix them",
724        );
725    }
726
727    // Stress test: multiple errors in one sentence, DFS must find correct suggestion path
728    // Note: This test is known to be brittle - it depends on SpellCheck dictionary and
729    // suggestion ranking. If it fails after a dictionary update, try different word combinations.
730    // Uses common misspellings that have unambiguous correct suggestions in the top 3.
731    #[test]
732    fn verify_fix_five_typos() {
733        assert_suggestion_result(
734            "Please recieve teh payment untill thier authorization occured",
735            TestLinter::new(&[
736                (&["recieve"], &["receive"]),
737                (&["teh"], &["the"]),
738                (&["untill"], &["until"]),
739                (&["thier"], &["their"]),
740                (&["occured"], &["occurred"]),
741            ]),
742            "Please receive the payment until their authorization occurred",
743        );
744    }
745
746    /// Asserts that none of the suggestions from the linter match the given text.
747    #[track_caller]
748    pub fn assert_not_in_suggestion_result(
749        text: &str,
750        mut linter: impl Linter,
751        bad_suggestion: &str,
752    ) {
753        if !search_for_suggestion(
754            DocumentType::PlainEnglish,
755            text,
756            &mut linter,
757            bad_suggestion,
758            0,
759        ) {
760            return;
761        }
762
763        panic!(
764            "A suggestion sequence produced the undesired result.\n\
765            Undesired: \"{bad_suggestion}\""
766        );
767    }
768
769    #[test]
770    fn verify_sole_suggestion_is_the_one_we_wanted() {
771        assert_not_in_suggestion_result(
772            "Baby cats are called kitens",
773            TestLinter::new(&[]),
774            "Baby cats are called puppies",
775        );
776    }
777
778    // TODO verify sole suggestion is not the one we wanted fails
779
780    #[test]
781    #[should_panic]
782    fn verify_sole_suggestion_not_in_result_fails() {
783        assert_not_in_suggestion_result(
784            "heloo",
785            TestLinter::new(&[(&["heloo"], &["hello"])]),
786            "hello",
787        );
788    }
789
790    // TODO verify many suggestions including the one we want succeeds
791    // TODO verify many suggestions but not the one we want fails
792
793    /// Asserts both that the given text matches the expected good suggestions and that none of the
794    /// suggestions are in the bad suggestions list.
795    /// TODO: Reimplement similar to `search_suggestion_tree`
796    #[track_caller]
797    pub fn assert_good_and_bad_suggestions(
798        text: &str,
799        mut linter: impl Linter,
800        good: &[&str],
801        bad: &[&str],
802    ) {
803        let test = Document::new_plain_english_curated(text);
804        let lints = linter.lint(&test);
805
806        let mut unseen_good: HashSet<_> = good.iter().cloned().collect();
807        let mut found_bad = Vec::new();
808        let mut found_good = Vec::new();
809
810        for (i, lint) in lints.into_iter().enumerate() {
811            for (j, suggestion) in lint.suggestions.into_iter().enumerate() {
812                let mut text_chars: Vec<char> = text.chars().collect();
813                suggestion.apply(lint.span, &mut text_chars);
814                let suggestion_text: String = text_chars.into_iter().collect();
815
816                // Check for bad suggestions
817                if bad.contains(&&*suggestion_text) {
818                    found_bad.push((i, j, suggestion_text.clone()));
819                    eprintln!(
820                        "  ❌ Found bad suggestion at lint[{i}].suggestions[{j}]: \"{suggestion_text}\""
821                    );
822                }
823                // Check for good suggestions
824                else if good.contains(&&*suggestion_text) {
825                    found_good.push((i, j, suggestion_text.clone()));
826                    eprintln!(
827                        "  ✅ Found good suggestion at lint[{i}].suggestions[{j}]: \"{suggestion_text}\""
828                    );
829                    unseen_good.remove(suggestion_text.as_str());
830                } else {
831                    eprintln!(
832                        "  ⚠️  Found unexpected suggestion at lint[{i}].suggestions[{j}]: \"{suggestion_text}\""
833                    );
834                }
835            }
836        }
837
838        // Print summary
839        if !found_bad.is_empty() || !unseen_good.is_empty() {
840            eprintln!("\n=== Test Summary ===");
841
842            if !found_bad.is_empty() {
843                eprintln!("\n❌ Found {} bad suggestions:", found_bad.len());
844                for (i, j, text) in &found_bad {
845                    eprintln!("  - lint[{i}].suggestions[{j}]: \"{text}\"");
846                }
847            }
848
849            if !unseen_good.is_empty() {
850                eprintln!(
851                    "\n❌ Missing {} expected good suggestions:",
852                    unseen_good.len()
853                );
854                for text in &unseen_good {
855                    eprintln!("  - \"{text}\"");
856                }
857            }
858
859            eprintln!("\n✅ Found {} good suggestions", found_good.len());
860            eprintln!("==================\n");
861
862            if !found_bad.is_empty() || !unseen_good.is_empty() {
863                panic!("Test failed - see error output above");
864            }
865        } else {
866            eprintln!(
867                "\n✅ All {} good suggestions found, no bad suggestions\n",
868                found_good.len()
869            );
870        }
871    }
872
873    // TODO test that having all the good and none of the bad succeeds
874    // TODO test that missing one of the good fails
875    // TODO test that having one of the bads fails
876
877    #[test]
878    #[should_panic]
879    fn verify_mutal_corrections_cause_failure() {
880        assert_suggestion_result(
881            "gooder",
882            TestLinter::new(&[(&["gooder"], &["more good"])]),
883            "better",
884        );
885    }
886
887    /// Asserts that the lint's message matches the expected message.
888    #[track_caller]
889    pub fn assert_lint_message(text: &str, mut linter: impl Linter, expected_message: &str) {
890        let test = Document::new_plain_english_curated(text);
891        let lints = linter.lint(&test);
892
893        // Just check the first lint for now - TODO
894        if let Some(lint) = lints.first()
895            && lint.message != expected_message
896        {
897            panic!(
898                "Expected lint message \"{expected_message}\", but got \"{}\"",
899                lint.message
900            );
901        }
902    }
903}