Skip to main content

cargo_spellcheck/
suggestion.rs

1//! Suggestions are individual correctable items where items are either words,
2//! punctuation or even complete sentences.
3//!
4//! ```raw
5//! error[spellcheck]: Spelling
6//! --> src/main.rs:138:16
7//!     |
8//! 138 | /// Thisf module is for easing the pain with printing text in the terminal.
9//!     |     ^^^^^
10//!     |     - The word "Thisf" is not in our dictionary. If you are sure this spelling is correcformatter,
11//!     |     - you can add it to your personal dictionary to prevent future alerts.
12//! ```
13
14use crate::documentation::{CheckableChunk, ContentOrigin};
15
16use std::cmp;
17use std::convert::TryFrom;
18
19use rayon::iter::{IntoParallelRefMutIterator, ParallelIterator};
20
21use crate::{Range, Span};
22
23/// Bitflag of available checkers by compilation / configuration.
24#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
25pub enum Detector {
26    /// Hunspell lib based detector.
27    Hunspell,
28    /// ZSpell, compatible with hunspell dictionaries and affix files.
29    ZSpell,
30    /// Spellbook
31    Spellbook,
32    /// Language server rules based on NLP detector.
33    NlpRules,
34    /// Reflow according to a given max column.
35    Reflow,
36    /// Detection of nothing, a test helper.
37    #[cfg(test)]
38    Dummy,
39}
40
41impl Detector {
42    /// Converts the detector to its static str representation.
43    pub const fn as_str(&self) -> &'static str {
44        match self {
45            Self::Hunspell => "Hunspell",
46            Self::ZSpell => "ZSpell",
47            Self::Spellbook => "Spellbook",
48            Self::NlpRules => "NlpRules",
49            Self::Reflow => "Reflow",
50            #[cfg(test)]
51            Self::Dummy => "Dummy",
52        }
53    }
54}
55
56/// Terminal size in characters.
57///
58/// Returns `80usize` for tests and in case the terminal size can not be
59/// retrieved.
60pub fn get_terminal_size() -> usize {
61    const DEFAULT_TERMINAL_SIZE: usize = 80;
62    #[cfg(not(test))]
63    match crossterm::terminal::size() {
64        Ok((terminal_size, _)) => terminal_size as usize,
65        Err(_) => {
66            use std::sync::Once;
67
68            static WARN_ONCE: Once = Once::new();
69            WARN_ONCE.call_once(|| {
70                log::warn!("Unable to get terminal size. Using default: {DEFAULT_TERMINAL_SIZE}");
71            });
72
73            DEFAULT_TERMINAL_SIZE
74        }
75    }
76    #[cfg(test)]
77    DEFAULT_TERMINAL_SIZE
78}
79
80// impl
81// // TODO use this to display included compiled backends
82// fn list_available() -> bool {
83//     match detector {
84//         Detector::Hunspell => cfg!(feature="hunspell") as bool,
85//         Detector::NlpRules => cfg!(feature="nlprules") as bool,
86//     }
87// }
88
89use std::fmt;
90
91impl fmt::Display for Detector {
92    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
93        formatter.write_str(self.as_str())
94    }
95}
96
97/// For long lines, literal will be trimmed to display in one terminal line.
98/// Misspelled words that are too long shall also be ellipsized.
99pub fn condition_display_content(
100    terminal_size: usize,
101    _indent: usize,
102    stripped_line: &str,
103    mistake_range: Range,
104    terminal_print_offset_left: usize,
105    marker_size: usize,
106) -> (String, usize, usize) {
107    // if we can fit the full line in there, avoid all the work as much as possible
108    if stripped_line.chars().count() + terminal_print_offset_left <= terminal_size {
109        return (stripped_line.to_owned(), mistake_range.start, marker_size);
110    }
111
112    // The paddings give some space for the ` {} ...` and extra indentation and formatting:
113    //
114    //|---------------------------------------------------------------------------------------| terminal_size
115    //|-------| padding_till_excerpt_start = indent (3+line_number_digit_count) + 2 white spaces = 7usize, for this case.
116    //
117    //   --> /home/tmhdev/Documents/cargo-spellcheck/src/suggestion.rs:62
118    //    |
119    // 62 |  Reasn of food, what's up with pie? There's strawberry pie, apple, pumpkin..
120    //    |  ^^^^^
121    //    | - there, Cherie, thither, or tither
122    //    |
123    //    |   Possible spelling mistake found.
124    //
125    const MAX_MISTAKE_LEN: usize = 20;
126
127    const HEAD_DISPLAY_LEN: usize = 4;
128    const TAIL_DISPLAY_LEN: usize = 4;
129
130    const CENTER_DOTS: &str = "...";
131    const LEFT_DOTS: &str = "..";
132    const RIGHT_DOTS: &str = LEFT_DOTS;
133    const NO_DOTS: &str = "";
134
135    // guarantees that `marker_size` is always less than the max length.
136    assert!(HEAD_DISPLAY_LEN + CENTER_DOTS.len() + TAIL_DISPLAY_LEN <= MAX_MISTAKE_LEN);
137
138    // worst case conservative estimate, should be calculated based on `indent`
139    const TOTAL_CONTEXT_CHAR_COUNT: usize = 6;
140
141    // We will be using ranges to help doing the fitting:
142    //
143    // |-----------------------------------excerpt--------------------------------------|
144    // |----------------------|---------misspelled_word---------|-----------------------|
145    // |-----left_context-----|head_sub_range|-tail_sub_range-|-----right_context-----|
146    //
147    // Obs: paddings are not being considered in the illustration, but info is above.
148
149    // Misspelled words that are too long will be shortened by ellipsizing parts of it.
150    let (marker_size, shortened) = if mistake_range.len() > MAX_MISTAKE_LEN {
151        let head_sub_range = Range {
152            start: mistake_range.start,
153            end: mistake_range.start + HEAD_DISPLAY_LEN,
154        };
155        let tail_sub_range = Range {
156            start: mistake_range
157                .end //non inclusive
158                .saturating_sub(TAIL_DISPLAY_LEN),
159            end: mistake_range.end,
160        };
161
162        //  too long word will be shorter as it follows:
163        //            |-------------------| > MAX_MISTAKE_LEN
164        //            therieeeeeeeeeeeeeeee
165        //   4 chars  ^^^^   ...       ^^^^  4 chars
166        //
167        //  result:      ther...eeee
168
169        let head_sub = stripped_line
170            .chars()
171            .skip(head_sub_range.start)
172            .take(HEAD_DISPLAY_LEN)
173            .collect::<String>();
174        let tail_sub = stripped_line
175            .chars()
176            .skip(tail_sub_range.start)
177            .take(TAIL_DISPLAY_LEN)
178            .collect::<String>();
179
180        let shortened = format!("{head_sub}...{tail_sub}");
181        // FIXME if characters width a width of ineq of 1
182        // ISSUE: https://github.com/drahnr/cargo-spellcheck/issues/145
183        let marker_size = head_sub_range.len() + CENTER_DOTS.len() + tail_sub_range.len();
184
185        (marker_size, shortened)
186    } else {
187        let full: String = stripped_line
188            .chars()
189            .skip(mistake_range.start)
190            .take(mistake_range.len())
191            .collect();
192        (marker_size, full)
193    };
194
195    let stripped_line_len = stripped_line.chars().count();
196
197    // full, uncut context coverage
198    let left_context = Range {
199        start: 0,
200        end: mistake_range.start,
201    };
202    let right_context = Range {
203        start: mistake_range.end,
204        end: stripped_line_len,
205    };
206
207    let avail_space = terminal_size
208        .saturating_sub(terminal_print_offset_left + marker_size + TOTAL_CONTEXT_CHAR_COUNT);
209
210    // left and right we would like to partition the remaining space equally
211    let avail_space_half = avail_space / 2usize;
212
213    // TODO introduce a threshold, so the shortened version is not longer than than the original
214    let (left_context, right_context, left_dots, right_dots) = match (
215        avail_space_half > left_context.len(),
216        avail_space_half > right_context.len(),
217    ) {
218        (true, false) => {
219            // left context does not use all the capacity avail
220            // allow the right context to consume the excess.
221            let right_avail_space = avail_space - left_context.len();
222            let rdots = if mistake_range.end + right_avail_space < stripped_line_len {
223                NO_DOTS
224            } else {
225                RIGHT_DOTS
226            };
227            (
228                left_context,
229                Range {
230                    start: right_context.end,
231                    end: cmp::min(mistake_range.end + right_avail_space, stripped_line_len),
232                },
233                NO_DOTS,
234                rdots,
235            )
236        }
237        (false, true) => {
238            // right context does not use all the capacity avail
239            // allow the left context to consume the excess.
240            let left_avail_space = avail_space - right_context.len();
241            let ldots = if left_avail_space > left_context.end {
242                NO_DOTS
243            } else {
244                LEFT_DOTS
245            };
246            (
247                Range {
248                    start: left_context.end.saturating_sub(left_avail_space),
249                    end: left_context.end,
250                },
251                right_context,
252                ldots,
253                NO_DOTS,
254            )
255        }
256        (false, false) => {
257            // both sides have excess chars, so yield `avail_space_half` to both sides
258            (
259                Range {
260                    start: left_context.end.saturating_sub(avail_space_half),
261                    end: left_context.end,
262                },
263                Range {
264                    start: right_context.start,
265                    end: right_context.start + avail_space_half,
266                },
267                LEFT_DOTS,
268                RIGHT_DOTS,
269            )
270        }
271        _ => {
272            // both sides are less than the allowed context, no need to modify
273            (left_context, right_context, NO_DOTS, NO_DOTS)
274        }
275    };
276
277    assert!(left_context.end == mistake_range.start);
278    assert!(right_context.end <= stripped_line_len);
279    assert!(left_context.len() + mistake_range.len() + right_context.len() <= stripped_line_len);
280
281    let offset = left_context.len();
282    let conditioned_line = format!(
283        "{}{}{}{}{}",
284        left_dots,
285        stripped_line
286            .chars()
287            .skip(left_context.start + left_dots.len())
288            .take(left_context.len() - left_dots.len())
289            .collect::<String>(),
290        shortened,
291        stripped_line
292            .chars()
293            .skip(right_context.start)
294            .take(right_context.len() - right_dots.len())
295            .collect::<String>(),
296        right_dots,
297    );
298    (conditioned_line, offset, marker_size)
299}
300
301/// A suggestion for certain offending span.
302#[derive(Clone, Hash, PartialEq, Eq)]
303pub struct Suggestion<'s> {
304    /// Which checker suggested the change.
305    pub detector: Detector,
306    /// Reference to the file location the `span` and `literal` relate to.
307    pub origin: ContentOrigin,
308    /// The suggestion is relative to a specific chunk.
309    pub chunk: &'s CheckableChunk,
310    /// The span (absolute!) within the file or chunk (depends on `origin`).
311    pub span: Span,
312    /// Range relative to the chunk the current suggestion is located.
313    pub range: Range,
314    /// Fix suggestions, might be words or the full sentence together with
315    /// leading whitespaces for some `CommentVariant`s.
316    pub replacements: Vec<String>,
317    /// Descriptive reason for the suggestion.
318    pub description: Option<String>,
319}
320
321impl<'s> Suggestion<'s> {
322    /// Determine if there is overlap.
323    pub fn is_overlapped(&self, other: &Self) -> bool {
324        if self.origin != other.origin {
325            return false;
326        }
327
328        if self.span.end.line < other.span.start.line || other.span.end.line < self.span.start.line
329        {
330            return false;
331        }
332
333        if self.span.start.line < other.span.start.line
334            || (self.span.start.line == other.span.start.line
335                && self.span.start.column < other.span.start.column)
336        {
337            self.span.end.column > other.span.start.column
338        } else {
339            self.span.start.column < other.span.end.column
340        }
341    }
342}
343
344impl<'s> fmt::Display for Suggestion<'s> {
345    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
346        use console::Style;
347
348        let highlight = Style::new().bold().white();
349        let error = Style::new().bold().red();
350        let arrow_marker = Style::new().blue();
351        let context_marker = Style::new().bold().blue();
352        let fix = Style::new().green();
353        let help = Style::new().yellow().bold();
354
355        let line_number_digit_count = self.span.start.line.to_string().len();
356        let indent = 3 + line_number_digit_count;
357
358        error.apply_to("error").fmt(formatter)?;
359        highlight
360            .apply_to(format!(": spellcheck({})", self.detector))
361            .fmt(formatter)?;
362        formatter.write_str("\n")?;
363
364        arrow_marker
365            .apply_to(format!("{:>width$}", "-->", width = indent + 1))
366            .fmt(formatter)?;
367
368        let x = self.span.start.line;
369        let (path, line) = match self.origin {
370            ContentOrigin::RustDocTest(ref path, ref span) => {
371                (path.display().to_string(), x + span.start.line)
372            }
373            ref origin => (origin.as_path().display().to_string(), x),
374        };
375        writeln!(formatter, " {path}:{line}", path = path, line = line)?;
376        context_marker
377            .apply_to(format!("{:>width$}", "|", width = indent))
378            .fmt(formatter)?;
379        formatter.write_str("\n")?;
380        context_marker
381            .apply_to(format!(
382                "{:>width$} |",
383                self.span.start.line,
384                width = indent - 2,
385            ))
386            .fmt(formatter)?;
387
388        // underline the relevant part with ^^^^^
389
390        // TODO this needs some more thought once multiline comments pop up
391        let marker_size = self.span.one_line_len().unwrap_or_else(|| {
392            self.chunk
393                .len_in_chars()
394                .saturating_sub(self.span.start.column)
395        });
396
397        // assumes the _mistake_ is within one line
398        // if not we chop it down to the first line
399        let mistake_lines = self.chunk.find_covered_lines(self.range.clone());
400        let (line_range, start_of_line_offset) = mistake_lines
401            .first()
402            .map(|line_range| {
403                (
404                    line_range,
405                    self.range.start.saturating_sub(line_range.start),
406                )
407            })
408            .expect("Lines covered must exist");
409
410        let intra_line_mistake_range = Range {
411            start: start_of_line_offset,
412            end: cmp::min(start_of_line_offset + self.range.len(), line_range.len()),
413        };
414        let relevant_line = self
415            .chunk
416            .as_str()
417            .chars()
418            .enumerate()
419            .skip_while(|(idx, _)| line_range.start > *idx)
420            .take(line_range.len())
421            .map(|(_, c)| c)
422            .collect::<String>();
423
424        let terminal_size = get_terminal_size();
425
426        // this values is dynamically calculated for each line where the doc is.
427        // the line being analysed can affect how the indentation is done.
428        let padding_till_excerpt_start = indent + 2;
429
430        let (formatted, offset, marker_size) = condition_display_content(
431            terminal_size,
432            indent,
433            relevant_line.as_str(),
434            intra_line_mistake_range,
435            padding_till_excerpt_start,
436            marker_size,
437        );
438
439        writeln!(formatter, " {formatted}")?;
440
441        if marker_size > 0 {
442            context_marker
443                .apply_to(format!("{:>width$}", "|", width = indent))
444                .fmt(formatter)?;
445            help.apply_to(format!(" {:>offset$}", "", offset = offset))
446                .fmt(formatter)?;
447            help.apply_to(format!("{:^>size$}", "", size = marker_size))
448                .fmt(formatter)?;
449            formatter.write_str("\n")?;
450            log::trace!(
451                "marker_size={} span {{ {:?} .. {:?} }} >> {:?} <<",
452                marker_size,
453                self.span.start,
454                self.span.end,
455                self,
456            );
457        } else {
458            log::warn!(
459                "marker_size={} span {{ {:?} .. {:?} }} >> {:?} <<",
460                marker_size,
461                self.span.start,
462                self.span.end,
463                self,
464            );
465        }
466
467        context_marker
468            .apply_to(format!("{:>width$}", "|", width = indent))
469            .fmt(formatter)?;
470
471        let replacement = match self.replacements.len() {
472            0 => String::new(),
473            1 => format!(" - {}", fix.apply_to(&self.replacements[0])),
474            2 => format!(
475                " - {} or {}",
476                fix.apply_to(&self.replacements[0]),
477                fix.apply_to(&self.replacements[1])
478            ),
479            n if (n < 7) => {
480                let last = fix.apply_to(&self.replacements[n - 1]).to_string();
481                let joined = self.replacements[..n - 1]
482                    .iter()
483                    .map(|x| fix.apply_to(x.to_owned()).to_string())
484                    .collect::<Vec<String>>()
485                    .as_slice()
486                    .join(", ");
487                format!(" - {joined}, or {last}")
488            }
489            _n => {
490                let joined = self.replacements[..=6]
491                    .iter()
492                    .map(|x| fix.apply_to(x.to_owned()).to_string())
493                    .collect::<Vec<String>>()
494                    .as_slice()
495                    .join(", ");
496
497                let remaining = self.replacements.len() - 6;
498                let remaining = fix.apply_to(format!("{remaining}")).to_string();
499                format!(" - {joined}, or one of {remaining} others")
500            }
501        };
502
503        error.apply_to(replacement).fmt(formatter)?;
504
505        if !self.replacements.is_empty() {
506            formatter.write_str("\n")?;
507            context_marker
508                .apply_to(format!("{:>width$}", "|\n", width = indent + 1))
509                .fmt(formatter)?;
510            context_marker
511                .apply_to(format!("{:>width$}", "|", width = indent))
512                .fmt(formatter)?;
513        }
514
515        if let Some(ref description) = self.description {
516            writeln!(formatter, "   {description}")?;
517        }
518        Ok(())
519    }
520}
521
522impl<'s> fmt::Debug for Suggestion<'s> {
523    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
524        match crate::documentation::ChunkDisplay::try_from((self.chunk, self.span)) {
525            Ok(printable) => write!(
526                formatter,
527                "({}, {:?}, {:?})",
528                printable,
529                printable.1,
530                self.replacements.as_slice()
531            ),
532            Err(e) => {
533                writeln!(formatter, "> span={:?}", self.span)?;
534                writeln!(
535                    formatter,
536                    "> Failed to create chunk display from chunk={:?} with {}",
537                    self.chunk, e
538                )
539            }
540        }
541    }
542}
543
544impl<'s> Ord for Suggestion<'s> {
545    fn cmp(&self, other: &Self) -> cmp::Ordering {
546        let cmp = self.span.start.cmp(&other.span.start);
547        if cmp != std::cmp::Ordering::Equal {
548            return cmp;
549        }
550
551        self.span.end.cmp(&other.span.end)
552    }
553}
554
555impl<'s> PartialOrd for Suggestion<'s> {
556    fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
557        Some(self.cmp(other))
558    }
559}
560
561/// A set of suggestions across multiple files, clustered per file
562#[derive(Debug, Clone)]
563pub struct SuggestionSet<'s> {
564    per_file: indexmap::IndexMap<ContentOrigin, Vec<Suggestion<'s>>>,
565}
566
567impl<'s> SuggestionSet<'s> {
568    /// Create a new and empty suggestion set.
569    pub fn new() -> Self {
570        Self {
571            per_file: indexmap::IndexMap::with_capacity(64),
572        }
573    }
574
575    /// Iterate over all suggestions tupled with the content origin of the file
576    /// the suggestion relates to.
577    pub fn iter<'a>(
578        &'a self,
579    ) -> impl DoubleEndedIterator<Item = (&'a ContentOrigin, &'a Vec<Suggestion<'s>>)> {
580        self.per_file.iter()
581    }
582
583    /// Adds a new suggestion to the set.
584    pub fn add(&mut self, origin: ContentOrigin, suggestion: Suggestion<'s>) {
585        self.per_file
586            .entry(origin)
587            .or_insert_with(|| Vec::with_capacity(1))
588            .push(suggestion);
589    }
590
591    /// Adds a slice of suggestions at once.
592    pub fn append(&mut self, origin: ContentOrigin, suggestions: &[Suggestion<'s>]) {
593        self.per_file
594            .entry(origin)
595            .or_insert_with(|| Vec::with_capacity(32))
596            .extend_from_slice(suggestions);
597    }
598
599    /// Alternative form of [`Self::append`](Self::append).
600    pub fn extend<I>(&mut self, origin: ContentOrigin, suggestions: I)
601    where
602        I: IntoIterator<Item = Suggestion<'s>>,
603    {
604        let v: &mut Vec<Suggestion<'s>> = self
605            .per_file
606            .entry(origin)
607            .or_insert_with(|| Vec::with_capacity(32));
608        v.extend(suggestions);
609    }
610
611    /// Obtain an accessor `Entry` for the given `origin`
612    pub fn entry(
613        &mut self,
614        origin: ContentOrigin,
615    ) -> indexmap::map::Entry<'_, ContentOrigin, Vec<Suggestion<'s>>> {
616        self.per_file.entry(origin)
617    }
618
619    /// Iterate over all files by reference
620    pub fn files<'i, 'a>(&'a mut self) -> impl DoubleEndedIterator<Item = &'i ContentOrigin>
621    where
622        's: 'i,
623        'a: 'i,
624    {
625        self.per_file.keys()
626    }
627
628    /// Iterate over all references given an origin
629    ///
630    /// panics if there is no such origin
631    pub fn suggestions<'a>(
632        &'a self,
633        origin: &ContentOrigin,
634    ) -> impl DoubleEndedIterator<Item = &'a Suggestion<'s>>
635    where
636        'a: 's,
637    {
638        if let Some(suggestions) = self.per_file.get(origin) {
639            suggestions.iter()
640        } else {
641            panic!("origin must exist")
642        }
643        // intermediate does not live long enough
644        // .map(|suggestions: &'s Vec<Suggestion<'s>>| -> std::slice::Iter<'a, Suggestion<'s>> {
645        //     (suggestions).into_iter()
646        // } ).iter().flatten()
647    }
648
649    /// Join two sets
650    ///
651    /// Merges another suggestion set into self.
652    pub fn join<I>(&mut self, other: I)
653    where
654        I: IntoIterator<Item = (ContentOrigin, Vec<Suggestion<'s>>)>,
655    {
656        other.into_iter().for_each(|(origin, suggestions)| {
657            self.entry(origin)
658                .or_insert_with(|| Vec::with_capacity(suggestions.len()))
659                .extend_from_slice(suggestions.as_slice())
660        })
661    }
662
663    /// Obtain the number of items in the set
664    #[inline]
665    pub fn len(&self) -> usize {
666        self.per_file.len()
667    }
668
669    /// Sorts the files in alphabetical order, then sorts the per-file
670    /// suggestions based on start and end spans.
671    pub fn sort(&mut self) {
672        self.per_file
673            .par_iter_mut()
674            .for_each(|(_origin, suggestions)| {
675                suggestions.sort_by(|a, b| {
676                    let cmp = a.span.start.cmp(&b.span.start);
677                    if cmp != std::cmp::Ordering::Equal {
678                        return cmp;
679                    }
680
681                    a.span.end.cmp(&b.span.end)
682                });
683            });
684        self.per_file
685            .sort_by(|origin_a, _a, origin_b, _b| -> std::cmp::Ordering {
686                origin_a.as_path().cmp(origin_b.as_path())
687            });
688    }
689
690    /// Count the number of suggestions across all files in total
691    pub fn total_count(&self) -> usize {
692        self.per_file.iter().map(|(_origin, vec)| vec.len()).sum()
693    }
694}
695
696impl<'s> IntoIterator for SuggestionSet<'s> {
697    type Item = (ContentOrigin, Vec<Suggestion<'s>>);
698    type IntoIter = indexmap::map::IntoIter<ContentOrigin, Vec<Suggestion<'s>>>;
699    fn into_iter(self) -> Self::IntoIter {
700        self.per_file.into_iter()
701    }
702}
703
704impl<'s> IntoIterator for &'s SuggestionSet<'s> {
705    type Item = (&'s ContentOrigin, &'s Vec<Suggestion<'s>>);
706    type IntoIter = indexmap::map::Iter<'s, ContentOrigin, Vec<Suggestion<'s>>>;
707    fn into_iter(self) -> Self::IntoIter {
708        self.per_file.iter()
709    }
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715    use crate::{CommentVariant, LineColumn};
716    use console;
717    use std::fmt;
718
719    /// A test helper comparing the output against an expected output.
720    ///
721    /// Strips all color codes from both the expected string and the
722    /// display-able object.
723    fn assert_display_eq<D: fmt::Display, S: AsRef<str>>(display: D, s: S) {
724        let expected = s.as_ref();
725        let expected = console::strip_ansi_codes(expected);
726
727        // uses the display impl
728        let reality = display.to_string();
729        let reality = console::strip_ansi_codes(reality.as_str());
730        assert_eq!(reality, expected);
731    }
732
733    #[test]
734    fn fmt_0_single() {
735        const CONTENT: &str = " Is it dyrck again?";
736        let chunk = CheckableChunk::from_str(
737            CONTENT,
738            indexmap::indexmap! { 0..18 => Span {
739                start: LineColumn {
740                    line: 1,
741                    column: 0,
742                },
743                end: LineColumn {
744                    line: 1,
745                    column: 17,
746                }
747            }
748            },
749            CommentVariant::TripleSlash,
750        );
751
752        let suggestion = Suggestion {
753            detector: Detector::Dummy,
754            origin: ContentOrigin::TestEntityRust,
755            chunk: &chunk,
756            range: 7..12,
757            span: Span {
758                start: LineColumn { line: 1, column: 6 },
759                end: LineColumn {
760                    line: 1,
761                    column: 10,
762                },
763            },
764            replacements: vec![
765                "replacement_0".to_owned(),
766                "replacement_1".to_owned(),
767                "replacement_2".to_owned(),
768            ],
769            description: Some("Possible spelling mistake found.".to_owned()),
770        };
771
772        const EXPECTED: &str = r#"error: spellcheck(Dummy)
773  --> /tmp/test/entity.rs:1
774   |
775 1 |  Is it dyrck again?
776   |        ^^^^^
777   | - replacement_0, replacement_1, or replacement_2
778   |
779   |   Possible spelling mistake found.
780"#;
781        assert_display_eq(suggestion, EXPECTED);
782    }
783
784    #[test]
785    fn fmt_0_no_suggestion() {
786        const CONTENT: &str = " Is it dyrck again?";
787        let chunk = CheckableChunk::from_str(
788            CONTENT,
789            indexmap::indexmap! { 0..18 => Span {
790                start: LineColumn {
791                    line: 1,
792                    column: 0,
793                },
794                end: LineColumn {
795                    line: 1,
796                    column: 17,
797                }
798            }
799            },
800            CommentVariant::TripleSlash,
801        );
802
803        let suggestion = Suggestion {
804            detector: Detector::Dummy,
805            origin: ContentOrigin::TestEntityRust,
806            chunk: &chunk,
807            range: 7..12,
808            span: Span {
809                start: LineColumn { line: 1, column: 6 },
810                end: LineColumn {
811                    line: 1,
812                    column: 10,
813                },
814            },
815            replacements: vec![],
816            description: Some("Possible spelling mistake found.".to_owned()),
817        };
818
819        const EXPECTED: &str = r#"error: spellcheck(Dummy)
820  --> /tmp/test/entity.rs:1
821   |
822 1 |  Is it dyrck again?
823   |        ^^^^^
824   |   Possible spelling mistake found.
825"#;
826        assert_display_eq(suggestion, EXPECTED);
827    }
828
829    #[test]
830    fn fmt_1_multi() {
831        const CONTENT: &str = r#" Line mitake 1
832 Anowher 2
833 Last"#;
834
835        let chunk = CheckableChunk::from_str(
836            CONTENT,
837            indexmap::indexmap! {
838                0..13 => Span {
839                    start: LineColumn {
840                        line: 1,
841                        column: 4,
842                    },
843                    end: LineColumn {
844                        line: 1,
845                        column: 16,
846                    }
847                },
848                14..24 => Span {
849                    start: LineColumn {
850                        line: 2,
851                        column: 4,
852                    },
853                    end: LineColumn {
854                        line: 2,
855                        column: 12,
856                    }
857                },
858                25..29 => Span {
859                    start: LineColumn {
860                        line: 3,
861                        column: 4,
862                    },
863                    end: LineColumn {
864                        line: 3,
865                        column: 7,
866                    }
867                }
868            },
869            CommentVariant::TripleSlash,
870        );
871
872        let suggestion = Suggestion {
873            detector: Detector::Dummy,
874            origin: ContentOrigin::TestEntityRust,
875            chunk: &chunk,
876            range: 6..12,
877            span: Span {
878                start: LineColumn {
879                    line: 1,
880                    column: 10,
881                },
882                end: LineColumn {
883                    line: 1,
884                    column: 15,
885                },
886            },
887            replacements: vec![
888                "replacement_0".to_owned(),
889                "replacement_1".to_owned(),
890                "replacement_2".to_owned(),
891            ],
892            description: Some("Possible spelling mistake found.".to_owned()),
893        };
894
895        const EXPECTED: &str = r#"error: spellcheck(Dummy)
896  --> /tmp/test/entity.rs:1
897   |
898 1 |  Line mitake 1
899   |       ^^^^^^
900   | - replacement_0, replacement_1, or replacement_2
901   |
902   |   Possible spelling mistake found.
903"#;
904
905        assert_display_eq(suggestion, EXPECTED);
906    }
907
908    #[test]
909    fn fmt_2_multi_80_plus() {
910        const CONTENT: &str = r#" Line mitake 1
911 Suuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuper duuuuuuuuuuuuuuuuuuuuuuuuper too long
912 "#;
913
914        let chunk = CheckableChunk::from_str(
915            CONTENT,
916            indexmap::indexmap! {
917                0..13 => Span {
918                    start: LineColumn {
919                        line: 1,
920                        column: 4,
921                    },
922                    end: LineColumn {
923                        line: 1,
924                        column: 16,
925                    }
926                },
927                14..101 => Span {
928                    start: LineColumn {
929                        line: 2,
930                        column: 4,
931                    },
932                    end: LineColumn {
933                        line: 2,
934                        column: 90,
935                    }
936                }
937            },
938            CommentVariant::TripleSlash,
939        );
940
941        let suggestion = Suggestion {
942            detector: Detector::Dummy,
943            origin: ContentOrigin::TestEntityRust,
944            chunk: &chunk,
945            range: 66..94,
946            span: Span {
947                start: LineColumn { line: 2, column: 5 },
948                end: LineColumn {
949                    line: 2,
950                    column: 92,
951                },
952            },
953            replacements: vec![
954                "replacement_0".to_owned(),
955                "replacement_1".to_owned(),
956                "replacement_2".to_owned(),
957            ],
958            description: Some("Possible spelling mistake found.".to_owned()),
959        };
960
961        const EXPECTED: &str = r#"error: spellcheck(Dummy)
962  --> /tmp/test/entity.rs:2
963   |
964 2 | ..uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuper duuu...uper too long
965   |                                                 ^^^^^^^^^^^
966   | - replacement_0, replacement_1, or replacement_2
967   |
968   |   Possible spelling mistake found.
969"#;
970
971        assert_display_eq(suggestion, EXPECTED);
972    }
973
974    #[test]
975    fn multiline_is_dbg_printable() {
976        let _ = env_logger::builder()
977            .is_test(true)
978            .filter(None, log::LevelFilter::Trace)
979            .try_init();
980
981        use crate::documentation::CheckableChunk;
982        let chunk = CheckableChunk::from_str(
983            r#"0
9842345
9857@n"#,
986            indexmap::indexmap! { 0..10 => Span {
987                start : LineColumn {
988                    line: 7usize,
989                    column: 8usize,
990                },
991                end : LineColumn {
992                    line: 9usize,
993                    column: 4usize,
994                }
995            } },
996            CommentVariant::TripleSlash,
997        );
998
999        let suggestion = Suggestion {
1000            detector: Detector::Dummy,
1001            origin: ContentOrigin::TestEntityRust,
1002            chunk: &chunk,
1003            span: Span {
1004                start: LineColumn {
1005                    line: 8usize,
1006                    column: 0,
1007                },
1008                end: LineColumn {
1009                    line: 8usize,
1010                    column: 3,
1011                },
1012            },
1013            range: 2..6,
1014            replacements: vec!["whocares".to_owned()],
1015            description: None,
1016        };
1017
1018        let suggestion = dbg!(suggestion);
1019
1020        log::info!("fmt debug=\n{suggestion:?}\n<");
1021        log::info!("fmt display=\n{suggestion}\n<");
1022    }
1023
1024    #[test]
1025    fn overlapped() {
1026        let chunk = CheckableChunk::from_str(
1027            r#"0
10282345
10297@n"#,
1030            indexmap::indexmap! { 0..10 => Span {
1031                start : LineColumn {
1032                    line: 7usize,
1033                    column: 8usize,
1034                },
1035                end : LineColumn {
1036                    line: 9usize,
1037                    column: 4usize,
1038                }
1039            } },
1040            CommentVariant::TripleSlash,
1041        );
1042        let suggestion = Suggestion {
1043            detector: Detector::Dummy,
1044            origin: ContentOrigin::TestEntityRust,
1045            chunk: &chunk,
1046            span: Span {
1047                start: LineColumn {
1048                    line: 8usize,
1049                    column: 1,
1050                },
1051                end: LineColumn {
1052                    line: 8usize,
1053                    column: 3,
1054                },
1055            },
1056            range: 2..6,
1057            replacements: vec!["whocares".to_owned()],
1058            description: None,
1059        };
1060        let overlapped_smaller_suggestion = Suggestion {
1061            detector: Detector::Dummy,
1062            origin: ContentOrigin::TestEntityRust,
1063            chunk: &chunk,
1064            span: Span {
1065                start: LineColumn {
1066                    line: 8usize,
1067                    column: 0,
1068                },
1069                end: LineColumn {
1070                    line: 8usize,
1071                    column: 2,
1072                },
1073            },
1074            range: 2..6,
1075            replacements: vec!["whocares".to_owned()],
1076            description: None,
1077        };
1078
1079        let overlapped_larger_suggestion = Suggestion {
1080            detector: Detector::Dummy,
1081            origin: ContentOrigin::TestEntityRust,
1082            chunk: &chunk,
1083            span: Span {
1084                start: LineColumn {
1085                    line: 8usize,
1086                    column: 2,
1087                },
1088                end: LineColumn {
1089                    line: 8usize,
1090                    column: 3,
1091                },
1092            },
1093            range: 2..6,
1094            replacements: vec!["whocares".to_owned()],
1095            description: None,
1096        };
1097
1098        assert!(suggestion.is_overlapped(&overlapped_smaller_suggestion));
1099        assert!(suggestion.is_overlapped(&overlapped_larger_suggestion));
1100    }
1101}