1use std::{
2    borrow::Cow,
3    cell::RefCell,
4    collections::{HashMap, HashSet},
5    ops::Range,
6    rc::Rc,
7};
8
9use crop::RopeSlice;
10use log::{debug, trace};
11use markdown::mdast;
12use regex::Regex;
13use suggestions::SuggestionMatcher;
14use supa_mdx_macros::RuleName;
15
16use crate::{
17    comments::LintTimeRuleConfigs,
18    context::{Context, ContextId},
19    errors::LintError,
20    fix::{LintCorrection, LintCorrectionReplace},
21    location::{
22        AdjustedOffset, AdjustedRange, DenormalizedLocation, MaybeEndedLineRange, RangeSet,
23    },
24    utils::{
25        self,
26        lru::LruCache,
27        regex::expand_regex,
28        words::{is_punctuation, BreakOnPunctuation, WordIterator, WordIteratorOptions},
29    },
30    LintLevel,
31};
32
33use super::{RegexBeginning, RegexEnding, RegexSettings, Rule, RuleName, RuleSettings};
34
35mod suggestions;
36
37const DICTIONARY: &str = include_str!("./rule003_spelling/dictionary.txt");
38
39#[derive(Debug, Clone)]
40enum HyphenatedPart {
41    MaybePrefix,
42    MaybeSuffix,
43}
44
45#[derive(Debug, Default)]
46struct LintTimeVocabAllowed(HashMap<String, Vec<MaybeEndedLineRange>>);
47
48#[derive(Default, RuleName)]
88pub struct Rule003Spelling {
89    allow_list: Vec<Regex>,
90    prefixes: HashSet<String>,
91    dictionary: HashSet<String>,
92    config_cache: Rc<RefCell<LruCache<ContextId, Option<LintTimeVocabAllowed>>>>,
93    suggestion_matcher: SuggestionMatcher,
94}
95
96impl std::fmt::Debug for Rule003Spelling {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        f.debug_struct("Rule003Spelling")
99            .field("allow_list", &self.allow_list)
100            .field("prefixes", &self.prefixes)
101            .field("configuration_cache", &self.config_cache)
102            .field("dictionary", &"[OMITTED (too large)]")
103            .finish()
104    }
105}
106
107impl Rule for Rule003Spelling {
108    fn default_level(&self) -> LintLevel {
109        LintLevel::Error
110    }
111
112    fn setup(&mut self, settings: Option<&mut RuleSettings>) {
113        if let Some(settings) = settings {
114            if let Some(vec) = settings.get_array_of_regexes(
115                "allow_list",
116                Some(&RegexSettings {
117                    beginning: Some(RegexBeginning::WordBoundary),
118                    ending: Some(RegexEnding::WordBoundary),
119                }),
120            ) {
121                self.allow_list = vec;
122            }
123
124            if let Some(vec) = settings.get_array_of_strings("prefixes") {
125                self.prefixes = HashSet::from_iter(vec);
126            }
127        }
128
129        self.setup_dictionary();
130    }
131
132    fn check(
133        &self,
134        ast: &mdast::Node,
135        context: &Context,
136        level: LintLevel,
137    ) -> Option<Vec<LintError>> {
138        self.check_node(ast, context, level)
139    }
140}
141
142impl Rule003Spelling {
143    fn message(word: &str) -> String {
144        format!("Word not found in dictionary: {}", word)
145    }
146
147    fn setup_dictionary(&mut self) {
148        let mut set: HashSet<String> = HashSet::new();
149        DICTIONARY
150            .lines()
151            .map(|line| {
152                line.split_once(' ')
153                    .expect("Every line in static dictionary file should have a space")
154                    .0
155            })
156            .for_each(|word| {
157                set.insert(word.to_owned());
158            });
159        self.dictionary = set;
160
161        let custom_words = self
162            .allow_list
163            .iter()
164            .flat_map(|regex| {
165                expand_regex()
166                    .regex(regex.as_str())
167                    .call()
168                    .into_iter()
169                    .flatten()
170            })
171            .collect::<Vec<_>>();
172        let suggestion_matcher = SuggestionMatcher::new(&custom_words);
173        self.suggestion_matcher = suggestion_matcher;
174    }
175
176    fn parse_lint_time_config(&self, cache_key: &ContextId, config: &LintTimeRuleConfigs) {
189        if self.config_cache.borrow().contains_key(cache_key) {
190            return;
191        }
192
193        let map = config.get(&self.name().into()).map(|list| {
194            let mut map = HashMap::new();
195            for (word, range) in list {
196                if !word.starts_with('+') {
197                    continue;
198                }
199                let word = word.trim_start_matches('+');
200                map.entry(word.to_string())
201                    .or_insert_with(Vec::new)
202                    .push(range.clone());
203            }
204            LintTimeVocabAllowed(map)
205        });
206        self.config_cache
207            .borrow_mut()
208            .insert(cache_key.clone(), map);
209    }
210
211    fn with_lint_time_config<F, R>(&self, cache_key: &ContextId, f: F) -> Option<R>
212    where
213        F: FnOnce(&LintTimeVocabAllowed) -> R,
214    {
215        self.config_cache
216            .borrow_mut()
217            .get(cache_key)?
218            .as_ref()
219            .map(f)
220    }
221
222    fn check_node(
223        &self,
224        node: &mdast::Node,
225        context: &Context,
226        level: LintLevel,
227    ) -> Option<Vec<LintError>> {
228        trace!("[Rule003Spelling] Checking node: {node:#?}");
229
230        let mut errors: Option<Vec<LintError>> = None;
231
232        if let mdast::Node::Text(_) = node {
233            if utils::mdast::is_export_const(node) {
234                return None;
235            };
236
237            if let Some(position) = node.position() {
238                self.parse_lint_time_config(&context.key, &context.lint_time_rule_configs);
239
240                let range = AdjustedRange::from_unadjusted_position(position, context);
241                let text = context
242                    .rope()
243                    .byte_slice(Into::<Range<usize>>::into(range.clone()));
244                self.check_spelling(text, range.start.into(), context, level, &mut errors);
245            }
246        }
247
248        errors
249    }
250
251    fn get_ignored_ranges(&self, text: &str, offset: usize, ctx: &Context) -> RangeSet {
252        let mut ignored_ranges: RangeSet = RangeSet::new();
253        for exception in self.allow_list.iter() {
254            trace!("Checking exception: {exception}");
255            let iter = exception.find_iter(text);
256            for match_result in iter {
257                trace!("Found exception match: {match_result:?}");
258                ignored_ranges.push(AdjustedRange::new(
259                    (match_result.start() + offset).into(),
260                    (match_result.end() + offset).into(),
261                ));
262            }
263        }
264        self.with_lint_time_config(&ctx.key, |config| {
265            config.0.iter().for_each(|(word, ranges)| {
266                let word_pattern =
267                    regex::Regex::new(&format!(r"\b{}\b", regex::escape(word))).unwrap();
268                for r#match in word_pattern.find_iter(text) {
269                    let word_start = r#match.start() + offset;
270                    let word_end = r#match.end() + offset;
271                    let word_range = AdjustedRange::new(word_start.into(), word_end.into());
272
273                    for range in ranges {
274                        if range.overlaps_lines(&word_range, ctx.rope()) {
275                            ignored_ranges.push(word_range.clone());
276                        }
277                    }
278                }
279            })
280        });
281        ignored_ranges
282    }
283
284    fn check_spelling(
285        &self,
286        text: RopeSlice,
287        text_offset_in_parent: usize,
288        context: &Context,
289        level: LintLevel,
290        errors: &mut Option<Vec<LintError>>,
291    ) {
292        let text_as_string = text.to_string();
293        let ignored_ranges =
294            self.get_ignored_ranges(&text_as_string, text_offset_in_parent, context);
295        debug!("Ignored ranges: {ignored_ranges:#?}");
296
297        trace!("Starting tokenizer with text_offset_in_parent: {text_offset_in_parent}");
298        let tokenizer =
299            WordIterator::new(text, text_offset_in_parent, WordIteratorOptions::default());
300        for (offset, word, _cap) in tokenizer {
301            let word_as_string = word.to_string();
302
303            let word_range = Self::normalize_word_range(word, offset);
304            trace!("Found word {word} in range {word_range:?}");
305            if ignored_ranges.completely_contains(&word_range) {
306                continue;
307            }
308
309            if word_as_string.contains('-') && !self.is_correct_spelling(&word_as_string, &None) {
310                let mut hyphenated_tokenizer = WordIterator::new(
312                    word,
313                    offset,
314                    WordIteratorOptions {
315                        break_on_punctuation: BreakOnPunctuation::Hyphen,
316                        ..Default::default()
317                    },
318                )
319                .enumerate()
320                .peekable();
321                while let Some((idx, (offset, part, _cap))) = hyphenated_tokenizer.next() {
322                    if idx == 0 {
323                        let adjusted_range =
324                            AdjustedRange::new(offset.into(), (offset + part.byte_len()).into());
325                        if ignored_ranges.completely_contains(&adjusted_range) {
326                            continue;
327                        }
328
329                        self.check_word_spelling(
330                            &part.to_string(),
331                            Some(HyphenatedPart::MaybePrefix),
332                            adjusted_range,
333                            context,
334                            level,
335                            errors,
336                        );
337                    } else if hyphenated_tokenizer.peek().is_none() {
338                        let adjusted_range = Self::normalize_word_range(part, offset);
339                        if ignored_ranges.completely_contains(&adjusted_range) {
340                            continue;
341                        }
342
343                        self.check_word_spelling(
344                            &part.to_string(),
345                            Some(HyphenatedPart::MaybeSuffix),
346                            adjusted_range,
347                            context,
348                            level,
349                            errors,
350                        );
351                    } else {
352                        let adjusted_range =
353                            AdjustedRange::new(offset.into(), (offset + part.byte_len()).into());
354                        if ignored_ranges.completely_contains(&adjusted_range) {
355                            continue;
356                        }
357
358                        self.check_word_spelling(
359                            &part.to_string(),
360                            None,
361                            adjusted_range,
362                            context,
363                            level,
364                            errors,
365                        );
366                    }
367                }
368            } else {
369                self.check_word_spelling(&word_as_string, None, word_range, context, level, errors);
370            }
371        }
372    }
373
374    fn check_word_spelling(
375        &self,
376        word: &str,
377        hyphenation: Option<HyphenatedPart>,
378        location: AdjustedRange,
379        context: &Context,
380        level: LintLevel,
381        errors: &mut Option<Vec<LintError>>,
382    ) {
383        if self.is_correct_spelling(word, &hyphenation) {
384            return;
385        }
386
387        let suggestions = match hyphenation {
388            None => {
389                let suggestions = self.suggestion_matcher.suggest(word);
390                if suggestions.is_empty() {
391                    None
392                } else {
393                    Some(
394                        suggestions
395                            .into_iter()
396                            .map(|s| {
397                                LintCorrection::Replace(LintCorrectionReplace {
398                                    text: s,
399                                    location: DenormalizedLocation::from_offset_range(
400                                        location.clone(),
401                                        context,
402                                    ),
403                                })
404                            })
405                            .collect::<Vec<_>>(),
406                    )
407                }
408            }
409            Some(_) => None,
410        };
411
412        let error = LintError::builder()
413            .rule(self.name())
414            .message(Rule003Spelling::message(word))
415            .level(level)
416            .location(location)
417            .context(context)
418            .maybe_suggestions(suggestions)
419            .build();
420        errors.get_or_insert_with(Vec::new).push(error);
421    }
422
423    fn is_correct_spelling(&self, word: &str, hyphenation: &Option<HyphenatedPart>) -> bool {
424        trace!("Checking spelling of word: {word} with hyphenation: {hyphenation:?}");
425        if word.len() < 2 {
426            return true;
427        }
428
429        if word
430            .chars()
431            .any(|c| !c.is_ascii_alphabetic() && !Self::is_included_punctuation(&c))
432        {
433            return true;
434        }
435
436        let word = Self::normalize_word(word);
437        if self.dictionary.contains(word.as_ref()) {
438            return true;
439        }
440
441        if let Some(HyphenatedPart::MaybePrefix) = hyphenation {
442            if self.prefixes.contains(word.as_ref()) {
443                return true;
444            }
445        }
446
447        false
448    }
449
450    fn normalize_word_range(word: RopeSlice<'_>, offset: usize) -> AdjustedRange {
451        let start: AdjustedOffset = offset.into();
452        let mut end: AdjustedOffset = (offset + word.byte_len()).into();
453
454        if word.byte_len() > 2
457            && word.is_char_boundary(word.byte_len() - 2)
458            && word
459                .byte_slice(word.byte_len() - 2..)
460                .chars()
461                .collect::<String>()
462                == "'s"
463        {
464            end -= 2.into();
465        }
466        else if word.byte_len() > 4 && word.is_char_boundary(word.byte_len() - 4) {
468            let ending = word
469                .byte_slice(word.byte_len() - 4..)
470                .chars()
471                .collect::<String>();
472            if ending == "‘s" || ending == "’s" {
473                end -= 4.into();
474            }
475        }
476
477        AdjustedRange::new(start, end)
478    }
479
480    fn normalize_word(word: &str) -> Cow<str> {
481        let mut word = Cow::Borrowed(word);
482
483        let quote_chars = ['‘', '’', '“', '”'];
484        if word.contains(|c| quote_chars.contains(&c)) || word.chars().any(|c| c.is_uppercase()) {
485            let modified = word
486                .replace("‘", "'")
487                .replace("’", "'")
488                .replace("“", "\"")
489                .replace("”", "\"")
490                .to_lowercase();
491            word = Cow::Owned(modified);
492        }
493
494        if word.ends_with("'s") {
497            match word {
498                Cow::Borrowed(s) => Cow::Borrowed(&s[..s.len() - 2]),
499                Cow::Owned(mut s) => {
500                    s.truncate(s.len() - 2);
501                    Cow::Owned(s)
502                }
503            }
504        } else {
505            word
506        }
507    }
508
509    fn is_included_punctuation(c: &char) -> bool {
510        is_punctuation(c)
511            && (*c == '-'
512                || *c == '–'
513                || *c == '—'
514                || *c == '―'
515                || *c == '\''
516                || *c == '‘'
517                || *c == '’'
518                || *c == '“'
519                || *c == '”'
520                || *c == '"'
521                || *c == '.')
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use crate::{location::AdjustedOffset, parser::parse};
528
529    use super::*;
530
531    #[test]
532    fn test_rule003_spelling_good() {
533        let mdx = "hello world";
534        let parse_result = parse(mdx).unwrap();
535        let context = Context::builder()
536            .parse_result(&parse_result)
537            .build()
538            .unwrap();
539
540        let mut rule = Rule003Spelling::default();
541        rule.setup(None);
542
543        let errors = rule.check(
544            parse_result
545                .ast()
546                .children()
547                .unwrap()
548                .get(0)
549                .unwrap()
550                .children()
551                .unwrap()
552                .get(0)
553                .unwrap(),
554            &context,
555            LintLevel::Error,
556        );
557        assert!(errors.is_none());
558    }
559
560    #[test]
561    fn test_rule003_spelling_bad() {
562        let mdx = "heloo world";
563        let parse_result = parse(mdx).unwrap();
564        let context = Context::builder()
565            .parse_result(&parse_result)
566            .build()
567            .unwrap();
568
569        let mut rule = Rule003Spelling::default();
570        rule.setup(None);
571
572        let errors = rule
573            .check(
574                parse_result
575                    .ast()
576                    .children()
577                    .unwrap()
578                    .get(0)
579                    .unwrap()
580                    .children()
581                    .unwrap()
582                    .get(0)
583                    .unwrap(),
584                &context,
585                LintLevel::Error,
586            )
587            .unwrap();
588        assert!(errors.len() == 1);
589
590        let error = &errors[0];
591        assert_eq!(error.message, "Word not found in dictionary: heloo");
592        assert_eq!(error.location.offset_range.start, AdjustedOffset::from(0));
593        assert_eq!(error.location.offset_range.end, AdjustedOffset::from(5));
594    }
595
596    #[test]
597    fn test_rule003_with_exception() {
598        let mdx = "heloo world";
599        let parse_result = parse(mdx).unwrap();
600        let context = Context::builder()
601            .parse_result(&parse_result)
602            .build()
603            .unwrap();
604
605        let mut rule = Rule003Spelling::default();
606        let mut settings = RuleSettings::with_array_of_strings("allow_list", vec!["heloo"]);
607        rule.setup(Some(&mut settings));
608
609        let errors = rule.check(
610            parse_result
611                .ast()
612                .children()
613                .unwrap()
614                .get(0)
615                .unwrap()
616                .children()
617                .unwrap()
618                .get(0)
619                .unwrap(),
620            &context,
621            LintLevel::Error,
622        );
623        assert!(errors.is_none());
624    }
625
626    #[test]
627    fn test_rule003_with_repeated_exception() {
628        let mdx = "heloo world heloo";
629        let parse_result = parse(mdx).unwrap();
630        let context = Context::builder()
631            .parse_result(&parse_result)
632            .build()
633            .unwrap();
634
635        let mut rule = Rule003Spelling::default();
636        let mut settings = RuleSettings::with_array_of_strings("allow_list", vec!["heloo"]);
637        rule.setup(Some(&mut settings));
638
639        let errors = rule.check(
640            parse_result
641                .ast()
642                .children()
643                .unwrap()
644                .get(0)
645                .unwrap()
646                .children()
647                .unwrap()
648                .get(0)
649                .unwrap(),
650            &context,
651            LintLevel::Error,
652        );
653        assert!(errors.is_none());
654    }
655
656    #[test]
657    fn test_rule003_with_regex_exception() {
658        let mdx = "Heloo world";
659        let parse_result = parse(mdx).unwrap();
660        let context = Context::builder()
661            .parse_result(&parse_result)
662            .build()
663            .unwrap();
664
665        let mut rule = Rule003Spelling::default();
666        let mut settings = RuleSettings::with_array_of_strings("allow_list", vec!["[Hh]eloo"]);
667        rule.setup(Some(&mut settings));
668
669        let errors = rule.check(
670            parse_result
671                .ast()
672                .children()
673                .unwrap()
674                .get(0)
675                .unwrap()
676                .children()
677                .unwrap()
678                .get(0)
679                .unwrap(),
680            &context,
681            LintLevel::Error,
682        );
683        assert!(errors.is_none());
684    }
685
686    #[test]
687    fn test_rule003_with_punctuation() {
688        let mdx = "heloo, 'asdf' world";
689        let parse_result = parse(mdx).unwrap();
690        let context = Context::builder()
691            .parse_result(&parse_result)
692            .build()
693            .unwrap();
694
695        let mut rule = Rule003Spelling::default();
696        rule.setup(None);
697
698        let errors = rule
699            .check(
700                parse_result
701                    .ast()
702                    .children()
703                    .unwrap()
704                    .get(0)
705                    .unwrap()
706                    .children()
707                    .unwrap()
708                    .get(0)
709                    .unwrap(),
710                &context,
711                LintLevel::Error,
712            )
713            .unwrap();
714        assert!(errors.len() == 2);
715
716        let error = &errors[0];
717        assert_eq!(error.message, "Word not found in dictionary: heloo");
718        assert_eq!(error.location.offset_range.start, AdjustedOffset::from(0));
719        assert_eq!(error.location.offset_range.end, AdjustedOffset::from(5));
720
721        let error = &errors[1];
722        assert_eq!(error.message, "Word not found in dictionary: asdf");
723        assert_eq!(error.location.offset_range.start, AdjustedOffset::from(8));
724        assert_eq!(error.location.offset_range.end, AdjustedOffset::from(12));
725    }
726
727    #[test]
728    fn test_rule003_with_midword_punctuation() {
729        let mdx = "hell'o world shouldn't work";
731        let parse_result = parse(mdx).unwrap();
732        let context = Context::builder()
733            .parse_result(&parse_result)
734            .build()
735            .unwrap();
736
737        let mut rule = Rule003Spelling::default();
738        rule.setup(None);
739
740        let errors = rule
741            .check(
742                parse_result
743                    .ast()
744                    .children()
745                    .unwrap()
746                    .get(0)
747                    .unwrap()
748                    .children()
749                    .unwrap()
750                    .get(0)
751                    .unwrap(),
752                &context,
753                LintLevel::Error,
754            )
755            .unwrap();
756        assert!(errors.len() == 1);
757
758        let error = &errors[0];
759        assert_eq!(error.message, "Word not found in dictionary: hell'o");
760        assert_eq!(error.location.offset_range.start, AdjustedOffset::from(0));
761        assert_eq!(error.location.offset_range.end, AdjustedOffset::from(6));
762    }
763
764    #[test]
765    fn test_rule003_with_multiple_lines() {
766        let mdx = "hello world\nhello world\nheloo world";
767        let parse_result = parse(mdx).unwrap();
768        let context = Context::builder()
769            .parse_result(&parse_result)
770            .build()
771            .unwrap();
772
773        let mut rule = Rule003Spelling::default();
774        rule.setup(None);
775
776        let errors = rule
777            .check(
778                parse_result
779                    .ast()
780                    .children()
781                    .unwrap()
782                    .get(0)
783                    .unwrap()
784                    .children()
785                    .unwrap()
786                    .get(0)
787                    .unwrap(),
788                &context,
789                LintLevel::Error,
790            )
791            .unwrap();
792        assert!(errors.len() == 1);
793
794        let error = &errors[0];
795        assert_eq!(error.message, "Word not found in dictionary: heloo");
796        assert_eq!(error.location.offset_range.start, AdjustedOffset::from(24));
797        assert_eq!(error.location.offset_range.end, AdjustedOffset::from(29));
798    }
799
800    #[test]
801    fn test_rule003_with_prefix() {
802        let mdx = "hello pre-world";
803        let parse_result = parse(mdx).unwrap();
804        let context = Context::builder()
805            .parse_result(&parse_result)
806            .build()
807            .unwrap();
808
809        let mut rule = Rule003Spelling::default();
810        let mut settings = RuleSettings::with_array_of_strings("prefixes", vec!["pre"]);
811        rule.setup(Some(&mut settings));
812
813        let errors = rule.check(
814            parse_result
815                .ast()
816                .children()
817                .unwrap()
818                .get(0)
819                .unwrap()
820                .children()
821                .unwrap()
822                .get(0)
823                .unwrap(),
824            &context,
825            LintLevel::Error,
826        );
827        assert!(errors.is_none());
828    }
829
830    #[test]
831    fn test_rule003_ignore_filenames() {
832        let mdx = "use the file hello.toml";
833        let parse_result = parse(mdx).unwrap();
834        let context = Context::builder()
835            .parse_result(&parse_result)
836            .build()
837            .unwrap();
838
839        let mut rule = Rule003Spelling::default();
840        let mut settings = RuleSettings::with_array_of_strings("allow_list", vec!["\\S+\\.toml"]);
841        rule.setup(Some(&mut settings));
842
843        let errors = rule.check(
844            parse_result
845                .ast()
846                .children()
847                .unwrap()
848                .get(0)
849                .unwrap()
850                .children()
851                .unwrap()
852                .get(0)
853                .unwrap(),
854            &context,
855            LintLevel::Error,
856        );
857        assert!(errors.is_none());
858    }
859
860    #[test]
861    fn test_rule003_ignore_complex_regex() {
862        let mdx = "test a thing [#rest-api-overview]";
863        let parse_result = parse(mdx).unwrap();
864        let context = Context::builder()
865            .parse_result(&parse_result)
866            .build()
867            .unwrap();
868
869        let mut rule = Rule003Spelling::default();
870        let mut settings =
871            RuleSettings::with_array_of_strings("allow_list", vec!["\\[#[A-Za-z-]+\\]"]);
872        rule.setup(Some(&mut settings));
873
874        let errors = rule.check(
875            parse_result
876                .ast()
877                .children()
878                .unwrap()
879                .get(0)
880                .unwrap()
881                .children()
882                .unwrap()
883                .get(0)
884                .unwrap(),
885            &context,
886            LintLevel::Error,
887        );
888        assert!(errors.is_none());
889    }
890
891    #[test]
892    fn test_rule003_ignore_emojis() {
893        let mdx = "hello 🤝 world";
894        let parse_result = parse(mdx).unwrap();
895        let context = Context::builder()
896            .parse_result(&parse_result)
897            .build()
898            .unwrap();
899
900        let mut rule = Rule003Spelling::default();
901        rule.setup(None);
902
903        let errors = rule.check(
904            parse_result
905                .ast()
906                .children()
907                .unwrap()
908                .get(0)
909                .unwrap()
910                .children()
911                .unwrap()
912                .get(0)
913                .unwrap(),
914            &context,
915            LintLevel::Error,
916        );
917        assert!(errors.is_none());
918    }
919
920    #[test]
921    fn test_rule003_bare_prefixes() {
922        let mdx = "pre- and post-world";
923        let parse_result = parse(mdx).unwrap();
924        let context = Context::builder()
925            .parse_result(&parse_result)
926            .build()
927            .unwrap();
928
929        let mut rule = Rule003Spelling::default();
930        let mut settings = RuleSettings::with_array_of_strings("prefixes", vec!["pre", "post"]);
931        rule.setup(Some(&mut settings));
932
933        let errors = rule.check(
934            parse_result
935                .ast()
936                .children()
937                .unwrap()
938                .get(0)
939                .unwrap()
940                .children()
941                .unwrap()
942                .get(0)
943                .unwrap(),
944            &context,
945            LintLevel::Error,
946        );
947        assert!(errors.is_none());
948    }
949
950    #[test]
951    fn test_rule003_suggestions() {
952        let mdx = "heloo wrld";
953        let parse_result = parse(mdx).unwrap();
954        let context = Context::builder()
955            .parse_result(&parse_result)
956            .build()
957            .unwrap();
958
959        let mut rule = Rule003Spelling::default();
960        rule.setup(None);
961
962        let errors = rule
963            .check(
964                parse_result
965                    .ast()
966                    .children()
967                    .unwrap()
968                    .get(0)
969                    .unwrap()
970                    .children()
971                    .unwrap()
972                    .get(0)
973                    .unwrap(),
974                &context,
975                LintLevel::Error,
976            )
977            .unwrap();
978        assert!(errors.len() == 2);
979
980        let error = &errors[0];
981        assert_eq!(error.message, "Word not found in dictionary: heloo");
982        assert_eq!(error.location.offset_range.start, AdjustedOffset::from(0));
983        assert_eq!(error.location.offset_range.end, AdjustedOffset::from(5));
984        assert!(error.suggestions.is_some());
985        let suggestions = error.suggestions.as_ref().unwrap();
986        assert!(suggestions.iter().any(|s| match s {
987            LintCorrection::Replace(replace) => replace.text == "hello",
988            _ => false,
989        }));
990
991        let error = &errors[1];
992        assert_eq!(error.message, "Word not found in dictionary: wrld");
993        assert_eq!(error.location.offset_range.start, AdjustedOffset::from(6));
994        assert_eq!(error.location.offset_range.end, AdjustedOffset::from(10));
995        assert!(error.suggestions.is_some());
996        let suggestions = error.suggestions.as_ref().unwrap();
997        assert!(suggestions.iter().any(|s| match s {
998            LintCorrection::Replace(replace) => replace.text == "world",
999            _ => false,
1000        }));
1001    }
1002}