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