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