diff --git a/Cargo.lock b/Cargo.lock
index c70c216..e8258fb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,658 @@
# It is not intended for manual editing.
version = 3
+[[package]]
+name = "aho-corasick"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e1ebcb11de5c03c67de28a7df593d32191b44939c482e97702baaaa6ab6a5"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2faccea4cc4ab4a667ce676a30e8ec13922a692c99bb8f5b11f1502c72e04220"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648"
+dependencies = [
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7"
+dependencies = [
+ "anstyle",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "080e9890a082662b09c1ad45f567faeeb47f22b5fb23895fbe1e651e718e25ca"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed570934406eb16438a4e976b1b4500774099c13b8cb96eec99f620f05090ddf"
+
+[[package]]
+name = "bstr"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c48f0051a4b4c5e0b6d365cd04af53aeaa209e3cc15ec2cdb69e73cc87fbd0dc"
+dependencies = [
+ "memchr",
+ "regex-automata",
+ "serde",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "clap"
+version = "4.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e578d6ec4194633722ccf9544794b71b1385c3c027efe0c55db226fc880865c"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4df4df40ec50c46000231c914968278b1eb05098cf8f1b3a518a95030e71d1c7"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+ "terminal_size",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf9804afaaf59a91e75b022a30fb7229a7901f60c755489cc61c9b423b836442"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "702fc72eb24e5a1e48ce58027a675bc24edd52096d5397d4aea7c6dd9eca0bd1"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7"
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345"
+
+[[package]]
+name = "diffy"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e616e59155c92257e84970156f506287853355f58cd4a6eb167385722c32b790"
+dependencies = [
+ "nu-ansi-term",
+]
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "encoding_rs_io"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83"
+dependencies = [
+ "encoding_rs",
+]
+
+[[package]]
+name = "errno"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a258e46cdc063eb8519c00b9fc845fc47bcfca4130e2f08e88665ceda8474245"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25cbce373ec4653f1a01a31e8a5e5ec0c622dc27ff9c4e6606eefef5cbbed4a5"
+
+[[package]]
+name = "globset"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1"
+dependencies = [
+ "aho-corasick",
+ "bstr",
+ "log",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "grep-matcher"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47a3141a10a43acfedc7c98a60a834d7ba00dfe7bec9071cbfc19b55b292ac02"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "grep-regex"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f748bb135ca835da5cbc67ca0e6955f968db9c5df74ca4f56b18e1ddbc68230d"
+dependencies = [
+ "bstr",
+ "grep-matcher",
+ "log",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "grep-searcher"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba536ae4f69bec62d8839584dd3153d3028ef31bb229f04e09fb5a9e5a193c54"
+dependencies = [
+ "bstr",
+ "encoding_rs",
+ "encoding_rs_io",
+ "grep-matcher",
+ "log",
+ "memchr",
+ "memmap2",
+]
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+[[package]]
+name = "ignore"
+version = "0.4.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b46810df39e66e925525d6e38ce1e7f6e1d208f72dc39757880fcb66e2c58af1"
+dependencies = [
+ "crossbeam-deque",
+ "globset",
+ "log",
+ "memchr",
+ "regex-automata",
+ "same-file",
+ "walkdir",
+ "winapi-util",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.153"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01cda141df6706de531b6c46c3a33ecca755538219bd484262fa09410c13539c"
+
+[[package]]
+name = "log"
+version = "0.4.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f"
+
+[[package]]
+name = "memchr"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149"
+
+[[package]]
+name = "memmap2"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe751422e4a8caa417e13c3ea66452215d7d63e19e604f4980461212f3ae1322"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.46.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84"
+dependencies = [
+ "overload",
+ "winapi",
+]
+
+[[package]]
+name = "overload"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.78"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f"
+
[[package]]
name = "repatch"
version = "0.1.0"
+dependencies = [
+ "anstyle",
+ "anyhow",
+ "bstr",
+ "clap",
+ "diffy",
+ "grep-matcher",
+ "grep-regex",
+ "grep-searcher",
+ "ignore",
+ "libc",
+ "tempfile",
+]
+
+[[package]]
+name = "rustix"
+version = "0.38.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ea3e1a662af26cd7a3ba09c0297a31af215563ecf42817c98df621387f4e949"
+dependencies = [
+ "bitflags 2.4.2",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.196"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.196"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "strsim"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
+
+[[package]]
+name = "syn"
+version = "2.0.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01ce4141aa927a6d1bd34a041795abd0db1cccba5d5f24b009f694bdf3a1f3fa"
+dependencies = [
+ "cfg-if",
+ "fastrand",
+ "redox_syscall",
+ "rustix",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "terminal_size"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "21bebf2b7c9e0a515f6e0f8c51dc0f8e4696391e6f1ff30379559f8365fb0df7"
+dependencies = [
+ "rustix",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a"
+
+[[package]]
+name = "walkdir"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-util"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f29e6f9198ba0d26b4c9f07dbe6f9ed633e1f3d5b8b414090084349e46a52596"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+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.0",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.0",
+ "windows_aarch64_msvc 0.52.0",
+ "windows_i686_gnu 0.52.0",
+ "windows_i686_msvc 0.52.0",
+ "windows_x86_64_gnu 0.52.0",
+ "windows_x86_64_gnullvm 0.52.0",
+ "windows_x86_64_msvc 0.52.0",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04"
diff --git a/Cargo.toml b/Cargo.toml
index 60affe2..f5c1b7b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -2,5 +2,21 @@
name = "repatch"
version = "0.1.0"
edition = "2021"
+license = "MIT"
+repository = "https://github.com/stevenengler/repatch"
+description = "A regex find-and-replace tool with a `git add --patch`-like interface."
+keywords = ["search", "find", "replace", "regex", "patch"]
+categories = ["command-line-utilities", "text processing", "filesystem"]
[dependencies]
+anstyle = "1.0.4"
+anyhow = "1.0.79"
+bstr = { version = "1.9.0", features = ["unicode"] }
+clap = { version = "4.4.16", features = ["derive", "wrap_help"] }
+diffy = "0.3.0"
+grep-matcher = "0.1.7"
+grep-regex = "0.1.12"
+grep-searcher = "0.1.13"
+ignore = "0.4.22"
+libc = "0.2.152"
+tempfile = "3.9.0"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..af08b72
--- /dev/null
+++ b/README.md
@@ -0,0 +1,76 @@
+# re:patch
+
+[![Latest Version]][crates.io]
+
+re:patch is a line-oriented find-and-replace tool with a [`git add
+--patch`][git-add-patch]-like interface. Regular expressions and capture groups
+are supported, and re:patch can be used with files and directories. Only Linux
+is currently supported.
+
+> [!WARNING]
+> This tool is still in development. While it Works For Me™, it does not yet
+> have any tests. It's recommended to only use this in directories that are
+> version controlled.
+
+[crates.io]: https://crates.io/crates/repatch
+[Latest Version]: https://img.shields.io/crates/v/repatch.svg
+[git-add-patch]: https://git-scm.com/docs/git-add#Documentation/git-add.txt---patch
+
+### Install
+
+You can install from source or through crates.io. You must have a recent
+[rust/cargo][rust] toolchain installed.
+
+```
+# install the latest release from crates.io
+cargo install repatch
+
+# install from source
+git clone https://github.com/stevenengler/repatch.git
+cd repatch && cargo install --path .
+```
+
+[rust]: https://www.rust-lang.org/tools/install
+
+### Example
+
+
+
+
+
+
+### Notes
+
+Similar to [ripgrep][ripgrep], gitignore rules are respected and hidden
+files/directories are ignored.
+
+The editor used to edit patches can be configured using environment variables
+or the git configuration. The search priority is `VISUAL`, `EDITOR`,
+`GIT_EDITOR`, and `git config core.editor`. Otherwise vim is used. Like `sudo
+-e` the editor value is split by whitespace characters and executed, and is not
+interpreted by a shell.
+
+Patches shown in the terminal will have ANSI escape sequences replaced with
+safe versions.
+
+Like most text editors, files are replaced and not edited in-place. This means
+that the file owner or other metadata may change after editing. The new file
+will have the same read/write/execute permissions as the original file. You
+will also need enough temporary disk space for this second file. For example if
+you're editing a 10 GB file, you must have at least 10 GB of disk space free so
+that the new file can be written before the original file is deleted.
+
+Large files (larger than the amount of available memory) are supported as long
+as they have a sufficient number of lines. For example a 10 GB file with 10,000
+lines should work fine, but a 10 GB file with a single line might exhaust the
+system memory and would not look very nice in the terminal.
+
+[ripgrep]: https://github.com/BurntSushi/ripgrep
+
+### Acknowledgements
+
+Most of the heavy lifting is done by the [ripgrep][ripgrep] family of crates,
+[clap][clap], and [diffy][diffy].
+
+[clap]: https://docs.rs/clap/latest/clap/
+[diffy]: https://docs.rs/diffy/latest/diffy/
diff --git a/docs/assets/example-dark.png b/docs/assets/example-dark.png
new file mode 100644
index 0000000..1c3660c
Binary files /dev/null and b/docs/assets/example-dark.png differ
diff --git a/docs/assets/example-light.png b/docs/assets/example-light.png
new file mode 100644
index 0000000..cab3ab3
Binary files /dev/null and b/docs/assets/example-light.png differ
diff --git a/docs/publishing.md b/docs/publishing.md
new file mode 100644
index 0000000..67ea5bd
--- /dev/null
+++ b/docs/publishing.md
@@ -0,0 +1,40 @@
+# Publishing a new release
+
+1. Update the code.
+
+ ```bash
+ # make sure we don't include personal information (such as our
+ # home directory name) in the release
+ cd /tmp
+
+ # make sure we don't include any untracked files in the release
+ git clone git@github.com:stevenengler/repatch.git
+ cd repatch
+
+ # update the version
+ vim Cargo.toml
+ cargo update --package repatch
+
+ # check for errors
+ git diff
+ cargo publish --dry-run --allow-dirty
+
+ # add and commit version changes with commit message, for example
+ # "Updated version to '0.2.1'"
+ git add --patch
+ git commit
+ git push
+ ```
+
+2. After CI tests finish on GitHub, mark it as a new release.
+
+3. Publish the crate.
+
+ ```bash
+ # make sure there are no untracked or changed files
+ git status
+
+ # publish
+ cargo publish --dry-run
+ cargo publish
+ ```
diff --git a/src/cli.rs b/src/cli.rs
new file mode 100644
index 0000000..afe0017
--- /dev/null
+++ b/src/cli.rs
@@ -0,0 +1,72 @@
+use std::path::PathBuf;
+
+use clap::Parser;
+
+const VERSION_STR: &str = concat!("re:patch ", env!("CARGO_PKG_VERSION"));
+
+/// re:patch is a line-oriented find-and-replace tool with a `git add --patch`-like interface.
+/// Directories are searched recursively. Hidden files/directories and binary files are ignored, as
+/// well as files/directories specified in gitignore rules. Regular expressions with capture groups
+/// are supported.
+#[derive(Debug, Parser)]
+#[command(version, name = "re:patch", max_term_width = 120, help_expected = true)]
+#[command(before_help(VERSION_STR))]
+pub struct Args {
+ /// Regex to search for, optionally with capture groups.
+ pub find: String,
+ /// Text to replace `` with. Capture group indices and names are supported.
+ pub replace: String,
+ /// Paths (files and/or directories) to search recursively.
+ #[clap(required = true)]
+ pub paths: Vec,
+ /// Case-insensitive search.
+ #[clap(long, short)]
+ pub ignore_case: bool,
+ /// Ignore filesystem-related errors while searching ("no such file", "permission denied", etc).
+ #[clap(long)]
+ pub ignore_errors: bool,
+ /// Generate diffs with `` lines of context; also accepts "infinite".
+ #[clap(long, default_value_t, value_name = "N")]
+ pub context: Context,
+ /// Show the changes without modifying any files.
+ ///
+ /// This does not generate valid patch files and is meant only for terminal output. ANSI escape
+ /// sequences are replaced in the generated patches.
+ #[clap(long, conflicts_with_all(["apply"]))]
+ pub show: bool,
+ /// Apply and write all changes automatically without any user input or confirmation.
+ #[clap(long)]
+ pub apply: bool,
+}
+
+#[derive(Copy, Clone, Debug)]
+pub enum Context {
+ Num(u64),
+ Infinite,
+}
+
+impl std::str::FromStr for Context {
+ type Err = std::num::ParseIntError;
+
+ fn from_str(s: &str) -> Result {
+ Ok(match s {
+ "infinite" => Self::Infinite,
+ x => Self::Num(x.parse()?),
+ })
+ }
+}
+
+impl std::fmt::Display for Context {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Num(x) => write!(f, "{x}"),
+ Self::Infinite => write!(f, "infinite"),
+ }
+ }
+}
+
+impl Default for Context {
+ fn default() -> Self {
+ Self::Num(5)
+ }
+}
diff --git a/src/main.rs b/src/main.rs
index f328e4d..a45ebf8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1 +1,417 @@
-fn main() {}
+#![deny(unsafe_op_in_unsafe_fn)]
+
+mod cli;
+mod parse;
+mod ui;
+mod util;
+
+use std::collections::BTreeMap;
+use std::fs::File;
+use std::io::{BufRead, BufReader, BufWriter, Write};
+use std::path::{Path, PathBuf};
+use std::process::ExitCode;
+use std::time::SystemTime;
+
+use anyhow::Context as anyhowContext;
+use bstr::ByteSlice;
+use clap::Parser;
+use grep_regex::{RegexMatcher, RegexMatcherBuilder};
+use grep_searcher::sinks::Bytes;
+use grep_searcher::Searcher;
+use ignore::WalkBuilder;
+
+use crate::cli::{Args, Context};
+use crate::ui::{error, style, MenuOption, PatchOption, COUNT_STYLE};
+use crate::util::ReplaceFileError;
+
+fn main() -> ExitCode {
+ if let Err(e) = run(Args::parse()) {
+ error!("{e:#}");
+ return ExitCode::FAILURE;
+ }
+
+ ExitCode::SUCCESS
+}
+
+fn run(args: Args) -> anyhow::Result<()> {
+ let mut matcher = RegexMatcherBuilder::new();
+ matcher.case_insensitive(args.ignore_case);
+ let matcher = matcher.build(&args.find)?;
+
+ let mut matches = match find_matches(&matcher, &args.paths, args.ignore_errors) {
+ Ok(x) => x,
+ Err(num_errors) => anyhow::bail!(
+ "found {} error{}",
+ style!(num_errors, &COUNT_STYLE),
+ if num_errors == 1 { "" } else { "s" },
+ ),
+ };
+
+ let match_count = matches.values().map(|i| i.lines.len()).sum::();
+ println!(
+ "Found {} match{} in {} file{}.",
+ style!(match_count, &COUNT_STYLE),
+ if match_count == 1 { "" } else { "es" },
+ style!(matches.len(), &COUNT_STYLE),
+ if matches.len() == 1 { "" } else { "s" },
+ );
+
+ // common options we'll use during the find & replace process across all files
+ let config = ReplaceOptions {
+ matcher: &matcher,
+ replace_with: args.replace.as_bytes(),
+ padding: match args.context {
+ Context::Num(x) => x,
+ Context::Infinite => u64::MAX,
+ },
+ };
+
+ // loop over each file that has matches
+ for (path, match_info) in matches.iter_mut() {
+ // separate files by a newline
+ println!();
+
+ // If '--show' is set, the program should effectively do a dry run where it shows the
+ // changes without making any modifications. While we could write a simpler function, we
+ // instead use the same `replace_file` function to ensure that the behaviour is the same as
+ // what would normally happen.
+
+ if args.show {
+ // we want to only show the patches, but not actually change anything
+ let src = std::fs::File::open(path).unwrap();
+
+ // perform the find & replace, but with no output file
+ let (cont, write_file) = replace_matches(
+ &config,
+ path,
+ &src,
+ None,
+ &mut match_info.lines,
+ Some(MenuOption::No),
+ );
+
+ // we provided `MenuOption::No`, so we shouldn't expect it to want to write
+ assert_eq!(cont, Continue::Yes);
+ assert_eq!(write_file, WriteFile::No);
+ } else {
+ // replace the file with a new file that we'll write to
+ let cont =
+ crate::util::replace_file(path, Some(match_info.modified), |original, new| {
+ // perform the find & replace
+ let (cont, write_file) = replace_matches(
+ &config,
+ path,
+ original,
+ Some(new),
+ &mut match_info.lines,
+ args.apply.then_some(MenuOption::Yes),
+ );
+
+ // inform `replace_file` whether it should replace the file or not
+ (write_file == WriteFile::Yes, cont)
+ });
+
+ // handle errors
+ let cont = match cont {
+ Ok(x) => x,
+ Err(ReplaceFileError::Io(e)) => {
+ return Err(e)
+ .with_context(|| format!("could not replace file '{}'", path.display()))
+ }
+ Err(ReplaceFileError::ModifiedTimeChanged) => {
+ return Err(anyhow::anyhow!(
+ "the file '{}' was modified by another program\n\
+ Discarding all patches to this file and exiting.",
+ path.display(),
+ ))
+ }
+ };
+
+ if cont == Continue::No {
+ break;
+ }
+ }
+ }
+
+ Ok(())
+}
+
+/// Find matches. Any errors will be printed to stdout. If there is an error:
+/// - If `continue_on_err` is true, the error will be printed.
+/// - If `continue_on_err` is false, the error will be printed and it will continue to walk the
+/// filesystem looking for more errors, but it will stop searching files.
+fn find_matches(
+ matcher: &RegexMatcher,
+ paths: &[impl AsRef],
+ continue_on_err: bool,
+) -> Result, u64> {
+ let mut matches = BTreeMap::new();
+ let mut num_errors = 0;
+
+ if paths.is_empty() {
+ return Ok(matches);
+ }
+
+ let mut searcher = Searcher::new();
+
+ let mut walk = WalkBuilder::new(paths.first().unwrap());
+ for path in &paths[1..] {
+ walk.add(path);
+ }
+ let walk = walk.build();
+
+ for result in walk {
+ match result {
+ Ok(entry) => {
+ let path = entry.path();
+ let meta = match std::fs::metadata(path) {
+ Ok(x) => x,
+ Err(e) => {
+ error!("{}: {e}", path.display());
+ num_errors += 1;
+ continue;
+ }
+ };
+ let modified_time = meta.modified().unwrap();
+
+ // this is only a very basic check; we may have already visited this file through
+ // some other path (relative or absolute path, another hard link to the same file,
+ // etc) and we don't defend against these here
+ if matches.contains_key(path) {
+ // already visited this path and it had a match
+ continue;
+ }
+
+ if meta.is_dir() {
+ continue;
+ }
+
+ if num_errors == 0 || continue_on_err {
+ let sink = Bytes(|line_num, _line| {
+ // TODO: even though we found a match, we might want to replace it with the
+ // same value (ex: "foo" -> "foo"), so we should also do a replace here and
+ // see if we really should record this
+ let MatchInfo { lines, .. } = matches
+ .entry(path.to_path_buf())
+ .or_insert(MatchInfo::new(modified_time));
+
+ // line numbers are given starting from 1
+ lines.push(line_num.checked_sub(1).unwrap());
+
+ Ok(true)
+ });
+
+ if let Err(e) = searcher.search_path(matcher, path, sink) {
+ // could not read the file
+ error!("{}: {e}", path.display());
+ num_errors += 1;
+ }
+ } else {
+ // if we've already had an error, we still check if we can open the remaining
+ // files
+ if let Err(e) = File::open(path) {
+ // could not read the file
+ error!("{}: {e}", path.display());
+ num_errors += 1;
+ }
+ }
+ }
+ Err(e) => {
+ error!("{e}");
+ num_errors += 1;
+ }
+ }
+ }
+
+ if num_errors == 0 || continue_on_err {
+ Ok(matches)
+ } else {
+ Err(num_errors)
+ }
+}
+
+struct MatchInfo {
+ modified: SystemTime,
+ lines: Vec,
+}
+
+impl MatchInfo {
+ pub fn new(modified: SystemTime) -> Self {
+ Self {
+ modified,
+ lines: Vec::new(),
+ }
+ }
+}
+
+fn replace_matches(
+ options: &ReplaceOptions,
+ path: &Path,
+ src: &File,
+ empty_dest: Option<&File>,
+ line_nums: &mut [u64],
+ input: Option,
+) -> (Continue, WriteFile) {
+ let mut src = BufReader::new(src);
+ let mut dest = empty_dest.map(BufWriter::new);
+
+ // group adjacent lines into ranges
+ line_nums.sort();
+ let hunk_ranges = crate::util::ranges(line_nums, options.padding);
+ let hunk_count: u64 = hunk_ranges.len().try_into().unwrap();
+
+ // current line of `src`
+ let mut current_line = 0;
+
+ // did we make any of our own changes to `dest`?
+ let mut made_change = false;
+
+ // do we want the program to continue after we return?
+ let mut cont = Continue::Yes;
+
+ // a reusable buffer
+ let mut buf = Vec::new();
+
+ for (hunk_idx, hunk_range) in hunk_ranges.into_iter().enumerate() {
+ let hunk_idx: u64 = hunk_idx.try_into().unwrap();
+ let path = (hunk_idx == 0).then_some(path);
+
+ // copy file lines to dest file until we get to the first line of the hunk
+ while !hunk_range.contains(¤t_line) {
+ buf.clear();
+ src.read_until(b'\n', &mut buf).unwrap();
+ if buf.is_empty() {
+ // EOF
+ break;
+ }
+ if let Some(ref mut dest) = dest {
+ dest.write_all(&buf).unwrap();
+ }
+ current_line += 1;
+ }
+
+ let mut current_hunk = Vec::new();
+ let hunk_start_line = current_line;
+
+ // copy file lines to buffer until we read all lines of the hunk
+ while hunk_range.contains(¤t_line) {
+ let initial_len = current_hunk.len();
+ src.read_until(b'\n', &mut current_hunk).unwrap();
+ if current_hunk.len() == initial_len {
+ // EOF
+ break;
+ }
+ current_line += 1;
+ }
+
+ // find & replace within this hunk
+ let mut replaced_hunk = Vec::new();
+ crate::util::replace_regex(
+ options.matcher,
+ options.replace_with,
+ ¤t_hunk,
+ &mut replaced_hunk,
+ )
+ .unwrap();
+
+ // check if anything changed
+ if current_hunk == replaced_hunk {
+ // nothing changed, so write the original hunk without applying any patch
+ if let Some(ref mut dest) = dest {
+ dest.write_all(¤t_hunk).unwrap();
+ }
+ continue;
+ }
+
+ // ask the user what to do
+ match crate::ui::patch_prompt(
+ ¤t_hunk,
+ &replaced_hunk,
+ path,
+ (hunk_idx, hunk_count),
+ hunk_start_line,
+ input,
+ ) {
+ PatchOption::WriteNew(x) => {
+ // this theoretically shouldn't be needed and it might panic on false positives, but
+ // it's unlikely that a patch would remove all lines of the hunk
+ if x.trim().is_empty() {
+ // TODO: remove this when we're more confident in the patches
+ let msg = "This patch removes all lines of the hunk. Are you sure that you want to continue [y/n]?";
+ if !crate::ui::yes_no_prompt(msg) {
+ // write the hunk without applying the patch
+ if let Some(ref mut dest) = dest {
+ dest.write_all(¤t_hunk).unwrap();
+ }
+
+ cont = Continue::No;
+ break;
+ }
+ }
+ // write the new hunk
+ if let Some(ref mut dest) = dest {
+ dest.write_all(&x).unwrap();
+ made_change = true;
+ }
+ }
+ PatchOption::WriteOriginal => {
+ // write the hunk without applying the patch
+ if let Some(ref mut dest) = dest {
+ dest.write_all(¤t_hunk).unwrap();
+ }
+ }
+ PatchOption::Quit => {
+ // write the hunk without applying the patch
+ if let Some(ref mut dest) = dest {
+ dest.write_all(¤t_hunk).unwrap();
+ }
+
+ cont = Continue::No;
+ break;
+ }
+ }
+ }
+
+ if !made_change {
+ return (cont, WriteFile::No);
+ }
+
+ // if we made changes, there must have been a destination file
+ let Some(mut dest) = dest else {
+ panic!("Changes were apparently written, but we have no dest file");
+ };
+
+ // TODO: we could possibly make this copy faster on specific Linux filesystems using
+ // `FICLONERANGE`
+
+ // write out any internally buffered data in `src`
+ std::io::copy(&mut src.buffer(), &mut dest).unwrap();
+
+ // convert back to `File` to hopefully take advantage of `copy_file_range` during
+ // `std::io::copy`
+ let mut src: &File = src.into_inner();
+ let mut dest: &File = dest.into_inner().unwrap();
+
+ // write remainder of file
+ std::io::copy(&mut src, &mut dest).unwrap();
+
+ (cont, WriteFile::Yes)
+}
+
+pub struct ReplaceOptions<'a> {
+ matcher: &'a RegexMatcher,
+ replace_with: &'a [u8],
+ padding: u64,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+enum WriteFile {
+ Yes,
+ No,
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+enum Continue {
+ Yes,
+ No,
+}
diff --git a/src/parse.rs b/src/parse.rs
new file mode 100644
index 0000000..3618c7f
--- /dev/null
+++ b/src/parse.rs
@@ -0,0 +1,38 @@
+use bstr::ByteSlice;
+
+pub fn lines_with_pos(bytes: &[u8]) -> impl Iterator- {
+ bytes.lines().scan(0, |line_start, line| {
+ let x = *line_start;
+ *line_start += line.len() + 1;
+ Some((line, x))
+ })
+}
+
+pub fn bytes_as_u64(bytes: &[u8]) -> Option {
+ std::str::from_utf8(bytes).ok()?.parse().ok()
+}
+
+pub fn patch_block_header(bytes: &[u8]) -> Option<((u64, u64), (u64, u64))> {
+ let header = bytes.strip_prefix(b"@@ ")?.strip_suffix(b" @@")?;
+
+ let (range_1, range_2) = header.split_at(header.find_byte(b' ')?);
+ let range_1 = range_1.strip_prefix(b"-")?;
+ let range_2 = range_2.strip_prefix(b" +")?;
+
+ let mut range_1 = range_1.split_at(range_1.find_byte(b',')?);
+ let mut range_2 = range_2.split_at(range_2.find_byte(b',')?);
+
+ range_1.1 = range_1.1.strip_prefix(b",")?;
+ range_2.1 = range_2.1.strip_prefix(b",")?;
+
+ let range_1 = (
+ crate::parse::bytes_as_u64(range_1.0)?,
+ crate::parse::bytes_as_u64(range_1.1)?,
+ );
+ let range_2 = (
+ crate::parse::bytes_as_u64(range_2.0)?,
+ crate::parse::bytes_as_u64(range_2.1)?,
+ );
+
+ Some((range_1, range_2))
+}
diff --git a/src/ui.rs b/src/ui.rs
new file mode 100644
index 0000000..48eb463
--- /dev/null
+++ b/src/ui.rs
@@ -0,0 +1,389 @@
+use std::ffi::{CStr, OsStr};
+use std::fs::File;
+use std::io::{BufRead, Read, Seek, Write};
+use std::os::fd::{AsRawFd, FromRawFd};
+use std::os::unix::process::CommandExt;
+use std::path::Path;
+use std::process::Command;
+
+use bstr::ByteSlice;
+
+use crate::util::label;
+
+const FILENAME_STYLE: anstyle::Style = anstyle::Style::new().bold();
+const STAGE_STYLE: anstyle::Style = anstyle::AnsiColor::Blue.on_default().bold();
+const HELP_STYLE: anstyle::Style = anstyle::AnsiColor::Red.on_default().bold();
+pub const ERROR_STYLE: anstyle::Style = anstyle::Style::new().bold();
+pub const COUNT_STYLE: anstyle::Style = anstyle::Style::new().bold();
+
+/// Start the editor with a file containing the given text. Once the user closes the editor, the
+/// updated text will be returned. `None` will be returned if the editor exited with a non-zero
+/// error code (for example `:cq` in vim).
+fn user_edit(
+ text: &[u8],
+ editor_cmd: impl IntoIterator
- >,
+) -> std::io::Result