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