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