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