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