From 40926ccba4fc332480f62398f2211d586dd3f444 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Fri, 11 Oct 2024 15:30:07 +0200 Subject: [PATCH 1/3] refactor: extract fix_file_name function --- crates/typos-cli/src/file.rs | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/crates/typos-cli/src/file.rs b/crates/typos-cli/src/file.rs index c7f377c71..0a9b9147e 100644 --- a/crates/typos-cli/src/file.rs +++ b/crates/typos-cli/src/file.rs @@ -127,11 +127,7 @@ impl FileChecker for FixTypos { } } if !fixes.is_empty() { - let file_name = file_name.to_owned().into_bytes(); - let new_name = fix_buffer(file_name, fixes.into_iter()); - let new_name = - String::from_utf8(new_name).expect("corrections are valid utf-8"); - let new_path = path.with_file_name(new_name); + let new_path = fix_file_name(path, file_name, fixes.into_iter())?; std::fs::rename(path, new_path)?; } } @@ -205,11 +201,7 @@ impl FileChecker for DiffTypos { } } if !fixes.is_empty() { - let file_name = file_name.to_owned().into_bytes(); - let new_name = fix_buffer(file_name, fixes.into_iter()); - let new_name = - String::from_utf8(new_name).expect("corrections are valid utf-8"); - new_path = Some(path.with_file_name(new_name)); + new_path = fix_file_name(path, file_name, fixes.into_iter()).ok(); } } } @@ -650,7 +642,7 @@ fn is_fixable(typo: &typos::Typo<'_>) -> bool { extract_fix(typo).is_some() } -fn fix_buffer(mut buffer: Vec, typos: impl Iterator>) -> Vec { +fn fix_buffer<'a>(mut buffer: Vec, typos: impl Iterator>) -> Vec { let mut offset = 0isize; for typo in typos { let fix = extract_fix(&typo).expect("Caller only provides fixable typos"); @@ -664,6 +656,18 @@ fn fix_buffer(mut buffer: Vec, typos: impl Iterator( + path: &std::path::Path, + file_name: &'a str, + fixes: impl Iterator>, +) -> Result { + let file_name = file_name.to_owned().into_bytes(); + let new_name = fix_buffer(file_name, fixes); + let new_name = String::from_utf8(new_name).expect("corrections are valid utf-8"); + let new_path = path.with_file_name(new_name); + Ok(new_path) +} + pub fn walk_path( walk: ignore::Walk, checks: &dyn FileChecker, From 85ec8ca2c8b9a4b4e5f5a747f4b401a77a716e27 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Fri, 11 Oct 2024 13:07:35 +0200 Subject: [PATCH 2/3] refactor: require passing correction index to fix_buffer and fix_file_name methods --- crates/typos-cli/src/file.rs | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/crates/typos-cli/src/file.rs b/crates/typos-cli/src/file.rs index 0a9b9147e..7955d141c 100644 --- a/crates/typos-cli/src/file.rs +++ b/crates/typos-cli/src/file.rs @@ -87,7 +87,7 @@ impl FileChecker for FixTypos { let mut accum_line_num = AccumulateLineNum::new(); for typo in check_bytes(&buffer, policy) { if is_fixable(&typo) { - fixes.push(typo.into_owned()); + fixes.push((typo.into_owned(), 0)); } else { let line_num = accum_line_num.line_num(&buffer, typo.byte_offset); let (line, line_offset) = extract_line(&buffer, typo.byte_offset); @@ -114,7 +114,7 @@ impl FileChecker for FixTypos { let mut fixes = Vec::new(); for typo in check_str(file_name, policy) { if is_fixable(&typo) { - fixes.push(typo.into_owned()); + fixes.push((typo.into_owned(), 0)); } else { let msg = report::Typo { context: Some(report::PathContext { path }.into()), @@ -160,7 +160,7 @@ impl FileChecker for DiffTypos { let mut accum_line_num = AccumulateLineNum::new(); for typo in check_bytes(&buffer, policy) { if is_fixable(&typo) { - fixes.push(typo.into_owned()); + fixes.push((typo.into_owned(), 0)); } else { let line_num = accum_line_num.line_num(&buffer, typo.byte_offset); let (line, line_offset) = extract_line(&buffer, typo.byte_offset); @@ -188,7 +188,7 @@ impl FileChecker for DiffTypos { let mut fixes = Vec::new(); for typo in check_str(file_name, policy) { if is_fixable(&typo) { - fixes.push(typo.into_owned()); + fixes.push((typo.into_owned(), 0)); } else { let msg = report::Typo { context: Some(report::PathContext { path }.into()), @@ -642,10 +642,17 @@ fn is_fixable(typo: &typos::Typo<'_>) -> bool { extract_fix(typo).is_some() } -fn fix_buffer<'a>(mut buffer: Vec, typos: impl Iterator>) -> Vec { +fn fix_buffer<'a>( + mut buffer: Vec, + typos: impl Iterator, usize)>, +) -> Vec { let mut offset = 0isize; - for typo in typos { - let fix = extract_fix(&typo).expect("Caller only provides fixable typos"); + for (typo, correction_index) in typos { + let fix = match &typo.corrections { + typos::Status::Corrections(c) => Some(c[correction_index].as_ref()), + _ => None, + } + .expect("Caller provided invalid fix index"); let start = ((typo.byte_offset as isize) + offset) as usize; let end = start + typo.typo.len(); @@ -659,7 +666,7 @@ fn fix_buffer<'a>(mut buffer: Vec, typos: impl Iterator( path: &std::path::Path, file_name: &'a str, - fixes: impl Iterator>, + fixes: impl Iterator, usize)>, ) -> Result { let file_name = file_name.to_owned().into_bytes(); let new_name = fix_buffer(file_name, fixes); @@ -777,10 +784,15 @@ mod test { let line = line.as_bytes().to_vec(); let corrections = corrections .into_iter() - .map(|(byte_offset, typo, correction)| typos::Typo { - byte_offset, - typo: typo.into(), - corrections: typos::Status::Corrections(vec![correction.into()]), + .map(|(byte_offset, typo, correction)| { + ( + typos::Typo { + byte_offset, + typo: typo.into(), + corrections: typos::Status::Corrections(vec![correction.into()]), + }, + 0, + ) }); let actual = fix_buffer(line, corrections); String::from_utf8(actual).unwrap() From b435635f0ee021cfa9dc1e5815826733f279b2f4 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Fri, 11 Oct 2024 13:07:49 +0200 Subject: [PATCH 3/3] feat: add --interactive option to prompt for each change --- Cargo.lock | 102 ++++++++++++++++++- crates/typos-cli/Cargo.toml | 3 + crates/typos-cli/src/bin/typos-cli/args.rs | 4 + crates/typos-cli/src/bin/typos-cli/main.rs | 10 ++ crates/typos-cli/src/file.rs | 111 +++++++++++++++++++++ 5 files changed, 227 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0375887f3..b73102e21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,6 +179,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "clap" version = "4.5.18" @@ -275,6 +281,19 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf0a07a401f374238ab8e2f11a104d2851bf9ce711ec69804834de8af45c7af" +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "unicode-width 0.1.14", + "windows-sys 0.52.0", +] + [[package]] name = "content_inspector" version = "0.2.4" @@ -338,6 +357,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctrlc" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90eeab0aa92f3f9b4e87f258c72b139c207d251f9cbc1080a0086b86a8870dd3" +dependencies = [ + "nix", + "windows-sys 0.59.0", +] + [[package]] name = "darling" version = "0.20.3" @@ -406,6 +435,19 @@ dependencies = [ "syn", ] +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror", + "zeroize", +] + [[package]] name = "dictgen" version = "0.2.9" @@ -477,6 +519,12 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "encoding_rs" version = "0.8.34" @@ -730,11 +778,17 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" -version = "0.2.149" +version = "0.2.161" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a08173bc88b7955d1b3145aa561539096c421ac8debde8cbc3612ec635fee29b" +checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" [[package]] name = "linux-raw-sys" @@ -790,6 +844,18 @@ dependencies = [ "unicase", ] +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.4.1", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -1117,6 +1183,12 @@ dependencies = [ "serde", ] +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "shlex" version = "1.2.0" @@ -1348,9 +1420,12 @@ dependencies = [ "clap", "clap-verbosity-flag", "colorchoice-clap", + "console", "content_inspector", + "ctrlc", "derive_more", "derive_setters", + "dialoguer", "difflib", "divan", "encoding_rs", @@ -1378,7 +1453,7 @@ dependencies = [ "unic-emoji-char", "unicase", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.0", "varcon-core", ] @@ -1473,6 +1548,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.0" @@ -1600,6 +1681,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -1758,3 +1848,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/crates/typos-cli/Cargo.toml b/crates/typos-cli/Cargo.toml index 65d58c330..937b95629 100644 --- a/crates/typos-cli/Cargo.toml +++ b/crates/typos-cli/Cargo.toml @@ -77,6 +77,9 @@ colorchoice-clap = "1.0.3" serde_regex = "1.1.0" regex = "1.10.4" encoding_rs = "0.8.34" +dialoguer = "0.11.0" +console = "0.15.8" +ctrlc = "3.4.5" [dev-dependencies] assert_fs = "1.1" diff --git a/crates/typos-cli/src/bin/typos-cli/args.rs b/crates/typos-cli/src/bin/typos-cli/args.rs index 3541533cf..4d00d249f 100644 --- a/crates/typos-cli/src/bin/typos-cli/args.rs +++ b/crates/typos-cli/src/bin/typos-cli/args.rs @@ -67,6 +67,10 @@ pub(crate) struct Args { #[arg(long, short = 'w', group = "mode", help_heading = "Mode")] pub(crate) write_changes: bool, + /// Prompt for each suggested correction whether to write the fix + #[arg(long, short = 'i', group = "mode", help_heading = "Mode")] + pub(crate) interactive: bool, + /// Debug: Print each file that would be spellchecked. #[arg(long, group = "mode", help_heading = "Mode")] pub(crate) files: bool, diff --git a/crates/typos-cli/src/bin/typos-cli/main.rs b/crates/typos-cli/src/bin/typos-cli/main.rs index 9dd8773d4..38e503713 100644 --- a/crates/typos-cli/src/bin/typos-cli/main.rs +++ b/crates/typos-cli/src/bin/typos-cli/main.rs @@ -32,6 +32,14 @@ fn run() -> proc_exit::ExitResult { init_logging(args.verbose.log_level()); + // HACK: Ensure the terminal gets reset to a good state if the user hits ctrl-c during a prompt + // https://github.com/console-rs/dialoguer/issues/294 + ctrlc::set_handler(move || { + let _ = console::Term::stdout().show_cursor(); + std::process::exit(130); + }) + .expect("Failed to set handler for Ctrl+C needed to restore terminal defaults after killing the process"); + if let Some(output_path) = args.dump_config.as_ref() { run_dump_config(&args, output_path) } else if args.type_list { @@ -289,6 +297,8 @@ fn run_checks(args: &args::Args) -> proc_exit::ExitResult { &typos_cli::file::Identifiers } else if args.words { &typos_cli::file::Words + } else if args.interactive { + &typos_cli::file::Interactive } else if args.write_changes { &typos_cli::file::FixTypos } else if args.diff { diff --git a/crates/typos-cli/src/file.rs b/crates/typos-cli/src/file.rs index 7955d141c..81ad2314c 100644 --- a/crates/typos-cli/src/file.rs +++ b/crates/typos-cli/src/file.rs @@ -1,4 +1,5 @@ use bstr::ByteSlice; +use dialoguer::{Confirm, Select}; use std::io::Read; use std::io::Write; @@ -137,6 +138,87 @@ impl FileChecker for FixTypos { } } +#[derive(Debug, Clone, Copy)] +pub struct Interactive; + +impl FileChecker for Interactive { + fn check_file( + &self, + path: &std::path::Path, + explicit: bool, + policy: &crate::policy::Policy<'_, '_, '_>, + reporter: &dyn report::Report, + ) -> Result<(), std::io::Error> { + if policy.check_files { + let (buffer, content_type) = read_file(path, reporter)?; + let bc = buffer.clone(); + if !explicit && !policy.binary && content_type.is_binary() { + let msg = report::BinaryFile { path }; + reporter.report(msg.into())?; + } else { + let mut fixes = Vec::new(); + + let mut accum_line_num = AccumulateLineNum::new(); + for typo in check_bytes(&bc, policy) { + let line_num = accum_line_num.line_num(&buffer, typo.byte_offset); + let (line, line_offset) = extract_line(&buffer, typo.byte_offset); + let msg = report::Typo { + context: Some(report::FileContext { path, line_num }.into()), + buffer: std::borrow::Cow::Borrowed(line), + byte_offset: line_offset, + typo: typo.typo.as_ref(), + corrections: typo.corrections.clone(), + }; + // HACK: we use the reporter to display the possible corrections to the user + // this will be looking very ugly with the format set to anything else than json + // technically we should only report typos when not correcting + reporter.report(msg.into())?; + + if let Some(correction_index) = select_fix(&typo) { + fixes.push((typo, correction_index)); + } + } + + if !fixes.is_empty() || path == std::path::Path::new("-") { + let buffer = fix_buffer(buffer, fixes.into_iter()); + write_file(path, content_type, buffer, reporter)?; + } + } + } + + if policy.check_filenames { + if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) { + let mut fixes = Vec::new(); + + for typo in check_str(file_name, policy) { + let msg = report::Typo { + context: Some(report::PathContext { path }.into()), + buffer: std::borrow::Cow::Borrowed(file_name.as_bytes()), + byte_offset: typo.byte_offset, + typo: typo.typo.as_ref(), + corrections: typo.corrections.clone(), + }; + // HACK: we use the reporter to display the possible corrections to the user + // this will be looking very ugly with the format set to anything else than json + // technically we should only report typos when not correcting + reporter.report(msg.into())?; + + if let Some(correction_index) = select_fix(&typo) { + fixes.push((typo, correction_index)); + } + } + + if !fixes.is_empty() { + let new_path = fix_file_name(path, file_name, fixes.into_iter())?; + std::fs::rename(path, new_path)?; + } + } + } + + Ok(()) + } +} + #[derive(Debug, Clone, Copy)] pub struct DiffTypos; @@ -675,6 +757,35 @@ fn fix_file_name<'a>( Ok(new_path) } +fn select_fix(typo: &typos::Typo<'_>) -> Option { + let corrections = match &typo.corrections { + typos::Status::Corrections(c) => c, + _ => return None, + }; + + if corrections.len() == 1 { + Confirm::new() + .with_prompt("Do you want to apply the fix suggested above?") + .default(true) + .show_default(true) + .interact() + .ok()?; + + Some(0) + } else { + let mut items = corrections.clone(); + + items.insert(0, std::borrow::Cow::from("None (skip)")); + let selection = Select::new() + .with_prompt("Please choose one of the following suggestions") + .items(&items) + .default(0) + .interact() + .ok()?; + selection.checked_sub(1) + } +} + pub fn walk_path( walk: ignore::Walk, checks: &dyn FileChecker,