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