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