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    /// Returns `true` if the set contains no items.
670    #[inline]
671    pub fn is_empty(&self) -> bool {
672        self.per_file.is_empty()
673    }
674
675    /// Sorts the files in alphabetical order, then sorts the per-file
676    /// suggestions based on start and end spans.
677    pub fn sort(&mut self) {
678        self.per_file
679            .par_iter_mut()
680            .for_each(|(_origin, suggestions)| {
681                suggestions.sort_by(|a, b| {
682                    let cmp = a.span.start.cmp(&b.span.start);
683                    if cmp != std::cmp::Ordering::Equal {
684                        return cmp;
685                    }
686
687                    a.span.end.cmp(&b.span.end)
688                });
689            });
690        self.per_file
691            .sort_by(|origin_a, _a, origin_b, _b| -> std::cmp::Ordering {
692                origin_a.as_path().cmp(origin_b.as_path())
693            });
694    }
695
696    /// Count the number of suggestions across all files in total
697    pub fn total_count(&self) -> usize {
698        self.per_file.iter().map(|(_origin, vec)| vec.len()).sum()
699    }
700}
701
702impl<'s> IntoIterator for SuggestionSet<'s> {
703    type Item = (ContentOrigin, Vec<Suggestion<'s>>);
704    type IntoIter = indexmap::map::IntoIter<ContentOrigin, Vec<Suggestion<'s>>>;
705    fn into_iter(self) -> Self::IntoIter {
706        self.per_file.into_iter()
707    }
708}
709
710impl<'s> IntoIterator for &'s SuggestionSet<'s> {
711    type Item = (&'s ContentOrigin, &'s Vec<Suggestion<'s>>);
712    type IntoIter = indexmap::map::Iter<'s, ContentOrigin, Vec<Suggestion<'s>>>;
713    fn into_iter(self) -> Self::IntoIter {
714        self.per_file.iter()
715    }
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721    use crate::{CommentVariant, LineColumn};
722    use console;
723    use std::fmt;
724
725    /// A test helper comparing the output against an expected output.
726    ///
727    /// Strips all color codes from both the expected string and the
728    /// display-able object.
729    fn assert_display_eq<D: fmt::Display, S: AsRef<str>>(display: D, s: S) {
730        let expected = s.as_ref();
731        let expected = console::strip_ansi_codes(expected);
732
733        // uses the display impl
734        let reality = display.to_string();
735        let reality = console::strip_ansi_codes(reality.as_str());
736        assert_eq!(reality, expected);
737    }
738
739    #[test]
740    fn fmt_0_single() {
741        const CONTENT: &str = " Is it dyrck again?";
742        let chunk = CheckableChunk::from_str(
743            CONTENT,
744            indexmap::indexmap! { 0..18 => Span {
745                start: LineColumn {
746                    line: 1,
747                    column: 0,
748                },
749                end: LineColumn {
750                    line: 1,
751                    column: 17,
752                }
753            }
754            },
755            CommentVariant::TripleSlash,
756        );
757
758        let suggestion = Suggestion {
759            detector: Detector::Dummy,
760            origin: ContentOrigin::TestEntityRust,
761            chunk: &chunk,
762            range: 7..12,
763            span: Span {
764                start: LineColumn { line: 1, column: 6 },
765                end: LineColumn {
766                    line: 1,
767                    column: 10,
768                },
769            },
770            replacements: vec![
771                "replacement_0".to_owned(),
772                "replacement_1".to_owned(),
773                "replacement_2".to_owned(),
774            ],
775            description: Some("Possible spelling mistake found.".to_owned()),
776        };
777
778        const EXPECTED: &str = r#"error: spellcheck(Dummy)
779  --> /tmp/test/entity.rs:1
780   |
781 1 |  Is it dyrck again?
782   |        ^^^^^
783   | - replacement_0, replacement_1, or replacement_2
784   |
785   |   Possible spelling mistake found.
786"#;
787        assert_display_eq(suggestion, EXPECTED);
788    }
789
790    #[test]
791    fn fmt_0_no_suggestion() {
792        const CONTENT: &str = " Is it dyrck again?";
793        let chunk = CheckableChunk::from_str(
794            CONTENT,
795            indexmap::indexmap! { 0..18 => Span {
796                start: LineColumn {
797                    line: 1,
798                    column: 0,
799                },
800                end: LineColumn {
801                    line: 1,
802                    column: 17,
803                }
804            }
805            },
806            CommentVariant::TripleSlash,
807        );
808
809        let suggestion = Suggestion {
810            detector: Detector::Dummy,
811            origin: ContentOrigin::TestEntityRust,
812            chunk: &chunk,
813            range: 7..12,
814            span: Span {
815                start: LineColumn { line: 1, column: 6 },
816                end: LineColumn {
817                    line: 1,
818                    column: 10,
819                },
820            },
821            replacements: vec![],
822            description: Some("Possible spelling mistake found.".to_owned()),
823        };
824
825        const EXPECTED: &str = r#"error: spellcheck(Dummy)
826  --> /tmp/test/entity.rs:1
827   |
828 1 |  Is it dyrck again?
829   |        ^^^^^
830   |   Possible spelling mistake found.
831"#;
832        assert_display_eq(suggestion, EXPECTED);
833    }
834
835    #[test]
836    fn fmt_1_multi() {
837        const CONTENT: &str = r#" Line mitake 1
838 Anowher 2
839 Last"#;
840
841        let chunk = CheckableChunk::from_str(
842            CONTENT,
843            indexmap::indexmap! {
844                0..13 => Span {
845                    start: LineColumn {
846                        line: 1,
847                        column: 4,
848                    },
849                    end: LineColumn {
850                        line: 1,
851                        column: 16,
852                    }
853                },
854                14..24 => Span {
855                    start: LineColumn {
856                        line: 2,
857                        column: 4,
858                    },
859                    end: LineColumn {
860                        line: 2,
861                        column: 12,
862                    }
863                },
864                25..29 => Span {
865                    start: LineColumn {
866                        line: 3,
867                        column: 4,
868                    },
869                    end: LineColumn {
870                        line: 3,
871                        column: 7,
872                    }
873                }
874            },
875            CommentVariant::TripleSlash,
876        );
877
878        let suggestion = Suggestion {
879            detector: Detector::Dummy,
880            origin: ContentOrigin::TestEntityRust,
881            chunk: &chunk,
882            range: 6..12,
883            span: Span {
884                start: LineColumn {
885                    line: 1,
886                    column: 10,
887                },
888                end: LineColumn {
889                    line: 1,
890                    column: 15,
891                },
892            },
893            replacements: vec![
894                "replacement_0".to_owned(),
895                "replacement_1".to_owned(),
896                "replacement_2".to_owned(),
897            ],
898            description: Some("Possible spelling mistake found.".to_owned()),
899        };
900
901        const EXPECTED: &str = r#"error: spellcheck(Dummy)
902  --> /tmp/test/entity.rs:1
903   |
904 1 |  Line mitake 1
905   |       ^^^^^^
906   | - replacement_0, replacement_1, or replacement_2
907   |
908   |   Possible spelling mistake found.
909"#;
910
911        assert_display_eq(suggestion, EXPECTED);
912    }
913
914    #[test]
915    fn fmt_2_multi_80_plus() {
916        const CONTENT: &str = r#" Line mitake 1
917 Suuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuper duuuuuuuuuuuuuuuuuuuuuuuuper too long
918 "#;
919
920        let chunk = CheckableChunk::from_str(
921            CONTENT,
922            indexmap::indexmap! {
923                0..13 => Span {
924                    start: LineColumn {
925                        line: 1,
926                        column: 4,
927                    },
928                    end: LineColumn {
929                        line: 1,
930                        column: 16,
931                    }
932                },
933                14..101 => Span {
934                    start: LineColumn {
935                        line: 2,
936                        column: 4,
937                    },
938                    end: LineColumn {
939                        line: 2,
940                        column: 90,
941                    }
942                }
943            },
944            CommentVariant::TripleSlash,
945        );
946
947        let suggestion = Suggestion {
948            detector: Detector::Dummy,
949            origin: ContentOrigin::TestEntityRust,
950            chunk: &chunk,
951            range: 66..94,
952            span: Span {
953                start: LineColumn { line: 2, column: 5 },
954                end: LineColumn {
955                    line: 2,
956                    column: 92,
957                },
958            },
959            replacements: vec![
960                "replacement_0".to_owned(),
961                "replacement_1".to_owned(),
962                "replacement_2".to_owned(),
963            ],
964            description: Some("Possible spelling mistake found.".to_owned()),
965        };
966
967        const EXPECTED: &str = r#"error: spellcheck(Dummy)
968  --> /tmp/test/entity.rs:2
969   |
970 2 | ..uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuper duuu...uper too long
971   |                                                 ^^^^^^^^^^^
972   | - replacement_0, replacement_1, or replacement_2
973   |
974   |   Possible spelling mistake found.
975"#;
976
977        assert_display_eq(suggestion, EXPECTED);
978    }
979
980    #[test]
981    fn multiline_is_dbg_printable() {
982        let _ = env_logger::builder()
983            .is_test(true)
984            .filter(None, log::LevelFilter::Trace)
985            .try_init();
986
987        use crate::documentation::CheckableChunk;
988        let chunk = CheckableChunk::from_str(
989            r#"0
9902345
9917@n"#,
992            indexmap::indexmap! { 0..10 => Span {
993                start : LineColumn {
994                    line: 7usize,
995                    column: 8usize,
996                },
997                end : LineColumn {
998                    line: 9usize,
999                    column: 4usize,
1000                }
1001            } },
1002            CommentVariant::TripleSlash,
1003        );
1004
1005        let suggestion = Suggestion {
1006            detector: Detector::Dummy,
1007            origin: ContentOrigin::TestEntityRust,
1008            chunk: &chunk,
1009            span: Span {
1010                start: LineColumn {
1011                    line: 8usize,
1012                    column: 0,
1013                },
1014                end: LineColumn {
1015                    line: 8usize,
1016                    column: 3,
1017                },
1018            },
1019            range: 2..6,
1020            replacements: vec!["whocares".to_owned()],
1021            description: None,
1022        };
1023
1024        let suggestion = dbg!(suggestion);
1025
1026        log::info!("fmt debug=\n{suggestion:?}\n<");
1027        log::info!("fmt display=\n{suggestion}\n<");
1028    }
1029
1030    #[test]
1031    fn overlapped() {
1032        let chunk = CheckableChunk::from_str(
1033            r#"0
10342345
10357@n"#,
1036            indexmap::indexmap! { 0..10 => Span {
1037                start : LineColumn {
1038                    line: 7usize,
1039                    column: 8usize,
1040                },
1041                end : LineColumn {
1042                    line: 9usize,
1043                    column: 4usize,
1044                }
1045            } },
1046            CommentVariant::TripleSlash,
1047        );
1048        let suggestion = Suggestion {
1049            detector: Detector::Dummy,
1050            origin: ContentOrigin::TestEntityRust,
1051            chunk: &chunk,
1052            span: Span {
1053                start: LineColumn {
1054                    line: 8usize,
1055                    column: 1,
1056                },
1057                end: LineColumn {
1058                    line: 8usize,
1059                    column: 3,
1060                },
1061            },
1062            range: 2..6,
1063            replacements: vec!["whocares".to_owned()],
1064            description: None,
1065        };
1066        let overlapped_smaller_suggestion = Suggestion {
1067            detector: Detector::Dummy,
1068            origin: ContentOrigin::TestEntityRust,
1069            chunk: &chunk,
1070            span: Span {
1071                start: LineColumn {
1072                    line: 8usize,
1073                    column: 0,
1074                },
1075                end: LineColumn {
1076                    line: 8usize,
1077                    column: 2,
1078                },
1079            },
1080            range: 2..6,
1081            replacements: vec!["whocares".to_owned()],
1082            description: None,
1083        };
1084
1085        let overlapped_larger_suggestion = Suggestion {
1086            detector: Detector::Dummy,
1087            origin: ContentOrigin::TestEntityRust,
1088            chunk: &chunk,
1089            span: Span {
1090                start: LineColumn {
1091                    line: 8usize,
1092                    column: 2,
1093                },
1094                end: LineColumn {
1095                    line: 8usize,
1096                    column: 3,
1097                },
1098            },
1099            range: 2..6,
1100            replacements: vec!["whocares".to_owned()],
1101            description: None,
1102        };
1103
1104        assert!(suggestion.is_overlapped(&overlapped_smaller_suggestion));
1105        assert!(suggestion.is_overlapped(&overlapped_larger_suggestion));
1106    }
1107}