diff --git a/.gitignore b/.gitignore index 5e0fd1c..0e4be8b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target +/test **/*.rs.bk *.code-workspace diff --git a/Cargo.lock b/Cargo.lock index 01128a6..b5c0210 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,38 +1,157 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "aho-corasick" -version = "0.7.18" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" dependencies = [ "memchr", ] [[package]] -name = "atty" -version = "0.2.14" +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ - "hermit-abi", "libc", - "winapi", +] + +[[package]] +name = "anstream" +version = "0.6.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" + +[[package]] +name = "anstyle-parse" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b2d16507662817a6a20a9ea92df6652ee4f94f914589377d69f3b21bc5798a9" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79947af37f4177cfead1110013d678905c37501914fba0efea834c3fe9a8d60c" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" +dependencies = [ + "anstyle", + "windows-sys 0.59.0", +] + +[[package]] +name = "anyhow" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" + +[[package]] +name = "ascii-canvas" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891" +dependencies = [ + "term", ] [[package]] name = "autocfg" -version = "1.1.0" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + +[[package]] +name = "bit-set" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" -version = "1.3.2" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "1be3f42a67d6d345ecd59f675f3f012d6974981560836e938c22b424b85ce1be" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a012a0df96dd6d06ba9a1b29d6402d1a5d77c6befd2566afdc26e10603dc93d7" +dependencies = [ + "shlex", +] [[package]] name = "cfg-if" @@ -42,155 +161,441 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.19" +version = "0.4.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" dependencies = [ - "libc", - "num-integer", + "android-tzdata", + "iana-time-zone", + "js-sys", "num-traits", - "time", - "winapi", + "wasm-bindgen", + "windows-targets", +] + +[[package]] +name = "chrono-tz" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd6dd8046d00723a59a2f8c5f295c515b9bb9a331ee4f8f3d4dd49e428acd3b6" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94fea34d77a245229e7746bd2beb786cd2a896f306ff491fb8cecb3074b10a7" +dependencies = [ + "parse-zoneinfo", + "phf_codegen", ] [[package]] name = "clap" -version = "3.2.5" +version = "4.5.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d53da17d37dba964b9b3ecb5c5a1f193a2762c700e6829201e645b9381c99dc7" +checksum = "a8eb5e908ef3a6efbe1ed62520fb7287959888c88485abe072543190ecc66783" dependencies = [ - "atty", - "bitflags", + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b01801b5fc6a0a232407abc821660c9c6d25a1cafc0d4f85f29fb8d9afc121" +dependencies = [ + "anstream", + "anstyle", "clap_lex", - "indexmap", "strsim", - "termcolor", - "textwrap", - "yaml-rust", ] [[package]] -name = "clap_lex" -version = "0.2.2" +name = "clap_derive" +version = "4.5.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5538cd660450ebeb4234cfecf8f2284b844ffc4c50531e66d584ad5b91293613" +checksum = "54b755194d6389280185988721fffba69495eed5ee9feeee9a599b53db80318c" dependencies = [ - "os_str_bytes", + "heck", + "proc-macro2", + "quote", + "syn", ] +[[package]] +name = "clap_lex" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" + +[[package]] +name = "colorchoice" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" + [[package]] name = "console" -version = "0.15.0" +version = "0.15.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28b32d32ca44b70c3e4acd7db1babf555fa026e385fb95f18028f88848b3c31" +checksum = "ea3c6ecd8059b57859df5c69830340ed3c41d30e3da0c1cbed90a96ac853041b" dependencies = [ "encode_unicode", "libc", "once_cell", - "regex", - "terminal_size", "unicode-width", - "winapi", + "windows-sys 0.59.0", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b80225097f2e5ae4e7179dd2266824648f3e2f49d9134d584b76389d31c4c3" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "dateparser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2ef451feee09ae5ecd8a02e738bd9adee9266b8fa9b44e22d3ce968d8694238" +dependencies = [ + "anyhow", + "chrono", + "lazy_static", + "regex", ] [[package]] name = "dialoguer" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8c8ae48e400addc32a8710c8d62d55cb84249a7d58ac4cd959daecfbaddc545" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" dependencies = [ "console", + "shell-words", "tempfile", + "thiserror", "zeroize", ] +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "either" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" + +[[package]] +name = "ena" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d248bdd43ce613d87415282f69b9bb99d947d290b10962dd6c56233312c2ad5" +dependencies = [ + "log", +] + [[package]] name = "encode_unicode" -version = "0.3.6" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "errno" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33d852cb9b869c2a9b3df2f71a3074817f01e1844f839a144f5fcef059a4eb5d" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] [[package]] name = "fastrand" -version = "1.7.0" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fixedbitset" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3fcf0cee53519c866c09b5de1f6c56ff9d647101f81c1964fa632e148896cdf" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ - "instant", + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" +dependencies = [ + "cfg-if", + "libc", + "wasi", ] [[package]] name = "glob" -version = "0.3.0" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" [[package]] name = "hashbrown" -version = "0.11.2" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" [[package]] -name = "hermit-abi" -version = "0.1.19" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "home" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" dependencies = [ - "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", ] [[package]] name = "indexmap" -version = "1.8.2" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6012d540c5baa3589337a98ce73408de9b5a25ec9fc2c6fd6be8f0d39e0ca5a" +checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ - "autocfg", + "equivalent", "hashbrown", ] [[package]] -name = "instant" -version = "0.1.12" +name = "is_terminal_polyfill" +version = "1.70.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" + +[[package]] +name = "itertools" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ - "cfg-if", + "either", +] + +[[package]] +name = "js-sys" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717b6b5b077764fb5966237269cb3c64edddde4b14ce42647430a78ced9e7b7" +dependencies = [ + "once_cell", + "wasm-bindgen", ] [[package]] name = "kamadak-exif" -version = "0.5.4" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70494964492bf8e491eb3951c5d70c9627eb7100ede6cc56d748b9a3f302cfb6" +checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837" dependencies = [ "mutate_once", ] +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "lalrpop" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06093b57658c723a21da679530e061a8c25340fa5a6f98e313b542268c7e2a1f" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools", + "lalrpop-util", + "petgraph", + "pico-args", + "regex", + "regex-syntax", + "sha3", + "string_cache", + "term", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "feee752d43abd0f4807a921958ab4131f692a44d4d599733d4419c5d586176ce" +dependencies = [ + "regex-automata", + "rustversion", +] + [[package]] name = "lazy_static" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.126" +version = "0.2.169" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349d5a591cd28b49e1d1037471617a32ddcda5731b99419008085f72d5a53836" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" [[package]] -name = "linked-hash-map" -version = "0.5.4" +name = "lock_api" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" +checksum = "07af8b9cdd281b7915f413fa73f29ebd5d55d0d3f0155584dade1ff18cea1b17" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" + +[[package]] +name = "logos" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "syn", +] + +[[package]] +name = "logos-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d" +dependencies = [ + "logos-codegen", +] [[package]] name = "memchr" -version = "2.5.0" +version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" [[package]] name = "mutate_once" @@ -199,20 +604,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16cf681a23b4d0a43fc35024c176437f9dcd818db34e0f42ab456a0ee5ad497b" [[package]] -name = "num-integer" -version = "0.1.45" +name = "new_debug_unreachable" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" -dependencies = [ - "autocfg", - "num-traits", -] +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "num-traits" -version = "0.2.15" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] @@ -222,69 +623,218 @@ name = "ocd-cli" version = "0.0.1" dependencies = [ "chrono", + "chrono-tz", "clap", + "dateparser", "dialoguer", "glob", + "heck", "kamadak-exif", - "lazy_static", + "lalrpop", + "lalrpop-util", + "logos", + "rand", "regex", - "remain", "tracing", - "unicode-segmentation", - "voca_rs", "walkdir", ] [[package]] name = "once_cell" -version = "1.12.0" +version = "1.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[package]] +name = "parking_lot" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf18183cf54e8d6059647fc3063646a1801cf30896933ec2311622cc4b9a27" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets", +] + +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7709cef83f0c1f58f666e746a08b21e0085f7440fa6a29cc194d68aac97a4225" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.1", +] [[package]] -name = "os_str_bytes" -version = "6.1.0" +name = "pico-args" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21326818e99cfe6ce1e524c2a805c189a99b5ae555a35d19f9a284b427d86afa" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" [[package]] name = "pin-project-lite" -version = "0.2.9" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a7ae3ac2f1173085d398531c705756c94a4c56843785df85a60c1a0afac116" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "ppv-lite86" +version = "0.2.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "proc-macro2" -version = "1.0.39" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c54b25569025b7fc9651de43004ae593a75ad88543b17178aa5e1b9c4f15f56f" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.18" +version = "1.0.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1feb54ed693b93a84e14094943b84b7c4eae204c512b7ccb95ab0c66d278ad1" +checksum = "0e4dccaaaf89514f546c693ddc140f729f958c247918a13380cccc6078391acc" dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + [[package]] name = "redox_syscall" -version = "0.2.13" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f25bc4c7e55e0b0b7a1d43fb893f4fa1361d0abe38b9ce4f323c2adfe6ef42" +checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ "bitflags", ] [[package]] name = "regex" -version = "1.5.6" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83f127d94bdbcda4c8cc2e50f6f84f4b611f69c902699ca385a39c3a75f9ff1" +checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", @@ -293,29 +843,28 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.6.26" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49b3de9ec5dc0a3417da371aab17d729997c15010e7fd24ff707773a33bddb64" +checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] -name = "remain" -version = "0.2.3" +name = "rustix" +version = "0.38.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c35270ea384ac1762895831cc8acb96f171468e52cec82ed9186f9416209fa4" +checksum = "a78891ee6bf2340288408954ac787aa063d8e8817e9f53abb37c695c6d834ef6" dependencies = [ - "proc-macro2", - "quote", - "syn", + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.59.0", ] [[package]] -name = "remove_dir_all" -version = "0.5.3" +name = "rustversion" +version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" -dependencies = [ - "winapi", -] +checksum = "f7c45b9784283f1b2e7fb61b42047c2fd678ef0960d4f6f1eba131594cc369d4" [[package]] name = "same-file" @@ -327,26 +876,75 @@ dependencies = [ ] [[package]] -name = "stfu8" -version = "0.2.5" +name = "scopeguard" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "019f0c664fd85d5a87dcfb62b40b691055392a35a6e59f4df83d4b770db7e876" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" dependencies = [ - "lazy_static", - "regex", + "digest", + "keccak", +] + +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "smallvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" + +[[package]] +name = "string_cache" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f91138e76242f575eb1d3b38b4f1362f10d3a43f47d182a5b359af488a02293b" +dependencies = [ + "new_debug_unreachable", + "once_cell", + "parking_lot", + "phf_shared 0.10.0", + "precomputed-hash", ] [[package]] name = "strsim" -version = "0.10.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" -version = "1.0.96" +version = "2.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0748dd251e24453cb8717f0354206b91557e4ec8703673a4b30208f2abaf1ebf" +checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80" dependencies = [ "proc-macro2", "quote", @@ -355,61 +953,54 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.3.0" +version = "3.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +checksum = "9a8a559c81686f576e8cd0290cd2a24a2a9ad80c98b3478856500fcbd7acd704" dependencies = [ "cfg-if", "fastrand", - "libc", - "redox_syscall", - "remove_dir_all", - "winapi", + "getrandom", + "once_cell", + "rustix", + "windows-sys 0.59.0", ] [[package]] -name = "termcolor" -version = "1.1.3" +name = "term" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" +checksum = "a3bb6001afcea98122260987f8b7b5da969ecad46dbf0b5453702f776b491a41" dependencies = [ - "winapi-util", + "home", + "windows-sys 0.52.0", ] [[package]] -name = "terminal_size" -version = "0.1.17" +name = "thiserror" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "libc", - "winapi", + "thiserror-impl", ] [[package]] -name = "textwrap" -version = "0.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" - -[[package]] -name = "time" -version = "0.1.44" +name = "thiserror-impl" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ - "libc", - "wasi", - "winapi", + "proc-macro2", + "quote", + "syn", ] [[package]] name = "tracing" -version = "0.1.35" +version = "0.1.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a400e31aa60b9d44a52a8ee0343b5b18566b03a8321e0d321f695cf56e940160" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" dependencies = [ - "cfg-if", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -417,9 +1008,9 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.21" +version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc6b8ad3567499f98a1db7a752b07a7c8c7c7c34c332ec00effb2b0027974b7c" +checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", @@ -428,101 +1019,242 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.27" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7709595b8878a4965ce5e87ebf880a7d39c9afc6837721b21a5a816a8117d921" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", ] [[package]] -name = "unicode-ident" -version = "1.0.1" +name = "typenum" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bd2fe26506023ed7b5e1e315add59d6f584c621d037f9368fea9cfb988f368c" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" [[package]] -name = "unicode-segmentation" -version = "1.9.0" +name = "unicode-ident" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" [[package]] name = "unicode-width" -version = "0.1.9" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] -name = "voca_rs" -version = "1.14.0" +name = "unicode-xid" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4646f8794c283a89bfc3bc1a49f99562163fc8d01b2771bcf9c844e9a6f38d94" -dependencies = [ - "regex", - "stfu8", - "unicode-segmentation", -] +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "walkdir" -version = "2.3.2" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ "same-file", - "winapi", "winapi-util", ] [[package]] name = "wasi" -version = "0.10.0+wasi-snapshot-preview1" +version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" [[package]] -name = "winapi" -version = "0.3.9" +name = "wasm-bindgen" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "a474f6281d1d70c17ae7aa6a613c87fce69a127e2624002df63dcb39d6cf6396" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "cfg-if", + "once_cell", + "wasm-bindgen-macro", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "wasm-bindgen-backend" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f89bb38646b4f81674e8f5c3fb81b562be1fd936d84320f3264486418519c79" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cc6181fd9a7492eef6fef1f33961e3695e4579b9872a6f7c83aee556666d4fe" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d7a95b763d3c45903ed6c81f156801839e5ee968bb07e534c44df0fcd330c2" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "943aab3fdaaa029a6e0271b35ea10b72b943135afe9bffca82384098ad0e06a6" [[package]] name = "winapi-util" -version = "0.1.5" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "winapi", + "windows-sys 0.59.0", ] [[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] [[package]] -name = "yaml-rust" -version = "0.4.5" +name = "windows-targets" +version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "linked-hash-map", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] name = "zeroize" -version = "1.5.5" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94693807d016b2f2d2e14420eb3bfcca689311ff775dcf113d74ea624b7cdf07" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/Cargo.toml b/Cargo.toml index dfb21ce..0ade224 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,14 +2,15 @@ name = "ocd-cli" version = "0.0.1" authors = ["Iñaki Garay "] -edition = "2018" +edition = "2021" +build = "build.rs" license = "MIT" homepage = "https://github.com/igaray/ocd-cli" repository = "https://github.com/igaray/ocd-cli" documentation = "https://docs.rs/ocd-cli" readme = "README.md" -keywords = ["cli","file","rename"] -categories = ["command-line-utilities","filesystem"] +keywords = ["cli", "file", "rename"] +categories = ["command-line-utilities", "filesystem"] description = """ A CLI file management swiss army knife. """ @@ -18,23 +19,32 @@ A CLI file management swiss army knife. name = "ocd" path = "src/main.rs" +[build-dependencies] +lalrpop = { version = "*", features = ["lexer"] } +clap = { version = "*", features = ["derive", "cargo"] } + [dependencies] -tracing = "*" -clap = {version = "*", features = ["yaml"]} +chrono = "*" +clap = { version = "*", features = ["derive", "cargo"] } +dateparser = "*" dialoguer = "*" -kamadak-exif = "*" glob = "*" -lazy_static = "*" +lalrpop-util = { version = "*", features = ["lexer"] } +logos = "0.14.2" regex = "*" +tracing = "*" walkdir = "*" -unicode-segmentation = "*" -remain = "*" -chrono = "*" -voca_rs = "*" -# case = "1.0.0" -# heck = "0.3.1" -# Inflector = "0.11.4" -# string_morph = "0.1.0" -# id3 = "0.3.0" -# indicatif = "*" -# console = "*" +rand = "*" + +# image processing +kamadak-exif = "*" + +# string wrangling +heck = "*" +chrono-tz = "0.10.0" +#unicode-segmentation = "*" + +# output +#indicatif = "*" +#console = "*" +#ratatui = "*" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ccae62b --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +.DEFAULT_GOAL = usage +default: usage + +.PHONY: usage +usage: + @echo "usage:" + @echo " build: build the command" + @echo " install: installs the command binary executable into $HOME/.local/bin/" + @echo " uninstall: removes the command binary executable from $HOME/.local/bin/" + +.PHONY: build +build: + cargo build --release + +.PHONY: install +install: uninstall build + @cp target/release/ocd ${HOME}/.local/bin/ + +.PHONY: uninstall +uninstall: + @rm -f ${HOME}/.local/bin/ocd diff --git a/README.md b/README.md index d19b832..a6c8772 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,161 @@ # OCD - A CLI file management swiss army knife. **ocd** is a quirky collection of simple command-line tools for manipulating -files, their names, and paths. - -It currently has two subcommands: a mass renamer and a file sorter. +filenames and paths, a file name swiss army knife, if you will. It operates on +single files or groups of files by populating a buffer, parsing the arguments +to generate a sequence of actions, processing the actions in order and applying +their effects to the contents of the file name buffer. The final state for +each file name is shown and confirmation is requested before renaming the files. ### License and Credits +There are many file renaming tools, but this one is mine. I was heavily +inspired by [pyRenamer](https://github.com/SteveRyherd/pyRenamer) by Adolfo +González Blázquez. + +Other work similar to this: +* [kevbradwick/pyrenamer](https://github.com/kevbradwick/pyrenamer) +* [italomaia/renamer](https://github.com/italomaia/renamer) +* [Donearm/Renamer](https://github.com/Donearm/Renamer) ### Requirements +Rust must be installed to compile the executable. ### Installation +The Makefile provides `install` and `uninstall` targets which will compile in +release mode and place or remove the resulting binary executable in +`$HOME/.local/bin/`. ### Usage +```bash +$ ocd +A swiss army knife of utilities to work with files. + +Usage: ocd + +Commands: + mrn Mass Re-Name + tss Time Stamp Sort + id3 Fix ID3 tags + lphc Run the Elephant client + lphs Start the Elephant server + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help + -V, --version Print version +``` + +## MRN: Mass ReNamer +```bash +Mass Re-Name + +Usage: ocd mrn [OPTIONS] [GLOB] + +Arguments: + The rewrite rules to apply to filenames. + The value is a comma-separated list of the following rules: + s Sanitize + cl Lower case + cu Upper case + ct Title case + cs Sentence case + jc Join camel case + jk Join kebab case + js Join snaje case + sc Split camel case + sk Split kebab case + ss Split snake case + r Replace with + and are both single-quote delimited strings + rdp Replace dashes with periods + rds Replace dashes with spaces + rdu Replace dashes with underscores + rpd Replace periods with dashes + rps Replace periods with spaces + rpu Replace periods with underscores + rsd Replace spaces with dashes + rsp Replace spaces with periods + rsu Replace spaces with underscores + rud Replace underscores with dashes + rup Replace underscores with periods + rus Replace underscores with spaces + i Insert at + is a single-quote delimited string + may be a non-negative integer or the keyword 'end' + d Delete from to + is a non-negative integer, + may be a non-negative integer or the keyword 'end' + ea Change the extension, or add it if the file has none. + er Remove the extension. + o Interactive reorder, see documentation on use. + p Pattern match, see documentation on use. + [GLOB] Operate only on files matching the glob pattern, e.g. `-g \"*.mp3\"`. + If --dir is specified as well it will be concatenated with the glob pattern. + If --recurse is also specified it will be ignored. + +Options: + -v... Sets the verbosity level. + Default is low, one medium, two high, three or more debug. + --silent Silences all output. + -d, --dir Run inside a given directory. + [default: ./] + --dry-run Do not effect any changes on the filesystem. + -u, --undo Create undo script. + --yes Do not ask for confirmation. + --git Rename files by calling `git mv` + -m, --mode Specified whether the rules are applied to directories, + files or all. + [default: files] + [possible values: all, directories, files] + --parser Specifies with parser to use. + [default: lalrpop] + [possible values: handwritten, lalrpop] + -r, --recurse Recurse directories. + -h, --help Print help +``` + +### Verbosity +* Level 0 is silent running and will produce no output. +* Level 1 is low, the default and will only show the final state of the file name buffer. +* Level 2 is medium, and will in addition list the actions to be applied. +* Level 3 is debug level and will in addition show the state of the file name buffer at each step. + +### `--yes` +Yes or non-interactive mode will not ask for confirmation and assume the user +confirms everything. Useful for batch scripts. + +### `--undo` +Creates a shell script `undo.sh` with commands which may be run to undo the last +renaming operations. + +### Rewrite instructions +The `INPUT` argument is a string of comma-separated rewrite instructions. + +Example: +```bash +$ ocd mrn "cl,rus,p '{a} {n}' '{2} {1}',i '-FINAL' end" +``` + +### Pattern Matching + +#### Match Pattern -## Mass ReNamer +#### Replace Pattern -It operates on single files or groups of files by populating a buffer with a listing of files, parsing the arguments to generate a sequence of actions, processing the actions in order and applying their effects to the contents of the file name buffer. The final state for each file name is shown and confirmation is requested before renaming the files. +### Interative Reorder -## Time Stamp Sorter +### Examples +## TSS: Time Stamp Sorter The time stamp sorter will examine all files in a directory and check them against a regular expression to see whether they contain something that looks like a date. -The regular expression is -`"\D*(20[01]\d).?(0[1-9]|1[012]).?(0[1-9]|[12]\d|30|31)\D*"` -which essentially looks for `YYYY?MM?DD` or `YYYYMMDD`, where `YYYY` in -`[2000-2019]`, `MM` in `[01-12]`, and `DD` in `[01-31]`. +The regular expression combines two common patterns: +- `YYYY?MM?DD` or `YYYYMMDD`, +- `DAY MONTH YEAR` where `DAY` may be 1-31 and `MONTH` is the case-insensitive + English name of the month or its three-letter abbreviations. If the filename does contain a date it will create a directory named after the date and move the file into it. diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..b30ea2e --- /dev/null +++ b/build.rs @@ -0,0 +1,30 @@ +extern crate clap; +extern crate lalrpop; + +use clap::crate_version; +use std::process::Command; + +fn main() { + // Set up LALRPOP. + let lalrpop_config = lalrpop::Configuration::new() + .log_verbose() + .process_current_dir(); + match lalrpop_config { + Ok(x) => { + println!("LALRPOP build ran correctly:\n{:?}", x); + } + Err(e) => { + eprintln!("Error while running LALRPOP during build: {:?}", e); + } + } + + // Get the current git commit hash. + // Taken from https://stackoverflow.com/questions/43753491/include-git-commit-hash-as-string-into-rust-program + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .unwrap(); + let git_hash = String::from_utf8(output.stdout).unwrap(); + let version_string = format!("{}-{}", crate_version!(), git_hash); + println!("cargo:rustc-env=VERSION_STR={}", version_string); +} diff --git a/src/main.rs b/src/main.rs index dc900ee..d2d9a28 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,39 +1,30 @@ -mod ocd; - -#[macro_use] extern crate clap; extern crate exif; -extern crate lazy_static; +extern crate lalrpop_util; extern crate regex; extern crate walkdir; -use crate::ocd::config::Config; -use crate::ocd::Command; -use std::process; -use tracing::{span, Level}; - -fn main() { - let span = span!(Level::TRACE, "main"); - let _guard = span.enter(); +use crate::clap::Parser; +use crate::ocd::Cli; +use crate::ocd::OcdCommand; - let config = Config::new().with_args().unwrap_or_else(|error| { - eprintln!("{}", error); - process::exit(1) - }); +mod ocd; - match config.subcommand { - Some(Command::MassRename { ref config }) => { - if let Err(reason) = crate::ocd::mrn::run(config) { - eprintln!("{}", reason); - process::exit(1) +fn main() { + let cli = Cli::parse(); + match cli.command { + OcdCommand::MassRename(args) => { + if let Err(error) = crate::ocd::mrn::run(&args) { + println!("Error: {:?}", error); } } - Some(Command::TimeStampSort { ref config }) => { - if let Err(reason) = crate::ocd::tss::run(config) { - eprintln!("{}", reason); - process::exit(1) + OcdCommand::TimeStampSort(args) => { + if let Err(error) = crate::ocd::tss::run(&args) { + println!("Error: {:?}", error); } } - None => unreachable!(), + _ => { + todo!("This subcommand has not been implemented yet!"); + } } } diff --git a/src/ocd/config.rs b/src/ocd/config.rs deleted file mode 100644 index 40cf653..0000000 --- a/src/ocd/config.rs +++ /dev/null @@ -1,98 +0,0 @@ -use crate::ocd::mrn::MassRenameConfig; -use crate::ocd::tss::TimeStampSortConfig; -use crate::ocd::Command; -use std::path::{Path, PathBuf}; - -#[remain::sorted] -#[derive(Copy, Clone, Debug)] -pub enum Mode { - All, - Directories, - Files, -} - -#[derive(Copy, Clone, Debug)] -pub enum Verbosity { - Silent, - Low, - Medium, - High, - Debug, -} - -impl Verbosity { - pub fn is_silent(self) -> bool { - matches!(self, Verbosity::Silent) - } -} - -#[derive(Debug)] -pub struct Config { - pub subcommand: Option, -} - -impl Default for Config { - fn default() -> Self { - Self::new() - } -} - -impl Config { - pub fn new() -> Config { - Config { - subcommand: Option::None, - } - } - - pub fn with_args(&self) -> Result { - let yaml = load_yaml!("config.yaml"); - let app = clap::App::from_yaml(yaml); - let ocd_matches = app.get_matches(); - - match ocd_matches.subcommand() { - Some(("mrn", subcommand_matches)) => { - let subcommand_config = MassRenameConfig::new().with_args(subcommand_matches); - let subcommand = Some(Command::MassRename { - config: subcommand_config, - }); - let config = Config { subcommand }; - Ok(config) - } - Some(("tss", subcommand_matches)) => { - let subcommand_config = TimeStampSortConfig::new().with_args(subcommand_matches); - let subcommand = Some(Command::TimeStampSort { - config: subcommand_config, - }); - let config = Config { subcommand }; - Ok(config) - } - Some((_, _)) => Err(String::from("Unknown command supplied.")), - _ => Err(String::from("No command supplied.")), - } - } -} - -pub fn verbosity_value(matches: &clap::ArgMatches) -> Verbosity { - let level = matches.occurrences_of("verbosity"); - let silent = matches.is_present("silent"); - match (silent, level) { - (true, _) => Verbosity::Silent, - (false, 0) => Verbosity::Low, - (false, 1) => Verbosity::Medium, - (false, 2) => Verbosity::High, - _ => Verbosity::Debug, - } -} - -pub fn directory_value(dir: &str) -> PathBuf { - Path::new(dir).to_path_buf() -} - -pub fn mode_value(mode: &str) -> Mode { - match mode { - "a" => Mode::All, - "d" => Mode::Directories, - "f" => Mode::Files, - _ => Mode::Files, - } -} diff --git a/src/ocd/config.yaml b/src/ocd/config.yaml deleted file mode 100644 index 6281bfd..0000000 --- a/src/ocd/config.yaml +++ /dev/null @@ -1,126 +0,0 @@ -name: ocd -version: "0.1.0" -author: "Iñaki Garay " -about: "A swiss army knife of utilities to work with files." -settings: - - ArgRequiredElseHelp -subcommands: - - mrn: - about: "Mass ReName" - args: - - verbosity: - multiple: true - short: v - help: > - Sets the verbosity level. Default is low, - one flag medium, two high, three or more - debug. - - silent: - long: silent - help: "Silences all output." - - dir: - takes_value: true - default_value: "./" - short: d - long: dir - help: "Run inside a given directory." - - mode: - takes_value: true - possible_values: ["a", "d", "f"] - default_value: f - short: m - long: mode - help: > - Specified whether the rules are applied to directories - (b), files (f) or all (a). - - dry-run: - long: dry-run - help: "Do not effect any changes on the filesystem." - - git: - long: git - help: "Rename files by calling `git mv`" - - recurse: - short: "r" - long: "recurse" - help: "Recurse directories." - - undo: - short: u - long: undo - help: Create undo script. - - yes: - long: yes - help: > - Do not ask for confirmation. - Useful for non-interactive batch scripts. - - glob: - takes_value: true - short: g - long: glob - help: | - Operate only on files matching the glob pattern, e.g. `-g \"*.mp3\"` - If --dir is specified as well it will be concatenated with the glob pattern. - If --recurse is also specified it will be ignored. - - rules: - index: 1 - required: true - takes_value: true - help: | - The rewrite rules to apply to filenames. - The value is a comma-separated list of the following rules: - lc Lower case - uc Upper case - tc Title case - sc Sentence case - ccj Camel case join - ccs Camel case split - i Insert - d Delete - s Sanitize - r Replace - sd Substitute space dash - sp Substitute space period - su Substitute space underscore - dp Substitute dash period - ds Substitute dash space - du Substitute dash underscore - pd Substitute period dash - ps Substitute period space - pu Substitute period under - ud Substitute underscore dash - up Substitute underscore period - us Substitute underscore space - ea Extension add - er Extension remove - p Pattern match - ip Interactive pattern match - it Interactive tokenize - - tss: - about: "Time Stamp Sort" - args: - - silent: - long: silent - help: "Silences all output." - - dir: - takes_value: true - default_value: "./" - short: d - long: dir - help: "Run inside a given directory." - - dry-run: - long: dry-run - help: "Do not effect any changes on the filesystem." - - undo: - short: u - long: undo - help: Create undo script. - - yes: - long: yes - help: > - Do not ask for confirmation. - Useful for non-interactive batch scripts. - # - id3: - # about: "Fix id3 tags" - # - lphc: - # about: "Elephant client" - # - lphs: - # about: "Elephant server" diff --git a/src/ocd/date.rs b/src/ocd/date.rs new file mode 100644 index 0000000..8fc6e8f --- /dev/null +++ b/src/ocd/date.rs @@ -0,0 +1,180 @@ +use chrono::Datelike; +use chrono::NaiveDateTime; +use exif::In; +use exif::Tag; +use exif::Value; +use regex::Regex; +use std::path::Path; +use std::path::PathBuf; +use std::sync::LazyLock; + +// The default date regex string. +pub(crate) const DATE_FLORB_REGEX_STR: &str = r"(?[0-9];{4}.?[0-9]{2}.?[0-9]{2}|(?:(?:\d{1,2})\s(?i)(?:jan|january|feb|february|mar|march|apr|april|may|jun|june|jul|july|aug|august|sep|september|oct|october|nov|november|dec|december)\s(?:\d{1,4})))"; +pub(crate) static DATE_FLORB_REGEX: LazyLock = + LazyLock::new(|| Regex::new(DEFAULT_DATEFINDER_REGEX_STR).unwrap()); + +/// The default datefinder reges string is the same as the default date regex but includes non-alphanumeric catch-all patterns before and after. +/// Case A: a date in the format YYYY?MM?DD or YYYYMMDD +/// `(?(?1\d\d\d|20\d\d).?(?0[1-9]|1[012]).?(?0[1-9]|[12]\d|30|31))` +/// Case B: case insensitive, DD MONTH YYYY +/// where MONTH may be the full month name or the three letter short version. +/// `(?i)(?(?\d{1,2})\s(?jan|january|feb|february|mar|march|apr|april|may|jun|june|jul|july|aug|august|sep|september|oct|october|nov|november|dec|december)\s(?\d{1,4}))` +const DEFAULT_DATEFINDER_REGEX_STR: &str = r"\D*(?(?1\d\d\d|20\d\d).?(?0[1-9]|1[012]).?(?0[1-9]|[12]\d|30|31))|(?i)(?(?\d{1,2})\s(?jan|january|feb|february|mar|march|apr|april|may|jun|june|jul|july|aug|august|sep|september|oct|october|nov|november|dec|december)\s(?\d{1,4}))\D*"; +pub(crate) static DEFAULT_DATEFINDER_REGEX: LazyLock = + LazyLock::new(|| Regex::new(DEFAULT_DATEFINDER_REGEX_STR).unwrap()); + +#[derive(Debug, PartialEq)] +pub(crate) enum DateSource { + Filename, + Exif, + Filesystem, +} + +pub(crate) fn regex_date(haystack: &str) -> Option<(u32, u32, u32)> { + DEFAULT_DATEFINDER_REGEX.captures(haystack).map(|capture| { + if capture.name("a").is_some() { + let year = capture.get(2).unwrap().as_str().parse::().unwrap(); + let month = capture.get(3).unwrap().as_str().parse::().unwrap(); + let day = capture.get(4).unwrap().as_str().parse::().unwrap(); + (year, month, day) + } else if capture.name("b").is_some() { + let year = capture.get(8).unwrap().as_str().parse::().unwrap(); + let month = capture.get(7).unwrap().as_str(); + let month = english_month_to_number(month); + let day = capture.get(6).unwrap().as_str().parse::().unwrap(); + (year, month, day) + } else { + // This branch is unreachable because if there are no captures, + // `map` will pass the `None` value directly. If the value was `Some` + // and the map closure was applied, there was a capture and either + // `a` or `b` will match. + unreachable!() + } + }) +} + +fn english_month_to_number(month: &str) -> u32 { + match month.to_lowercase().as_str() { + "jan" | "january" => 1, + "feb" | "february" => 2, + "mar" | "march" => 3, + "apr" | "april" => 4, + "may" => 5, + "jun" | "june" => 6, + "jul" | "july" => 7, + "aug" | "august" => 8, + "sep" | "september" => 9, + "oct" | "october" => 10, + "nov" | "november" => 11, + "dec" | "december" => 12, + unexpected => { + panic!("Unknown month value! {}", unexpected); + } + } +} + +/// Given a filename, extracts a date by matching against a regex. +pub(crate) fn filename_date(file_name: &Path) -> Option<(DateSource, u32, u32, u32)> { + file_name + .to_str() + .and_then(crate::ocd::date::regex_date) + .map(|(year, month, day)| (DateSource::Filename, year, month, day)) +} + +/// Attempts to extract the creation data from the EXIF data in an image file. +/// In order, this function tries to: +/// - open the file +/// - read the exif data +/// - get the `DateTimeOriginal` field +/// - parse the result with `NaiveDateTime::parse_from_str` as a date with format `%Y:%m:%d %H:%M:%S`. +/// This format is specified in the [CIPA EXIF standard document](https://www.cipa.jp/std/documents/download_e.html?DC-008-Translation-2023-E) for the DateTimeOriginal tag. +/// - if for some reason the exif tag is not in the right format and +/// `chrono::NaiveDateTime::parse_from_str` cannot parse it, parse the result +/// with `dateparser::parse` as a date with format `"%Y-%m-%d`. +pub(crate) fn exif_date(path: &PathBuf) -> Option<(DateSource, u32, u32, u32)> { + std::fs::File::open(path).ok().and_then(|file| { + let mut bufreader = std::io::BufReader::new(&file); + exif::Reader::new() + .read_from_container(&mut bufreader) + .ok() + .and_then(|exif| { + exif.get_field(Tag::DateTimeOriginal, In::PRIMARY) + .and_then(|datetimeoriginal| { + if let Value::Ascii(text) = &datetimeoriginal.value { + let text = String::from_utf8(text[0].clone()).unwrap(); + let parsed_result = + NaiveDateTime::parse_from_str(&text, "%Y:%m:%d %H:%M:%S"); + match parsed_result { + Ok(parsed) => { + let year = parsed.year() as u32; + let month = parsed.month(); + let day = parsed.day(); + Some((DateSource::Exif, year, month, day)) + } + Err(_) => dateparser::parse(&text).ok().map(|parsed| { + let year = parsed.year() as u32; + let month = parsed.month(); + let day = parsed.day(); + (DateSource::Exif, year, month, day) + }), + } + } else { + None + } + }) + }) + }) +} + +/// Attempts to extract the date from the filesystem metadata. +/// In order, this function tries to: +/// - obtain the file metadata +/// - get the `created` field +/// - check whether the created is the same as the current date +pub(crate) fn metadata_date(path: &PathBuf) -> Option<(DateSource, u32, u32, u32)> { + std::fs::metadata(path).ok().and_then(|metadata| { + metadata.created().ok().and_then(|system_time| { + let today: chrono::DateTime = chrono::Local::now(); + let creation_date: chrono::DateTime = + chrono::DateTime::from(system_time); + if creation_date != today { + let year = creation_date.year() as u32; + let month = creation_date.month(); + let day = creation_date.day(); + Some((DateSource::Filesystem, year, month, day)) + } else { + None + } + }) + }) +} + +#[cfg(test)] +mod test { + use super::*; + use std::path::Path; + + #[test] + fn filename_date1() { + let file_name = Path::new("An image file from 2024-12-31.jpg"); + let expected = Some((DateSource::Filename, 2024, 12, 31)); + let result = filename_date(file_name); + assert_eq!(expected, result); + } + + #[test] + fn filename_date2() { + let file_name = Path::new("An image file from 20241231.jpg"); + let expected = Some((DateSource::Filename, 2024, 12, 31)); + let result = filename_date(file_name); + assert_eq!(expected, result); + } + + #[test] + fn filename_date3() { + let file_name = Path::new("An image file from 2024-12-01 to 2024-12-31.jpg"); + let expected = Some((DateSource::Filename, 2024, 12, 1)); + let result = filename_date(file_name); + assert_eq!(expected, result); + } +} diff --git a/src/ocd/input.rs b/src/ocd/input.rs deleted file mode 100644 index f34cea1..0000000 --- a/src/ocd/input.rs +++ /dev/null @@ -1,8 +0,0 @@ -use dialoguer::Confirm; - -pub fn user_confirm() -> bool { - Confirm::new() - .with_prompt("Do you want to continue?") - .interact() - .unwrap_or(false) -} diff --git a/src/ocd/mod.rs b/src/ocd/mod.rs index 183e8bf..d806823 100644 --- a/src/ocd/mod.rs +++ b/src/ocd/mod.rs @@ -1,20 +1,370 @@ -pub mod config; -pub mod input; -pub mod mrn; -pub mod output; -pub mod tss; - -use crate::ocd::mrn::MassRenameConfig; -use crate::ocd::tss::TimeStampSortConfig; - -/// The Command enum represents which subcommand ocd will run and carries its -/// configuration with it. -#[remain::sorted] -#[derive(Clone, Debug)] -pub enum Command { - MassRename { config: MassRenameConfig }, - TimeStampSort { config: TimeStampSortConfig }, - // FixID3 { config: FixID3Config }, - // ElephantClient{ config: ElephantClientConfig }, - // ElephantServer{ config: ElephantServerConfig }, +//! Main OCD module. +mod date; +pub(crate) mod mrn; +pub(crate) mod tss; + +use crate::ocd::date::DateSource; +use clap::Parser; +use clap::Subcommand; +use clap::ValueEnum; +use dialoguer::Confirm; +use dialoguer::Input; +use std::collections::BTreeMap; +use std::collections::HashSet; +use std::error::Error; +use std::fmt; +use std::fmt::Display; +use std::fs; +use std::io; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; + +/// The command line interface configuration. +#[derive(Debug, Parser)] +#[clap(name = "ocd")] +#[clap(author = "Iñaki Garay ")] +#[clap(version = env!("VERSION_STR") )] // set in build.rs +#[clap(about = "A swiss army knife of utilities to work with files.")] +#[clap(long_about = None)] +pub struct Cli { + #[clap(subcommand)] + pub command: OcdCommand, +} + +/// All OCD commands. +#[derive(Clone, Debug, Subcommand)] +pub enum OcdCommand { + #[clap(about = "Mass Re-Name")] + #[clap(name = "mrn")] + MassRename(crate::ocd::mrn::MassRenameArgs), + + #[clap(about = "Time Stamp Sort")] + #[clap(name = "tss")] + TimeStampSort(crate::ocd::tss::TimeStampSortArgs), + + #[clap(about = "Fix ID3 tags")] + #[clap(name = "id3")] + FixID3 {}, + + #[clap(about = "Run the Elephant client")] + #[clap(name = "lphc")] + ElephantClient {}, + + #[clap(about = "Start the Elephant server")] + #[clap(name = "lphs")] + ElephantServer {}, +} + +/// File processing mode, filters only regular files, only directories, or both. +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +enum Mode { + All, + Directories, + Files, +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +enum Verbosity { + Silent, + Low, + Medium, + High, + Debug, +} + +impl Verbosity { + fn new(silent: bool, level: u8) -> Verbosity { + match (silent, level) { + (true, _) => Verbosity::Silent, + (false, 0) => Verbosity::Low, + (false, 1) => Verbosity::Medium, + (false, 2) => Verbosity::High, + (false, _) => Verbosity::Debug, + } + } + + fn is_silent(&self) -> bool { + matches!(self, Verbosity::Silent) + } +} + +impl fmt::Display for Verbosity { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +trait Speaker { + /// Default implementation: + /// ``` + /// impl Speaker for TimeStampSortArgs { + /// fn verbosity(self: &Self) -> Verbosity { + /// crate::ocd::Verbosity::new(self.silent, self.verbosity) + /// } + /// } + /// ``` + fn verbosity(&self) -> Verbosity; +} + +/// An action on a file can be either move the file to a new directory, +/// or rename the file. +/// The date source included in the Move variant is a bit of a hack. +/// It is intended to help track where the date was obtained from, and should +/// probably either be a field in a struct that wraps this enum, or be present +/// in both variants. Since at this time, the date source is only tracked for +/// the `Move` variant which is only used in the Time Stamp Sorted utility, it +/// is inluded there. +#[derive(Debug)] +enum Action { + Move { + date_source: Option, + path: PathBuf, + }, + Rename { + path: PathBuf, + }, +} + +impl Display for Action { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +/// A plan consists of a mapping from file names to actions on said filenames. +/// An action can be either a move, in which case when the plan is executed the +/// file will be moved to said directory, or a rename. +/// The plan also stores some metadata, such as the set of directories that are +/// created (to include deletion instructions in an undo file), whether or not +/// git is to be used to perform actions on the filesystem, and string lengths +/// for presentation. +struct Plan { + pub actions: BTreeMap, + dirs: HashSet, + use_git: bool, + max_src_len: usize, + max_dst_len: usize, +} + +impl Plan { + fn new() -> Self { + Plan { + dirs: HashSet::new(), + actions: BTreeMap::new(), + use_git: false, + max_src_len: 0, + max_dst_len: 0, + } + } + + fn with_git(mut self, use_git: bool) -> Self { + self.use_git = use_git; + self + } + + fn with_files(mut self, files: Vec) -> Self { + for file in files { + self.insert(file.clone(), Action::Rename { path: file.clone() }); + } + self + } + + /// Removes all actions in plan which would result in the file being renamed + /// into itself or moved into the current directory. + fn clean(&mut self) { + // Retains only the elements specified by the predicate. + // In other words, remove all pairs for which the predicate returns false. + self.actions.retain(|src, action| match action { + Action::Move { .. } => true, + Action::Rename { path } => src != path, + }) + } + + fn insert(&mut self, src: PathBuf, action: Action) { + let path = match action { + Action::Move { ref path, .. } => { + // In the case of a move, the program will have created a + // directory into which the file will be moved, and it must be + // remembered so that the undo script can remove it. + self.dirs.insert(path.clone()); + path + } + Action::Rename { ref path } => path, + }; + + // Maximum source character length + let msl = path_length(&src); + if msl > self.max_src_len { + self.max_src_len = msl + } + // Maximum destination character length + let mdl = path_length(path); + if mdl > self.max_dst_len { + self.max_dst_len = mdl + } + self.actions.insert(src, action); + } + + fn present_short(&self) { + let msl = self.max_src_len; + let mdl = self.max_dst_len; + for (src, action) in &self.actions { + match action { + Action::Move { path, .. } => { + println!("{: { + println!( + "{: { + println!(" move"); + println!(" * date source: {date_source:?}"); + println!(" - {}", src.display()); + println!(" > {}", path.display()); + } + Action::Rename { path } => { + println!(" rename"); + println!(" - {}", src.display()); + println!(" + {}", path.display()); + } + } + } + } + + fn execute(&self) -> Result<(), Box> { + for (src, action) in &self.actions { + match action { + Action::Move { path, .. } => { + create_directory(path)?; + move_file(src, path)?; + } + Action::Rename { path } => { + fs_rename_file(self.use_git, src, path)?; + } + }; + } + Ok(()) + } + + fn create_undo(&self) -> io::Result<()> { + let git = if self.use_git { "git " } else { "" }; + let mut undo_file = std::fs::File::create("undo.sh")?; + for (src, action) in &self.actions { + match action { + Action::Move { path, .. } => { + let mut dst_path = PathBuf::new(); + dst_path.push(path); + dst_path.push(src.file_name().unwrap()); + writeln!( + undo_file, + "{}mv \"{}\" \"{}\"", + git, + dst_path.display(), + src.display() + )?; + } + Action::Rename { path } => { + writeln!( + undo_file, + "{}mv \"{}\" \"{}\"", + git, + path.display(), + src.display() + )?; + } + }; + } + for dir in &self.dirs { + writeln!(undo_file, "rmdir {}", dir.display())?; + } + Ok(()) + } +} + +fn path_length(path: &Path) -> usize { + path.as_os_str() + .to_str() + .expect("Unable to convert file path into string.") + .chars() + .count() +} + +/// Asks the user for confirmation before proceeding. +fn user_confirm() -> bool { + Confirm::new() + .with_prompt("Do you want to continue?") + .interact() + .unwrap_or(false) +} + +fn user_input() -> String { + Input::new().with_prompt(">").interact_text().unwrap() +} + +/// Returns true if the directory entry begins with a period. +fn is_hidden(entry: &Path) -> bool { + entry + .file_name() + .and_then(|s| s.to_str()) + .is_some_and(|s| (s != "." || s != "./") && s.starts_with('.')) +} + +/// Given a path, creates a directory. +fn create_directory(directory: &Path) -> io::Result<()> { + let mut full_path = PathBuf::new(); + full_path.push(directory); + match std::fs::create_dir(&full_path) { + Ok(_) => Ok(()), + Err(reason) => match reason.kind() { + io::ErrorKind::AlreadyExists => Ok(()), + _ => Err(reason), + }, + } +} + +/// Given source and destination paths, will move the source to the destination. +fn move_file(src: &PathBuf, dir: &PathBuf) -> io::Result<()> { + let mut dst = PathBuf::new(); + dst.push(dir); + dst.push(src.file_name().unwrap()); + std::fs::rename(src, dst)?; + Ok(()) +} + +fn rename_file(path: &mut PathBuf, filename: String) { + let extension = match path.extension() { + None => String::new(), + Some(extension) => String::from(extension.to_str().unwrap()), + }; + path.set_file_name(filename); + path.set_extension(extension); +} + +fn fs_rename_file(use_git: bool, src: &PathBuf, dst: &PathBuf) -> io::Result<()> { + if use_git { + let src = src.to_str().unwrap(); + let dst = dst.to_str().unwrap(); + let _output = Command::new("git") + .args(["mv", src, dst]) + .output() + .expect("Error invoking git."); + // TODO: do something with the output + } else { + fs::rename(src, dst)? + } + Ok(()) } diff --git a/src/ocd/mrn/lalrpop/mod.rs b/src/ocd/mrn/lalrpop/mod.rs new file mode 100644 index 0000000..c09c352 --- /dev/null +++ b/src/ocd/mrn/lalrpop/mod.rs @@ -0,0 +1,218 @@ +pub mod mrn_lexer; +pub mod mrn_tokens; + +#[allow(clippy::all)] +pub mod mrn_parser { + include!(concat!(env!("OUT_DIR"), "/ocd/mrn/lalrpop/mrn_parser.rs")); +} + +#[cfg(test)] +mod test { + use super::*; + use crate::ocd; + use crate::ocd::mrn::program::Instruction; + use crate::ocd::mrn::program::ReplaceArg; + use crate::ocd::mrn::program::ReplacePattern; + use crate::ocd::mrn::program::ReplacePatternComponent; + use crate::ocd::mrn::Position; + + fn parse_input(input: &str) -> Vec { + let lexer = ocd::mrn::lalrpop::mrn_lexer::Lexer::new(input); + let parser = ocd::mrn::lalrpop::mrn_parser::ProgramParser::new(); + parser.parse(lexer).unwrap() + } + + #[test] + fn mrn_lexer_test() { + let input = "i 0 'str1 str2'"; + let lexer = mrn_lexer::Lexer::new(input); + let result: Vec> = + lexer.collect(); + let expected = vec![ + Ok((0, mrn_tokens::Token::Insert, 1)), + Ok((2, mrn_tokens::Token::Index(0), 3)), + Ok(( + 4, + mrn_tokens::Token::StringValue(String::from("str1 str2")), + 15, + )), + ]; + assert_eq!(expected, result); + } + + #[test] + fn parse_empty() { + let input = ""; + let expected: Vec = vec![]; + let result = parse_input(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn parse_simple_instructions() { + let input = + "s,cl,cu,ct,cs,jc,js,jk,sc,ss,sk,rdp,rds,rdu,rpd,rps,rpu,rsd,rsp,rsu,rud,rup,rus,er,o"; + let expected: Vec = vec![ + Instruction::Sanitize, + Instruction::CaseLower, + Instruction::CaseUpper, + Instruction::CaseTitle, + Instruction::CaseSentence, + Instruction::JoinCamel, + Instruction::JoinSnake, + Instruction::JoinKebab, + Instruction::SplitCamel, + Instruction::SplitSnake, + Instruction::SplitKebab, + Instruction::Replace { + pattern: ReplaceArg::Dash, + replace: ReplaceArg::Period, + }, + Instruction::Replace { + pattern: ReplaceArg::Dash, + replace: ReplaceArg::Space, + }, + Instruction::Replace { + pattern: ReplaceArg::Dash, + replace: ReplaceArg::Underscore, + }, + Instruction::Replace { + pattern: ReplaceArg::Period, + replace: ReplaceArg::Dash, + }, + Instruction::Replace { + pattern: ReplaceArg::Period, + replace: ReplaceArg::Space, + }, + Instruction::Replace { + pattern: ReplaceArg::Period, + replace: ReplaceArg::Underscore, + }, + Instruction::Replace { + pattern: ReplaceArg::Space, + replace: ReplaceArg::Dash, + }, + Instruction::Replace { + pattern: ReplaceArg::Space, + replace: ReplaceArg::Period, + }, + Instruction::Replace { + pattern: ReplaceArg::Space, + replace: ReplaceArg::Underscore, + }, + Instruction::Replace { + pattern: ReplaceArg::Underscore, + replace: ReplaceArg::Dash, + }, + Instruction::Replace { + pattern: ReplaceArg::Underscore, + replace: ReplaceArg::Period, + }, + Instruction::Replace { + pattern: ReplaceArg::Underscore, + replace: ReplaceArg::Space, + }, + Instruction::ExtensionRemove, + Instruction::Reorder, + ]; + let result = parse_input(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn parse_replace() { + let input = "r 'str1' 'str2'"; + let expected: Vec = vec![Instruction::Replace { + pattern: ReplaceArg::Text(String::from("str1")), + replace: ReplaceArg::Text(String::from("str2")), + }]; + let result = parse_input(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn parse_insert_beginning() { + let input = "i 0 'str'"; + let expected: Vec = vec![Instruction::Insert { + position: Position::Index(0), + text: String::from("str"), + }]; + let result = parse_input(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn parse_insert_middle() { + let input = "i 3 'str'"; + let expected: Vec = vec![Instruction::Insert { + position: Position::Index(3), + text: String::from("str"), + }]; + let result = parse_input(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn parse_insert_end() { + let input = "i end 'str'"; + let expected: Vec = vec![Instruction::Insert { + position: Position::End, + text: String::from("str"), + }]; + let result = parse_input(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn parse_delete_middle() { + let input = "d 0 1"; + let expected: Vec = vec![Instruction::Delete { + from: 0, + to: Position::Index(1), + }]; + let result = parse_input(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn parse_delete_end() { + let input = "d 0 end"; + let expected: Vec = vec![Instruction::Delete { + from: 0, + to: Position::End, + }]; + let result = parse_input(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn parse_extension_add() { + let input = "ea 'mp3'"; + let expected: Vec = vec![Instruction::ExtensionAdd(String::from("mp3"))]; + let result = parse_input(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn parse_pattern_match() { + let input = "p '{N} - {X}' '{1} {sng10+2,2} {2}'"; + let expected: Vec = vec![Instruction::PatternMatch { + match_pattern: String::from(r"^([[:digit:]]*) - (.*)$"), + replace_pattern: ReplacePattern { + components: vec![ + ReplacePatternComponent::Florb(1), + ReplacePatternComponent::Literal(String::from(" ")), + ReplacePatternComponent::SequentialNumberGenerator { + start: 10, + step: 2, + padding: 2, + }, + ReplacePatternComponent::Literal(String::from(" ")), + ReplacePatternComponent::Florb(2), + ], + }, + }]; + let result = parse_input(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } +} diff --git a/src/ocd/mrn/lalrpop/mrn_lexer.rs b/src/ocd/mrn/lalrpop/mrn_lexer.rs new file mode 100644 index 0000000..e59f399 --- /dev/null +++ b/src/ocd/mrn/lalrpop/mrn_lexer.rs @@ -0,0 +1,29 @@ +use crate::ocd::mrn::lalrpop::mrn_tokens::{LexicalError, Token}; +use logos::{Logos, SpannedIter}; + +pub type Spanned = Result<(Loc, Tok, Loc), Error>; + +pub struct Lexer<'input> { + // instead of an iterator over characters, we have a token iterator + token_stream: SpannedIter<'input, Token>, +} + +impl<'input> Lexer<'input> { + pub fn new(input: &'input str) -> Self { + // the Token::lexer() method is provided by the Logos trait + Self { + token_stream: Token::lexer(input).spanned(), + } + } +} + +impl Iterator for Lexer<'_> { + type Item = Spanned; + + fn next(&mut self) -> Option { + self.token_stream.next().map(|(token, span)| { + // dbg!(&token); + Ok((span.start, token?, span.end)) + }) + } +} diff --git a/src/ocd/mrn/lalrpop/mrn_parser.lalrpop b/src/ocd/mrn/lalrpop/mrn_parser.lalrpop new file mode 100644 index 0000000..40e7dce --- /dev/null +++ b/src/ocd/mrn/lalrpop/mrn_parser.lalrpop @@ -0,0 +1,110 @@ +use crate::ocd::mrn::pattern_match::process_match; +use crate::ocd::mrn::pattern_match::process_replace; +use crate::ocd::mrn::program::Instruction; +use crate::ocd::mrn::program::Position; +use crate::ocd::mrn::program::ReplaceArg; +use crate::ocd::mrn::lalrpop::mrn_tokens::Token; +use crate::ocd::mrn::lalrpop::mrn_tokens::LexicalError; +use lalrpop_util::ParseError; + +grammar; + +extern { + type Location = usize; + type Error = LexicalError; + + enum Token { + "stringvalue" => Token::StringValue(), + "index" => Token::Index(), + "'" => Token::Apostrophe, + "," => Token::Comma, + "s" => Token::Sanitize, + "cl" => Token::CaseLower, + "cu" => Token::CaseUpper, + "ct" => Token::CaseTitle, + "cs" => Token::CaseSentence, + "jc" => Token::JoinCamel, + "js" => Token::JoinSnake, + "jk" => Token::JoinKebab, + "sc" => Token::SplitCamel, + "ss" => Token::SplitSnake, + "sk" => Token::SplitKebab, + "rdp" => Token::ReplaceDashPeriod, + "rds" => Token::ReplaceDashSpace, + "rdu" => Token::ReplaceDashUnderscore, + "rpd" => Token::ReplacePeriodDash, + "rps" => Token::ReplacePeriodSpace, + "rpu" => Token::ReplacePeriodUnderscore, + "rsd" => Token::ReplaceSpaceDash, + "rsp" => Token::ReplaceSpacePeriod, + "rsu" => Token::ReplaceSpaceUnderscore, + "rud" => Token::ReplaceUnderscoreDash, + "rup" => Token::ReplaceUnderscorePeriod, + "rus" => Token::ReplaceUnderscoreSpace, + "r" => Token::Replace, + "i" => Token::Insert, + "end" => Token::End, + "d" => Token::Delete, + "ea" => Token::ExtensionAdd, + "er" => Token::ExtensionRemove, + "o" => Token::Reorder, + "p" => Token::PatternMatch, + } +} + +Comma: Vec = { + ",")*> => match e { + None => v, + Some(e) => { + v.push(e); + v + } + } +}; + +pub Program = Comma; + +Operation: Instruction = { + "s" => Instruction::Sanitize, + "cl" => Instruction::CaseLower, + "cu" => Instruction::CaseUpper, + "ct" => Instruction::CaseTitle, + "cs" => Instruction::CaseSentence, + "jc" => Instruction::JoinCamel, + "js" => Instruction::JoinSnake, + "jk" => Instruction::JoinKebab, + "sc" => Instruction::SplitCamel, + "ss" => Instruction::SplitSnake, + "sk" => Instruction::SplitKebab, + "rdp" => Instruction::Replace{ pattern: ReplaceArg::Dash, replace: ReplaceArg::Period }, + "rds" => Instruction::Replace{ pattern: ReplaceArg::Dash, replace: ReplaceArg::Space }, + "rdu" => Instruction::Replace{ pattern: ReplaceArg::Dash, replace: ReplaceArg::Underscore }, + "rpd" => Instruction::Replace{ pattern: ReplaceArg::Period, replace: ReplaceArg::Dash }, + "rps" => Instruction::Replace{ pattern: ReplaceArg::Period, replace: ReplaceArg::Space }, + "rpu" => Instruction::Replace{ pattern: ReplaceArg::Period, replace: ReplaceArg::Underscore }, + "rsd" => Instruction::Replace{ pattern: ReplaceArg::Space, replace: ReplaceArg::Dash }, + "rsp" => Instruction::Replace{ pattern: ReplaceArg::Space, replace: ReplaceArg::Period }, + "rsu" => Instruction::Replace{ pattern: ReplaceArg::Space, replace: ReplaceArg::Underscore }, + "rud" => Instruction::Replace{ pattern: ReplaceArg::Underscore, replace: ReplaceArg::Dash }, + "rup" => Instruction::Replace{ pattern: ReplaceArg::Underscore, replace: ReplaceArg::Period }, + "rus" => Instruction::Replace{ pattern: ReplaceArg::Underscore, replace: ReplaceArg::Space }, + "r" => Instruction::Replace{ pattern: ReplaceArg::Text(p), replace: ReplaceArg::Text(r) }, + "i" => Instruction::Insert{position: p, text: s}, + "d" => Instruction::Delete{from: f, to: t}, + "ea" => Instruction::ExtensionAdd(e), + "er" => Instruction::ExtensionRemove, + "o" => Instruction::Reorder, + "p" =>? { + let m = process_match(m); + match process_replace(r) { + Ok(r) => Ok(Instruction::PatternMatch{ match_pattern: m, replace_pattern: r }), + Err(_e) => Err(ParseError::User{ error: LexicalError::InvalidReplacePattern }), // TOOD do something with this error + } + }, +} + +// A position may either be the keyword 'end' or an index. +Position: Position = { + "end" => Position::End, + => Position::Index(i), +} diff --git a/src/ocd/mrn/lalrpop/mrn_tokens.rs b/src/ocd/mrn/lalrpop/mrn_tokens.rs new file mode 100644 index 0000000..7ee8087 --- /dev/null +++ b/src/ocd/mrn/lalrpop/mrn_tokens.rs @@ -0,0 +1,105 @@ +use logos::Logos; +use std::fmt; +use std::num::ParseIntError; + +#[allow(clippy::enum_variant_names)] +#[derive(Default, Debug, Clone, PartialEq)] +pub enum LexicalError { + InvalidInteger(ParseIntError), + InvalidReplacePattern, + #[default] + InvalidToken, +} + +impl fmt::Display for LexicalError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From for LexicalError { + fn from(err: ParseIntError) -> Self { + LexicalError::InvalidInteger(err) + } +} + +#[derive(Logos, Clone, Debug, PartialEq)] +#[logos(skip r"[ \t\n\f]+", error = LexicalError)] +pub enum Token { + #[regex("'[^']*'", |lex| lex.slice().trim_matches('\'').to_string())] + StringValue(String), + #[regex("[0-9]+", |lex| lex.slice().parse())] + Index(usize), + #[token("'")] + Apostrophe, + #[token(",")] + Comma, + #[token("s")] + Sanitize, + #[token("cl")] + CaseLower, + #[token("cu")] + CaseUpper, + #[token("ct")] + CaseTitle, + #[token("cs")] + CaseSentence, + #[token("jc")] + JoinCamel, + #[token("js")] + JoinSnake, + #[token("jk")] + JoinKebab, + #[token("sc")] + SplitCamel, + #[token("ss")] + SplitSnake, + #[token("sk")] + SplitKebab, + #[token("r")] + Replace, + #[token("rdp")] + ReplaceDashPeriod, + #[token("rds")] + ReplaceDashSpace, + #[token("rdu")] + ReplaceDashUnderscore, + #[token("rpd")] + ReplacePeriodDash, + #[token("rps")] + ReplacePeriodSpace, + #[token("rpu")] + ReplacePeriodUnderscore, + #[token("rsd")] + ReplaceSpaceDash, + #[token("rsp")] + ReplaceSpacePeriod, + #[token("rsu")] + ReplaceSpaceUnderscore, + #[token("rud")] + ReplaceUnderscoreDash, + #[token("rup")] + ReplaceUnderscorePeriod, + #[token("rus")] + ReplaceUnderscoreSpace, + #[token("i")] + Insert, + #[token("end")] + End, + #[token("d")] + Delete, + #[token("ea")] + ExtensionAdd, + #[token("er")] + ExtensionRemove, + #[token("o")] + Reorder, + #[token("p")] + PatternMatch, +} + +impl fmt::Display for Token { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:?}", self) + } +} diff --git a/src/ocd/mrn/lexer.rs b/src/ocd/mrn/lexer.rs deleted file mode 100644 index 1b31244..0000000 --- a/src/ocd/mrn/lexer.rs +++ /dev/null @@ -1,1358 +0,0 @@ -use crate::ocd::mrn::MassRenameConfig; -use std::fmt; -use std::fmt::Display; -use std::{error::Error, mem}; -use tracing::{span, Level}; - -#[derive(Debug, PartialEq)] -pub enum Token { - Comma, - Space, - End, - Number { value: usize }, - String { value: String }, - PatternMatch, - LowerCase, - UpperCase, - TitleCase, - SentenceCase, - CamelCaseJoin, - CamelCaseSplit, - ExtensionAdd, - ExtensionRemove, - Insert, - InteractiveTokenize, - InteractivePatternMatch, - Delete, - Replace, - ReplaceSpaceDash, - ReplaceSpacePeriod, - ReplaceSpaceUnder, - ReplaceDashSpace, - ReplaceDashPeriod, - ReplaceDashUnder, - ReplacePeriodDash, - ReplacePeriodSpace, - ReplacePeriodUnder, - ReplaceUnderSpace, - ReplaceUnderDash, - ReplaceUnderPeriod, - Sanitize, -} - -#[derive(Debug)] -pub enum TokenizerErrorKind { - Unexpected, - UnfinishedString, - UnfinishedRule, - ParseIntError, -} - -#[derive(Debug)] -pub struct TokenizerError { - kind: TokenizerErrorKind, - // input: String, - state: TokenizerState, - // position: usize, - msg: String, -} - -impl Error for TokenizerError { - fn source(&self) -> Option<&(dyn Error + 'static)> { - None - } -} - -impl Display for TokenizerError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "TokenizerError") - } -} - -#[derive(Debug)] -#[allow(clippy::upper_case_acronyms)] -enum TokenizerState { - Init, - Error, - Comma, - Space, - String, - Number, - C, - CC, - CCJ, - CCS, - DP, - DS, - DU, - D, - E, - EN, - END, - EA, - ER, - I, - IP, - IT, - L, - LC, - P, - PD, - PS, - PU, - R, - S, - SC, - SP, - SD, - SU, - T, - TC, - U, - UC, - UD, - US, - UP, -} - -struct Tokenizer { - state: TokenizerState, - string: String, - number: String, - tokens: Vec, -} - -impl Tokenizer { - pub fn new() -> Tokenizer { - Tokenizer { - state: TokenizerState::Init, - string: String::new(), - number: String::new(), - tokens: Vec::new(), - } - } - - pub fn run( - &mut self, - config: &MassRenameConfig, - input: &str, - ) -> Result, Box> { - let span = span!(Level::TRACE, "lexer"); - let _guard = span.enter(); - - for c in input.chars() { - match self.state { - TokenizerState::Init => self.state_init(config, c), - TokenizerState::Comma => self.state_comma(config, c), - TokenizerState::Space => self.state_space(config, c), - TokenizerState::String => self.state_string(config, c), - TokenizerState::Number => self.state_number(config, c), - TokenizerState::C => self.state_c(config, c), - TokenizerState::CC => self.state_cc(config, c), - TokenizerState::CCJ => self.state_ccj(config, c), - TokenizerState::CCS => self.state_ccs(config, c), - TokenizerState::DP => self.state_dp(config, c), - TokenizerState::DS => self.state_ds(config, c), - TokenizerState::DU => self.state_du(config, c), - TokenizerState::D => self.state_d(config, c), - TokenizerState::E => self.state_e(config, c), - TokenizerState::EN => self.state_en(config, c), - TokenizerState::END => self.state_end(config, c), - TokenizerState::EA => self.state_ea(config, c), - TokenizerState::ER => self.state_er(config, c), - TokenizerState::I => self.state_i(config, c), - TokenizerState::IP => self.state_ip(config, c), - TokenizerState::IT => self.state_it(config, c), - TokenizerState::L => self.state_l(config, c), - TokenizerState::LC => self.state_lc(config, c), - TokenizerState::P => self.state_p(config, c), - TokenizerState::PS => self.state_ps(config, c), - TokenizerState::PD => self.state_pd(config, c), - TokenizerState::PU => self.state_pu(config, c), - TokenizerState::R => self.state_r(config, c), - TokenizerState::S => self.state_s(config, c), - TokenizerState::SC => self.state_sc(config, c), - TokenizerState::SP => self.state_sp(config, c), - TokenizerState::SD => self.state_sd(config, c), - TokenizerState::SU => self.state_su(config, c), - TokenizerState::T => self.state_t(config, c), - TokenizerState::TC => self.state_tc(config, c), - TokenizerState::U => self.state_u(config, c), - TokenizerState::UC => self.state_uc(config, c), - TokenizerState::UD => self.state_ud(config, c), - TokenizerState::US => self.state_us(config, c), - TokenizerState::UP => self.state_up(config, c), - TokenizerState::Error => { - return Err(Box::new(TokenizerError { - kind: TokenizerErrorKind::Unexpected, - state: TokenizerState::Error, - msg: String::from("Unexpected lexer error"), - })) - } - } - } - match self.state { - TokenizerState::Init => {} - TokenizerState::Comma => { - self.tokens.push(Token::Comma); - } - TokenizerState::Space => { - self.tokens.push(Token::Space); - } - TokenizerState::Number => match self.number.parse::() { - Ok(value) => { - self.tokens.push(Token::Number { value }); - } - Err(err) => { - return Err(Box::new(TokenizerError { - kind: TokenizerErrorKind::ParseIntError, - state: TokenizerState::Number, - msg: format!("Error: unable to read number: {:?}", err), - })) - } - }, - TokenizerState::CCJ => { - self.tokens.push(Token::CamelCaseJoin); - } - TokenizerState::CCS => { - self.tokens.push(Token::CamelCaseSplit); - } - TokenizerState::D => { - self.tokens.push(Token::Delete); - } - TokenizerState::DP => { - self.tokens.push(Token::ReplaceDashPeriod); - } - TokenizerState::DS => { - self.tokens.push(Token::ReplaceDashSpace); - } - TokenizerState::DU => { - self.tokens.push(Token::ReplaceDashUnder); - } - TokenizerState::EA => { - self.tokens.push(Token::ExtensionAdd); - } - TokenizerState::ER => { - self.tokens.push(Token::ExtensionRemove); - } - TokenizerState::END => { - self.tokens.push(Token::End); - } - TokenizerState::I => { - self.tokens.push(Token::Insert); - } - TokenizerState::IP => { - self.tokens.push(Token::InteractivePatternMatch); - } - TokenizerState::IT => { - self.tokens.push(Token::InteractiveTokenize); - } - TokenizerState::LC => { - self.tokens.push(Token::LowerCase); - } - TokenizerState::P => { - self.tokens.push(Token::PatternMatch); - } - TokenizerState::PD => { - self.tokens.push(Token::ReplacePeriodDash); - } - TokenizerState::PS => { - self.tokens.push(Token::ReplacePeriodSpace); - } - TokenizerState::PU => { - self.tokens.push(Token::ReplacePeriodUnder); - } - TokenizerState::R => { - self.tokens.push(Token::Replace); - } - TokenizerState::S => { - self.tokens.push(Token::Sanitize); - } - TokenizerState::SC => { - self.tokens.push(Token::SentenceCase); - } - TokenizerState::SP => { - self.tokens.push(Token::ReplaceSpacePeriod); - } - TokenizerState::SD => { - self.tokens.push(Token::ReplaceSpaceDash); - } - TokenizerState::SU => { - self.tokens.push(Token::ReplaceSpaceUnder); - } - TokenizerState::TC => { - self.tokens.push(Token::TitleCase); - } - TokenizerState::UC => { - self.tokens.push(Token::UpperCase); - } - TokenizerState::UD => { - self.tokens.push(Token::ReplaceUnderDash); - } - TokenizerState::UP => { - self.tokens.push(Token::ReplaceUnderPeriod); - } - TokenizerState::US => { - self.tokens.push(Token::ReplaceUnderSpace); - } - TokenizerState::String => { - return Err(Box::new(TokenizerError { - kind: TokenizerErrorKind::UnfinishedString, - state: TokenizerState::String, - msg: String::from("Error: unfinished string"), - })) - } - TokenizerState::C => { - return Err(Box::new(TokenizerError { - kind: TokenizerErrorKind::UnfinishedRule, - state: TokenizerState::C, - msg: String::from("Error: unfinished case rule"), - })) - } - TokenizerState::CC => { - return Err(Box::new(TokenizerError { - kind: TokenizerErrorKind::UnfinishedRule, - state: TokenizerState::CC, - msg: String::from("Error: unfinished rule, read: 'cc'"), - })) - } - TokenizerState::E => { - return Err(Box::new(TokenizerError { - kind: TokenizerErrorKind::UnfinishedRule, - state: TokenizerState::E, - msg: String::from("Error: unfinished rule, read: 'e'"), - })) - } - TokenizerState::EN => { - return Err(Box::new(TokenizerError { - kind: TokenizerErrorKind::UnfinishedRule, - state: TokenizerState::EN, - msg: String::from("Error: unfinished end"), - })) - } - TokenizerState::L => { - return Err(Box::new(TokenizerError { - kind: TokenizerErrorKind::UnfinishedRule, - state: TokenizerState::L, - msg: String::from("Error: unfinished rule, read: 'l'"), - })) - } - TokenizerState::T => { - return Err(Box::new(TokenizerError { - kind: TokenizerErrorKind::UnfinishedRule, - state: TokenizerState::T, - msg: String::from("Error: unfinished rule, read: 't'"), - })) - } - TokenizerState::U => { - return Err(Box::new(TokenizerError { - kind: TokenizerErrorKind::UnfinishedRule, - state: TokenizerState::U, - msg: String::from("Error: unfinished rule, read: 'u'"), - })) - } - TokenizerState::Error => { - return Err(Box::new(TokenizerError { - kind: TokenizerErrorKind::UnfinishedRule, - state: TokenizerState::Error, - msg: String::from("Error while reading input"), - })) - } - } - let mut tokens = Vec::new(); - mem::swap(&mut self.tokens, &mut tokens); - Ok(tokens) - } - - fn state_init(&mut self, config: &MassRenameConfig, c: char) { - match c { - ',' => { - self.state = TokenizerState::Comma; - } - ' ' => { - self.state = TokenizerState::Space; - } - '"' => { - self.string.clear(); - self.state = TokenizerState::String; - } - '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' => { - self.number.clear(); - self.number.push(c); - self.state = TokenizerState::Number; - } - 'c' => { - self.state = TokenizerState::C; - } - 'd' => { - self.state = TokenizerState::D; - } - 'e' => { - self.state = TokenizerState::E; - } - 'i' => { - self.state = TokenizerState::I; - } - 'l' => { - self.state = TokenizerState::L; - } - 'p' => { - self.state = TokenizerState::P; - } - 'r' => { - self.state = TokenizerState::R; - } - 's' => { - self.state = TokenizerState::S; - } - 't' => { - self.state = TokenizerState::T; - } - 'u' => { - self.state = TokenizerState::U; - } - _ => { - crate::ocd::output::mrn_lexer_error(config.verbosity, "*Init*"); - self.state = TokenizerState::Error; - } - } - } - - fn state_comma(&mut self, config: &MassRenameConfig, c: char) { - match c { - ',' => { - self.tokens.push(Token::Comma); - self.state = TokenizerState::Comma; - } - ' ' => { - self.tokens.push(Token::Comma); - self.state = TokenizerState::Space; - } - '"' => { - self.string.clear(); - self.state = TokenizerState::String; - } - '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' => { - self.tokens.push(Token::Comma); - self.number.clear(); - self.number.push(c); - self.state = TokenizerState::Number; - } - 'c' => { - self.tokens.push(Token::Comma); - self.state = TokenizerState::C; - } - 'd' => { - self.tokens.push(Token::Comma); - self.state = TokenizerState::D; - } - 'e' => { - self.tokens.push(Token::Comma); - self.state = TokenizerState::E; - } - 'i' => { - self.tokens.push(Token::Comma); - self.state = TokenizerState::I; - } - 'l' => { - self.tokens.push(Token::Comma); - self.state = TokenizerState::L; - } - 'p' => { - self.tokens.push(Token::Comma); - self.state = TokenizerState::P; - } - 'r' => { - self.tokens.push(Token::Comma); - self.state = TokenizerState::R; - } - 's' => { - self.tokens.push(Token::Comma); - self.state = TokenizerState::S; - } - 't' => { - self.tokens.push(Token::Comma); - self.state = TokenizerState::T; - } - 'u' => { - self.tokens.push(Token::Comma); - self.state = TokenizerState::U; - } - _ => { - crate::ocd::output::mrn_lexer_error(config.verbosity, "*Comma*"); - self.state = TokenizerState::Error; - } - } - } - - fn state_space(&mut self, config: &MassRenameConfig, c: char) { - match c { - ' ' => {} - ',' => { - self.tokens.push(Token::Space); - self.state = TokenizerState::Comma; - } - '"' => { - self.tokens.push(Token::Space); - self.string.clear(); - self.state = TokenizerState::String; - } - '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' => { - self.tokens.push(Token::Space); - self.number.clear(); - self.number.push(c); - self.state = TokenizerState::Number; - } - 'c' => { - self.tokens.push(Token::Space); - self.state = TokenizerState::C; - } - 'd' => { - self.tokens.push(Token::Space); - self.state = TokenizerState::D; - } - 'e' => { - self.tokens.push(Token::Space); - self.state = TokenizerState::E; - } - 'i' => { - self.tokens.push(Token::Space); - self.state = TokenizerState::I; - } - 'l' => { - self.tokens.push(Token::Space); - self.state = TokenizerState::L; - } - 'p' => { - self.tokens.push(Token::Space); - self.state = TokenizerState::P; - } - 'r' => { - self.tokens.push(Token::Space); - self.state = TokenizerState::R; - } - 's' => { - self.tokens.push(Token::Space); - self.state = TokenizerState::S; - } - 't' => { - self.tokens.push(Token::Space); - self.state = TokenizerState::T; - } - 'u' => { - self.tokens.push(Token::Space); - self.state = TokenizerState::U; - } - _ => { - crate::ocd::output::mrn_lexer_error(config.verbosity, "*Space*"); - self.state = TokenizerState::Error; - } - } - } - - fn state_string(&mut self, _config: &MassRenameConfig, c: char) { - match c { - '"' => { - self.tokens.push(Token::String { - value: self.string.clone(), - }); - self.string.clear(); - self.state = TokenizerState::Init; - } - _ => { - self.string.push(c); - } - } - } - - fn state_number(&mut self, config: &MassRenameConfig, c: char) { - match c { - ',' => match self.number.parse::() { - Ok(value) => { - self.tokens.push(Token::Number { value }); - self.state = TokenizerState::Comma; - } - Err(_err) => { - crate::ocd::output::mrn_lexer_error( - config.verbosity, - format!("*Number* err: {}", _err).as_str(), - ); - self.state = TokenizerState::Error; - } - }, - ' ' => match self.number.parse::() { - Ok(value) => { - self.tokens.push(Token::Number { value }); - self.state = TokenizerState::Space; - } - Err(err) => { - crate::ocd::output::mrn_lexer_error( - config.verbosity, - format!("*Number* err: {}", err).as_str(), - ); - self.state = TokenizerState::Error; - } - }, - '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' => { - self.number.push(c); - self.state = TokenizerState::Number; - } - _ => { - crate::ocd::output::mrn_lexer_error( - config.verbosity, - format!("*Number* c: {}", c).as_str(), - ); - self.state = TokenizerState::Error; - } - } - } - - fn state_c(&mut self, config: &MassRenameConfig, c: char) { - match c { - 'c' => { - self.state = TokenizerState::CC; - } - _ => { - crate::ocd::output::mrn_lexer_error(config.verbosity, "*C*"); - self.state = TokenizerState::Error; - } - } - } - - fn state_cc(&mut self, config: &MassRenameConfig, c: char) { - match c { - 'j' => { - self.state = TokenizerState::CCJ; - } - 's' => { - self.state = TokenizerState::CCS; - } - _ => { - crate::ocd::output::mrn_lexer_error(config.verbosity, "*CC*"); - self.state = TokenizerState::Error; - } - } - } - - fn state_ccj(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::CamelCaseJoin, "*CCJ*") - } - - fn state_ccs(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::CamelCaseSplit, "*CCS*") - } - - fn state_d(&mut self, config: &MassRenameConfig, c: char) { - match c { - ',' => { - self.tokens.push(Token::Delete); - self.state = TokenizerState::Comma; - } - ' ' => { - self.tokens.push(Token::Delete); - self.state = TokenizerState::Space; - } - 'p' => { - self.state = TokenizerState::DP; - } - 's' => { - self.state = TokenizerState::DS; - } - 'u' => { - self.state = TokenizerState::DU; - } - _ => { - crate::ocd::output::mrn_lexer_error(config.verbosity, "*D*"); - self.state = TokenizerState::Error; - } - } - } - - fn state_dp(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::ReplaceDashPeriod, "*DP*") - } - - fn state_ds(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::ReplaceDashSpace, "*DS*") - } - - fn state_du(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::ReplaceDashUnder, "*DU*") - } - - fn state_e(&mut self, config: &MassRenameConfig, c: char) { - match c { - 'a' => { - self.state = TokenizerState::EA; - } - 'r' => { - self.state = TokenizerState::ER; - } - 'n' => { - self.state = TokenizerState::EN; - } - _ => { - crate::ocd::output::mrn_lexer_error(config.verbosity, "*E*"); - self.state = TokenizerState::Error; - } - } - } - - fn state_ea(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::ExtensionAdd, "*EA*") - } - - fn state_er(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::ExtensionRemove, "*ER*") - } - - fn state_en(&mut self, config: &MassRenameConfig, c: char) { - match c { - 'd' => { - self.state = TokenizerState::END; - } - _ => { - crate::ocd::output::mrn_lexer_error(config.verbosity, "*EN*"); - self.state = TokenizerState::Error; - } - } - } - - fn state_end(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::End, "*END*") - } - - fn state_i(&mut self, config: &MassRenameConfig, c: char) { - match c { - ',' => { - self.tokens.push(Token::Insert); - self.state = TokenizerState::Comma; - } - ' ' => { - self.tokens.push(Token::Insert); - self.state = TokenizerState::Space; - } - 'p' => { - self.state = TokenizerState::IP; - } - 't' => { - self.state = TokenizerState::IT; - } - _ => { - crate::ocd::output::mrn_lexer_error(config.verbosity, "*I*"); - self.state = TokenizerState::Error; - } - } - } - - fn state_ip(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::InteractivePatternMatch, "*IP*") - } - - fn state_it(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::InteractiveTokenize, "*IT*") - } - - fn state_l(&mut self, config: &MassRenameConfig, c: char) { - match c { - 'c' => { - self.state = TokenizerState::LC; - } - _ => { - crate::ocd::output::mrn_lexer_error(config.verbosity, "*L*"); - self.state = TokenizerState::Error; - } - } - } - - fn state_lc(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::LowerCase, "*LC*") - } - - fn state_p(&mut self, config: &MassRenameConfig, c: char) { - match c { - ',' => { - self.tokens.push(Token::PatternMatch); - self.state = TokenizerState::Comma; - } - ' ' => { - self.tokens.push(Token::PatternMatch); - self.state = TokenizerState::Space; - } - 's' => { - self.state = TokenizerState::PS; - } - 'd' => { - self.state = TokenizerState::PD; - } - 'u' => { - self.state = TokenizerState::PU; - } - _ => { - crate::ocd::output::mrn_lexer_error(config.verbosity, "*P*"); - self.state = TokenizerState::Error; - } - } - } - - fn state_pd(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::ReplacePeriodDash, "*PD*") - } - - fn state_ps(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::ReplacePeriodSpace, "*PS*") - } - - fn state_pu(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::ReplacePeriodUnder, "*PU*") - } - - fn state_r(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::Replace, "*R*") - } - - fn state_s(&mut self, config: &MassRenameConfig, c: char) { - match c { - ',' => { - self.tokens.push(Token::Sanitize); - self.state = TokenizerState::Comma; - } - ' ' => { - self.tokens.push(Token::Sanitize); - self.state = TokenizerState::Space; - } - 'c' => { - self.state = TokenizerState::SC; - } - 'p' => { - self.state = TokenizerState::SP; - } - 'd' => { - self.state = TokenizerState::SD; - } - 'u' => { - self.state = TokenizerState::SU; - } - _ => { - crate::ocd::output::mrn_lexer_error(config.verbosity, "*S*"); - self.state = TokenizerState::Error; - } - } - } - - fn state_sc(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::SentenceCase, "*SC*") - } - - fn state_sp(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::ReplaceSpacePeriod, "*SP*") - } - - fn state_sd(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::ReplaceSpaceDash, "*SD*") - } - - fn state_su(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::ReplaceSpaceUnder, "*SU*") - } - - fn state_t(&mut self, config: &MassRenameConfig, c: char) { - match c { - 'c' => { - self.state = TokenizerState::TC; - } - _ => { - crate::ocd::output::mrn_lexer_error(config.verbosity, "*T*"); - self.state = TokenizerState::Error; - } - } - } - - fn state_tc(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::TitleCase, "*TC*") - } - - fn state_u(&mut self, config: &MassRenameConfig, c: char) { - match c { - 'c' => { - self.state = TokenizerState::UC; - } - 'd' => { - self.state = TokenizerState::UD; - } - 'p' => { - self.state = TokenizerState::UP; - } - 's' => { - self.state = TokenizerState::US; - } - _ => { - crate::ocd::output::mrn_lexer_error(config.verbosity, "*U*"); - self.state = TokenizerState::Error; - } - } - } - - fn state_uc(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::UpperCase, "*UC*") - } - - fn state_ud(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::ReplaceUnderDash, "*UD*") - } - - fn state_us(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::ReplaceUnderSpace, "*US*") - } - - fn state_up(&mut self, config: &MassRenameConfig, c: char) { - self.emit_token(config, c, Token::ReplaceUnderPeriod, "*UP*") - } - - fn emit_token(&mut self, config: &MassRenameConfig, c: char, token: Token, error_msg: &str) { - match c { - ',' => { - self.tokens.push(token); - self.state = TokenizerState::Comma; - } - ' ' => { - self.tokens.push(token); - self.state = TokenizerState::Space; - } - _ => { - crate::ocd::output::mrn_lexer_error(config.verbosity, error_msg); - self.state = TokenizerState::Error; - } - } - } -} - -pub fn tokenize(config: &MassRenameConfig, input: &str) -> Result, Box> { - Tokenizer::new().run(config, input) -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn empty_test() { - let empty: [Token; 0] = []; - assert_eq!( - &empty, - tokenize(&MassRenameConfig::new(), "").unwrap().as_slice() - ); - } - - #[test] - fn comma_test() { - assert_eq!( - &[Token::Comma], - tokenize(&MassRenameConfig::new(), ",").unwrap().as_slice() - ); - } - - #[test] - fn space_test() { - assert_eq!( - &[Token::Space], - tokenize(&MassRenameConfig::new(), " ").unwrap().as_slice() - ); - } - - #[test] - fn multiple_spaces_test() { - assert_eq!( - &[Token::Space], - tokenize(&MassRenameConfig::new(), " ") - .unwrap() - .as_slice() - ); - } - - #[test] - fn string_test() { - assert_eq!( - &[Token::String { - value: String::from("look, a string") - }], - tokenize(&MassRenameConfig::new(), "\"look, a string\"") - .unwrap() - .as_slice() - ); - } - #[test] - fn zero_test() { - assert_eq!( - &[Token::Number { value: 0 }], - tokenize(&MassRenameConfig::new(), "0").unwrap().as_slice() - ); - } - #[test] - fn number_test() { - assert_eq!( - &[Token::Number { value: 10 }], - tokenize(&MassRenameConfig::new(), "10").unwrap().as_slice() - ); - } - #[test] - fn large_number_test() { - assert_eq!( - &[Token::Number { value: 105 }], - tokenize(&MassRenameConfig::new(), "105") - .unwrap() - .as_slice() - ); - } - - #[test] - fn end_test() { - assert_eq!( - &[Token::End], - tokenize(&MassRenameConfig::new(), "end") - .unwrap() - .as_slice() - ); - } - - #[test] - fn pattern_match_test() { - assert_eq!( - &[Token::PatternMatch], - tokenize(&MassRenameConfig::new(), "p").unwrap().as_slice() - ); - } - - #[test] - fn lower_case_test() { - assert_eq!( - &[Token::LowerCase], - tokenize(&MassRenameConfig::new(), "lc").unwrap().as_slice() - ); - } - - #[test] - fn upper_case_test() { - assert_eq!( - &[Token::UpperCase], - tokenize(&MassRenameConfig::new(), "uc").unwrap().as_slice() - ); - } - - #[test] - fn title_case_test() { - assert_eq!( - &[Token::TitleCase], - tokenize(&MassRenameConfig::new(), "tc").unwrap().as_slice() - ); - } - - #[test] - fn sentence_case_test() { - assert_eq!( - &[Token::SentenceCase], - tokenize(&MassRenameConfig::new(), "sc").unwrap().as_slice() - ); - } - - #[test] - fn camel_case_join_test() { - assert_eq!( - &[Token::CamelCaseJoin], - tokenize(&MassRenameConfig::new(), "ccj") - .unwrap() - .as_slice() - ); - } - - #[test] - fn camel_case_split_test() { - assert_eq!( - &[Token::CamelCaseSplit], - tokenize(&MassRenameConfig::new(), "ccs") - .unwrap() - .as_slice() - ); - } - - #[test] - fn extension_add_test() { - assert_eq!( - &[Token::ExtensionAdd], - tokenize(&MassRenameConfig::new(), "ea").unwrap().as_slice() - ); - } - - #[test] - fn extension_remove_test() { - assert_eq!( - &[Token::ExtensionRemove], - tokenize(&MassRenameConfig::new(), "er").unwrap().as_slice() - ); - } - - #[test] - fn insert_test() { - assert_eq!( - &[Token::Insert], - tokenize(&MassRenameConfig::new(), "i").unwrap().as_slice() - ); - } - - #[test] - fn interactive_tokenize_test() { - assert_eq!( - &[Token::InteractiveTokenize], - tokenize(&MassRenameConfig::new(), "it").unwrap().as_slice() - ); - } - - #[test] - fn interactive_pattern_match_test() { - assert_eq!( - &[Token::InteractivePatternMatch], - tokenize(&MassRenameConfig::new(), "ip").unwrap().as_slice() - ); - } - - #[test] - fn delete_test() { - assert_eq!( - &[Token::Delete], - tokenize(&MassRenameConfig::new(), "d").unwrap().as_slice() - ); - } - - #[test] - fn replace_test() { - assert_eq!( - &[Token::Replace], - tokenize(&MassRenameConfig::new(), "r").unwrap().as_slice() - ); - } - - #[test] - fn sanitize_test() { - assert_eq!( - &[Token::Sanitize], - tokenize(&MassRenameConfig::new(), "s").unwrap().as_slice() - ); - } - - #[test] - fn replace_space_dash_test() { - assert_eq!( - &[Token::ReplaceSpaceDash], - tokenize(&MassRenameConfig::new(), "sd").unwrap().as_slice() - ); - } - - #[test] - fn replace_space_period_test() { - assert_eq!( - &[Token::ReplaceSpacePeriod], - tokenize(&MassRenameConfig::new(), "sp").unwrap().as_slice() - ); - } - - #[test] - fn replace_space_underscore_test() { - assert_eq!( - &[Token::ReplaceSpaceUnder], - tokenize(&MassRenameConfig::new(), "su").unwrap().as_slice() - ); - } - - #[test] - fn replace_dash_space_test() { - assert_eq!( - &[Token::ReplaceDashSpace], - tokenize(&MassRenameConfig::new(), "ds").unwrap().as_slice() - ); - } - - #[test] - fn replace_dash_period_test() { - assert_eq!( - &[Token::ReplaceDashPeriod], - tokenize(&MassRenameConfig::new(), "dp").unwrap().as_slice() - ); - } - - #[test] - fn replace_dash_under_test() { - assert_eq!( - &[Token::ReplaceDashUnder], - tokenize(&MassRenameConfig::new(), "du").unwrap().as_slice() - ); - } - - #[test] - fn replace_period_space_test() { - assert_eq!( - &[Token::ReplacePeriodSpace], - tokenize(&MassRenameConfig::new(), "ps").unwrap().as_slice() - ); - } - - #[test] - fn replace_period_dash_test() { - assert_eq!( - &[Token::ReplacePeriodDash], - tokenize(&MassRenameConfig::new(), "pd").unwrap().as_slice() - ); - } - - #[test] - fn replace_period_under_test() { - assert_eq!( - &[Token::ReplacePeriodUnder], - tokenize(&MassRenameConfig::new(), "pu").unwrap().as_slice() - ); - } - - #[test] - fn replace_under_space_test() { - assert_eq!( - &[Token::ReplaceUnderSpace], - tokenize(&MassRenameConfig::new(), "us").unwrap().as_slice() - ); - } - - #[test] - fn replace_under_dash_test() { - assert_eq!( - &[Token::ReplaceUnderDash], - tokenize(&MassRenameConfig::new(), "ud").unwrap().as_slice() - ); - } - - #[test] - fn replace_underscore_period_test() { - assert_eq!( - &[Token::ReplaceUnderPeriod], - tokenize(&MassRenameConfig::new(), "up").unwrap().as_slice() - ); - } - - #[test] - fn pattern_match_with_pattern_test() { - assert_eq!( - &[ - Token::PatternMatch, - Token::Space, - Token::String { - value: String::from("{#} - {X}") - }, - Token::Space, - Token::String { - value: String::from("{1}. {2}") - }, - Token::Comma, - Token::LowerCase, - ], - tokenize(&MassRenameConfig::new(), "p \"{#} - {X}\" \"{1}. {2}\",lc") - .unwrap() - .as_slice() - ); - } - - #[test] - fn all_case_changes_test() { - assert_eq!( - &[ - Token::LowerCase, - Token::Comma, - Token::UpperCase, - Token::Comma, - Token::TitleCase, - Token::Comma, - Token::SentenceCase, - ], - tokenize(&MassRenameConfig::new(), "lc,uc,tc,sc") - .unwrap() - .as_slice() - ); - } - - #[test] - fn all_replace_changes_test() { - assert_eq!( - &[ - Token::ReplaceDashPeriod, - Token::Comma, - Token::ReplaceDashSpace, - Token::Comma, - Token::ReplaceDashUnder, - Token::Comma, - Token::ReplacePeriodDash, - Token::Comma, - Token::ReplacePeriodSpace, - Token::Comma, - Token::ReplacePeriodUnder, - Token::Comma, - Token::ReplaceSpaceDash, - Token::Comma, - Token::ReplaceSpacePeriod, - Token::Comma, - Token::ReplaceSpaceUnder, - Token::Comma, - Token::ReplaceUnderDash, - Token::Comma, - Token::ReplaceUnderPeriod, - Token::Comma, - Token::ReplaceUnderSpace, - ], - tokenize( - &MassRenameConfig::new(), - "dp,ds,du,pd,ps,pu,sd,sp,su,ud,up,us" - ) - .unwrap() - .as_slice() - ); - } - - #[test] - fn all_extension_changes_test() { - assert_eq!( - &[ - Token::ExtensionRemove, - Token::Comma, - Token::ExtensionAdd, - Token::Space, - Token::String { - value: String::from("txt") - }, - ], - tokenize(&MassRenameConfig::new(), "er,ea \"txt\"") - .unwrap() - .as_slice() - ); - } - - #[test] - fn insert_with_pattern_test() { - assert_eq!( - &[ - Token::Insert, - Token::Space, - Token::String { - value: String::from("text") - }, - Token::Space, - Token::End, - Token::Comma, - Token::Insert, - Token::Space, - Token::String { - value: String::from("text") - }, - Token::Space, - Token::Number { value: 0 } - ], - tokenize(&MassRenameConfig::new(), "i \"text\" end,i \"text\" 0") - .unwrap() - .as_slice() - ); - } -} diff --git a/src/ocd/mrn/mod.rs b/src/ocd/mrn/mod.rs index 047c069..7e753ad 100644 --- a/src/ocd/mrn/mod.rs +++ b/src/ocd/mrn/mod.rs @@ -1,154 +1,223 @@ -extern crate clap; -extern crate dialoguer; -extern crate glob; -extern crate walkdir; - -pub mod lexer; -pub mod parser; - -use self::walkdir::WalkDir; -use crate::ocd::config::{directory_value, mode_value, verbosity_value, Mode, Verbosity}; -use lazy_static::lazy_static; +//! Mass Re-Name +//! +//! This command implements a small interpreter with a number of shortcuts to +//! common filename manipulation actions. + +use crate::ocd::mrn::program::Instruction; +use crate::ocd::mrn::program::Position; +use crate::ocd::mrn::program::Program; +use crate::ocd::mrn::program::ReplaceArg; +use crate::ocd::Action; +use crate::ocd::Mode; +use crate::ocd::Plan; +use crate::ocd::Speaker; +use crate::ocd::Verbosity; +use clap::Args; +use clap::ValueEnum; +use heck::ToKebabCase; +use heck::ToSnakeCase; +use heck::ToTitleCase; +use heck::ToUpperCamelCase; use regex::Regex; -use std::collections::BTreeMap; use std::error::Error; use std::fs; -use std::fs::File; -use std::io::Write; use std::path::PathBuf; -use std::process::Command; - -#[derive(Debug, PartialEq)] -pub enum Position { - End, - Index { value: usize }, +use std::sync::LazyLock; +use walkdir::WalkDir; + +mod lalrpop; +mod pattern_match; +mod program; + +/// Arguments to the Mass Re-Name +#[derive(Clone, Debug, Args)] +#[command(args_conflicts_with_subcommands = true)] +pub(crate) struct MassRenameArgs { + #[arg(action = clap::ArgAction::Count)] + #[arg(help = r#"Sets the verbosity level. +Default is low, one medium, two high, three or more debug."#)] + #[arg(short = 'v')] + verbosity: u8, + + #[arg(help = "Silences all output.")] + #[arg(long)] + silent: bool, + + #[arg(default_value = "./")] + #[arg(help = "Run inside a given directory.")] + #[arg(long)] + #[arg(short = 'd')] + dir: PathBuf, + + #[arg(help = "Do not effect any changes on the filesystem.")] + #[arg(long = "dry-run")] + dry_run: bool, + + #[arg(help = "Create undo script.")] + #[arg(long)] + #[arg(short = 'u')] + undo: bool, + + #[arg(help = "Do not ask for confirmation.")] + #[arg(long)] + yes: bool, + + #[arg(help = "Rename files by calling `git mv`")] + #[arg(long)] + git: bool, + + #[arg(default_value = "files")] + #[arg(help = "Specified whether the rules are applied to directories, files or all.")] + #[arg(short = 'm')] + #[arg(long)] + mode: Mode, + + #[arg(default_value = "lalrpop")] + #[arg(help = "Specifies with parser to use.")] + #[arg(long)] + parser: crate::ocd::mrn::MassRenameParser, + + #[arg(help = "Recurse directories.")] + #[arg(long)] + #[arg(short = 'r')] + recurse: bool, + + #[arg(help = r#"The rewrite rules to apply to filenames. +The value is a comma-separated list of the following rules: +s Sanitize +cl Lower case +cu Upper case +ct Title case +cs Sentence case +jc Join camel case +jk Join kebab case +js Join snaje case +sc Split camel case +sk Split kebab case +ss Split snake case +r Replace with + and are both single-quote delimited strings +rdp Replace dashes with periods +rds Replace dashes with spaces +rdu Replace dashes with underscores +rpd Replace periods with dashes +rps Replace periods with spaces +rpu Replace periods with underscores +rsd Replace spaces with dashes +rsp Replace spaces with periods +rsu Replace spaces with underscores +rud Replace underscores with dashes +rup Replace underscores with periods +rus Replace underscores with spaces +i Insert at + is a single-quote delimited string + may be a non-negative integer or the keyword 'end' +d Delete from to + is a non-negative integer, + may be a non-negative integer or the keyword 'end' +ea Change the extension, or add it if the file has none. +er Remove the extension. +o Interactive reorder, see documentation on use. +p Pattern match, see documentation on use."#)] + input: String, + + #[arg( + help = r#"Operate only on files matching the glob pattern, e.g. `-g \"*.mp3\"`. +If --dir is specified as well it will be concatenated with the glob pattern. +If --recurse is also specified it will be ignored."# + )] + glob: Option, } -#[derive(Debug, PartialEq)] -pub enum Rule { - LowerCase, - UpperCase, - TitleCase, - SentenceCase, - CamelCaseJoin, - CamelCaseSplit, - Replace { pattern: String, replace: String }, - ReplaceSpaceDash, - ReplaceSpacePeriod, - ReplaceSpaceUnder, - ReplaceDashPeriod, - ReplaceDashSpace, - ReplaceDashUnder, - ReplacePeriodDash, - ReplacePeriodSpace, - ReplacePeriodUnder, - ReplaceUnderDash, - ReplaceUnderPeriod, - ReplaceUnderSpace, - Sanitize, - PatternMatch { pattern: String, replace: String }, - ExtensionAdd { extension: String }, - ExtensionRemove, - Insert { text: String, position: Position }, - InteractiveTokenize, - InteractivePatternMatch, - Delete { from: usize, to: Position }, +impl Speaker for MassRenameArgs { + fn verbosity(&self) -> Verbosity { + crate::ocd::Verbosity::new(self.silent, self.verbosity) + } } -#[derive(Clone, Debug)] -pub struct MassRenameConfig { - pub verbosity: Verbosity, - pub mode: Mode, - pub dir: PathBuf, - pub dryrun: bool, - pub git: bool, - pub recurse: bool, - pub undo: bool, - pub yes: bool, - pub glob: Option, - pub rules_raw: Option, +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, ValueEnum)] +enum MassRenameParser { + Handwritten, + Lalrpop, } -impl MassRenameConfig { - pub fn new() -> MassRenameConfig { - MassRenameConfig { - verbosity: Verbosity::Low, - mode: Mode::Files, - dir: PathBuf::new(), - dryrun: true, - git: false, - recurse: false, - undo: false, - yes: false, - glob: None, - rules_raw: None, - } +pub(crate) fn run(config: &MassRenameArgs) -> Result<(), Box> { + if config.verbosity() >= Verbosity::Silent { + println!("Verbosity: {:?}", config.verbosity()) } - pub fn with_args(&self, matches: &clap::ArgMatches) -> MassRenameConfig { - fn glob_value(glob: Option<&str>) -> Option { - glob.map(String::from) - } - - fn rules_value(matches: &clap::ArgMatches) -> Option { - matches - .value_of("rules") - .map(|rules_input| rules_input.to_string()) - } - - MassRenameConfig { - verbosity: verbosity_value(matches), - mode: mode_value(matches.value_of("mode").unwrap()), - dir: directory_value(matches.value_of("dir").unwrap()), - dryrun: matches.is_present("dry-run"), - git: matches.is_present("git"), - recurse: matches.is_present("recurse"), - undo: matches.is_present("undo"), - yes: matches.is_present("yes"), - glob: glob_value(matches.value_of("glob")), - rules_raw: rules_value(matches), - } + // Parse instructions + let program = match config.parser { + MassRenameParser::Handwritten => parse_with_handwritten(config)?, + MassRenameParser::Lalrpop => parse_with_lalrpop(config)?, + }; + if config.verbosity() >= Verbosity::Debug { + println!("Program: \n{:#?}", &program); } -} - -pub fn run(config: &MassRenameConfig) -> Result<(), Box> { - let rules_raw = config.rules_raw.clone().unwrap(); - let tokens = crate::ocd::mrn::lexer::tokenize(&config, &rules_raw)?; - let rules = crate::ocd::mrn::parser::parse(&config, &tokens)?; - let files = entries(&config)?; - crate::ocd::output::mrn_state(config, &tokens, &rules, &files); + // Initialize plan + let mut plan = create_plan(config)?; - let buffer = apply_rules(&config, &rules, &files)?; + // Apply intructions + apply_program(config, program, &mut plan)?; + if !config.verbosity().is_silent() { + plan.present_long() + } - if !config.dryrun && config.undo { - create_undo_script(config, &buffer); + // Maybe create undo script + if !config.dry_run && config.undo { + if !config.verbosity().is_silent() { + println!("Creating undo script."); + } + plan.create_undo()?; } - if config.yes || crate::ocd::input::user_confirm() { - execute_rules(&config, &buffer)? + // Skip if dry run, execute unconditionally or ask for confirmation + if !config.dry_run && (config.yes || crate::ocd::user_confirm()) { + plan.execute()?; } Ok(()) } -fn entries(config: &MassRenameConfig) -> Result, String> { - /* - recurse | glob | mode - F | none | f - F | none | m - F | none | b - F | some | f - F | some | m - F | some | b - T | none | f - T | none | m - T | none | b - T | some | f - T | some | m - T | some | b - */ - let mut entries_vec: Vec = Vec::new(); +fn parse_with_handwritten(_config: &MassRenameArgs) -> Result> { + todo!("Parsing with the handwritten parser is not implemented yet!") +} + +fn parse_with_lalrpop(config: &MassRenameArgs) -> Result> { + let lexer = crate::ocd::mrn::lalrpop::mrn_lexer::Lexer::new(&config.input); + let parser = crate::ocd::mrn::lalrpop::mrn_parser::ProgramParser::new(); + let instructions = parser.parse(lexer)?; + let mut program = Program::new(instructions); + program.check()?; + Ok(program) +} +fn create_plan(config: &MassRenameArgs) -> Result> { + let files = entries(config)?; + Ok(Plan::new().with_git(config.git).with_files(files)) +} + +/// Navigating the files to operate on differs depending on the combination of options that filter entries, which are: +/// - whether to recurse the directory tree or not +/// - whether a glob filter must be applied or not +/// - whether operations are to be applies to files, directories, or all +/// +/// recurse | glob | mode | case +/// --------|------|------|----- +/// F | none | f | 1 +/// F | none | d | 2 +/// F | none | a | 3 +/// T | none | f | 4 +/// T | none | d | 5 +/// T | none | a | 6 +/// F | some | f | 7 +/// T | some | f | 7 +/// F | some | d | 8 +/// T | some | d | 8 +/// T | some | a | 9 +/// F | some | a | 9 +fn entries(config: &MassRenameArgs) -> Result, Box> { + let mut entries_vec: Vec = Vec::new(); match (config.recurse, &config.glob, &config.mode) { (false, None, Mode::Files) => match fs::read_dir(&config.dir) { Ok(iterator) => { @@ -159,11 +228,13 @@ fn entries(config: &MassRenameConfig) -> Result, String> { entries_vec.push(file.path()); } } - Err(err) => return Err(format!("Error while listing files: {:?}", err)), + Err(err) => { + return Err(format!("Error while listing files: {:?}", err).into()) + } } } } - Err(err) => return Err(format!("Error while listing files: {:?}", err)), + Err(err) => return Err(format!("Error while listing files: {:?}", err).into()), }, (false, None, Mode::Directories) => match fs::read_dir(&config.dir) { Ok(iterator) => { @@ -174,11 +245,13 @@ fn entries(config: &MassRenameConfig) -> Result, String> { entries_vec.push(file.path()); } } - Err(err) => return Err(format!("Error while listing files: {:?}", err)), + Err(err) => { + return Err(format!("Error while listing files: {:?}", err).into()) + } } } } - Err(err) => return Err(format!("Error while listing files: {:?}", err)), + Err(err) => return Err(format!("Error while listing files: {:?}", err).into()), }, (false, None, Mode::All) => match fs::read_dir(&config.dir) { Ok(iterator) => { @@ -187,11 +260,11 @@ fn entries(config: &MassRenameConfig) -> Result, String> { Ok(file) => { entries_vec.push(file.path()); } - Err(_err) => return Err(String::from("Error while listing files")), + Err(_err) => return Err(String::from("Error while listing files").into()), } } } - Err(_err) => return Err(String::from("Error while listing files")), + Err(_err) => return Err(String::from("Error while listing files").into()), }, (true, None, Mode::Files) => { let iter = WalkDir::new(&config.dir).into_iter(); @@ -202,7 +275,7 @@ fn entries(config: &MassRenameConfig) -> Result, String> { entries_vec.push(entry.path().to_path_buf()); } } - Err(_err) => return Err(String::from("Error listing files")), + Err(_err) => return Err(String::from("Error listing files").into()), } } } @@ -215,7 +288,7 @@ fn entries(config: &MassRenameConfig) -> Result, String> { entries_vec.push(entry.path().to_path_buf()); } } - Err(_err) => return Err(String::from("Error listing files")), + Err(_err) => return Err(String::from("Error listing files").into()), } } } @@ -259,135 +332,123 @@ fn entries(config: &MassRenameConfig) -> Result, String> { Ok(entries_vec) } -fn apply_rules( - config: &MassRenameConfig, - rules: &[Rule], - files: &[PathBuf], -) -> Result, String> { - let mut buffer = new_buffer(files); - - for rule in rules { - for (index, (_src, mut dst)) in buffer.iter_mut().enumerate() { - apply_rule(index, &rule, &mut dst); +fn apply_program( + config: &MassRenameArgs, + program: Program, + plan: &mut Plan, +) -> Result<(), Box> { + for instruction in program.instructions() { + for (index, (src, action)) in plan.actions.iter_mut().enumerate() { + if config.verbosity() == Verbosity::Debug { + println!("Applying"); + println!(" instruction: {:?}", instruction); + println!(" index: {:?}", index); + println!(" src: {:?}", src); + println!(" action: {:?}", action); + } + apply_instruction(config, index, instruction, action); } } + plan.clean(); + Ok(()) +} - let clean_buffer = clean_buffer(buffer); - crate::ocd::output::mrn_result(config.verbosity, &clean_buffer); - Ok(clean_buffer) +fn apply_instruction( + config: &MassRenameArgs, + index: usize, + instruction: &Instruction, + action: &mut Action, +) { + if let Action::Rename { ref mut path } = action { + let filename = path.file_stem().unwrap(); + let filename = filename.to_str().unwrap(); + match instruction { + Instruction::Sanitize => { + let filename = apply_sanitize(filename); + crate::ocd::rename_file(path, filename); + } + Instruction::CaseLower => { + let filename = apply_lower_case(filename); + crate::ocd::rename_file(path, filename); + } + Instruction::CaseUpper => { + let filename = apply_upper_case(filename); + crate::ocd::rename_file(path, filename); + } + Instruction::CaseTitle => { + let filename = apply_title_case(filename); + crate::ocd::rename_file(path, filename); + } + Instruction::CaseSentence => { + let filename = apply_sentence_case(filename); + crate::ocd::rename_file(path, filename); + } + Instruction::JoinCamel => { + let filename = apply_join_camel_case(filename); + crate::ocd::rename_file(path, filename); + } + Instruction::JoinSnake => { + let filename = apply_join_snake_case(filename); + crate::ocd::rename_file(path, filename); + } + Instruction::JoinKebab => { + let filename = apply_join_kebab_case(filename); + crate::ocd::rename_file(path, filename); + } + Instruction::SplitCamel => { + let filename = apply_split_camel_case(filename); + crate::ocd::rename_file(path, filename); + } + Instruction::SplitSnake => { + let filename = apply_split_snake_case(filename); + crate::ocd::rename_file(path, filename); + } + Instruction::SplitKebab => { + let filename = apply_split_kebab_case(filename); + crate::ocd::rename_file(path, filename); + } + Instruction::Replace { pattern, replace } => { + let filename = apply_replace(filename, pattern, replace); + crate::ocd::rename_file(path, filename); + } + Instruction::Insert { position, text } => { + let filename = apply_insert(filename, text, position); + crate::ocd::rename_file(path, filename); + } + Instruction::Delete { from, to } => { + let filename = apply_delete(filename, *from, to); + crate::ocd::rename_file(path, filename); + } + Instruction::PatternMatch { + match_pattern: pattern, + replace_pattern: replace, + } => { + let filename = pattern_match::apply(config, index, filename, pattern, replace); + crate::ocd::rename_file(path, filename); + } + Instruction::ExtensionAdd(extension) => { + path.set_extension(extension); + } + Instruction::ExtensionRemove => { + path.set_extension(""); + } + Instruction::Reorder => { + let filename = apply_interactive_reorder(filename); + crate::ocd::rename_file(path, filename); + } + }; + } } -fn apply_rule(index: usize, rule: &Rule, path: &mut PathBuf) { - let filename = path.file_stem().unwrap(); - let filename = filename.to_str().unwrap(); - match rule { - Rule::LowerCase => { - let filename = apply_lower_case(filename); - rename_file(path, filename); - } - Rule::UpperCase => { - let filename = apply_upper_case(filename); - rename_file(path, filename); - } - Rule::TitleCase => { - let filename = apply_title_case(filename); - rename_file(path, filename); - } - Rule::SentenceCase => { - let filename = apply_sentence_case(filename); - rename_file(path, filename); - } - Rule::CamelCaseJoin => { - let filename = apply_camel_case_join(filename); - rename_file(path, filename); - } - Rule::CamelCaseSplit => { - let filename = apply_camel_case_split(filename); - rename_file(path, filename); - } - Rule::Sanitize => { - let filename = apply_sanitize(filename); - rename_file(path, filename); - } - Rule::Replace { pattern, replace } => { - let filename = apply_replace(filename, pattern, replace); - rename_file(path, filename); - } - Rule::ReplaceSpaceDash => { - let filename = apply_replace(filename, " ", "-"); - rename_file(path, filename); - } - Rule::ReplaceSpacePeriod => { - let filename = apply_replace(filename, " ", "."); - rename_file(path, filename); - } - Rule::ReplaceSpaceUnder => { - let filename = apply_replace(filename, " ", "_"); - rename_file(path, filename); - } - Rule::ReplaceDashPeriod => { - let filename = apply_replace(filename, "-", "."); - rename_file(path, filename); - } - Rule::ReplaceDashSpace => { - let filename = apply_replace(filename, "-", " "); - rename_file(path, filename); - } - Rule::ReplaceDashUnder => { - let filename = apply_replace(filename, "-", "_"); - rename_file(path, filename); - } - Rule::ReplacePeriodDash => { - let filename = apply_replace(filename, ".", "-"); - rename_file(path, filename); - } - Rule::ReplacePeriodSpace => { - let filename = apply_replace(filename, ".", " "); - rename_file(path, filename); - } - Rule::ReplacePeriodUnder => { - let filename = apply_replace(filename, ".", "_"); - rename_file(path, filename); - } - Rule::ReplaceUnderDash => { - let filename = apply_replace(filename, "_", "-"); - rename_file(path, filename); - } - Rule::ReplaceUnderPeriod => { - let filename = apply_replace(filename, "_", "."); - rename_file(path, filename); - } - Rule::ReplaceUnderSpace => { - let filename = apply_replace(filename, "_", " "); - rename_file(path, filename); - } - Rule::PatternMatch { pattern, replace } => { - let filename = apply_pattern_match(index, filename, pattern, replace); - rename_file(path, filename); - } - Rule::ExtensionAdd { extension } => { - path.set_extension(extension); - } - Rule::ExtensionRemove => { - path.set_extension(""); - } - Rule::Insert { text, position } => { - let filename = apply_insert(filename, text, position); - rename_file(path, filename); - } - Rule::InteractiveTokenize => { - let filename = apply_interactive_tokenize(filename); - rename_file(path, filename); - } - Rule::InteractivePatternMatch => { - let filename = apply_interactive_pattern_match(filename); - rename_file(path, filename); - } - Rule::Delete { from, to } => { - let filename = apply_delete(filename, *from, to); - rename_file(path, filename); - } +fn apply_sanitize(filename: &str) -> String { + static ALPHANUMERIC_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"([a-zA-Z0-9])+").unwrap()); + + let mut all = Vec::new(); + for capture in ALPHANUMERIC_REGEX.captures_iter(filename) { + all.push(String::from(&capture[0])); } + all.join(" ") } fn apply_lower_case(filename: &str) -> String { @@ -399,23 +460,23 @@ fn apply_upper_case(filename: &str) -> String { } fn apply_title_case(filename: &str) -> String { + // Original + // let mut titlecase_words = Vec::new(); + // for word in filename.split_whitespace() { + // let titlecase_word = titlecase_word(word); + // titlecase_words.push(titlecase_word); + // } + // titlecase_words.join(" ") + // An alternative is this single-line implementation: // voca_rs::case::title_case(filename) // but it doesn't have the same behavior. - let mut titlecase_words = Vec::new(); - for word in filename.split_whitespace() { - let titlecase_word = titlecase_word(word); - titlecase_words.push(titlecase_word); - } - titlecase_words.join(" ") + + filename.to_title_case() } fn apply_sentence_case(filename: &str) -> String { - // An alternative is this single-line implementation: - // voca_rs::case::capitalize(filename, true) - // but it doesn't have the same behavior. - // Split the words in the filename separated by whitespace, - // and collect them into a vector so we can call split_first() + // Original let words: Vec<&str> = filename.split_whitespace().collect(); if let Some((first_word, remaining_words)) = words.split_first() { let titlecase_word = titlecase_word(first_word); @@ -427,6 +488,12 @@ fn apply_sentence_case(filename: &str) -> String { } else { String::from(filename) } + + // An alternative is this single-line implementation: + // voca_rs::case::capitalize(filename, true) + // but it doesn't have the same behavior. + // Split the words in the filename separated by whitespace, + // and collect them into a vector so we can call split_first() } fn titlecase_word(word: &str) -> String { @@ -445,242 +512,44 @@ fn titlecase_word(word: &str) -> String { titlecase_word } -fn apply_camel_case_join(_filename: &str) -> String { - unimplemented!() +fn apply_join_camel_case(filename: &str) -> String { + filename.to_upper_camel_case() } -fn apply_camel_case_split(_filename: &str) -> String { - unimplemented!() +fn apply_join_snake_case(filename: &str) -> String { + filename.to_snake_case() } -fn apply_sanitize(filename: &str) -> String { - lazy_static! { - static ref ALPHANUMERIC_REGEX: Regex = Regex::new(r"([a-zA-Z0-9])+").unwrap(); - } - - let mut all = Vec::new(); - for capture in ALPHANUMERIC_REGEX.captures_iter(filename) { - all.push(String::from(&capture[0])); - } - all.join(" ") +fn apply_join_kebab_case(filename: &str) -> String { + filename.to_kebab_case() } -fn apply_replace(filename: &str, pattern: &str, replace: &str) -> String { - filename.replace(pattern, replace) +fn apply_split_camel_case(filename: &str) -> String { + filename.to_title_case() } -fn apply_pattern_match( - _index: usize, - filename: &str, - match_pattern: &str, - replace_pattern: &str, -) -> String { - fn month_to_number(month: &str) -> &str { - match month { - "jan" | "Jan" | "january" | "January" => "01", - "feb" | "Feb" | "february" | "February" => "02", - "mar" | "Mar" | "march" | "March" => "03", - "apr" | "Apr" | "april" | "April" => "04", - "may" | "May" => "05", - "jun" | "Jun" | "june" | "June" => "06", - "jul" | "Jul" | "july" | "July" => "07", - "aug" | "Aug" | "august" | "August" => "08", - "sep" | "Sep" | "september" | "September" => "09", - "oct" | "Oct" | "october" | "October" => "10", - "nov" | "Nov" | "november" | "November" => "11", - "dec" | "Dec" | "december" | "December" => "12", - unexpected => { - panic!("Unknown month value! {}", unexpected); - } - } - } - - crate::ocd::output::mrn_pattern_match( - Verbosity::Debug, - filename, - match_pattern, - replace_pattern, - ); +fn apply_split_snake_case(filename: &str) -> String { + filename.to_title_case() +} - lazy_static! { - static ref FLORB_REGEX: Regex = Regex::new(r"\{[aA]\}|\{[nN]\}|\{[xX]\}|\{[dD]\}").unwrap(); - } +fn apply_split_kebab_case(filename: &str) -> String { + filename.to_title_case() +} - let florbs: Vec<&str> = FLORB_REGEX - .captures_iter(&match_pattern) - .map(|c: regex::Captures| c.get(0).unwrap().as_str()) - .collect(); - - // println!("florbs in match pattern: {:?}", florbs); - let mut match_pattern = String::from(match_pattern); - match_pattern.insert(0, '^'); - match_pattern.push('$'); - let match_pattern = match_pattern.replace(".", r"\."); - let match_pattern = match_pattern.replace("[", r"\["); - let match_pattern = match_pattern.replace("]", r"\]"); - let match_pattern = match_pattern.replace("(", r"\("); - let match_pattern = match_pattern.replace(")", r"\)"); - let match_pattern = match_pattern.replace("?", r"\?"); - let match_pattern = match_pattern.replace("{A}", r"([[:alpha:]]*)"); // Alphabetic - let match_pattern = match_pattern.replace("{N}", r"([[:digit:]]*)"); // Digits - let match_pattern = match_pattern.replace("{X}", r"(.*)"); // Anything - let date_regex = r"((?:\d{1,2})\s(?i:January|February|March|April|May|June|July|August|September|October|November|December)\s(?:\d{1,4}))"; - let match_pattern = match_pattern.replace("{D}", date_regex); // Date - // println!("match pattern after replacement: {:?}", match_pattern); - - // TODO Replace data generators - // n = n.replace("{date}", time.strftime("%Y-%m-%d", time.localtime())) - // n = n.replace("{year}", time.strftime("%Y", time.localtime())) - // n = n.replace("{month}", time.strftime("%m", time.localtime())) - // n = n.replace("{monthname}", time.strftime("%B", time.localtime())) - // n = n.replace("{monthsimp}", time.strftime("%b", time.localtime())) - // n = n.replace("{day}", time.strftime("%d", time.localtime())) - // n = n.replace("{dayname}", time.strftime("%A", time.localtime())) - // n = n.replace("{daysimp}", time.strftime("%a", time.localtime())) - - // TODO Replace random number generators - // # Replace {rand} with random number between 0 and 100. - // # If {rand500} the number will be between 0 and 500 - // # If {rand10-20} the number will be between 10 and 20 - // # If you add ,[ 5 the number will be padded with 5 digits - // # ie. {rand20,5} will be a number between 0 and 20 of 5 digits (00012) - // rnd = "" - // cr = re.compile("{(rand)([0-9]*)}" - // "|{(rand)([0-9]*)(\-)([0-9]*)}" - // "|{(rand)([0-9]*)(\,)([0-9]*)}" - // "|{(rand)([0-9]*)(\-)([0-9]*)(\,)([0-9]*)}") - // cg = cr.search(newname).groups() - // if len(cg) == 16: - // if (cg[0] == "rand"): - // if (cg[1] == ""): - // # {rand} - // rnd = random.randint(0, 100) - // else: - // # {rand2} - // rnd = random.randint(0, int(cg[1])) - // elif rand_case_1(cg): - // # {rand10-100} - // rnd = random.randint(int(cg[3]), int(cg[5])) - // elif rand_case_2(cg): - // if (cg[7] == ""): - // # {rand,2} - // rnd = str(random.randint(0, 100)).zfill(int(cg[9])) - // else: - // # {rand10,2} - // rnd = str(random.randint(0, int(cg[7]))).zfill(int(cg[9])) - // elif rand_case_3(cg): - // # {rand2-10,3} - // s = str(random.randint(int(cg[11]), int(cg[13]))) - // rnd = s.zfill(int(cg[15])) - // newname = cr.sub(str(rnd), newname) - - // TODO Replace sequential number generators - // # Replace {num} with item number. - // # If {num2} the number will be 02 - // # If {num3+10} the number will be 010 - // count = str(count) - // cr = re.compile("{(num)([0-9]*)}|{(num)([0-9]*)(\+)([0-9]*)}") - // cg = cr.search(newname).groups() - // if len(cg) == 6: - // if cg[0] == "num": - // # {num2} - // if cg[1] != "": - // count = count.zfill(int(cg[1])) - // newname = cr.sub(count, newname) - // elif cg[2] == "num" and cg[4] == "+": - // # {num2+5} - // if cg[5] != "": - // count = str(int(count)+int(cg[5])) - // if cg[3] != "": - // count = count.zfill(int(cg[3])) - // newname = cr.sub(count, newname) - - let match_regex = Regex::new(&match_pattern).unwrap(); - match match_regex.captures(&filename) { - None => { - println!("No match on {:?}", filename); - String::from(filename) - } - Some(capture) => { - let mut replace_pattern = replace_pattern.to_string(); - let mut ci = 1; - for (fi, f) in florbs.iter().enumerate() { - let mark = format!("{{{}}}", fi + 1); - match *f { - "{A}" | "{N}" | "{X}" => { - let content = capture.get(ci).unwrap().as_str(); - replace_pattern = replace_pattern.replace(&mark, &content); - ci += 1; - } - "{D}" => { - lazy_static! { - // This regex recognizes human-readable dates and its subparts - static ref IOS_DATE_FORMAT_REGEX: Regex = Regex::new(r"(?i)(?P\d{1,2})\s(?PJanuary|February|March|April|May|June|July|August|September|October|November|December)\s(?P\d{1,4})").unwrap(); - } - // println!(" capture: {:?}", capture); - // println!(" ci: {}", ci); - let date_text = capture.get(ci).unwrap().as_str(); - // println!(" date_text: {:?}", date_text); - let date_capture = IOS_DATE_FORMAT_REGEX.captures(date_text).unwrap(); - // println!(" date_capture: {:?}", date_capture); - let day_text = format!( - "{:02}", - date_capture - .name("d") - .unwrap() - .as_str() - .parse::() - .unwrap() - ); - let month_text = month_to_number(date_capture.name("m").unwrap().as_str()); - let year_text = format!( - "{:02}", - date_capture - .name("y") - .unwrap() - .as_str() - .parse::() - .unwrap() - ); - // let content = date_capture.get(ci).unwrap().as_str(); - let mut content = String::new(); - content.push_str(&year_text); - content.push('-'); - content.push_str(&month_text); - content.push('-'); - content.push_str(&day_text); - // println!(" content: {:?}", content); - replace_pattern = replace_pattern.replace(&mark, &content); - ci += 1; - } - _ => { - panic!("Unrecognized florb!"); - } - } - } - replace_pattern - } - } +fn apply_replace(filename: &str, pattern: &ReplaceArg, replace: &ReplaceArg) -> String { + filename.replace(pattern.as_str(), replace.as_str()) } fn apply_insert(filename: &str, text: &str, position: &Position) -> String { let mut new = String::from(filename); match position { Position::End => new.push_str(text), - Position::Index { value: index } if index >= &new.len() => new.push_str(text), - Position::Index { value: index } => new.insert_str(*index, text), + Position::Index(index) if index >= &new.len() => new.push_str(text), + Position::Index(index) => new.insert_str(*index, text), } new } -fn apply_interactive_tokenize(_filename: &str) -> String { - unimplemented!() -} - -fn apply_interactive_pattern_match(_filename: &str) -> String { - unimplemented!() -} - fn apply_delete(filename: &str, from_idx: usize, to: &Position) -> String { // This was the previous implementation: // let mut filename2 = String::new(); @@ -702,7 +571,7 @@ fn apply_delete(filename: &str, from_idx: usize, to: &Position) -> String { // filename2 let to_idx = match *to { Position::End => filename.len(), - Position::Index { value } => { + Position::Index(value) => { if value > filename.len() { filename.len() } else { @@ -715,99 +584,21 @@ fn apply_delete(filename: &str, from_idx: usize, to: &Position) -> String { s } -fn create_undo_script(config: &MassRenameConfig, buffer: &BTreeMap) { - if !config.verbosity.is_silent() { - println!("Creating undo script."); - match File::create("./undo.sh") { - Ok(mut output_file) => { - for (src, dst) in buffer { - let result = if config.git { - writeln!(output_file, "git mv {:?} {:?}", dst, src) - } else { - writeln!(output_file, "mv -i {:?} {:?}", dst, src) - }; - if let Err(reason) = result { - eprintln!("Error writing to undo file: {:?}", reason); - } - } - } - Err(reason) => { - eprintln!("Error creating undo file: {:?}", reason); - } - } - } -} - -fn execute_rules( - config: &MassRenameConfig, - buffer: &BTreeMap, -) -> Result<(), String> { - for (src, dst) in buffer { - crate::ocd::output::file_move(config.verbosity, src, dst); - if !config.dryrun { - if config.git { - let src = src.to_str().unwrap(); - let dst = dst.to_str().unwrap(); - let _output = Command::new("git") - .args(&["mv", src, dst]) - .output() - .expect("Error invoking git."); - // TODO: do something with output - } else { - match fs::rename(src, dst) { - Ok(_) => {} - Err(reason) => { - eprintln!("Error moving file: {:?}", reason); - return Err(String::from("Error moving file.")); - } - } - } - } - } - Ok(()) -} - -fn new_buffer(files: &[PathBuf]) -> BTreeMap { - let mut buffer = BTreeMap::new(); - for file in files { - buffer.insert(file.clone(), file.clone()); - } - buffer -} - -fn clean_buffer(dirty_buffer: BTreeMap) -> BTreeMap { - let mut buffer = BTreeMap::new(); - for (src, dst) in dirty_buffer.iter().filter(|(src, dst)| src != dst) { - buffer.insert(src.clone(), dst.clone()); - } - buffer -} - -fn rename_file(path: &mut PathBuf, filename: String) { - let extension = match path.extension() { - None => String::new(), - Some(extension) => String::from(extension.to_str().unwrap()), - }; - path.set_file_name(filename); - path.set_extension(extension); +fn apply_interactive_reorder(_filename: &str) -> String { + // split filename into substrings + // print each substring with its index below + // read user input + let _input = crate::ocd::user_input(); + // process input into a series of indices + // generate new string + todo!("Interactive reorder instruction not implemented yet!") } #[cfg(test)] mod test { - // use crate::ocd::mrn::apply_camel_case_join; - // use crate::ocd::mrn::apply_camel_case_split; - use crate::ocd::mrn::apply_delete; - use crate::ocd::mrn::apply_insert; - use crate::ocd::mrn::apply_lower_case; - use crate::ocd::mrn::apply_pattern_match; - use crate::ocd::mrn::apply_replace; - use crate::ocd::mrn::apply_sanitize; - use crate::ocd::mrn::apply_sentence_case; - use crate::ocd::mrn::apply_title_case; - use crate::ocd::mrn::apply_upper_case; - use crate::ocd::mrn::Position; - - macro_rules! t { + use super::*; + + macro_rules! test { ($t:ident : $s1:expr => $s2:expr) => { #[test] fn $t() { @@ -816,87 +607,66 @@ mod test { }; } - // t!(test3: "MixedUP CamelCase, with some Spaces" => "Mixed Up Camel Case With Some Spaces"); - // t!(test4: "mixed_up_ snake_case, with some _spaces" => "Mixed Up Snake Case With Some Spaces"); - // t!(test5: "kebab-case" => "Kebab Case"); - // t!(test6: "SHOUTY_SNAKE_CASE" => "Shouty Snake Case"); - // t!(test7: "snake_case" => "Snake Case"); - // t!(test8: "this-contains_ ALLKinds OfWord_Boundaries" => "This Contains All Kinds Of Word Boundaries"); - - t!(lower_case_test: + test!(lower_case: apply_lower_case("LoWeRcAsE") => "lowercase"); - t!(upper_case_test: + test!(upper_case_test: apply_upper_case("UpPeRcAsE") => "UPPERCASE"); - t!(title_case_test_1: - apply_title_case("A tItLe HaS mUlTiPlE wOrDs") => "A Title Has Multiple Words"); - t!(title_case_test_2: - apply_title_case("XΣXΣ baffle") => "Xσxσ Baffle"); - t!(sentence_case_test_1: + // test!(title_case_test_1: + // apply_title_case("A tItLe HaS mUlTiPlE wOrDs") => "A Title Has Multiple Words"); + test!(sentence_case_test_1: apply_sentence_case("A sEnTeNcE HaS mUlTiPlE wOrDs") => "A sentence has multiple words"); - t!(sentence_case_test_2: + test!(sentence_case_test_2: apply_sentence_case("a sentence has multiple words") => "A sentence has multiple words"); - t!(sentence_case_test_3: + test!(sentence_case_test_3: apply_sentence_case("A SENTENCE HAS MULTIPLE WORDS") => "A sentence has multiple words"); - t!(sentence_case_test_4: + test!(sentence_case_test_4: apply_sentence_case("A sEnTeNcE HaS mUlTiPlE wOrDs") => "A sentence has multiple words"); - // t!(camel_case_join_test: - // apply_camel_case_join("Camel case Join") => "CamelCaseJoin"); - // t!(camel_case_split_test_1: - // apply_camel_case_split("CamelCase") => "Camel Case"); - // t!(camel_case_split_test_2: - // apply_camel_case_split("CamelCaseSplit") => "Camel Case Split"); - // t!(camel_case_split_test_3: - // apply_camel_case_split("XMLHttpRequest") => "Xml Http Request"); - t!(replace_test: - apply_replace("aa bbccdd ee", "cc", "ff") => "aa bbffdd ee"); - t!(replace_space_dash_test: - apply_replace("aa bb cc dd", " ", "-") => "aa-bb-cc-dd"); - t!(replace_space_period_test: - apply_replace("aa bb cc dd", " ", ".") => "aa.bb.cc.dd"); - t!(replace_space_under_test: - apply_replace("aa bb cc dd", " ", "_") => "aa_bb_cc_dd"); - t!(replace_dash_period_test: - apply_replace("aa-bb-cc-dd", "-", ".") => "aa.bb.cc.dd"); - t!(replace_dash_space_test: - apply_replace("aa-bb-cc-dd", "-", " ") => "aa bb cc dd"); - t!(replace_dash_under_test: - apply_replace("aa-bb-cc-dd", "-", "_") => "aa_bb_cc_dd"); - t!(replace_period_dash_test: - apply_replace("aa.bb.cc.dd", ".", "-") => "aa-bb-cc-dd"); - t!(replace_period_space_test: - apply_replace("aa.bb.cc.dd", ".", " ") => "aa bb cc dd"); - t!(replace_period_under_test: - apply_replace("aa.bb.cc.dd", ".", "_") => "aa_bb_cc_dd"); - t!(replace_under_dash_test: - apply_replace("aa_bb_cc_dd", "_", "-") => "aa-bb-cc-dd"); - t!(replace_under_period_test: - apply_replace("aa_bb_cc_dd", "_", ".") => "aa.bb.cc.dd"); - t!(replace_under_space_test: - apply_replace("aa_bb_cc_dd", "_", " ") => "aa bb cc dd"); - t!(pattern_match_test_1: - apply_pattern_match(0, "aa bb", "{X} {X}", "{2} {1}") => "bb aa"); - t!(pattern_match_test_2: - apply_pattern_match(0, "Dave Brubeck - 01. Take five", "{X} - {N}. {X}", "{1} {2} {3}") => "Dave Brubeck 01 Take five"); - t!(pattern_match_test_3: - apply_pattern_match(0, "Bahia Blanca, 21 October 2019", "{X}, {D}", "{1} {2}") => "Bahia Blanca 2019-10-21"); - t!(pattern_match_test_4: - apply_pattern_match(0, "Foo 123 B_a_r", "{A} {N} {X}", "{3} {2} {1}") => "B_a_r 123 Foo"); - t!(pattern_match_test_5: - apply_pattern_match(0, "Bahia Blanca, 21 October 2019", "{X}, {D}", "{2} {1}") => "2019-10-21 Bahia Blanca"); - t!(pattern_match_test_6: - apply_pattern_match(0, "Bahia Blanca, 21 October 2019, FooBarBaz", "{X}, {D}, {X}", "{2} {1} {3}") => "2019-10-21 Bahia Blanca FooBarBaz"); - t!(insert_test_1: + test!(camel_case_join_test: + apply_join_camel_case("Camel case Join") => "CamelCaseJoin"); + test!(camel_case_split_test_1: + apply_split_camel_case("CamelCase") => "Camel Case"); + test!(camel_case_split_test_2: + apply_split_camel_case("CamelCaseSplit") => "Camel Case Split"); + test!(camel_case_split_test_3: + apply_split_camel_case("XMLHttpRequest") => "Xml Http Request"); + test!(insert_test_1: apply_insert("aa bb", " cc", &Position::End) => "aa bb cc"); - t!(insert_test_2: - apply_insert("aa bb", " cc", &Position::Index { value: 2 }) => "aa cc bb"); - t!(insert_test_3: - apply_insert("aa bb", "cc ", &Position::Index { value: 0 }) => "cc aa bb"); - t!(sanitize_test: + test!(insert_test_2: + apply_insert("aa bb", " cc", &Position::Index(2)) => "aa cc bb"); + test!(insert_test_3: + apply_insert("aa bb", "cc ", &Position::Index(0)) => "cc aa bb"); + test!(sanitize_test: apply_sanitize("04 Three village scenes_ Lakodalom [BB 87_B]") => "04 Three village scenes Lakodalom BB 87 B"); - t!(delete_test_1: + test!(delete_test_1: apply_delete("aa bb cc", 0, &Position::End) => ""); - t!(delete_test_2: - apply_delete("aa bb cc", 0, &Position::Index { value: 3 }) => "bb cc"); - t!(delete_test_3: - apply_delete("aa bb cc", 0, &Position::Index { value: 42 }) => ""); + test!(delete_test_2: + apply_delete("aa bb cc", 0, &Position::Index(3)) => "bb cc"); + test!(delete_test_3: + apply_delete("aa bb cc", 0, &Position::Index(42)) => ""); + test!(replace_test: + apply_replace("aa bbccdd ee", &ReplaceArg::Text("cc".to_string()), &ReplaceArg::Text("ff".to_string())) => "aa bbffdd ee"); + test!(replace_space_dash_test: + apply_replace("aa bb cc dd", &ReplaceArg::Space, &ReplaceArg::Dash) => "aa-bb-cc-dd"); + test!(replace_space_period_test: + apply_replace("aa bb cc dd", &ReplaceArg::Space, &ReplaceArg::Period) => "aa.bb.cc.dd"); + test!(replace_space_under_test: + apply_replace("aa bb cc dd", &ReplaceArg::Space, &ReplaceArg::Underscore) => "aa_bb_cc_dd"); + test!(replace_dash_period_test: + apply_replace("aa-bb-cc-dd", &ReplaceArg::Dash, &ReplaceArg::Period) => "aa.bb.cc.dd"); + test!(replace_dash_space_test: + apply_replace("aa-bb-cc-dd", &ReplaceArg::Dash, &ReplaceArg::Space) => "aa bb cc dd"); + test!(replace_dash_under_test: + apply_replace("aa-bb-cc-dd", &ReplaceArg::Dash, &ReplaceArg::Underscore) => "aa_bb_cc_dd"); + test!(replace_period_dash_test: + apply_replace("aa.bb.cc.dd", &ReplaceArg::Period, &ReplaceArg::Dash) => "aa-bb-cc-dd"); + test!(replace_period_space_test: + apply_replace("aa.bb.cc.dd", &ReplaceArg::Period, &ReplaceArg::Space) => "aa bb cc dd"); + test!(replace_period_under_test: + apply_replace("aa.bb.cc.dd", &ReplaceArg::Period, &ReplaceArg::Underscore) => "aa_bb_cc_dd"); + test!(replace_under_dash_test: + apply_replace("aa_bb_cc_dd", &ReplaceArg::Underscore, &ReplaceArg::Dash) => "aa-bb-cc-dd"); + test!(replace_under_period_test: + apply_replace("aa_bb_cc_dd", &ReplaceArg::Underscore, &ReplaceArg::Period) => "aa.bb.cc.dd"); + test!(replace_under_space_test: + apply_replace("aa_bb_cc_dd", &ReplaceArg::Underscore, &ReplaceArg::Space) => "aa bb cc dd"); } diff --git a/src/ocd/mrn/parser.rs b/src/ocd/mrn/parser.rs deleted file mode 100644 index 8b92277..0000000 --- a/src/ocd/mrn/parser.rs +++ /dev/null @@ -1,816 +0,0 @@ -use crate::ocd::mrn::lexer::Token; -use crate::ocd::mrn::{Position, Rule}; - -pub fn parse( - _config: &crate::ocd::mrn::MassRenameConfig, - tokens: &[crate::ocd::mrn::lexer::Token], -) -> Result, String> { - let mut rules = Vec::new(); - match tokens.len() { - 0 => Ok(rules), - 1 => { - parse_rules(&tokens[0], &[], &mut rules)?; - Ok(rules) - } - 2 => Err(String::from("Error: unexpected token")), - _ => { - parse_rules(&tokens[0], &tokens[1..], &mut rules)?; - Ok(rules) - } - } -} - -fn parse_rules<'a, 'b>( - token: &Token, - tokens: &'a [Token], - rules: &'b mut Vec, -) -> Result<&'a [Token], String> { - let tokens = parse_rule(token, tokens, rules)?; - match tokens.len() { - 0 => Ok(tokens), - 1 => Err(String::from("Syntax error: unexpected token")), - _ => match tokens[0] { - Token::Comma => { - let tokens = parse_rules(&tokens[1], &tokens[2..], rules)?; - Ok(tokens) - } - _ => Err(String::from("Syntax error: unexpected token")), - }, - } -} - -fn parse_rule<'a, 'b>( - token: &Token, - tokens: &'a [Token], - rules: &'b mut Vec, -) -> Result<&'a [Token], String> { - match *token { - Token::Comma => return Err(String::from("Syntax error: unexpected comma")), - Token::Space => return Err(String::from("Syntax error: unexpected space")), - Token::End => return Err(String::from("Syntax error: unexpected end keyword")), - Token::String { value: ref _value } => { - return Err(String::from("Syntax error: unexpected string")) - } - Token::Number { value: _value } => { - return Err(String::from("Syntax error: unexpected number")) - } - Token::LowerCase => { - rules.push(Rule::LowerCase); - } - Token::UpperCase => { - rules.push(Rule::UpperCase); - } - Token::TitleCase => { - rules.push(Rule::TitleCase); - } - Token::SentenceCase => { - rules.push(Rule::SentenceCase); - } - Token::CamelCaseJoin => { - rules.push(Rule::CamelCaseJoin); - } - Token::CamelCaseSplit => { - rules.push(Rule::CamelCaseSplit); - } - Token::ExtensionAdd => match tokens.len() { - 0 => { - return Err(String::from( - "Syntax error: insufficient parameters for extension add", - )) - } - _ => { - let tokens = parse_extension_add(&tokens[0], &tokens[1..], rules)?; - return Ok(tokens); - } - }, - Token::ExtensionRemove => { - rules.push(Rule::ExtensionRemove); - } - Token::PatternMatch => { - if tokens.is_empty() { - return Err(String::from( - "Syntax error: insufficient parameters for pattern match", - )); - } else { - let tokens = parse_pattern_match(&tokens[0], &tokens[1..], rules)?; - return Ok(tokens); - } - } - Token::Insert => { - if tokens.is_empty() { - return Err(String::from( - "Syntax error: insufficient parameters for insert", - )); - } else { - let tokens = parse_insert(&tokens[0], &tokens[1..], rules)?; - return Ok(tokens); - } - } - Token::InteractiveTokenize => { - rules.push(Rule::InteractiveTokenize); - } - Token::InteractivePatternMatch => { - rules.push(Rule::InteractivePatternMatch); - } - Token::Delete => { - if tokens.is_empty() { - return Err(String::from( - "Syntax error: insufficient parameters for delete", - )); - } else { - let tokens = parse_delete(&tokens[0], &tokens[1..], rules)?; - return Ok(tokens); - } - } - Token::Replace => { - if tokens.is_empty() { - return Err(String::from( - "Syntax error: insufficient parameters for replace", - )); - } else { - let tokens = parse_replace(&tokens[0], &tokens[1..], rules)?; - return Ok(tokens); - } - } - Token::Sanitize => { - rules.push(Rule::Sanitize); - } - Token::ReplaceSpaceDash => { - rules.push(Rule::ReplaceSpaceDash); - } - Token::ReplaceSpacePeriod => { - rules.push(Rule::ReplaceSpacePeriod); - } - Token::ReplaceSpaceUnder => { - rules.push(Rule::ReplaceSpaceUnder); - } - Token::ReplaceDashPeriod => { - rules.push(Rule::ReplaceDashPeriod); - } - Token::ReplaceDashSpace => { - rules.push(Rule::ReplaceDashSpace); - } - Token::ReplaceDashUnder => { - rules.push(Rule::ReplaceDashUnder); - } - Token::ReplacePeriodDash => { - rules.push(Rule::ReplacePeriodDash); - } - Token::ReplacePeriodSpace => { - rules.push(Rule::ReplacePeriodSpace); - } - Token::ReplacePeriodUnder => { - rules.push(Rule::ReplacePeriodUnder); - } - Token::ReplaceUnderDash => { - rules.push(Rule::ReplaceUnderDash); - } - Token::ReplaceUnderPeriod => { - rules.push(Rule::ReplaceUnderPeriod); - } - Token::ReplaceUnderSpace => { - rules.push(Rule::ReplaceUnderSpace); - } - } - Ok(tokens) -} - -fn parse_pattern_match<'a, 'b>( - token: &Token, - tokens: &'a [Token], - rules: &'b mut Vec, -) -> Result<&'a [Token], String> { - match token { - Token::Space => { - if tokens.is_empty() { - Err(String::from("Syntax error: pattern match expected a space")) - } else { - match &tokens[0] { - Token::String { - value: ref match_pattern, - } => { - let tokens = &tokens[1..]; - if tokens.is_empty() { - Err(String::from( - "Syntax error: pattern match expected a space after the pattern", - )) - } else { - match &tokens[0] { - Token::Space => { - let tokens = &tokens[1..]; - if tokens.is_empty() { - Err(String::from( - "Synatx error: pattern match expected a second string", - )) - } else { - match &tokens[0] { - Token::String{value: ref replace_pattern} => { - let mp = match_pattern.to_string(); - let rp = replace_pattern.to_string(); - rules.push(Rule::PatternMatch{pattern: mp, replace: rp}); - Ok(&tokens[1..]) - }, - _ => Err(String::from("Syntax error: pattern match expected a second string")) - } - } - } - _ => Err(String::from( - "Syntax error: pattern match expected a space between patterns", - )), - } - } - } - _ => Err(String::from("Syntax error: pattern expected string")), - } - } - } - _ => Err(String::from("Syntax error: expected space")), - } -} - -fn parse_extension_add<'a, 'b>( - token: &Token, - tokens: &'a [Token], - rules: &'b mut Vec, -) -> Result<&'a [Token], String> { - match token { - Token::Space => { - if tokens.is_empty() { - Err(String::from( - "Syntax error: extensinon add expected a string", - )) - } else { - match tokens[0] { - Token::String { - value: ref extension, - } => { - let extension = extension.to_string(); - rules.push(Rule::ExtensionAdd { extension }); - Ok(&tokens[1..]) - } - _ => Err(String::from( - "Syntax error: extension add expected a string", - )), - } - } - } - _ => Err(String::from("Syntax error: extension add expected a space")), - } -} - -fn parse_insert<'a, 'b>( - token: &Token, - tokens: &'a [Token], - rules: &'b mut Vec, -) -> Result<&'a [Token], String> { - match token { - Token::Space => { - if tokens.is_empty() { - Err(String::from("Syntax error: insert expected a string")) - } else { - match tokens[0] { - Token::String { value: ref text } => { - let tokens = &tokens[1..]; - if tokens.is_empty() { - Err(String::from("Syntax error: insert expected a space")) - } else { - match tokens[0] { - Token::Space => { - let tokens = &tokens[1..]; - if tokens.is_empty() { - Err(String::from( - "Syntax error: insert expected an index or end keyword", - )) - } else { - match &tokens[0] { - Token::End => { - rules.push(Rule::Insert{text: text.to_string(), position: Position::End}); - Ok(&tokens[1..]) - }, - &Token::Number{value: position} => { - rules.push(Rule::Insert{text: text.to_string(), position: Position::Index{value: position}}); - Ok(&tokens[1..]) - }, - _ => Err(String::from("Syntax error: insert expected an index or end keyword")) - } - } - } - _ => Err(String::from("Syntax error: inssert expected a space")), - } - } - } - _ => Err(String::from("Syntax error: insert expected a string")), - } - } - } - _ => Err(String::from("Syntax error: insert expected a space")), - } -} - -fn parse_delete<'a, 'b>( - token: &Token, - tokens: &'a [Token], - rules: &'b mut Vec, -) -> Result<&'a [Token], String> { - match token { - Token::Space => { - if tokens.is_empty() { - Err(String::from( - "Syntax error: delete expected an index number", - )) - } else { - match tokens[0] { - Token::Number { value: from } => { - let tokens = &tokens[1..]; - if tokens.is_empty() { - Err(String::from("Syntax error: delete expected a space")) - } else { - match tokens[0] { - Token::Space => { - let tokens = &tokens[1..]; - if tokens.is_empty() { - Err(String::from( - "Syntax error: delete expected a position", - )) - } else { - match &tokens[0] { - Token::End => { - rules.push(Rule::Delete{from, to: Position::End}); - Ok(&tokens[1..]) - }, - &Token::Number{value: to} => { - rules.push(Rule::Delete{from, to: Position::Index{value: to}}); - Ok(&tokens[1..]) - }, - _ => Err(String::from("Syntax error: delete expected either end of an index number")) - } - } - } - _ => Err(String::from("Syntax error: delete expected a space")), - } - } - } - _ => Err(String::from( - "Syntax error: delete expected an index number", - )), - } - } - } - _ => Err(String::from("Syntax error: delete expected a space")), - } -} - -fn parse_replace<'a, 'b>( - token: &Token, - tokens: &'a [Token], - rules: &'b mut Vec, -) -> Result<&'a [Token], String> { - match token { - Token::Space => { - if tokens.is_empty() { - Err(String::from("Syntax error: replace expected a string")) - } else { - match tokens[0] { - Token::String { value: ref string1 } => { - let tokens = &tokens[1..]; - if tokens.is_empty() { - Err(String::from("Syntax error: replace expected a space")) - } else { - match tokens[0] { - Token::Space => { - let tokens = &tokens[1..]; - if tokens.is_empty() { - Err(String::from( - "Syntax error: replace expected a second string", - )) - } else { - match tokens[0] { - Token::String { value: ref string2 } => { - let pattern = string1.to_string(); - let replace = string2.to_string(); - rules.push(Rule::Replace { pattern, replace }); - Ok(&tokens[1..]) - } - _ => Err(String::from( - "Syntax error: replace expected a second string", - )), - } - } - } - _ => Err(String::from("Syntax error: replace expected a space")), - } - } - } - _ => Err(String::from("Syntax error: replace expected a string")), - } - } - } - _ => Err(String::from("Syntax error: replace expected a space")), - } -} - -#[cfg(test)] -mod test { - use crate::ocd::mrn::lexer::tokenize; - use crate::ocd::mrn::parser::parse; - use crate::ocd::mrn::MassRenameConfig; - use crate::ocd::mrn::{Position, Rule}; - - #[test] - fn empty_test() { - let config = MassRenameConfig::new(); - let empty: [Rule; 0] = []; - assert_eq!( - &empty, - parse(&config, &tokenize(&config, &"").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn lower_case_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::LowerCase], - parse(&config, &tokenize(&config, &"lc").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn upper_case_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::UpperCase], - parse(&config, &tokenize(&config, &"uc").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn title_case_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::TitleCase], - parse(&config, &tokenize(&config, &"tc").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn sentence_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::SentenceCase], - parse(&config, &tokenize(&config, &"sc").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn camel_case_join_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::CamelCaseJoin], - parse(&config, &tokenize(&config, &"ccj").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn camel_case_split_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::CamelCaseSplit], - parse(&config, &tokenize(&config, &"ccs").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn sanitize_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::Sanitize], - parse(&config, &tokenize(&config, &"s").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn replace_space_dash_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::ReplaceSpaceDash], - parse(&config, &tokenize(&config, &"sd").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn replace_space_period_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::ReplaceSpacePeriod], - parse(&config, &tokenize(&config, &"sp").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn replace_space_under_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::ReplaceSpaceUnder], - parse(&config, &tokenize(&config, &"su").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn replace_dash_period_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::ReplaceDashPeriod], - parse(&config, &tokenize(&config, &"dp").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn replace_dash_space_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::ReplaceDashSpace], - parse(&config, &tokenize(&config, &"ds").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn replace_dash_under_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::ReplaceDashUnder], - parse(&config, &tokenize(&config, &"du").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn replace_period_dash_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::ReplacePeriodDash], - parse(&config, &tokenize(&config, &"pd").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn replace_period_space_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::ReplacePeriodSpace], - parse(&config, &tokenize(&config, &"ps").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn replace_period_under_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::ReplacePeriodUnder], - parse(&config, &tokenize(&config, &"pu").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn replace_under_dash_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::ReplaceUnderDash], - parse(&config, &tokenize(&config, &"ud").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn replace_under_period_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::ReplaceUnderPeriod], - parse(&config, &tokenize(&config, &"up").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn replace_under_space_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::ReplaceUnderSpace], - parse(&config, &tokenize(&config, &"us").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn interactive_tokenize_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::InteractiveTokenize], - parse(&config, &tokenize(&config, &"it").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn interactive_pattern_match_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::InteractivePatternMatch], - parse(&config, &tokenize(&config, &"ip").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn pattern_match_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::PatternMatch { - pattern: String::from("a"), - replace: String::from("b") - }], - parse(&config, &tokenize(&config, &"p \"a\" \"b\"").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn extension_add_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::ExtensionAdd { - extension: String::from("mp3") - }], - parse(&config, &tokenize(&config, &"ea \"mp3\"").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn extension_remove_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::ExtensionRemove], - parse(&config, &tokenize(&config, &"er").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn insert_with_end_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::Insert { - text: String::from("text"), - position: Position::End - }], - parse(&config, &tokenize(&config, &"i \"text\" end").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn insert_with_zero_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::Insert { - text: String::from("text"), - position: Position::Index { value: 0 } - }], - parse(&config, &tokenize(&config, &"i \"text\" 0").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn insert_with_position_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::Insert { - text: String::from("text"), - position: Position::Index { value: 5 } - }], - parse(&config, &tokenize(&config, &"i \"text\" 5").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn delete_with_end_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::Delete { - from: 0, - to: Position::End - }], - parse(&config, &tokenize(&config, &"d 0 end").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn delete_with_position_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::Delete { - from: 0, - to: Position::Index { value: 10 } - }], - parse(&config, &tokenize(&config, &"d 0 10").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn replace_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::Replace { - pattern: String::from("text"), - replace: String::from("TEXT") - }], - parse(&config, &tokenize(&config, &"r \"text\" \"TEXT\"").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn sanitize_interactive_tokenize_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[Rule::Sanitize, Rule::InteractiveTokenize,], - parse(&config, &tokenize(&config, &"s,it").unwrap()) - .unwrap() - .as_slice() - ); - } - - #[test] - fn pattern_match_with_pattern_test() { - let config = MassRenameConfig::new(); - assert_eq!( - &[ - Rule::PatternMatch { - pattern: String::from("{#} - {X}"), - replace: String::from("{1}. {2}") - }, - Rule::ReplaceDashSpace, - Rule::ReplacePeriodSpace, - Rule::ReplaceUnderSpace, - ], - parse( - &config, - &tokenize(&config, &"p \"{#} - {X}\" \"{1}. {2}\",ds,ps,us").unwrap() - ) - .unwrap() - .as_slice() - ); - } -} diff --git a/src/ocd/mrn/pattern_match/mod.rs b/src/ocd/mrn/pattern_match/mod.rs new file mode 100644 index 0000000..d7b82d9 --- /dev/null +++ b/src/ocd/mrn/pattern_match/mod.rs @@ -0,0 +1,697 @@ +use crate::ocd::mrn::program::ReplacePattern; +use crate::ocd::mrn::program::ReplacePatternComponent; +use crate::ocd::mrn::MassRenameArgs; +use crate::ocd::mrn::Speaker; +use crate::ocd::Verbosity; +use rand::distributions::Distribution; +use rand::distributions::Uniform; +use regex::Regex; + +pub mod replace_pattern_lexer; +pub mod replace_pattern_tokens; + +#[allow(clippy::all)] +pub mod replace_pattern_parser { + include!(concat!( + env!("OUT_DIR"), + "/ocd/mrn/pattern_match/replace_pattern_parser.rs" + )); +} + +pub fn process_match(match_pattern: String) -> String { + let match_pattern = match_pattern.replace('.', r"\."); + let match_pattern = match_pattern.replace('[', r"\["); + let match_pattern = match_pattern.replace(']', r"\]"); + let match_pattern = match_pattern.replace('(', r"\("); + let match_pattern = match_pattern.replace(')', r"\)"); + let match_pattern = match_pattern.replace('?', r"\?"); + let match_pattern = match_pattern.replace("{A}", r"([[:alpha:]]*)"); + let match_pattern = match_pattern.replace("{N}", r"([[:digit:]]*)"); + let match_pattern = match_pattern.replace("{X}", r"(.*)"); + let match_pattern = match_pattern.replace("{D}", crate::ocd::date::DATE_FLORB_REGEX_STR); + let mut match_pattern = match_pattern; + match_pattern.insert(0, '^'); + match_pattern.push('$'); + match_pattern +} + +pub fn process_replace( + replace_pattern: String, +) -> Result< + ReplacePattern, + lalrpop_util::ParseError< + usize, + replace_pattern_tokens::Token, + replace_pattern_tokens::LexicalError, + >, +> { + let lexer = crate::ocd::mrn::pattern_match::replace_pattern_lexer::Lexer::new(&replace_pattern); + let parser = + crate::ocd::mrn::pattern_match::replace_pattern_parser::ReplacePatternParser::new(); + let components = parser.parse(lexer)?; + Ok(ReplacePattern { components }) +} + +pub fn apply( + config: &MassRenameArgs, + index: usize, + filename: &str, + match_pattern: &str, + replace_pattern: &ReplacePattern, +) -> String { + let florb_matches = extract_florb_matches(filename, match_pattern); + if config.verbosity() == Verbosity::Debug { + println!("Pattern match instruction"); + println!(" index: {index:?}"); + println!(" filename: {filename:?}"); + println!(" input match pattern: {match_pattern:?}"); + println!(" input replace pattern: {replace_pattern:?}"); + println!(" florb matches: {florb_matches:?}"); + } + + let mut filename = String::new(); + for rpc in &replace_pattern.components { + match rpc { + ReplacePatternComponent::Florb(ref index) => { + // TODO error/warning message if the else happens, and in general check florb indexes + // if a florb index was not there, perhaps it should cancel the entire apply and just return the input + if let Some(florb_match) = florb_matches.get(*index - 1) { + filename.push_str(florb_match.as_str()) + } + } + ReplacePatternComponent::Literal(literal) => { + filename.push_str(literal.as_str()); + } + ReplacePatternComponent::RandomNumberGenerator { + start, + end, + padding, + } => { + let between = Uniform::new(start, end); + let mut rng = rand::thread_rng(); + let n: usize = between.sample(&mut rng); + let num = format!("{:0padding$}", n); + filename.push_str(num.as_str()); + } + ReplacePatternComponent::SequentialNumberGenerator { + start, + step, + padding, + } => { + let num = format!("{:0padding$}", start + (index * step)); + filename.push_str(num.as_str()); + } + } + } + filename +} + +/// Extract data from filename using the match pattern +fn extract_florb_matches(filename: &str, match_pattern: &str) -> Vec { + match Regex::new(match_pattern) { + Ok(match_regex) => match match_regex.captures(filename) { + None => { + eprintln!("No captures found for \n regex {match_pattern:?} \n in filename {filename:?}"); + vec![] + } + Some(captures) => captures + .iter() + .skip(1) + .filter(|e| e.is_some()) + .map(|e| { + let e = e.unwrap().as_str(); + if crate::ocd::date::DATE_FLORB_REGEX.is_match(e) { + let (year, month, day) = crate::ocd::date::regex_date(e).unwrap(); + format!("{year}-{month}-{day}") + } else { + e.to_string() + } + }) + .collect::>(), + }, + Err(e) => { + // TODO handle this error better + eprintln!("{:?}", e); + panic!( + "Could not compile the match pattern regex: {:?}", + match_pattern + ); + } + } +} + +#[cfg(test)] +mod test { + use crate::clap::Parser; + use crate::ocd::mrn::pattern_match::replace_pattern_lexer; + use crate::ocd::mrn::pattern_match::replace_pattern_parser; + use crate::ocd::mrn::pattern_match::replace_pattern_tokens; + use crate::ocd::mrn::pattern_match::replace_pattern_tokens::Token; + use crate::ocd::mrn::program::ReplacePatternComponent; + use crate::ocd::Cli; + use crate::ocd::OcdCommand; + + fn test_pattern( + index: usize, + filename: &str, + match_pattern_str: &str, + replace_pattern_str: &str, + expected: &str, + ) { + let config = Cli::parse_from(vec!["ocd", "mrn", "-vvv", ""]); + if let OcdCommand::MassRename(config) = config.command { + let match_pattern = super::process_match(String::from(match_pattern_str)); + let replace_pattern = + super::process_replace(String::from(replace_pattern_str)).unwrap(); + let result = super::apply(&config, index, filename, &match_pattern, &replace_pattern); + assert_eq!(expected, result); + } else { + panic!() + } + } + + /* + case | rng | start | end | padding | meaning + 1 | {rng} | no | no | no | random number between 1 and 100 + 2 | {rng,5} | no | no | yes | random number between 1 and 100 padded 5 spaces + 3 | {rng20} | no | yes | no | random number between 1 and 20 + 4 | {rng20,5} | no | yes | yes | random number between 1 and 20 padded 5 spaces + 5 | {rng-20} | yes | no | no | invalid + 6 | {rng-20,5} | yes | no | yes | invalid + 7 | {rng10-20} | yes | yes | no | random number between 10 and 20 + 8 | {rng10-20,5} | yes | yes | yes | random number between 10 and 20 padded 5 spaces + + case | sng | start | step | padding | + 1 | {sng} | no | no | no | + 2 | {sng,5} | no | no | yes | + 3 | {sng+2} | no | yes | no | + 4 | {sng+2,5} | no | yes | yes | + 5 | {sng10} | yes | no | no | + 6 | {sng10,5} | yes | no | yes | + 7 | {sng10+2} | yes | yes | no | + 8 | {sng10+2,5} | yes | yes | yes | + */ + + fn lex(input: &str) -> Vec { + let lexer = replace_pattern_lexer::Lexer::new(input); + lexer.map(|r| r.unwrap()).map(|(_x, y, _z)| y).collect() + } + + fn parse(input: &str) -> Vec { + let lexer = replace_pattern_lexer::Lexer::new(input); + let parser = replace_pattern_parser::ReplacePatternParser::new(); + parser.parse(lexer).unwrap() + } + + #[test] + fn parser_test() { + // let input = "str 123 {1} {rng10-20,5} {sng}"; + // let input = "{ str1,str2 + } {sng}"; + let input = "{sng10+2,5}"; + let program = parse(input); + dbg!(&program); + } + + #[test] + fn lexer_test() { + let input = "str 123 {a} {A} {rng} {rng10} {rng10+5}"; + // let input = "{123"; // fails with InvalidToken + let tokens = lex(input); + dbg!(tokens); + } + + #[test] + fn florb0() { + let input = "{wtf}"; + let expected = vec![Token::OpeningBrace, Token::Text(String::from("wtf}"))]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn florb1() { + let input = "{1}"; + let expected = vec![Token::Florb(1)]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn florb2() { + let input = "str1 {1} {2} str2"; + let expected = vec![ + Token::Text(String::from("str")), + Token::Integer(1), + Token::Whitespace(String::from(" ")), + Token::Florb(1), + Token::Whitespace(String::from(" ")), + Token::Florb(2), + Token::Whitespace(String::from(" ")), + Token::Text(String::from("str")), + Token::Integer(2), + ]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn rng_case1_lex() { + let input = "{rng}"; + let expected = vec![Token::RandomNumberGenerator, Token::ClosingBrace]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn rng_case1_parse() { + let input = "{rng}"; + let expected = vec![ReplacePatternComponent::RandomNumberGenerator { + start: 1, + end: 100, + padding: 0, + }]; + let result = parse(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn rng_case2_lex() { + let input = "{rng,5}"; + let expected = vec![ + Token::RandomNumberGenerator, + Token::Comma, + Token::Integer(5), + Token::ClosingBrace, + ]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn rng_case2_parse() { + let input = "{rng,5}"; + let expected = vec![ReplacePatternComponent::RandomNumberGenerator { + start: 1, + end: 100, + padding: 5, + }]; + let result = parse(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn rng_case3_lex() { + let input = "{rng10}"; + let expected = vec![ + Token::RandomNumberGenerator, + Token::Integer(10), + Token::ClosingBrace, + ]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn rng_case3_parse() { + let input = "{rng10}"; + let expected = vec![ReplacePatternComponent::RandomNumberGenerator { + start: 1, + end: 10, + padding: 0, + }]; + let result = parse(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn rng_case4_lex() { + let input = "{rng20,5}"; + let expected = vec![ + Token::RandomNumberGenerator, + Token::Integer(20), + Token::Comma, + Token::Integer(5), + Token::ClosingBrace, + ]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn rng_case4_parse() { + let input = "{rng20,5}"; + let expected = vec![ReplacePatternComponent::RandomNumberGenerator { + start: 1, + end: 20, + padding: 5, + }]; + let result = parse(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn rng_case5_lex() { + let input = "{rng-10}"; + let expected = vec![ + Token::RandomNumberGenerator, + Token::Dash, + Token::Integer(10), + Token::ClosingBrace, + ]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + #[should_panic] // TODO: Removed the unwrap from .parse() and verify the error is correct + fn rng_case5_parse() { + let input = "{rng-10}"; + let _result = parse(input); + } + + #[test] + fn rng_case6_lex() { + let input = "{rng-10,5}"; + let expected = vec![ + Token::RandomNumberGenerator, + Token::Dash, + Token::Integer(10), + Token::Comma, + Token::Integer(5), + Token::ClosingBrace, + ]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + #[should_panic] // TODO: Removed the unwrap from .parse() and verify the error is correct + fn rng_case6_parse() { + let input = "{rng-10,5}"; + let _result = parse(input); + } + + #[test] + fn rng_case7_lex() { + let input = "{rng10-20}"; + let expected = vec![ + Token::RandomNumberGenerator, + Token::Integer(10), + Token::Dash, + Token::Integer(20), + Token::ClosingBrace, + ]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn rng_case7_parse() { + let input = "{rng10-20}"; + let expected = vec![ReplacePatternComponent::RandomNumberGenerator { + start: 10, + end: 20, + padding: 0, + }]; + let result = parse(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn rng_case8_lex() { + let input = "{rng10-20,5}"; + let expected = vec![ + Token::RandomNumberGenerator, + Token::Integer(10), + Token::Dash, + Token::Integer(20), + Token::Comma, + Token::Integer(5), + Token::ClosingBrace, + ]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn rng_case8_parse() { + let input = "{rng10-20,5}"; + let expected = vec![ReplacePatternComponent::RandomNumberGenerator { + start: 10, + end: 20, + padding: 5, + }]; + let result = parse(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn sng_case1_lex() { + let input = "{sng}"; + let expected = vec![Token::SequentialNumberGenerator, Token::ClosingBrace]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn sng_case1_parse() { + let input = "{sng}"; + let expected = vec![ReplacePatternComponent::SequentialNumberGenerator { + start: 1, + step: 1, + padding: 0, + }]; + let result = parse(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn sng_case2_lex() { + let input = "{sng,5}"; + let expected = vec![ + Token::SequentialNumberGenerator, + Token::Comma, + Token::Integer(5), + Token::ClosingBrace, + ]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn sng_case2_parse() { + let input = "{sng,5}"; + let expected = vec![ReplacePatternComponent::SequentialNumberGenerator { + start: 1, + step: 1, + padding: 5, + }]; + let result = parse(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn sng_case3_lex() { + let input = "{sng+2}"; + let expected = vec![ + Token::SequentialNumberGenerator, + Token::Plus, + Token::Integer(2), + Token::ClosingBrace, + ]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn sng_case3_parse() { + let input = "{sng+2}"; + let expected = vec![ReplacePatternComponent::SequentialNumberGenerator { + start: 1, + step: 2, + padding: 0, + }]; + let result = parse(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn sng_case4_lex() { + let input = "{sng+2,5}"; + let expected = vec![ + Token::SequentialNumberGenerator, + Token::Plus, + Token::Integer(2), + Token::Comma, + Token::Integer(5), + Token::ClosingBrace, + ]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn sng_case4_parse() { + let input = "{sng+2,5}"; + let expected = vec![ReplacePatternComponent::SequentialNumberGenerator { + start: 1, + step: 2, + padding: 5, + }]; + let result = parse(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn sng_case5_lex() { + let input = "{sng10}"; + let expected = vec![ + Token::SequentialNumberGenerator, + Token::Integer(10), + Token::ClosingBrace, + ]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn sng_case5_parse() { + let input = "{sng10}"; + let expected = vec![ReplacePatternComponent::SequentialNumberGenerator { + start: 10, + step: 1, + padding: 0, + }]; + let result = parse(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn sng_case6_lex() { + let input = "{sng10,5}"; + let expected = vec![ + Token::SequentialNumberGenerator, + Token::Integer(10), + Token::Comma, + Token::Integer(5), + Token::ClosingBrace, + ]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn sng_case6_parse() { + let input = "{sng10,5}"; + let expected = vec![ReplacePatternComponent::SequentialNumberGenerator { + start: 10, + step: 1, + padding: 5, + }]; + let result = parse(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn sng_case7_lex() { + let input = "{sng10+2}"; + let expected = vec![ + Token::SequentialNumberGenerator, + Token::Integer(10), + Token::Plus, + Token::Integer(2), + Token::ClosingBrace, + ]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn sng_case7_parse() { + let input = "{sng10+2}"; + let expected = vec![ReplacePatternComponent::SequentialNumberGenerator { + start: 10, + step: 2, + padding: 0, + }]; + let result = parse(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn sng_case8_lex() { + let input = "{sng10+2,5}"; + let expected = vec![ + Token::SequentialNumberGenerator, + Token::Integer(10), + Token::Plus, + Token::Integer(2), + Token::Comma, + Token::Integer(5), + Token::ClosingBrace, + ]; + let result = lex(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn sng_case8_parse() { + let input = "{sng10+2,5}"; + let expected = vec![ReplacePatternComponent::SequentialNumberGenerator { + start: 10, + step: 2, + padding: 5, + }]; + let result = parse(input); + assert_eq!(expected.as_slice(), result.as_slice()); + } + + #[test] + fn pattern_match_1() { + test_pattern(0, "aa bb", "{X} {X}", "{2} {1}", "bb aa"); + } + + #[test] + fn pattern_match_2() { + test_pattern( + 0, + "Dave Brubeck - 01. Take five", + "{X} - {N}. {X}", + "{1} {2} {3}", + "Dave Brubeck 01 Take five", + ) + } + + #[test] + fn pattern_match_3() { + test_pattern( + 0, + "Bahia Blanca, 21 October 2019", + "{X}, {D}", + "{2} {1}", + "2019-10-21 Bahia Blanca", + ) + } + + #[test] + fn pattern_match_4() { + test_pattern( + 0, + "Foo 123 B_a_r", + "{A} {N} {X}", + "{3} {2} {1}", + "B_a_r 123 Foo", + ) + } + + #[test] + fn pattern_match_5() { + test_pattern( + 0, + "Bahia Blanca, 21 October 2019, FooBarBaz", + "{X}, {D}, {X}", + "{2} {1} {3}", + "2019-10-21 Bahia Blanca FooBarBaz", + ) + } +} diff --git a/src/ocd/mrn/pattern_match/replace_pattern_lexer.rs b/src/ocd/mrn/pattern_match/replace_pattern_lexer.rs new file mode 100644 index 0000000..5af810f --- /dev/null +++ b/src/ocd/mrn/pattern_match/replace_pattern_lexer.rs @@ -0,0 +1,29 @@ +use crate::ocd::mrn::pattern_match::replace_pattern_tokens::{LexicalError, Token}; +use logos::{Logos, SpannedIter}; + +pub type Spanned = Result<(Loc, Tok, Loc), Error>; + +pub struct Lexer<'input> { + // instead of an iterator over characters, we have a token iterator + token_stream: SpannedIter<'input, Token>, +} + +impl<'input> Lexer<'input> { + pub fn new(input: &'input str) -> Self { + // the Token::lexer() method is provided by the Logos trait + Self { + token_stream: Token::lexer(input).spanned(), + } + } +} + +impl Iterator for Lexer<'_> { + type Item = Spanned; + + fn next(&mut self) -> Option { + self.token_stream.next().map(|(token, span)| { + // dbg!(&token); + Ok((span.start, token?, span.end)) + }) + } +} diff --git a/src/ocd/mrn/pattern_match/replace_pattern_parser.lalrpop b/src/ocd/mrn/pattern_match/replace_pattern_parser.lalrpop new file mode 100644 index 0000000..7cad854 --- /dev/null +++ b/src/ocd/mrn/pattern_match/replace_pattern_parser.lalrpop @@ -0,0 +1,137 @@ +use crate::ocd::mrn::pattern_match::replace_pattern_tokens::Token; +use crate::ocd::mrn::pattern_match::replace_pattern_tokens::LexicalError; +use crate::ocd::mrn::program::ReplacePatternComponent; + +grammar; + +extern { + type Location = usize; + type Error = LexicalError; + + enum Token { + "comma" => Token::Comma, + "plus" => Token::Plus, + "dash" => Token::Dash, + "obrace" => Token::OpeningBrace, + "cbrace" => Token::ClosingBrace, + "date" => Token::DateGenerator, + "sng" => Token::SequentialNumberGenerator, + "rng" => Token::RandomNumberGenerator, + "florb" => Token::Florb(), + "int" => Token::Integer(), + "whitespace" => Token::Whitespace(), + "text" => Token::Text(), + } +} + +List: Vec = { + => vec![<>], + > => { + s.push(n); + s + }, +}; + +pub ReplacePattern = List; + +RPC: ReplacePatternComponent = { + "comma" => ReplacePatternComponent::Literal(String::from(",")), + "plus" => ReplacePatternComponent::Literal(String::from("+")), + "dash" => ReplacePatternComponent::Literal(String::from("-")), + "obrace" => ReplacePatternComponent::Literal(String::from("{")), + "cbrace" => ReplacePatternComponent::Literal(String::from("}")), + "sng" => sng, + "rng" => rng, + => ReplacePatternComponent::Florb(t), + => ReplacePatternComponent::Literal(t.to_string()), + => ReplacePatternComponent::Literal(t.to_string()), + => ReplacePatternComponent::Literal(t.to_string()) +} + +RNG: ReplacePatternComponent = { + "cbrace" => ReplacePatternComponent::RandomNumberGenerator{ + start: 1, + end: 100, + padding: 0, + }, + "cbrace" => ReplacePatternComponent::RandomNumberGenerator{ + start: 1, + end: end, + padding: 0, + }, + "dash" "cbrace" => ReplacePatternComponent::RandomNumberGenerator{ + start: start, + end: end, + padding: 0, + }, + "comma" "cbrace" => ReplacePatternComponent::RandomNumberGenerator{ + start: 1, + end: 100, + padding: pad, + }, + "comma" "cbrace" => ReplacePatternComponent::RandomNumberGenerator{ + start: 1, + end: end, + padding: pad, + }, + "dash" "comma" "cbrace" => ReplacePatternComponent::RandomNumberGenerator{ + start: start, + end: end, + padding: pad, + }, +} + +/* + sng | start | step | padding | + {sng} | no | no | no | + {sng,5} | no | no | yes | + {sng+2} | no | yes | no | + {sng+2,5} | no | yes | yes | + {sng10} | yes | no | no | + {sng10,5} | yes | no | yes | + {sng10+2} | yes | yes | no | + {sng10+2,5} | yes | yes | yes | +*/ + +SNG: ReplacePatternComponent = { + "cbrace" => ReplacePatternComponent::SequentialNumberGenerator{ + start: 1, + step: 1, + padding: 0, + }, + "comma" "cbrace" => ReplacePatternComponent::SequentialNumberGenerator{ + start: 1, + step: 1, + padding: pad, + }, + "plus" "cbrace" => ReplacePatternComponent::SequentialNumberGenerator{ + start: 1, + step: step, + padding: 0, + }, + "plus" "comma" "cbrace" => ReplacePatternComponent::SequentialNumberGenerator{ + start: 1, + step: step, + padding: pad, + }, + "cbrace" => ReplacePatternComponent::SequentialNumberGenerator{ + start: start, + step: 1, + padding: 0, + }, + "comma" "cbrace" => ReplacePatternComponent::SequentialNumberGenerator{ + start: start, + step: 1, + padding: pad, + }, + "plus" "cbrace" => ReplacePatternComponent::SequentialNumberGenerator{ + start: start, + step: step, + padding: 0, + }, + "plus" "comma" "cbrace" => ReplacePatternComponent::SequentialNumberGenerator{ + start: start, + step: step, + padding: pad, + }, +} diff --git a/src/ocd/mrn/pattern_match/replace_pattern_tokens.rs b/src/ocd/mrn/pattern_match/replace_pattern_tokens.rs new file mode 100644 index 0000000..ee04da2 --- /dev/null +++ b/src/ocd/mrn/pattern_match/replace_pattern_tokens.rs @@ -0,0 +1,57 @@ +use logos::Logos; +use std::fmt; +use std::num::ParseIntError; + +#[derive(Default, Debug, Clone, PartialEq)] +pub enum LexicalError { + InvalidInteger(ParseIntError), + #[default] + InvalidToken, +} + +impl fmt::Display for LexicalError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +impl From for LexicalError { + fn from(err: ParseIntError) -> Self { + LexicalError::InvalidInteger(err) + } +} + +#[derive(Logos, Clone, Debug, PartialEq)] +#[logos(error = LexicalError)] +pub enum Token { + #[token(",", priority = 3)] + Comma, + #[token("+", priority = 3)] + Plus, + #[token("-", priority = 3)] + Dash, + #[token("{", priority = 3)] + OpeningBrace, + #[token("}", priority = 3)] + ClosingBrace, + #[token("{date}")] + DateGenerator, + #[token("{sng")] + SequentialNumberGenerator, + #[token("{rng")] + RandomNumberGenerator, + #[regex(r"\{[0-9]+\}", |lex| lex.slice().trim_matches('{').trim_matches('}').parse(), priority = 3)] + Florb(usize), + #[regex("[0-9]+", |lex| lex.slice().parse(), priority = 3)] + Integer(usize), + #[regex("[ ]+", |lex| lex.slice().to_string(), priority = 2)] + Whitespace(String), + #[regex("[^ -0123456789{]+", |lex| lex.slice().to_string(), priority = 2)] + Text(String), +} + +impl fmt::Display for Token { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:?}", self) + } +} diff --git a/src/ocd/mrn/program.rs b/src/ocd/mrn/program.rs new file mode 100644 index 0000000..4e62e91 --- /dev/null +++ b/src/ocd/mrn/program.rs @@ -0,0 +1,101 @@ +use std::error::Error; +use std::fmt::Debug; + +#[derive(Debug)] +pub struct Program(Vec); + +impl Program { + pub fn new(instructions: Vec) -> Self { + Program(instructions) + } + + pub fn instructions(&self) -> &Vec { + &self.0 + } + + pub fn check(&mut self) -> Result<(), Box> { + Ok(()) + } +} + +#[derive(Debug, PartialEq)] +pub enum Instruction { + Sanitize, + CaseLower, + CaseUpper, + CaseTitle, + CaseSentence, + JoinCamel, + JoinSnake, + JoinKebab, + SplitCamel, + SplitSnake, + SplitKebab, + Replace { + pattern: ReplaceArg, + replace: ReplaceArg, + }, + Insert { + position: Position, + text: String, + }, + Delete { + from: usize, + to: Position, + }, + PatternMatch { + match_pattern: String, + replace_pattern: ReplacePattern, + }, + ExtensionAdd(String), + ExtensionRemove, + Reorder, +} + +#[derive(Debug, PartialEq)] +pub enum ReplaceArg { + Dash, + Space, + Period, + Underscore, + Text(String), +} + +impl ReplaceArg { + pub fn as_str(&self) -> &str { + match self { + ReplaceArg::Dash => "-", + ReplaceArg::Space => " ", + ReplaceArg::Period => ".", + ReplaceArg::Underscore => "_", + ReplaceArg::Text(text) => text.as_str(), + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub enum Position { + End, + Index(usize), +} + +#[derive(Debug, PartialEq)] +pub struct ReplacePattern { + pub components: Vec, +} + +#[derive(Debug, PartialEq)] +pub enum ReplacePatternComponent { + Literal(String), + Florb(usize), + RandomNumberGenerator { + start: usize, + end: usize, + padding: usize, + }, + SequentialNumberGenerator { + start: usize, + step: usize, + padding: usize, + }, +} diff --git a/src/ocd/output.rs b/src/ocd/output.rs deleted file mode 100644 index ee06273..0000000 --- a/src/ocd/output.rs +++ /dev/null @@ -1,65 +0,0 @@ -use crate::ocd::config::Verbosity; -use crate::ocd::mrn::lexer::Token; -use crate::ocd::mrn::Rule; - -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; - -pub fn mrn_lexer_error(verbosity: Verbosity, msg: &str) { - if verbosity.is_silent() { - return; - } - println!("{}", msg); -} - -pub fn mrn_state( - config: &crate::ocd::mrn::MassRenameConfig, - tokens: &[Token], - rules: &[Rule], - files: &[PathBuf], -) { - if let Verbosity::Debug = config.verbosity { - println!("{:#?}", &config); - println!("Tokens:\n{:#?}", &tokens); - println!("Rules:\n{:#?}", &rules); - println!("Files:\n{:#?}", &files); - } -} - -pub fn mrn_pattern_match( - verbosity: Verbosity, - filename: &str, - match_pattern: &str, - replace_pattern: &str, -) { - if verbosity.is_silent() { - return; - } - println!("filename: {:?}", filename); - println!("match pattern: {:?}", match_pattern); - println!("replace pattern: {:?}", replace_pattern); -} - -pub fn mrn_result(verbosity: Verbosity, buffer: &BTreeMap) { - if verbosity.is_silent() { - return; - } - println!("Result:"); - for (src, dst) in buffer { - println!("---\n {:?}\n {:?}", src, dst) - } -} - -pub fn undo_script(verbosity: Verbosity) { - if verbosity.is_silent() { - return; - } - println!("Creating undo script."); -} - -pub fn file_move(verbosity: Verbosity, src: &Path, dst: &Path) { - if verbosity.is_silent() { - return; - } - println!("Moving {:?}\n to {:?}", src, dst); -} diff --git a/src/ocd/tss/mod.rs b/src/ocd/tss/mod.rs index ed5f184..be7d731 100644 --- a/src/ocd/tss/mod.rs +++ b/src/ocd/tss/mod.rs @@ -1,146 +1,206 @@ -use crate::ocd::config::{directory_value, verbosity_value, Verbosity}; -use lazy_static::lazy_static; -use regex::Regex; -use std::collections::BTreeMap; +//! Time Stamp Sorter +//! +//! This command sorts image files into folders named after a date extracted from the image. + +use crate::ocd::date::exif_date; +use crate::ocd::date::filename_date; +use crate::ocd::date::metadata_date; +use crate::ocd::date::DateSource; +use crate::ocd::Action; +use crate::ocd::Plan; +use crate::ocd::Speaker; +use crate::ocd::Verbosity; +use clap::Args; use std::error::Error; -use std::fs; -use std::io; -use std::option; -use std::path::{Path, PathBuf}; -use walkdir::{DirEntry, WalkDir}; - -#[derive(Clone, Debug)] -pub struct TimeStampSortConfig { - pub verbosity: Verbosity, - pub dir: PathBuf, - pub dryrun: bool, - pub undo: bool, - pub yes: bool, -} +use std::path::Path; +use std::path::PathBuf; +use walkdir::DirEntry; +use walkdir::WalkDir; -impl TimeStampSortConfig { - pub fn new() -> TimeStampSortConfig { - TimeStampSortConfig { - verbosity: Verbosity::Low, - dir: PathBuf::new(), - dryrun: true, - undo: false, - yes: false, - } - } +/// Arguments to the time stamp sort command. +#[derive(Clone, Debug, Args)] +#[command(args_conflicts_with_subcommands = true)] +pub struct TimeStampSortArgs { + #[arg(action = clap::ArgAction::Count)] + #[arg(help = r#"Sets the verbosity level. +Default is low, one medium, two high, three or more debug."#)] + #[arg(short = 'v')] + verbosity: u8, - pub fn with_args(&self, matches: &clap::ArgMatches) -> TimeStampSortConfig { - TimeStampSortConfig { - verbosity: verbosity_value(matches), - dir: directory_value(matches.value_of("dir").unwrap()), - dryrun: matches.is_present("dry-run"), - undo: matches.is_present("undo"), - yes: matches.is_present("yes"), - } + #[arg(help = "Silences all output.")] + #[arg(long)] + silent: bool, + + #[arg(default_value = "./")] + #[arg(help = "Run inside a given directory.")] + #[arg(long)] + #[arg(short = 'd')] + dir: PathBuf, + + #[arg(help = "Do not effect any changes on the filesystem.")] + #[arg(long = "dry-run")] + dry_run: bool, + + #[arg(help = "Create undo script.")] + #[arg(long)] + #[arg(short = 'u')] + undo: bool, + + #[arg(help = "Do not ask for confirmation.")] + #[arg(long)] + yes: bool, + + #[arg(help = "Rename files by calling `git mv`.")] + #[arg(long)] + git: bool, + + #[arg(help = "Recurse directories.")] + #[arg(long)] + #[arg(short = 'r')] + recurse: bool, + + #[arg(help = "Restricts sources for inferring the image date.")] + #[arg(long)] + source: bool, +} + +impl Speaker for TimeStampSortArgs { + fn verbosity(&self) -> Verbosity { + crate::ocd::Verbosity::new(self.silent, self.verbosity) } } -pub fn run(config: &TimeStampSortConfig) -> Result<(), Box> { - if !config.dryrun && config.undo { - crate::ocd::output::undo_script(config.verbosity); - // TODO: implement undo +pub fn run(config: &TimeStampSortArgs) -> Result<(), Box> { + if config.source { + todo!("Selection of date source is not implemented yet!"); } - let mut files = BTreeMap::new(); - for entry in WalkDir::new(&config.dir) { - match entry { - Ok(entry) => { - insert_if_timestamped(config, &mut files, entry); - } - Err(reason) => return Err(Box::new(reason)), - } + // Initialize plan + let plan = create_plan(config)?; + + // Present plan to user. + // If verbosity is Low or Medium use the short presentation. + // If verbosity is High or Debug use the long presentation. + if Verbosity::Silent < config.verbosity() && config.verbosity() < Verbosity::High { + plan.present_short(); + } + if Verbosity::Medium < config.verbosity() { + plan.present_long(); } - if config.yes || crate::ocd::input::user_confirm() { - for (src, dst) in files { - create_dir_and_move_file(config, src, dst)?; + // Maybe create undo script + if !config.dry_run && config.undo { + if !config.verbosity().is_silent() { + println!("Creating undo script."); } + plan.create_undo()?; } + // Skip if dry run, execute unconditionally or ask for confirmation + if !config.dry_run && (config.yes || crate::ocd::user_confirm()) { + plan.execute()?; + } Ok(()) } -fn insert_if_timestamped( - config: &TimeStampSortConfig, - files: &mut BTreeMap, - entry: DirEntry, -) { - let path = entry.into_path(); - if !path.is_dir() { - if let Some(destination) = destination(&config.dir, &path) { - files.insert(path, destination); - } - } -} +fn create_plan(config: &TimeStampSortArgs) -> Result> { + // version 1 + // for entry in WalkDir::new(&config.dir) { + // match entry { + // Ok(entry) => { + // insert_if_timestamped(config, &mut files, entry); + // } + // Err(reason) => { + // // reason is Error, so we need to box it for it to be Box + // return Err(Box::new(reason)); + // } + // } + // } -fn create_dir_and_move_file( - config: &TimeStampSortConfig, - file: PathBuf, - destination: PathBuf, -) -> Result<(), Box> { - create_directory(config, &destination)?; - move_file(config, &file, &destination)?; - Ok(()) -} + // version 2 + // for entry in WalkDir::new(&config.dir) { + // if let Err(reason) = entry.and_then(|entry| { + // insert_if_timestamped(config, &mut files, entry); + // Ok(()) + // }) { + // return Err(Box::new(reason)); + // } + // } -fn destination(base_dir: &Path, file_name: &Path) -> option::Option { - // let file = std::fs::File::open(file_name).unwrap(); - // let reader = exif::Reader::new(&mut std::io::BufReader::new(&file)).unwrap(); - // for f in reader.fields() { - // f.tag, f.thumbnail, f.value.display_as(f.tag)); + // version 3 + // for entry in WalkDir::new(&config.dir) { + // entry.and_then(|entry| { insert_if_timestamped(config, &mut files, entry); Ok(()) })?; // } - file_name - .to_str() - .and_then(date) - .map(|(year, month, day)| base_dir.join(format!("{}-{}-{}", year, month, day))) + + // version 4 + // WalkDir::new(&config.dir) + // .into_iter() + // .try_for_each(|entry| { + // entry.and_then(|entry| { + // insert_if_timestamped(config, &mut files, entry); + // Ok(()) + // }) + // })?; + + // version 5 + let mut plan = Plan::new(); + let max_depth = if config.recurse { usize::MAX } else { 1 }; + WalkDir::new(&config.dir) + .max_depth(max_depth) + .sort_by_file_name() + .into_iter() + .try_for_each(|entry| { + entry.map(|entry| { + maybe_insert(config, &mut plan, entry); + }) + })?; + Ok(plan) } -fn date(filename: &str) -> Option<(&str, &str, &str)> { - lazy_static! { - // YYYY?MM?DD or YYYYMMDD, - // where YYYY in [1000-2999], MM in [01-12], DD in [01-31] - static ref RE: Regex = Regex::new(r"\D*(1\d\d\d|20\d\d).?(0[1-9]|1[012]).?(0[1-9]|[12]\d|30|31)\D*").unwrap(); - } - RE.captures(filename).map(|captures| { - let year = captures.get(1).unwrap().as_str(); - let month = captures.get(2).unwrap().as_str(); - let day = captures.get(3).unwrap().as_str(); - (year, month, day) - }) +/// Returns true if the directory entry has an extension of `jpg`, `jpeg`, `tif`, `tiff`, `webp`, or `png`. +fn is_image(entry: &Path) -> bool { + entry + .extension() + .and_then(|s| s.to_str()) + .is_some_and(|ext| { + ext.ends_with("jpg") + || ext.ends_with("jpeg") + || ext.ends_with("tif") + || ext.ends_with("tiff") + || ext.ends_with("webp") + || ext.ends_with("png") + }) } -fn create_directory(config: &TimeStampSortConfig, directory: &Path) -> io::Result<()> { - if !config.dryrun { - let mut full_path = PathBuf::new(); - full_path.push(directory); - match fs::create_dir(&full_path) { - Ok(_) => return Ok(()), - Err(reason) => match reason.kind() { - io::ErrorKind::AlreadyExists => return Ok(()), - _ => return Err(reason), - }, +/// Given a directory entry, will insert it into the map of files to be +/// relocated to their destinations, if the entry is a regular file, is not +/// hidden, is an image, and a date can be extracted from the file either from +/// its filename, exif data, or if its creation date is not today. +fn maybe_insert(config: &TimeStampSortArgs, plan: &mut Plan, entry: DirEntry) { + let entry_path = entry.into_path(); + if entry_path.is_file() && !crate::ocd::is_hidden(&entry_path) && is_image(&entry_path) { + if let Some((source, path)) = destination(config, &entry_path) { + let action = Action::Move { + date_source: Some(source), + path, + }; + plan.insert(entry_path, action); } } - Ok(()) } -fn move_file(config: &TimeStampSortConfig, from: &Path, dest: &Path) -> io::Result<()> { - let mut to = PathBuf::new(); - to.push(dest); - to.push(from.file_name().unwrap()); - - crate::ocd::output::file_move(config.verbosity, from, &to); - - if !config.dryrun { - if config.undo { - // TODO implement undo script - } - fs::rename(from, to)? - } - Ok(()) +/// This function tries to determine a destination for a given file. +/// - It first tries to find a date in the file name, by matching it against a regex. +/// - If that fails, it tries to examine the EXIF data to find a datetime field. +/// - If that fails, it tries to figure out a data from the filesystem metadata, +/// by looking at the created date field. If however the creation date is today, +/// it is discarded as we can assume that the original creation date has been lost. +fn destination(config: &TimeStampSortArgs, path: &PathBuf) -> Option<(DateSource, PathBuf)> { + filename_date(path) + .or_else(|| exif_date(path)) + .or_else(|| metadata_date(path)) + .map(|(source, year, month, day)| { + let pathbuf = config.dir.join(format!("{year}-{month}-{day}")); + (source, pathbuf) + }) }