use super::*;
use crate::checker::Checkers;
use crate::errors::*;
use crate::reflow::Reflow;
use fs_err as fs;
use futures::stream::{self, StreamExt};
use rayon::iter::ParallelIterator;
use std::io::{Read, Write};
use std::path::PathBuf;
pub mod bandaid;
pub mod interactive;
pub(crate) use bandaid::*;
use interactive::{UserPicked, UserSelection};
#[derive(Debug, Clone, Copy)]
pub enum Finish {
Success,
Abort,
MistakeCount(usize),
}
impl Finish {
pub fn found_any(&self) -> bool {
match *self {
Self::MistakeCount(n) if n > 0 => true,
_ => false,
}
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub(crate) enum Patch {
Replace {
replace_span: Span,
replacement: String,
},
Insert {
insert_at: LineColumn,
content: String,
},
}
impl<'a> From<&'a BandAid> for Patch {
fn from(bandaid: &'a BandAid) -> Self {
Self::from(bandaid.clone())
}
}
impl From<BandAid> for Patch {
fn from(bandaid: BandAid) -> Self {
match bandaid {
bandaid if bandaid.span.start == bandaid.span.end => Self::Insert {
insert_at: bandaid.span.start,
content: bandaid.content,
},
_ => Self::Replace {
replace_span: bandaid.span,
replacement: bandaid.content,
},
}
}
}
pub(crate) fn apply_patches<'s, II, I>(
patches: II,
source_buffer: &str,
mut sink: impl Write,
) -> Result<()>
where
II: IntoIterator<IntoIter = I, Item = Patch>,
I: Iterator<Item = Patch>,
{
let mut patches = patches.into_iter().peekable();
let mut source_iter =
iter_with_line_column_from(source_buffer, LineColumn { line: 1, column: 0 }).peekable();
const TARGET: &str = "patch";
let mut write_to_sink = |topic: &str, data: &str| -> Result<()> {
log::trace!(target: TARGET, "w<{}>: {}", topic, data.escape_debug());
sink.write_all(data.as_bytes())?;
Ok(())
};
let mut cc_end_byte_offset = 0;
let mut current = None;
let mut byte_cursor = 0usize;
loop {
let cc_start_byte_offset = if let Some(ref current) = current {
let (cc_start, data, insertion) = match current {
Patch::Replace {
replace_span,
replacement,
} => (replace_span.end, replacement.as_str(), false),
Patch::Insert { insert_at, content } => (*insert_at, content.as_str(), true),
};
write_to_sink("new", data)?;
if insertion {
byte_cursor
} else {
let mut cc_start_byte_offset = byte_cursor;
'skip: while let Some((c, byte_offset, _idx, linecol)) = source_iter.peek() {
let byte_offset = *byte_offset;
let linecol = *linecol;
cc_start_byte_offset = byte_offset + c.len_utf8();
if linecol >= cc_start {
log::trace!(
target: TARGET,
"skip buffer: >{}<",
&source_buffer[cc_end_byte_offset..cc_start_byte_offset].escape_debug()
);
break 'skip;
}
log::trace!(target: TARGET, "skip[{}]: >{}<", _idx, c.escape_debug());
let _ = source_iter.next();
}
cc_start_byte_offset
}
} else {
byte_cursor
};
debug_assert!(byte_cursor <= cc_start_byte_offset);
byte_cursor = cc_start_byte_offset;
cc_end_byte_offset = if let Some(upcoming) = patches.peek() {
let cc_end = match upcoming {
Patch::Replace { replace_span, .. } => replace_span.start,
Patch::Insert { insert_at, .. } => *insert_at,
};
let mut cc_end_byte_offset = byte_cursor;
'cc: while let Some((c, byte_offset, _idx, linecol)) = source_iter.peek() {
let byte_offset = *byte_offset;
let linecol = *linecol;
if linecol >= cc_end {
log::trace!(
target: TARGET,
"copy buffer: >{}<",
&source_buffer[cc_start_byte_offset..cc_end_byte_offset].escape_debug()
);
break 'cc;
}
cc_end_byte_offset = byte_offset + c.len_utf8();
log::trace!(target: TARGET, "copy[{}]: >{}<", _idx, c.escape_debug());
let _ = source_iter.next();
}
std::cmp::min(cc_end_byte_offset, source_buffer.len())
} else {
source_buffer.len()
};
debug_assert!(byte_cursor <= cc_end_byte_offset);
byte_cursor = cc_end_byte_offset;
let cc_range = cc_start_byte_offset..cc_end_byte_offset;
write_to_sink("cc", &source_buffer[cc_range])?;
current = patches.next();
if current.is_none() {
break;
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum Action {
Check,
Fix,
Reflow,
ListFiles,
}
impl Action {
pub fn write_changes_to_disk(
&self,
origin: ContentOrigin,
bandaids: impl IntoIterator<Item = BandAid>,
) -> Result<()> {
match origin {
ContentOrigin::CargoManifestDescription(path) => self.correct_file(path, bandaids),
ContentOrigin::CommonMarkFile(path) => self.correct_file(path, bandaids),
ContentOrigin::RustSourceFile(path) => self.correct_file(path, bandaids),
ContentOrigin::RustDocTest(path, _span) => self.correct_file(path, bandaids),
ContentOrigin::TestEntityRust => unreachable!("Use a proper file"),
ContentOrigin::TestEntityCommonMark => unreachable!("Use a proper file"),
}
}
fn correct_file(
&self,
path: PathBuf,
bandaids: impl IntoIterator<Item = BandAid>,
) -> Result<()> {
let path = fs::canonicalize(path.as_path())?;
let path = path.as_path();
log::trace!("Attempting to open {} as read", path.display());
let ro = fs::OpenOptions::new().read(true).open(path)?;
let mut reader = std::io::BufReader::new(ro);
const TEMPORARY: &str = ".spellcheck.tmp";
let tmp_name = TEMPORARY.to_owned() + uuid::Uuid::new_v4().to_string().as_str();
let tmp = std::env::current_dir()
.expect("Must have cwd")
.join(tmp_name);
let wr = fs::OpenOptions::new()
.write(true)
.truncate(true)
.create(true)
.open(&tmp)?;
let mut writer = std::io::BufWriter::with_capacity(1024, wr);
let mut content = String::with_capacity(2e6 as usize);
reader.get_mut().read_to_string(&mut content)?;
{
let th = crate::TinHat::on();
apply_patches(
bandaids.into_iter().map(Patch::from),
content.as_str(), &mut writer,
)?;
writer.flush()?;
drop(writer);
drop(reader);
fs::rename(tmp, path)?;
drop(th);
}
Ok(())
}
pub fn write_user_pick_changes_to_disk(
&self,
userpicked: interactive::UserPicked,
) -> Result<()> {
if userpicked.total_count() > 0 {
log::debug!("Writing changes back to disk");
for (origin, bandaids) in userpicked.bandaids.into_iter() {
self.write_changes_to_disk(origin, bandaids.into_iter())?;
}
} else {
log::debug!("No band aids to apply");
}
Ok(())
}
pub async fn run(self, documents: Documentation, config: Config) -> Result<Finish> {
let fin = match self {
Self::ListFiles { .. } => self.run_list_files(documents, &config)?,
Self::Reflow { .. } => self.run_reflow(documents, config).await?,
Self::Check { .. } => self.run_check(documents, config).await?,
Self::Fix { .. } => self.run_fix_interactive(documents, config).await?,
};
Ok(fin)
}
fn run_list_files(self, documents: Documentation, _config: &Config) -> Result<Finish> {
for (origin, _chunks) in documents.iter() {
println!("{}", origin.as_path().display())
}
Ok(Finish::Success)
}
async fn run_fix_interactive(self, documents: Documentation, config: Config) -> Result<Finish> {
let n_cpus = num_cpus::get();
let checkers = Checkers::new(config)?;
let n = documents.entry_count();
log::debug!("Running checkers on all documents {n}");
let mut pick_stream = stream::iter(documents.iter().enumerate())
.map(|(mut idx, (origin, chunks))| {
idx += 1;
log::trace!("Running checkers on {idx}/{n},{origin:?}");
let suggestions = checkers.check(origin, &chunks[..]);
async move { Ok::<_, color_eyre::eyre::Report>((idx, origin, suggestions?)) }
})
.buffered(n_cpus)
.fuse();
let mut collected_picks = UserPicked::default();
while let Some(result) = pick_stream.next().await {
match result {
Ok((idx, origin, suggestions)) => {
let (picked, user_sel) =
interactive::UserPicked::select_interactive(origin.clone(), suggestions)?;
match user_sel {
UserSelection::Quit => break,
UserSelection::Abort => return Ok(Finish::Abort),
UserSelection::Nop if !picked.is_empty() => {
log::debug!(
"User picked patches to be applied for {idx}/{n},{origin:?}"
);
collected_picks.extend(picked);
}
UserSelection::Nop => {
log::debug!("Nothing to do for {idx}/{n},{origin:?}");
}
_ => unreachable!(
"All other variants are only internal to `select_interactive`. qed"
),
}
}
Err(e) => Err(e)?,
}
}
let total = collected_picks.total_count();
self.write_user_pick_changes_to_disk(collected_picks)?;
Ok(Finish::MistakeCount(total))
}
async fn run_check(self, documents: Documentation, config: Config) -> Result<Finish> {
let checkers = Checkers::new(config)?;
let num_mistakes = documents
.into_par_iter()
.map(|(origin, chunks)| {
checkers.check(&origin, &chunks).map(|suggestions| {
let path = origin.as_path();
let n = suggestions.len();
match suggestions.is_empty() {
true => log::info!("✅ {}", path.display()),
false => log::info!("❌ {} : {}", path.display(), n),
};
for suggestion in suggestions {
println!("{suggestion}");
}
n
})
})
.try_fold_with(0, |count, res| res.map(|it| it + count))
.try_reduce(|| 0, |l, r| Ok(l + r))?;
if num_mistakes > 0 {
Ok(Finish::MistakeCount(num_mistakes))
} else {
Ok(Finish::Success)
}
}
async fn run_reflow(self, documents: Documentation, config: Config) -> Result<Finish> {
let reflow_config = config.reflow.clone().unwrap_or_default();
let reflow = Reflow::new(reflow_config)?;
documents
.into_par_iter()
.map(|(origin, chunks)| {
let mut picked = UserPicked::default();
let suggestions = reflow.check(&origin, &chunks[..])?;
for suggestion in suggestions {
let bandaids = suggestion.replacements.first().map(|replacement| {
super::BandAid::from((replacement.to_owned(), &suggestion.span))
});
picked.add_bandaids(&origin, bandaids);
}
Ok::<_, color_eyre::eyre::Report>(picked)
})
.try_for_each(move |picked| {
self.write_user_pick_changes_to_disk(picked?)?;
Ok::<_, color_eyre::eyre::Report>(())
})?;
Ok(Finish::Success)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::convert::TryInto;
macro_rules! verify_correction {
($text:literal, $bandaids:expr, $expected:literal) => {
let mut sink: Vec<u8> = Vec::with_capacity(1024);
apply_patches(
$bandaids.into_iter().map(|bandaid| Patch::from(bandaid)),
$text,
&mut sink,
)
.expect("Line correction must work in unit test!");
assert_eq!(String::from_utf8_lossy(sink.as_slice()), $expected);
};
}
#[test]
fn patch_full() {
let _ = env_logger::Builder::new()
.filter_level(log::LevelFilter::Trace)
.is_test(true)
.try_init();
let patches = vec![
Patch::Replace {
replace_span: Span {
start: LineColumn { line: 1, column: 6 },
end: LineColumn {
line: 2,
column: 12,
},
},
replacement: "& Omega".to_owned(),
},
Patch::Insert {
insert_at: LineColumn { line: 3, column: 0 },
content: "Icecream truck".to_owned(),
},
];
verify_correction!(
r#"Alpha beta gamma
zeta eta beta.
"#,
patches,
r#"Alpha & Omega.
Icecream truck"#
);
}
#[test]
fn patch_replace_1() {
let _ = env_logger::Builder::new()
.filter_level(log::LevelFilter::Trace)
.is_test(true)
.try_init();
let bandaids = vec![Patch::Replace {
replace_span: (1_usize, 0..1).try_into().unwrap(),
replacement: "Y".to_owned(),
}];
verify_correction!("T🐠🐠U", bandaids, "Y🐠🐠U");
}
#[test]
fn patch_replace_2() {
let _ = env_logger::Builder::new()
.filter_level(log::LevelFilter::Trace)
.is_test(true)
.try_init();
let bandaids = vec![Patch::Replace {
replace_span: (1_usize, 1..3).try_into().unwrap(),
replacement: "Y".to_owned(),
}];
verify_correction!("T🐠🐠U", bandaids, "TYU");
}
#[test]
fn patch_replace_3() {
let _ = env_logger::Builder::new()
.filter_level(log::LevelFilter::Trace)
.is_test(true)
.try_init();
let bandaids = vec![Patch::Replace {
replace_span: (1_usize, 3..4).try_into().unwrap(),
replacement: "Y".to_owned(),
}];
verify_correction!("T🐠🐠U", bandaids, "T🐠🐠Y");
}
#[test]
fn patch_injection_1() {
let _ = env_logger::Builder::new()
.filter_level(log::LevelFilter::Trace)
.is_test(true)
.try_init();
let patches = vec![Patch::Insert {
insert_at: LineColumn {
line: 1_usize,
column: 0,
},
content: "Q".to_owned(),
}];
verify_correction!("A🐢C", patches, "QA🐢C");
}
#[test]
fn patch_injection_2() {
let _ = env_logger::Builder::new()
.filter_level(log::LevelFilter::Trace)
.is_test(true)
.try_init();
let patches = vec![Patch::Insert {
insert_at: LineColumn {
line: 1_usize,
column: 2,
},
content: "Q".to_owned(),
}];
verify_correction!("A🐢C", patches, "A🐢QC");
}
#[test]
fn patch_injection_3() {
let _ = env_logger::Builder::new()
.filter_level(log::LevelFilter::Trace)
.is_test(true)
.try_init();
let patches = vec![Patch::Insert {
insert_at: LineColumn {
line: 1_usize,
column: 3,
},
content: "Q".to_owned(),
}];
verify_correction!("A🐢C", patches, "A🐢CQ");
}
}