Skip to main content

cargo_spellcheck/action/
mod.rs

1//! Covers all user triggered actions (except for signals).
2
3use super::*;
4use crate::checker::Checkers;
5use crate::errors::*;
6use crate::reflow::Reflow;
7
8use fs_err as fs;
9use futures::stream::{self, StreamExt};
10use rayon::iter::ParallelIterator;
11
12use std::io::{Read, Write};
13use std::path::PathBuf;
14
15pub mod bandaid;
16pub mod interactive;
17
18pub(crate) use bandaid::*;
19
20use interactive::{UserPicked, UserSelection};
21
22/// State of conclusion.
23#[derive(Debug, Clone, Copy)]
24pub enum Finish {
25    /// Operation ran to the end, successfully.
26    Success,
27    /// Abort is user requested, either by signal or key stroke.
28    Abort,
29    /// Completion of the check run, with the resulting number of mistakes
30    /// accumulated.
31    MistakeCount(usize),
32}
33
34impl Finish {
35    /// A helper to determine if any mistakes were found.
36    pub fn found_any(&self) -> bool {
37        match *self {
38            Self::MistakeCount(n) if n > 0 => true,
39            _ => false,
40        }
41    }
42}
43
44/// A patch to be stitched on-top of another string.
45///
46/// Has intentionally no awareness of any rust or cmark/markdown semantics.
47#[derive(Debug, Clone, Eq, PartialEq)]
48pub(crate) enum Patch {
49    /// Replace the area spanned by `replace` with `replacement`. Since `Span`
50    /// is inclusive, `Replace` will always replace a character in the original
51    /// sources.
52    Replace {
53        replace_span: Span,
54        replacement: String,
55    },
56    /// Location where to insert.
57    Insert {
58        insert_at: LineColumn,
59        content: String,
60    },
61}
62
63impl<'a> From<&'a BandAid> for Patch {
64    fn from(bandaid: &'a BandAid) -> Self {
65        // TODO XXX
66        Self::from(bandaid.clone())
67    }
68}
69
70impl From<BandAid> for Patch {
71    fn from(bandaid: BandAid) -> Self {
72        match bandaid {
73            bandaid if bandaid.span.start == bandaid.span.end => Self::Insert {
74                insert_at: bandaid.span.start,
75                content: bandaid.content,
76            },
77            _ => Self::Replace {
78                replace_span: bandaid.span,
79                replacement: bandaid.content,
80            },
81        }
82    }
83}
84
85/// Correct lines by applying patches.
86///
87/// Assumes all `BandAids` do not overlap when replacing. Inserting multiple
88/// times at a particular `LineColumn` is OK, but replacing overlapping `Span`s
89/// of the original source is not.
90///
91/// This function is not concerned with _any_ semantics or comments or
92/// whatsoever at all, it blindly replaces what is given to it.
93pub(crate) fn apply_patches<'s, II, I>(
94    patches: II,
95    source_buffer: &str,
96    mut sink: impl Write,
97) -> Result<()>
98where
99    II: IntoIterator<IntoIter = I, Item = Patch>,
100    I: Iterator<Item = Patch>,
101{
102    let mut patches = patches.into_iter().peekable();
103
104    let mut source_iter =
105        iter_with_line_column_from(source_buffer, LineColumn { line: 1, column: 0 }).peekable();
106
107    const TARGET: &str = "patch";
108    let mut write_to_sink = |topic: &str, data: &str| -> Result<()> {
109        log::trace!(target: TARGET, "w<{}>: {}", topic, data.escape_debug());
110        sink.write_all(data.as_bytes())?;
111        Ok(())
112    };
113
114    let mut cc_end_byte_offset = 0;
115
116    let mut current = None;
117    let mut byte_cursor = 0usize;
118    loop {
119        let cc_start_byte_offset = if let Some(ref current) = current {
120            let (cc_start, data, insertion) = match current {
121                Patch::Replace {
122                    replace_span,
123                    replacement,
124                } => (replace_span.end, replacement.as_str(), false),
125                Patch::Insert { insert_at, content } => (*insert_at, content.as_str(), true),
126            };
127
128            write_to_sink("new", data)?;
129
130            if insertion {
131                // do not advance anythin on insertion
132                byte_cursor
133            } else {
134                // skip the range of chars based on the line column
135                // so the cursor continues after the "replaced" characters
136                let mut cc_start_byte_offset = byte_cursor;
137                'skip: while let Some((c, byte_offset, _idx, linecol)) = source_iter.peek() {
138                    let byte_offset = *byte_offset;
139                    let linecol = *linecol;
140
141                    cc_start_byte_offset = byte_offset + c.len_utf8();
142
143                    if linecol >= cc_start {
144                        log::trace!(
145                            target: TARGET,
146                            "skip buffer: >{}<",
147                            &source_buffer[cc_end_byte_offset..cc_start_byte_offset].escape_debug()
148                        );
149
150                        break 'skip;
151                    }
152
153                    log::trace!(target: TARGET, "skip[{}]: >{}<", _idx, c.escape_debug());
154
155                    let _ = source_iter.next();
156                }
157                cc_start_byte_offset
158            }
159        } else {
160            byte_cursor
161        };
162        debug_assert!(byte_cursor <= cc_start_byte_offset);
163        byte_cursor = cc_start_byte_offset;
164
165        cc_end_byte_offset = if let Some(upcoming) = patches.peek() {
166            let cc_end = match upcoming {
167                Patch::Replace { replace_span, .. } => replace_span.start,
168                Patch::Insert { insert_at, .. } => *insert_at,
169            };
170
171            // do not write anything
172
173            // carbon copy until this byte offset
174            let mut cc_end_byte_offset = byte_cursor;
175            'cc: while let Some((c, byte_offset, _idx, linecol)) = source_iter.peek() {
176                let byte_offset = *byte_offset;
177                let linecol = *linecol;
178
179                if linecol >= cc_end {
180                    log::trace!(
181                        target: TARGET,
182                        "copy buffer: >{}<",
183                        &source_buffer[cc_start_byte_offset..cc_end_byte_offset].escape_debug()
184                    );
185                    break 'cc;
186                }
187
188                cc_end_byte_offset = byte_offset + c.len_utf8();
189
190                log::trace!(target: TARGET, "copy[{}]: >{}<", _idx, c.escape_debug());
191
192                let _ = source_iter.next();
193                // we need to drag this one behind, since...
194            }
195            // in the case we reach EOF here the `cc_end_byte_offset` could never be updated correctly
196            std::cmp::min(cc_end_byte_offset, source_buffer.len())
197        } else {
198            source_buffer.len()
199        };
200        debug_assert!(byte_cursor <= cc_end_byte_offset);
201
202        byte_cursor = cc_end_byte_offset;
203
204        let cc_range = cc_start_byte_offset..cc_end_byte_offset;
205
206        write_to_sink("cc", &source_buffer[cc_range])?;
207
208        // move on to the next
209        current = patches.next();
210
211        if current.is_none() {
212            // we already made sure earlier to write out everything
213            break;
214        }
215    }
216
217    Ok(())
218}
219
220/// Mode in which `cargo-spellcheck` operates.
221///
222/// Eventually to be used directly in parsing arguments.
223#[derive(Debug, Clone, Copy, Eq, PartialEq)]
224pub enum Action {
225    /// Only show errors
226    Check,
227
228    /// Interactively choose from checker provided suggestions.
229    Fix,
230
231    /// Reflow doc comments, so they adhere to a given maximum column width.
232    Reflow,
233
234    /// List all files in depth first sorted order in which they would be
235    /// checked.
236    ListFiles,
237}
238
239impl Action {
240    /// Apply bandaids to the file represented by content origin.
241    pub fn write_changes_to_disk(
242        &self,
243        origin: ContentOrigin,
244        bandaids: impl IntoIterator<Item = BandAid>,
245    ) -> Result<()> {
246        match origin {
247            ContentOrigin::CargoManifestDescription(path) => self.correct_file(path, bandaids),
248            ContentOrigin::CommonMarkFile(path) => self.correct_file(path, bandaids),
249            ContentOrigin::RustSourceFile(path) => self.correct_file(path, bandaids),
250            ContentOrigin::RustDocTest(path, _span) => self.correct_file(path, bandaids),
251            ContentOrigin::TestEntityRust => unreachable!("Use a proper file"),
252            ContentOrigin::TestEntityCommonMark => unreachable!("Use a proper file"),
253        }
254    }
255
256    /// assumes suggestions are sorted by line number and column number and must
257    /// be non overlapping
258    fn correct_file(
259        &self,
260        path: PathBuf,
261        bandaids: impl IntoIterator<Item = BandAid>,
262    ) -> Result<()> {
263        let path = fs::canonicalize(path.as_path())?;
264        let path = path.as_path();
265        log::trace!("Attempting to open {} as read", path.display());
266        let ro = fs::OpenOptions::new().read(true).open(path)?;
267
268        let mut reader = std::io::BufReader::new(ro);
269
270        const TEMPORARY: &str = ".spellcheck.tmp";
271
272        // Avoid issues when processing multiple files in parallel
273        let tmp_name = TEMPORARY.to_owned() + uuid::Uuid::new_v4().to_string().as_str();
274
275        let tmp = std::env::current_dir()
276            .expect("Must have cwd")
277            .join(tmp_name);
278        let wr = fs::OpenOptions::new()
279            .write(true)
280            .truncate(true)
281            .create(true)
282            .open(&tmp)?;
283
284        let mut writer = std::io::BufWriter::with_capacity(1024, wr);
285
286        let mut content = String::with_capacity(2e6 as usize);
287        reader.get_mut().read_to_string(&mut content)?;
288
289        {
290            let th = crate::TinHat::on();
291
292            apply_patches(
293                bandaids.into_iter().map(Patch::from),
294                content.as_str(), // FIXME for efficiency, correct_lines should integrate with `BufRead` instead of a `String` buffer
295                &mut writer,
296            )?;
297
298            writer.flush()?;
299            // Required for windows support, which does not allow
300            // to move a file while it is opened, see
301            // <https://github.com/drahnr/cargo-spellcheck/issues/251>
302            drop(writer);
303            drop(reader);
304            fs::rename(tmp, path)?;
305
306            // Writing for this file is done, unblock the signal handler.
307            drop(th);
308        }
309
310        Ok(())
311    }
312
313    /// Consumingly apply the user picked changes to a file.
314    ///
315    /// **Attention**: Must be consuming, repeated usage causes shifts in spans
316    /// and would destroy the file structure!
317    pub fn write_user_pick_changes_to_disk(
318        &self,
319        userpicked: interactive::UserPicked,
320    ) -> Result<()> {
321        if userpicked.total_count() > 0 {
322            log::debug!("Writing changes back to disk");
323            for (origin, bandaids) in userpicked.bandaids.into_iter() {
324                self.write_changes_to_disk(origin, bandaids.into_iter())?;
325            }
326        } else {
327            log::debug!("No band aids to apply");
328        }
329        Ok(())
330    }
331    /// Run the requested action.
332    pub async fn run(self, documents: Documentation, config: Config) -> Result<Finish> {
333        let fin = match self {
334            Self::ListFiles { .. } => self.run_list_files(documents, &config)?,
335            Self::Reflow { .. } => self.run_reflow(documents, config).await?,
336            Self::Check { .. } => self.run_check(documents, config).await?,
337            Self::Fix { .. } => self.run_fix_interactive(documents, config).await?,
338        };
339        Ok(fin)
340    }
341
342    /// Run the requested action.
343    fn run_list_files(self, documents: Documentation, _config: &Config) -> Result<Finish> {
344        for (origin, _chunks) in documents.iter() {
345            println!("{}", origin.as_path().display())
346        }
347        Ok(Finish::Success)
348    }
349
350    /// Run the requested action _interactively_, waiting for user input.
351    async fn run_fix_interactive(self, documents: Documentation, config: Config) -> Result<Finish> {
352        let n_cpus = num_cpus::get();
353
354        let checkers = Checkers::new(config)?;
355
356        let n = documents.entry_count();
357        log::debug!("Running checkers on all documents {n}");
358        let mut pick_stream = stream::iter(documents.iter().enumerate())
359            .map(|(mut idx, (origin, chunks))| {
360                // align the debug output with the user output
361                idx += 1;
362                log::trace!("Running checkers on {idx}/{n},{origin:?}");
363                let suggestions = checkers.check(origin, &chunks[..]);
364                async move { Ok::<_, color_eyre::eyre::Report>((idx, origin, suggestions?)) }
365            })
366            .buffered(n_cpus)
367            .fuse();
368
369        let mut collected_picks = UserPicked::default();
370        while let Some(result) = pick_stream.next().await {
371            match result {
372                Ok((idx, origin, suggestions)) => {
373                    let (picked, user_sel) =
374                        interactive::UserPicked::select_interactive(origin.clone(), suggestions)?;
375
376                    match user_sel {
377                        UserSelection::Quit => break,
378                        UserSelection::Abort => return Ok(Finish::Abort),
379                        UserSelection::Nop if !picked.is_empty() => {
380                            log::debug!(
381                                "User picked patches to be applied for {idx}/{n},{origin:?}"
382                            );
383                            collected_picks.extend(picked);
384                        }
385                        UserSelection::Nop => {
386                            log::debug!("Nothing to do for {idx}/{n},{origin:?}");
387                        }
388                        _ => unreachable!(
389                            "All other variants are only internal to `select_interactive`. qed"
390                        ),
391                    }
392                }
393                Err(e) => Err(e)?,
394            }
395        }
396        let total = collected_picks.total_count();
397        // clustering per file is not reasonable
398        // since user abort (`<CTRL>-C` or `q`) should not
399        // leave any residue on disk.
400        self.write_user_pick_changes_to_disk(collected_picks)?;
401
402        Ok(Finish::MistakeCount(total))
403    }
404
405    /// Run the requested action.
406    async fn run_check(self, documents: Documentation, config: Config) -> Result<Finish> {
407        let checkers = Checkers::new(config)?;
408        let num_mistakes = documents
409            .into_par_iter()
410            .map(|(origin, chunks)| {
411                checkers.check(&origin, &chunks).map(|suggestions| {
412                    let path = origin.as_path();
413                    let n = suggestions.len();
414                    match suggestions.is_empty() {
415                        true => log::info!("✅ {}", path.display()),
416                        false => log::info!("❌ {} : {}", path.display(), n),
417                    };
418                    for suggestion in suggestions {
419                        println!("{suggestion}");
420                    }
421                    n
422                })
423            })
424            .try_fold_with(0, |count, res| res.map(|it| it + count))
425            .try_reduce(|| 0, |l, r| Ok(l + r))?;
426
427        if num_mistakes > 0 {
428            Ok(Finish::MistakeCount(num_mistakes))
429        } else {
430            Ok(Finish::Success)
431        }
432    }
433
434    /// Run the requested action.
435    async fn run_reflow(self, documents: Documentation, config: Config) -> Result<Finish> {
436        let reflow_config = config.reflow.clone().unwrap_or_default();
437        let reflow = Reflow::new(reflow_config)?;
438
439        documents
440            .into_par_iter()
441            .map(|(origin, chunks)| {
442                let mut picked = UserPicked::default();
443                let suggestions = reflow.check(&origin, &chunks[..])?;
444                for suggestion in suggestions {
445                    let bandaids = suggestion.replacements.first().map(|replacement| {
446                        super::BandAid::from((replacement.to_owned(), &suggestion.span))
447                    });
448
449                    picked.add_bandaids(&origin, bandaids);
450                }
451                Ok::<_, color_eyre::eyre::Report>(picked)
452            })
453            .try_for_each(move |picked| {
454                self.write_user_pick_changes_to_disk(picked?)?;
455                Ok::<_, color_eyre::eyre::Report>(())
456            })?;
457
458        Ok(Finish::Success)
459    }
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use std::convert::TryInto;
466
467    macro_rules! verify_correction {
468        ($text:literal, $bandaids:expr, $expected:literal) => {
469            let mut sink: Vec<u8> = Vec::with_capacity(1024);
470
471            apply_patches(
472                $bandaids.into_iter().map(|bandaid| Patch::from(bandaid)),
473                $text,
474                &mut sink,
475            )
476            .expect("Line correction must work in unit test!");
477
478            assert_eq!(String::from_utf8_lossy(sink.as_slice()), $expected);
479        };
480    }
481
482    #[test]
483    fn patch_full() {
484        let _ = env_logger::Builder::new()
485            .filter_level(log::LevelFilter::Trace)
486            .is_test(true)
487            .try_init();
488
489        let patches = vec![
490            Patch::Replace {
491                replace_span: Span {
492                    start: LineColumn { line: 1, column: 6 },
493                    end: LineColumn {
494                        line: 2,
495                        column: 12,
496                    },
497                },
498                replacement: "& Omega".to_owned(),
499            },
500            Patch::Insert {
501                insert_at: LineColumn { line: 3, column: 0 },
502                content: "Icecream truck".to_owned(),
503            },
504        ];
505        verify_correction!(
506            r#"Alpha beta gamma
507zeta eta beta.
508"#,
509            patches,
510            r#"Alpha & Omega.
511Icecream truck"#
512        );
513    }
514
515    #[test]
516    fn patch_replace_1() {
517        let _ = env_logger::Builder::new()
518            .filter_level(log::LevelFilter::Trace)
519            .is_test(true)
520            .try_init();
521        let bandaids = vec![Patch::Replace {
522            replace_span: (1_usize, 0..1).try_into().unwrap(),
523            replacement: "Y".to_owned(),
524        }];
525        verify_correction!("T🐠🐠U", bandaids, "Y🐠🐠U");
526    }
527
528    #[test]
529    fn patch_replace_2() {
530        let _ = env_logger::Builder::new()
531            .filter_level(log::LevelFilter::Trace)
532            .is_test(true)
533            .try_init();
534        let bandaids = vec![Patch::Replace {
535            replace_span: (1_usize, 1..3).try_into().unwrap(),
536            replacement: "Y".to_owned(),
537        }];
538        verify_correction!("T🐠🐠U", bandaids, "TYU");
539    }
540
541    #[test]
542    fn patch_replace_3() {
543        let _ = env_logger::Builder::new()
544            .filter_level(log::LevelFilter::Trace)
545            .is_test(true)
546            .try_init();
547        let bandaids = vec![Patch::Replace {
548            replace_span: (1_usize, 3..4).try_into().unwrap(),
549            replacement: "Y".to_owned(),
550        }];
551        verify_correction!("T🐠🐠U", bandaids, "T🐠🐠Y");
552    }
553
554    #[test]
555    fn patch_injection_1() {
556        let _ = env_logger::Builder::new()
557            .filter_level(log::LevelFilter::Trace)
558            .is_test(true)
559            .try_init();
560
561        let patches = vec![Patch::Insert {
562            insert_at: LineColumn {
563                line: 1_usize,
564                column: 0,
565            },
566            content: "Q".to_owned(),
567        }];
568        verify_correction!("A🐢C", patches, "QA🐢C");
569    }
570
571    #[test]
572    fn patch_injection_2() {
573        let _ = env_logger::Builder::new()
574            .filter_level(log::LevelFilter::Trace)
575            .is_test(true)
576            .try_init();
577
578        let patches = vec![Patch::Insert {
579            insert_at: LineColumn {
580                line: 1_usize,
581                column: 2,
582            },
583            content: "Q".to_owned(),
584        }];
585        verify_correction!("A🐢C", patches, "A🐢QC");
586    }
587    #[test]
588    fn patch_injection_3() {
589        let _ = env_logger::Builder::new()
590            .filter_level(log::LevelFilter::Trace)
591            .is_test(true)
592            .try_init();
593
594        let patches = vec![Patch::Insert {
595            insert_at: LineColumn {
596                line: 1_usize,
597                column: 3,
598            },
599            content: "Q".to_owned(),
600        }];
601        verify_correction!("A🐢C", patches, "A🐢CQ");
602    }
603}