supa_mdx_lint/rules/
rule003_spelling.rs

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/// Words are checked for correct spelling.
49///
50/// There are four ways to deal with words that are flagged, even though you're spelling them as intended:
51///
52/// 1. For proper nouns and jargon, you can add them to the [Vocabulary](#vocabulary).
53/// 2. For function, method, and variable names, you can format them as inline code. For example, instead of `foo`, write `` `foo` ``.
54/// 3. You can add a temporary configuration, which will take effect for either the next line or the rest of the file. This configuration adds the specified words to the vocabulary temporarily. Words added are case-sensitive.
55///    ```markdown
56///    {/* supa-mdx-lint-configure Rule003Spelling +Supabase */}
57///    {/* supa-mdx-lint-configure-next-line Rule003Spelling +pgTAP */}
58///    ```
59/// 4. You can disable the rule by using one of the disable directives. This should be used as a last resort.
60///    ```markdown
61///    {/* supa-mdx-lint-disable Rule003Spelling */}
62///    {/* supa-mdx-lint-disable-next-line Rule003Spelling */}
63///    ```
64///
65/// ## Examples
66///
67/// ### Valid
68///
69/// ```markdown
70/// This is correctly spelled.
71/// ```
72///
73/// ### Invalid
74///
75/// ```markdown
76/// This is incorrectyl spelled.
77/// ```
78///
79/// ## Vocabulary
80///
81/// Vocabulary can be added via the `allow_list` and `prefixes` arrays.
82///
83/// - `allow_list`: A list of words (or regex patterns to match words) that are considered correctly spelled.
84/// - `prefixes`: A list of prefixes that are not standalone words, but that can be used in a prefix before a hyphen (e.g., `pre`, `bi`).
85///
86/// See an  [example from the Supabase repo](https://github.com/supabase/supabase/blob/master/supa-mdx-lint/Rule003Spelling.toml).
87#[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    /// Parse lint-time configuration comments for this rule.
177    ///
178    /// ## Examples
179    ///
180    /// 1. Allows "Supabase" for the rest of the file:
181    ///    ```mdx
182    ///    {/* supa-mdx-lint-configure Rule003Spelling +Supabase */}
183    ///    ```
184    /// 1. Allows "Supabase" for the next line:
185    ///    ```mdx
186    ///    {/* supa-mdx-lint-configure-next-line Rule003Spelling +Supabase */}
187    ///    ```
188    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                // Deal with hyphenated words
311                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        // 's is too common for us to list every single word that could end with it,
455        // just ignore it
456        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        // Smart quotes are three bytes long
467        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        // 's is too common for us to list every single word that could end with it,
495        // just ignore it
496        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        // Shouldn't is in dictionary, but hell'o is not
730        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}