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 + + + + Command-line usage 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>> { + let mut editor_cmd = editor_cmd.into_iter(); + + // TODO: replace with C string literals in rust 1.77 + fn as_cstr(null_terminated_bytes: &[u8]) -> &CStr { + CStr::from_bytes_with_nul(null_terminated_bytes).unwrap() + } + + // create a memfd file + let edit_file = unsafe { libc::memfd_create(as_cstr(b"edit\0").as_ptr(), libc::MFD_CLOEXEC) }; + assert!(edit_file >= 0); + let mut edit_file = unsafe { File::from_raw_fd(edit_file) }; + + let edit_fd = edit_file.as_raw_fd(); + + // write the text to the file + edit_file.write_all(text)?; + + let mut cmd = Command::new(editor_cmd.next().expect("editor_cmd was empty")); + cmd.args(editor_cmd); + cmd.arg(format!("/proc/self/fd/{edit_fd}")); + + // remove the CLOEXEC flag after the fork + unsafe { + cmd.pre_exec(move || { + let flags = libc::fcntl(edit_fd, libc::F_GETFD, 0); + assert!(flags >= 0); + let flags = flags & !libc::FD_CLOEXEC; + let rv = libc::fcntl(edit_fd, libc::F_SETFD, flags); + assert_eq!(rv, 0); + Ok(()) + }); + } + + if !cmd.status()?.success() { + return Ok(None); + } + + // seek to the beginning of the file + edit_file.rewind()?; + + // read the modified file + let mut buf = Vec::new(); + edit_file.read_to_end(&mut buf)?; + + Ok(Some(buf)) +} + +fn menu_prompt( + patch: &diffy::Patch<[u8]>, + path: Option<&Path>, + progress: (u64, u64), + line_num: u64, + input: Option, +) -> MenuOption { + // format the patch + let mut patch_bytes = Vec::new(); + diffy::PatchFormatter::new() + .with_color() + .write_patch_into(patch, &mut patch_bytes) + .unwrap(); + + let patch_bytes = + crate::util::rewrite_patch_line_start(&patch_bytes, line_num as i128, true).unwrap(); + + let patch = String::from_utf8_lossy(&patch_bytes); + let mut patch = patch.trim(); + + if let Some(path) = path { + // show the file path + style_println!( + &FILENAME_STYLE, + "diff --{} {}", + env!("CARGO_PKG_NAME"), + path.display() + ); + } else { + // remove the first two lines ('---' and '+++') + let start = patch.match_indices('\n').nth(1).unwrap().0 + 1; + patch = &patch[start..]; + } + println!("{patch}"); + + if let Some(input) = input { + return input; + } + + let options = MenuOption::list() + .iter() + .map(|x| x.as_char()) + .chain(std::iter::once("?")) + .collect::>() + .join(","); + + let help = MenuOption::list() + .iter() + .map(|x| [x.as_char(), x.help()].join(" - ")) + .chain(std::iter::once("? - print help".to_string())) + .collect::>() + .join("\n"); + + loop { + style_print!( + &STAGE_STYLE, + "({}/{}) Apply this patch [{options}]? ", + progress.0 + 1, + progress.1, + ); + std::io::stdout().flush().unwrap(); + + // get the command from the user + let mut input = String::new(); + std::io::stdin().lock().read_line(&mut input).unwrap(); + + match input.trim().parse() { + Ok(x) => return x, + Err(_) => { + // could not parse the input, so print help text and patch then restart + style_println!(&HELP_STYLE, "{help}"); + println!("{patch}"); + } + } + } +} + +pub fn yes_no_prompt(prompt: &str) -> bool { + loop { + style_print!(&STAGE_STYLE, "{prompt} "); + std::io::stdout().flush().unwrap(); + + let mut input = String::new(); + std::io::stdin().lock().read_line(&mut input).unwrap(); + + match input.trim().chars().next() { + Some('y') => return true, + Some('n') => return false, + _ => {} + } + } +} + +pub fn patch_prompt( + original: &[u8], + replaced: &[u8], + mut src_path: Option<&Path>, + progress: (u64, u64), + line_num: u64, + input: Option, +) -> PatchOption { + // use a large context length so that diffy does not do its own hunking + let mut diff_options = diffy::DiffOptions::new(); + diff_options.set_context_len(usize::MAX); + + // the real patch + let patch = diff_options.create_patch_bytes(original, replaced); + + const ESC_STYLE: anstyle::Style = anstyle::Style::new().invert(); + let esc_styled = style!("ESC", &ESC_STYLE).to_string(); + + // a modified patch that is safe to print to the terminal + let safe_current = original.replace("\u{001b}", &esc_styled); + let safe_replaced = replaced.replace("\u{001b}", &esc_styled); + let safe_patch = diff_options.create_patch_bytes(&safe_current, &safe_replaced); + + label!('patch_prompt: { + // take the file path so that it's only ever shown once + let src_path = src_path.take(); + + // show the patch to the user and have them choose how to proceed + match menu_prompt(&safe_patch, src_path, progress, line_num, input) { + MenuOption::Yes => { + // apply the patch + let new_hunk = diffy::apply_bytes(original, &patch).unwrap(); + PatchOption::WriteNew(new_hunk) + } + MenuOption::No => PatchOption::WriteOriginal, + MenuOption::Quit => PatchOption::Quit, + MenuOption::Edit => label!('edit_prompt: { + const INVALID_PATCH_PROMPT: &str = + "Your patch is invalid. Edit again (saying \"no\" discards!) [y/n]?"; + const DOES_NOT_APPLY_PROMPT: &str = + "Your edited hunk does not apply. Edit again (saying \"no\" discards!) [y/n]?"; + + let edited = 'edit_hunk: { + let editor_cmd = crate::util::editor_cmd(); + + // allow the user to edit the patch + let Some(patch) = user_edit(&patch.to_bytes(), editor_cmd).unwrap() else { + // the editor didn't exit successfully + error!("The editor did not exit successfully."); + continue 'patch_prompt; + }; + + // if not valid utf-8, then it must not be empty + let is_empty = std::str::from_utf8(&patch) + .map(|x| x.trim().is_empty()) + .unwrap_or(false); + + // this also ignores whitespace since editors may add a newline at the end of + // the file + if is_empty { + // not even the patch header exists anymore + error!("The edited patch file was empty."); + continue 'patch_prompt; + } + + let patch = crate::util::rewrite_patch_line_counts(&patch); + + // create and apply the patch + let patch = match diffy::Patch::from_bytes(&patch) { + Ok(x) => x, + Err(e) => { + error!("{e}"); + break 'edit_hunk Err(INVALID_PATCH_PROMPT); + } + }; + let new_hunk = match diffy::apply_bytes(original, &patch) { + Ok(x) => x, + Err(e) => { + println!("{e}"); + break 'edit_hunk Err(DOES_NOT_APPLY_PROMPT); + } + }; + + Ok(new_hunk) + }; + + match edited { + Ok(edited) => PatchOption::WriteNew(edited), + Err(msg) => { + if yes_no_prompt(msg) { + // answered "yes", so edit again + continue 'edit_prompt; + } + // answered "no", so discard and use original + PatchOption::WriteOriginal + } + } + }), + } + }) +} + +pub enum PatchOption { + WriteNew(Vec), + WriteOriginal, + Quit, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum MenuOption { + Yes, + No, + Quit, + Edit, +} + +impl MenuOption { + pub const fn list() -> &'static [Self] { + &[Self::Yes, Self::No, Self::Quit, Self::Edit] + } + + pub const fn as_char(&self) -> &'static str { + // return a str instead of a char since they're much easier to work with (there is no char + // -> str const function) + match self { + Self::Yes => "y", + Self::No => "n", + Self::Quit => "q", + Self::Edit => "e", + } + } + + pub const fn help(&self) -> &'static str { + match self { + Self::Yes => "replace this hunk", + Self::No => "do not replace this hunk", + Self::Quit => "quit; do not replace this hunk or any future hunks", + Self::Edit => "manually edit the current hunk", + } + } +} + +impl std::str::FromStr for MenuOption { + type Err = (); + + fn from_str(s: &str) -> Result { + const YES_STR: &str = MenuOption::Yes.as_char(); + const NO_STR: &str = MenuOption::No.as_char(); + const QUIT_STR: &str = MenuOption::Quit.as_char(); + const EDIT_STR: &str = MenuOption::Edit.as_char(); + + Ok(match s { + YES_STR => Self::Yes, + NO_STR => Self::No, + QUIT_STR => Self::Quit, + EDIT_STR => Self::Edit, + _ => return Err(()), + }) + } +} + +macro_rules! style { + ($str:expr, $style:expr) => {{ + // for type checking + let _style: &anstyle::Style = $style; + format_args!("{}{}{}", $style, $str, anstyle::Reset) + }}; +} +pub(crate) use style; + +macro_rules! style_print { + () => {{ + print!() + }}; + ($style:expr) => {{ + // for type checking + let _style: &anstyle::Style = $style; + print!() + }}; + ($style:expr, $fmt:literal $($arg:tt)*) => {{ + let style: &anstyle::Style = $style; + print!("{style}{}{style:#}", format_args!($fmt $($arg)*)) + }}; +} +pub(crate) use style_print; + +macro_rules! style_println { + () => {{ + println!() + }}; + ($style:expr) => {{ + // for type checking + let _style: &anstyle::Style = $style; + println!() + }}; + ($style:expr, $fmt:literal $($arg:tt)*) => {{ + let style: &anstyle::Style = $style; + println!("{style}{}{style:#}", format_args!($fmt $($arg)*)) + }}; +} +pub(crate) use style_println; + +macro_rules! error { + () => {{ + error!("") + }}; + ($fmt:literal $($arg:tt)*) => {{ + println!("{} {}", style!("ERROR:", &crate::ui::ERROR_STYLE), format_args!($fmt $($arg)*)) + }}; +} +pub(crate) use error; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_patch_options() { + for (option, as_str) in MenuOption::list().iter().map(|x| (*x, x.as_char())) { + // test round-trip + assert_eq!(as_str.parse(), Ok(option)); + } + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..64e8a51 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,513 @@ +use std::ffi::{CString, OsStr, OsString}; +use std::fs::{File, OpenOptions}; +use std::io::Write; +use std::os::fd::AsRawFd; +use std::os::unix::ffi::{OsStrExt, OsStringExt}; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::Path; +use std::process::Command; +use std::sync::OnceLock; +use std::time::SystemTime; + +use bstr::ByteSlice; +use grep_matcher::{Captures, Matcher}; +use grep_regex::RegexMatcher; + +pub fn ranges(sorted_list: &[u64], padding: u64) -> Vec> { + let mut ranges = Vec::new(); + let padding = std::num::Saturating(padding); + + for x in sorted_list { + let x = std::num::Saturating(*x); + + let Some(range) = ranges.last_mut() else { + let start = x - padding; + let end = x + padding; + ranges.push(start.0..=end.0); + continue; + }; + + if range.contains(&(x - padding).0) { + if *range.end() < (x + padding).0 { + let end = x + padding; + *range = *range.start()..=end.0; + } + continue; + } + + let start = x - padding; + let end = x + padding; + ranges.push(start.0..=end.0); + } + + ranges +} + +pub fn replace_file( + path: impl AsRef, + modified_at: Option, + f: impl FnOnce(&File, &File) -> (bool, T), +) -> Result { + let path = path.as_ref(); + + if !path.is_file() { + return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "not a file").into()); + } + + // TODO: this path may already exist, so choose a better path? (linkat below won't overwrite + // existing files, so this won't cause us to lose data) + let tmp_path = { + let mut ext = path.extension().unwrap_or(OsStr::new("")).to_os_string(); + ext.push(OsStr::new(".asdf123.tmp")); + path.with_extension(ext) + }; + + let tmp_c_path = CString::new(tmp_path.as_os_str().as_bytes()).unwrap(); + + let original = File::open(path)?; + + // for paths like "foo", rust will return a parent of "" which is not useful for syscalls so we + // replace it with "./" + let mut parent_path = path.parent().unwrap(); + if parent_path == Path::new("") { + parent_path = Path::new("./"); + } + + // create an unnamed file on the mount for the path + let new = OpenOptions::new() + .write(true) + .truncate(true) + .custom_flags(libc::O_TMPFILE) + .open(parent_path)?; + + // copy only the user/group/other read/write/execute permission bits + let mask = libc::S_IRWXU | libc::S_IRWXG | libc::S_IRWXO; + + // set the permissions after creating the file so that it's not affected by the umask + new.set_permissions(read_permissions(&original, mask)?)?; + + // the path to the new file in the /proc mount + let mut procfd_c_path = Vec::new(); + procfd_c_path.extend(b"/proc/self/fd/"); + procfd_c_path.extend(new.as_raw_fd().to_string().as_bytes()); + let procfd_c_path = CString::new(procfd_c_path).unwrap(); + + // TODO: use fallocate() to ensure we have approx enough space (the new file might be larger or + // smaller than the original, but will typically be similar)? + + let (do_replace_file, rv) = f(&original, &new); + + // the user-provided closure asked us to stop + if !do_replace_file { + return Ok(rv); + }; + + if let Some(modified_at) = modified_at { + // the current "modified" time for the file + let latest_modified = std::fs::metadata(path)?.modified()?; + + // return an error if the file's "modified" timestamps differ + if latest_modified != modified_at { + return Err(ReplaceFileError::ModifiedTimeChanged); + } + } + + // give the new file a temporary name + let linkat_rv = unsafe { + libc::linkat( + libc::AT_FDCWD, + procfd_c_path.as_ptr(), + libc::AT_FDCWD, + tmp_c_path.as_ptr(), + libc::AT_SYMLINK_FOLLOW, + ) + }; + if linkat_rv != 0 { + // may have failed if a file at `tmp_path` already exists + return Err(std::io::Error::last_os_error().into()); + } + + // replace the original file at `path` with the new file + std::fs::rename(&tmp_path, path)?; + + Ok(rv) +} + +#[derive(Debug)] +pub enum ReplaceFileError { + Io(std::io::Error), + ModifiedTimeChanged, +} + +impl From for ReplaceFileError { + fn from(e: std::io::Error) -> Self { + Self::Io(e) + } +} + +impl std::fmt::Display for ReplaceFileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(e) => write!(f, "{e}"), + Self::ModifiedTimeChanged => { + write!(f, "the file's \"modified\" timestamp unexpectedly changed") + } + } + } +} + +impl std::error::Error for ReplaceFileError {} + +/// Returns the file permissions without any file type bits. Also applies an additional bitmask to +/// the returned mode. +fn read_permissions(file: &File, mask: u32) -> std::io::Result { + // `std::fs::Metadata::permissions()` contains everything in the `st_mode` stat field, which + // also contains the file type which we mask out + let mode = file.metadata()?.permissions().mode() & !libc::S_IFMT; + let mode = mode & mask; + Ok(std::fs::Permissions::from_mode(mode)) +} + +pub fn editor_cmd() -> impl Iterator> { + static EDITOR_CMD: OnceLock> = OnceLock::new(); + + // this is roughly what `sudo -e` does when parsing env variables + fn split_whitespace(bytes: &[u8]) -> Vec { + bytes + .fields() + .map(|x| OsString::from_vec(x.to_vec())) + .collect() + } + + // returns `None` if `name` isn't set or if empty + fn env_var(name: &str) -> Option> { + if let Some(cmd) = std::env::var_os(name) { + let cmd = split_whitespace(cmd.as_bytes()); + if !cmd.is_empty() { + return Some(cmd); + } + } + None + } + + let cmd = EDITOR_CMD.get_or_init(|| { + if let Some(cmd) = env_var("VISUAL") { + return cmd; + } + + if let Some(cmd) = env_var("EDITOR") { + return cmd; + } + + if let Some(cmd) = env_var("GIT_EDITOR") { + return cmd; + } + + if let Ok(output) = Command::new("git") + .arg("config") + .arg("--null") + .arg("core.editor") + .output() + { + let mut output = output.stdout; + // the last byte should be a nul + assert_eq!(Some(0), output.pop()); + + if !output.is_empty() { + let cmd = split_whitespace(&output); + if !cmd.is_empty() { + return cmd; + } + } + } + + // if we can't find an editor, choose the best editor + [OsString::from_vec(b"vim".to_vec())].to_vec() + }); + + assert!(!cmd.is_empty()); + + cmd.iter() +} + +pub fn replace_regex( + matcher: &RegexMatcher, + replacement: &[u8], + haystack: &[u8], + dest: &mut Vec, +) -> Result<(), ::Error> { + let mut captures = matcher.new_captures().unwrap(); + matcher.replace_with_captures(haystack, &mut captures, dest, |caps, dest| { + caps.interpolate( + |name| matcher.capture_index(name), + haystack, + replacement, + dest, + ); + true + }) +} + +pub fn rewrite_patch_line_counts(bytes: &[u8]) -> std::borrow::Cow<[u8]> { + let result = (|| { + let mut lines = crate::parse::lines_with_pos(bytes); + + let (header, header_start) = lines.nth(2)?; + + let (range_1, range_2) = crate::parse::patch_block_header(header)?; + + let mut content_start = None; + let mut line_counts = (0, 0); + + // count the number of + and - lines + for (line, pos) in lines { + if content_start.is_none() { + content_start = Some(pos); + } + + match line.first() { + Some(b' ') | None => { + line_counts.0 += 1; + line_counts.1 += 1; + } + Some(b'-') => line_counts.0 += 1, + Some(b'+') => line_counts.1 += 1, + _ => return None, + } + } + + if (range_1.1, range_2.1) == line_counts { + // no need to change the patch + return None; + } + + let content_start = content_start?; + + // build the new patch + let mut new_patch = Vec::new(); + + // add the header + new_patch.extend_from_slice(&bytes[..header_start]); + + // write the new line numbers + writeln!( + &mut new_patch, + "@@ -{},{} +{},{} @@", + range_1.0, line_counts.0, range_2.0, line_counts.1, + ) + .ok()?; + + // add the patch contents + new_patch.extend_from_slice(&bytes[content_start..]); + + Some(new_patch) + })(); + + match result { + Some(x) => std::borrow::Cow::Owned(x), + None => std::borrow::Cow::Borrowed(bytes), + } +} + +pub fn rewrite_patch_line_start(bytes: &[u8], offset: i128, ansi: bool) -> Option> { + let mut lines = crate::parse::lines_with_pos(bytes); + let (mut header, header_start) = lines.nth(2)?; + let (_, content_start) = lines.next()?; + + const ANSI_RESET: &[u8] = b"\x1b[0m"; + const ANSI_HEADER_COLOR: &[u8] = b"\x1b[36m"; + + if ansi { + header = header.strip_prefix(ANSI_RESET)?; + header = header.strip_prefix(ANSI_HEADER_COLOR)?; + header = header.strip_suffix(ANSI_RESET)?; + } + + let (mut pair_1, mut pair_2) = crate::parse::patch_block_header(header)?; + + let (offset, positive_offset) = if offset >= 0 { + (u64::try_from(offset).ok()?, true) + } else { + (u64::try_from(-offset).ok()?, false) + }; + + if positive_offset { + pair_1.0 = pair_1.0.checked_add(offset)?; + pair_2.0 = pair_2.0.checked_add(offset)?; + } else { + pair_1.0 = pair_1.0.checked_sub(offset)?; + pair_2.0 = pair_2.0.checked_sub(offset)?; + } + + // build the new patch + let mut new_patch = Vec::new(); + + // add the header + new_patch.extend_from_slice(&bytes[..header_start]); + + if ansi { + new_patch.extend_from_slice(ANSI_RESET); + new_patch.extend_from_slice(ANSI_HEADER_COLOR); + } + + // write the new line numbers + write!( + &mut new_patch, + "@@ -{},{} +{},{} @@", + pair_1.0, pair_1.1, pair_2.0, pair_2.1, + ) + .ok()?; + + if ansi { + new_patch.extend_from_slice(ANSI_RESET); + } + + writeln!(&mut new_patch).unwrap(); + + // add the patch contents + new_patch.extend_from_slice(&bytes[content_start..]); + + Some(new_patch) +} + +/// A label you can jump to using `continue`. +/// +/// ``` +/// let x: u32 = label!('start { +/// let input = todo!(); +/// match input { +/// "retry" => continue 'start, +/// "one" => 1, +/// "two" => 2, +/// _ => input.parse().unwrap(), +/// } +/// }); +/// ``` +macro_rules! label { + ($label:lifetime: $code:block) => { + $label: loop { + let _rv = { + $code + }; + #[allow(unreachable_code)] + { + break $label _rv; + } + } + }; +} +pub(crate) use label; + +#[cfg(test)] +mod tests { + use super::*; + + use std::io::{Read, Write}; + + #[test] + fn test_ranges() { + let list = [1, 2, 10, 12, 35, 38, 55, u64::MAX]; + let padding = 5; + assert_eq!( + ranges(&list, padding), + [0..=17, 30..=43, 50..=60, u64::MAX - 5..=u64::MAX], + ); + + let list = [1, 2, 10, 12, 35, 38, 55, u64::MAX]; + let padding = u64::MAX; + assert_eq!(ranges(&list, padding), [0..=u64::MAX]); + + let list = []; + let padding = 5; + assert_eq!(ranges(&list, padding), []); + + let list = [1, 2, 5, 7, 100]; + let padding = 0; + assert_eq!( + ranges(&list, padding), + [1..=1, 2..=2, 5..=5, 7..=7, 100..=100] + ); + + let list = [1, 2, 5, 7, 100]; + let padding = 1; + assert_eq!(ranges(&list, padding), [0..=3, 4..=8, 99..=101]); + } + + #[test] + fn test_replace_file() { + let mut file = tempfile::Builder::new().tempfile().unwrap(); + file.write_all(b"hello world\n").unwrap(); + + replace_file(file.path(), None, |mut original, mut new| { + new.write_all(b"foo ").unwrap(); + let mut buf = Vec::new(); + original.read_to_end(&mut buf).unwrap(); + new.write_all(&buf).unwrap(); + (true, ()) + }) + .unwrap(); + + // `file` doesn't point to the new file located at `file.path()`, so it's confusing to leave + // the file open + let file = file.into_temp_path(); + + // verify the nre file has the correct contents + assert_eq!(std::fs::read(&file).unwrap(), b"foo hello world\n"); + + ///////// + + let mut file = tempfile::Builder::new().tempfile().unwrap(); + file.write_all(b"hello world\n").unwrap(); + + replace_file(file.path(), None, |mut original, mut new| { + new.write_all(b"foo ").unwrap(); + let mut buf = Vec::new(); + original.read_to_end(&mut buf).unwrap(); + new.write_all(&buf).unwrap(); + (false, ()) + }) + .unwrap(); + + // verify the file has the same contents + assert_eq!(std::fs::read(file.path()).unwrap(), b"hello world\n"); + + ///////// + + let mut file = tempfile::Builder::new().tempfile().unwrap(); + file.write_all(b"hello world\n").unwrap(); + + // user readable and executable + let target_permissions = std::fs::Permissions::from_mode(libc::S_IXUSR | libc::S_IRUSR); + + // set the permissions for the file + file.as_file() + .set_permissions(target_permissions.clone()) + .unwrap(); + assert_eq!( + read_permissions(&file.as_file(), u32::MAX).unwrap(), + target_permissions, + ); + + replace_file(file.path(), None, |mut original, mut new| { + new.write_all(b"foo ").unwrap(); + let mut buf = Vec::new(); + original.read_to_end(&mut buf).unwrap(); + new.write_all(&buf).unwrap(); + (true, ()) + }) + .unwrap(); + + // `file` doesn't point to the new file located at `file.path()`, so it's confusing to leave + // the file open + let file = file.into_temp_path(); + + // verify the nre file has the correct contents + assert_eq!(std::fs::read(&file).unwrap(), b"foo hello world\n"); + + // verify the new file has the same permissions + assert_eq!( + read_permissions(&File::open(&file).unwrap(), u32::MAX).unwrap(), + target_permissions, + ); + } +}