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}