diff --git a/Cargo.lock b/Cargo.lock index dd5d9851..4801db63 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + [[package]] name = "aho-corasick" version = "0.7.20" @@ -79,6 +90,12 @@ version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "bumpalo" version = "3.12.0" @@ -114,9 +131,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.1.6" +version = "4.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0b0588d44d4d63a87dbd75c136c166bbfd9a86a31cb89e09906521c7d3f5e3" +checksum = "2f3061d6db6d8fcbbd4b05e057f2acace52e64e96b498c08c2d7a4e65addd340" dependencies = [ "bitflags", "clap_derive", @@ -129,9 +146,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.1.0" +version = "4.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "684a277d672e91966334af371f1a7b5833f9aa00b07c84e92fbce95e00208ce8" +checksum = "34d122164198950ba84a918270a3bb3f7ededd25e15f7451673d986f55bd2667" dependencies = [ "heck", "proc-macro-error", @@ -142,13 +159,35 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "783fe232adfca04f90f56201b26d79682d4cd2625e0bc7290b95123afe558ade" +checksum = "350b9cf31731f9957399229e9b2adc51eeabdfbe9d71d9a0552275fd12710d09" dependencies = [ "os_str_bytes", ] +[[package]] +name = "clipboard" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25a904646c0340239dcf7c51677b33928bf24fdf424b79a57909c0109075b2e7" +dependencies = [ + "clipboard-win", + "objc", + "objc-foundation", + "objc_id", + "x11-clipboard", +] + +[[package]] +name = "clipboard-win" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a093d6fed558e5fe24c3dfc85a68bb68f1c824f440d3ba5aca189e2998786b" +dependencies = [ + "winapi", +] + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -171,6 +210,20 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" +[[package]] +name = "crossbeam" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2801af0d36612ae591caa9568261fddce32ce6e08a7275ea334a06a4ad021a2c" +dependencies = [ + "cfg-if", + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + [[package]] name = "crossbeam-channel" version = "0.5.6" @@ -181,6 +234,40 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "715e8152b692bba2d374b53d4875445368fdf21a94751410af607a5ac677d1fc" +dependencies = [ + "cfg-if", + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a9af1f4c2ef74bb8aa1f7e19706bc72d03598c8a570bb5de72243c7a9d9d5a" +dependencies = [ + "autocfg", + "cfg-if", + "crossbeam-utils", + "memoffset 0.7.1", + "scopeguard", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1cfb3ea8a53f37c40dea2c7bedcbd88bdfae54f5e2175d6ecaff1c988353add" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.14" @@ -222,6 +309,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" +[[package]] +name = "ctor" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d2301688392eb071b0bf1a37be05c469d3cc4dbbd95df672fe28ab021e6a096" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "cxx" version = "1.0.91" @@ -301,9 +398,9 @@ checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" [[package]] name = "ena" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7402b94a93c24e742487327a7cd839dc9d36fec9de9fb25b09f2dae459f36c3" +checksum = "b2e5d13ca2353ab7d0230988629def93914a8c4015f621f9b13ed2955614731d" dependencies = [ "log", ] @@ -329,6 +426,18 @@ dependencies = [ "libc", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "1.9.0" @@ -346,7 +455,7 @@ checksum = "8ef1a30ae415c3a691a4f41afddc2dbcd6d70baf338368d85ebc1e8ed92cedb9" dependencies = [ "cfg-if", "rustix", - "windows-sys", + "windows-sys 0.45.0", ] [[package]] @@ -355,6 +464,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" +[[package]] +name = "gethostname" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ebd34e35c46e00bb73e81363248d627782724609fe1b6396f553f68fe3862e" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "getrandom" version = "0.2.8" @@ -371,6 +490,18 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69fe1fcf8b4278d860ad0548329f892a3631fb63f82574df68275f34cdbe0ffa" +dependencies = [ + "hashbrown", +] [[package]] name = "heck" @@ -443,19 +574,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1abeb7a0dd0f8181267ff8adc397075586500b81b28a73e8a0208b00fc170fb3" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.45.0", ] [[package]] name = "is-terminal" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0a45d56fe973d6db23972bf5bc46f988a4a2385deac9cc29572f09daef" +checksum = "21b6b32576413a8e69b90e952e4a026476040d81017b80445deda5f2d3921857" dependencies = [ "hermit-abi 0.3.1", "io-lifetimes", "rustix", - "windows-sys", + "windows-sys 0.45.0", ] [[package]] @@ -467,6 +598,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad582f4b9e86b6caa621cabeb0963332d92eea04729ab12892c2533951e6440" + [[package]] name = "js-sys" version = "0.3.61" @@ -514,6 +651,17 @@ version = "0.2.139" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "201de327520df007757c1f0adce6e827fe8562fbc28bfd9c15571c66ca1f5f79" +[[package]] +name = "libsqlite3-sys" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29f835d03d717946d28b1d1ed632eb6f0e24a299388ee623d0c23118d3e8a7fa" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "link-cplusplus" version = "1.0.8" @@ -548,6 +696,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "memchr" version = "2.5.0" @@ -563,6 +720,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "memoffset" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5de893c32cde5f383baa4c04c5d6dbdd735cfd4a794b0debdb2bb1b421da5ff4" +dependencies = [ + "autocfg", +] + [[package]] name = "mio" version = "0.8.6" @@ -572,7 +738,7 @@ dependencies = [ "libc", "log", "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys", + "windows-sys 0.45.0", ] [[package]] @@ -591,7 +757,7 @@ dependencies = [ "bitflags", "cfg-if", "libc", - "memoffset", + "memoffset 0.6.5", "pin-utils", ] @@ -624,11 +790,40 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + [[package]] name = "once_cell" -version = "1.17.0" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f61fba1741ea2b3d6a1e3178721804bb716a68a6aeba1149b5d52e3d464ea66" +checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" [[package]] name = "os_str_bytes" @@ -636,6 +831,15 @@ version = "6.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b7820b9daea5457c9f21c69448905d723fbd21136ccf521748f23fd49e723ee" +[[package]] +name = "output_vt100" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628223faebab4e3e40667ee0b2336d34a5b960ff60ea743ddfdbcf7770bcfb66" +dependencies = [ + "winapi", +] + [[package]] name = "overload" version = "0.1.1" @@ -662,14 +866,14 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-sys", + "windows-sys 0.45.0", ] [[package]] name = "petgraph" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5014253a1331579ce62aa67443b4a658c5e7dd03d4bc6d302b94474888143" +checksum = "4dd7d28ee937e54fe3080c91faa1c3a46c06de6252988a7f4592ba2310ef22a4" dependencies = [ "fixedbitset", "indexmap", @@ -706,12 +910,30 @@ dependencies = [ "syn", ] +[[package]] +name = "pkg-config" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" + [[package]] name = "precomputed-hash" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "pretty_assertions" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a25e9bcb20aa780fd0bb16b72403a9064d6b3f22f026946029acb941a50af755" +dependencies = [ + "ctor", + "diff", + "output_vt100", + "yansi", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -738,9 +960,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.50" +version = "1.0.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef7d57beacfaf2d8aee5937dab7b7f28de3cb8b1828479bb5de2a7106f2bae2" +checksum = "5d727cae5b39d21da60fa540906919ad737832fe0b1c165da3a34d6548c849d6" dependencies = [ "unicode-ident", ] @@ -777,18 +999,24 @@ dependencies = [ [[package]] name = "reedline" version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a02723e44c03f5ef62ad35665a43cfa1df3b78cbb75e31ea9ddc6d2f0d9b4658" dependencies = [ "chrono", + "clipboard", + "crossbeam", "crossterm", "fd-lock", + "gethostname", "itertools", "nu-ansi-term", + "pretty_assertions", + "rstest", + "rusqlite", "serde", + "serde_json", "strip-ansi-escapes", "strum", "strum_macros", + "tempfile", "thiserror", "unicode-segmentation", "unicode-width", @@ -811,15 +1039,6 @@ version = "0.6.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" -[[package]] -name = "remove_dir_all" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" -dependencies = [ - "winapi", -] - [[package]] name = "rexpect" version = "0.5.0" @@ -833,6 +1052,53 @@ dependencies = [ "thiserror", ] +[[package]] +name = "rstest" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b07f2d176c472198ec1e6551dc7da28f1c089652f66a7b722676c2238ebc0edf" +dependencies = [ + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7229b505ae0706e64f37ffc54a9c163e11022a6636d58fe1f3f52018257ff9f7" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-ident", +] + +[[package]] +name = "rusqlite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e213bc3ecb39ac32e81e51ebe31fd888a940515173e3a18a35f8c6e896422a" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.36.8" @@ -844,7 +1110,7 @@ dependencies = [ "io-lifetimes", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.45.0", ] [[package]] @@ -853,6 +1119,12 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5583e89e108996506031660fe09baa5011b9dd0341b89029313006d1fb508d70" +[[package]] +name = "ryu" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde" + [[package]] name = "scopeguard" version = "1.1.0" @@ -865,6 +1137,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddccb15bcce173023b3fedd9436f882a0739b8dfb45e4f6b6002bee5929f61b2" +[[package]] +name = "semver" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58bc9567378fc7690d6b2addae4e60ac2eeea07becb2c64b9f218b53865cba2a" + [[package]] name = "serde" version = "1.0.152" @@ -885,6 +1163,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.93" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad406b69c91885b5107daf2c29572f6c8cdb3c66826821e286c533490c0bc76" +dependencies = [ + "itoa", + "ryu", + "serde", +] + [[package]] name = "shrs" version = "0.1.0" @@ -993,9 +1282,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.107" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f4064b5b16e03ae50984a5a8ed5d4f8803e6bc1fd170a3cda91a1be4b18e3f5" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", "quote", @@ -1004,16 +1293,15 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +checksum = "af18f7ae1acd354b992402e9ec5864359d693cd8a79dcbef59f76891701c1e95" dependencies = [ "cfg-if", "fastrand", - "libc", "redox_syscall", - "remove_dir_all", - "winapi", + "rustix", + "windows-sys 0.42.0", ] [[package]] @@ -1106,6 +1394,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "936e4b492acfd135421d8dca4b1aa80a7bfc26e702ef3af710e0752684df5372" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.4" @@ -1230,6 +1524,21 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + [[package]] name = "windows-sys" version = "0.45.0" @@ -1295,3 +1604,28 @@ name = "windows_x86_64_msvc" version = "0.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "447660ad36a13288b1db4d4248e857b510e8c3a225c822ba4fb748c0aafecffd" + +[[package]] +name = "x11-clipboard" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89bd49c06c9eb5d98e6ba6536cf64ac9f7ee3a009b2f53996d405b3944f6bcea" +dependencies = [ + "xcb", +] + +[[package]] +name = "xcb" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e917a3f24142e9ff8be2414e36c649d47d6cc2ba81f16201cdef96e533e02de" +dependencies = [ + "libc", + "log", +] + +[[package]] +name = "yansi" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" diff --git a/Cargo.toml b/Cargo.toml index aac12036..081d0cd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,32 +1,5 @@ -[package] -name = "shrs" -version = "0.1.0" -edition = "2021" -license = "MIT OR Apache-2.0" -authors = ["MrPicklePinosaur"] -description = "modular library to build your own shell in rust" -repository = "https://github.com/MrPicklePinosaur/sh.rs" -build = "build.rs" - -[dependencies] -lalrpop-util = { version = "0.19.8", features = ["lexer"] } -regex = "1" -signal-hook = "0.3" -crossbeam-channel = "0.5" -clap = { version = "4.1", features = ["derive"] } - -reedline = "0.16" - -pino_deref = "0.1" - -thiserror = "1" -anyhow = "1" - -[dev-dependencies] -rexpect = "0.5" - -[build-dependencies] -lalrpop = { version = "0.19.8", features = ["lexer"] } - -[[example]] -name = "simple" \ No newline at end of file +[workspace] +members = [ + "shrs_lib", + "reedline" +] diff --git a/reedline/.github/ISSUE_TEMPLATE/bug_report.md b/reedline/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..2f37c131 --- /dev/null +++ b/reedline/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,21 @@ +--- +name: Bug report +about: Something doesn't work as expected or is broken +title: '' +labels: bug +assignees: '' + +--- + +**Platform** e.g. macOS, Windows 10, ... +**Terminal software** e.g. gnome-terminal, Apple Terminal.app, ... + +Describe the problem you are observing. + +## Steps to reproduce +1. minimal posssible steps necessary +2. to cause the problem + +## Screenshots/Screencaptures + +Very helpful if the display doesn't seem to work diff --git a/reedline/.github/ISSUE_TEMPLATE/missing-feature.md b/reedline/.github/ISSUE_TEMPLATE/missing-feature.md new file mode 100644 index 00000000..833feb21 --- /dev/null +++ b/reedline/.github/ISSUE_TEMPLATE/missing-feature.md @@ -0,0 +1,14 @@ +--- +name: Missing feature +about: Suggest an idea for reedline +title: '' +labels: enhancement +assignees: '' + +--- + +Let us know about features you really want to see in reedline. + +## References + +If the feature you are interested in exists in other shells or terminal editors, please share links to documentation or screenshots to easily communicate the desired behavior! diff --git a/reedline/.github/workflows/ci.yml b/reedline/.github/workflows/ci.yml new file mode 100644 index 00000000..cd72c8e8 --- /dev/null +++ b/reedline/.github/workflows/ci.yml @@ -0,0 +1,65 @@ +on: + pull_request: + push: # Run CI on the main branch after every merge. This is important to fill the GitHub Actions cache in a way that pull requests can see it + branches: + - main + +name: continuous-integration + +jobs: + build-lint-test: + strategy: + fail-fast: false + matrix: + platform: [ubuntu-latest] + rust: + - stable + # Define the feature sets that will be built here (for caching you define a separate name) + style: [bashisms, default, sqlite, basqlite, external_printer] + include: + - style: bashisms + flags: "--features bashisms" + - style: external_printer + flags: "--features external_printer" + - style: default + flags: "" + - style: sqlite + flags: "--features sqlite" + - style: basqlite + flags: "--features bashisms,sqlite" + + runs-on: ${{ matrix.platform }} + + steps: + - uses: actions/checkout@v3 + + - name: Setup Rust toolchain + uses: actions-rust-lang/setup-rust-toolchain@v1.3.4 + + - name: Setup nextest + uses: taiki-e/install-action@nextest + + - name: Rustfmt + uses: actions-rs/cargo@v1.0.1 + with: + command: fmt + args: --all -- --check + + - name: Clippy + uses: actions-rs/cargo@v1.0.1 + with: + command: clippy + args: ${{ matrix.flags }} --all -- -D warnings + + + - name: Tests + uses: actions-rs/cargo@v1.0.1 + with: + command: nextest + args: run --all ${{ matrix.flags }} + + - name: Doctests + uses: actions-rs/cargo@v1.0.1 + with: + command: test + args: --doc ${{ matrix.flags }} diff --git a/reedline/.gitignore b/reedline/.gitignore new file mode 100644 index 00000000..68008fe3 --- /dev/null +++ b/reedline/.gitignore @@ -0,0 +1,11 @@ +target/ +history.txt +history.sqlite3* +.DS_Store +target-coverage/ +tarpaulin-report.html +.vscode +.helix + +# ignore the git mailmap file +.mailmap diff --git a/reedline/CONTRIBUTING.md b/reedline/CONTRIBUTING.md new file mode 100644 index 00000000..9d930baa --- /dev/null +++ b/reedline/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing + +reedline's development is primarily driven by the [nushell project](https://github.com/nushell) at the moment to provide its interactive REPL. +Our goal is to explore options for a pleasant interaction with a shell and programming language. +While the maintainers might currently prioritize working on features for nushell, we are open to ideas and contributions by people and projects interested in using reedline for other projects. +Feel free to open an [issue](https://github.com/nushell/reedline/issues/new/choose) or chat with us on the [nushell discord](https://discordapp.com/invite/NtAbbGn) in the dedicated `#reedline` channel + +## Good starting points + +If you want to get started, check out the list of [issues](https://github.com/nushell/reedline/issues) with the ["good first issue" label](https://github.com/nushell/reedline/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). + +If you want to follow along with the history of how reedline got started, you can watch the [recordings](https://youtube.com/playlist?list=PLP2yfE2-FXdQw0I6O4YdIX_mzBeF5TDdv) of [JT](https://github.com/jntrnr)`s [live-coding streams](https://www.twitch.tv/jntrnr). + +[Playlist: Creating a line editor in Rust](https://youtube.com/playlist?list=PLP2yfE2-FXdQw0I6O4YdIX_mzBeF5TDdv) + +## Developing + +### Set up + +This is no different than other Rust projects. + +```bash +git clone https://github.com/nushell/reedline +cd reedline +# To try our example program +cargo run --example basic +``` + +### Code style + +We follow the standard rust formatting style and conventions suggested by [clippy](https://github.com/rust-lang/rust-clippy). + +### To make the CI gods happy + +Before submitting a PR make sure to run: + +- for formatting + + ```shell + cargo fmt --all + ``` + +- the clippy lints + + ```shell + cargo clippy + ``` + +- the test suite + + ```shell + cargo test + ``` diff --git a/reedline/Cargo.lock b/reedline/Cargo.lock new file mode 100644 index 00000000..8648b20d --- /dev/null +++ b/reedline/Cargo.lock @@ -0,0 +1,1081 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ahash" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcb51a0695d8f838b1ee009b3fbf66bda078cd64590202a864a8f3e8c4315c47" +dependencies = [ + "getrandom", + "once_cell", + "version_check", +] + +[[package]] +name = "android_system_properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7ed72e1635e121ca3e79420540282af22da58be50de153d36f81ddc6b83aa9e" +dependencies = [ + "libc", +] + +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "bumpalo" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ad822118d20d2c234f427000d5acc36eabe1e29a348c89b63dd60b13f28e5d" + +[[package]] +name = "cc" +version = "1.0.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfd4d1b31faaa3a89d7934dbded3111da0d2ef28e3ebccdb4f0179f5929d1ef1" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-integer", + "num-traits", + "time", + "wasm-bindgen", + "winapi", +] + +[[package]] +name = "clipboard" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25a904646c0340239dcf7c51677b33928bf24fdf424b79a57909c0109075b2e7" +dependencies = [ + "clipboard-win", + "objc", + "objc-foundation", + "objc_id", + "x11-clipboard", +] + +[[package]] +name = "clipboard-win" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a093d6fed558e5fe24c3dfc85a68bb68f1c824f440d3ba5aca189e2998786b" +dependencies = [ + "winapi", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5827cebf4670468b8772dd191856768aedcb1b0278a04f989f7766351917b9dc" + +[[package]] +name = "crossbeam" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2801af0d36612ae591caa9568261fddce32ce6e08a7275ea334a06a4ad021a2c" +dependencies = [ + "cfg-if", + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2dd04ddaf88237dc3b8d8f9a3c1004b506b54b3313403944054d23c0870c521" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "715e8152b692bba2d374b53d4875445368fdf21a94751410af607a5ac677d1fc" +dependencies = [ + "cfg-if", + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045ebe27666471bb549370b4b0b3e51b07f56325befa4284db65fc89c02511b1" +dependencies = [ + "autocfg", + "cfg-if", + "crossbeam-utils", + "memoffset", + "once_cell", + "scopeguard", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd42583b04998a5363558e5f9291ee5a5ff6b49944332103f251e7479a82aa7" +dependencies = [ + "cfg-if", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51887d4adc7b564537b15adcfb307936f8075dfcd5f00dde9a9f1d29383682bc" +dependencies = [ + "cfg-if", + "once_cell", +] + +[[package]] +name = "crossterm" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab9f7409c70a38a56216480fba371ee460207dd8926ccf5b4160591759559170" +dependencies = [ + "bitflags", + "crossterm_winapi", + "libc", + "mio", + "parking_lot", + "serde", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae1b35a484aa10e07fe0638d02301c5ad24de82d310ccbd2f3693da5f09bf1c" +dependencies = [ + "winapi", +] + +[[package]] +name = "ctor" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdffe87e1d521a10f9696f833fe502293ea446d7f256c06128293a4119bdf4cb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + +[[package]] +name = "either" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797" + +[[package]] +name = "errno" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f639046355ee4f37944e44f60642c6f3a7efa3cf6b78c78a0d989a8ce6c396a1" +dependencies = [ + "errno-dragonfly", + "libc", + "winapi", +] + +[[package]] +name = "errno-dragonfly" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa68f1b12764fab894d2755d2518754e71b4fd80ecfb822714a1206c2aab39bf" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499" +dependencies = [ + "instant", +] + +[[package]] +name = "fd-lock" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11dcc7e4d79a8c89b9ab4c6f5c30b1fc4a83c420792da3542fd31179ed5f517" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys", +] + +[[package]] +name = "gethostname" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1ebd34e35c46e00bb73e81363248d627782724609fe1b6396f553f68fe3862e" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "getrandom" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4eb1a864a501629691edf6c15a593b7a51eebaa1e8468e9ddc623de7c9b58ec6" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.0+wasi-snapshot-preview1", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d452c155cb93fecdfb02a73dd57b5d8e442c2063bd7aac72f1bc5e4263a43086" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "heck" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2540771e65fc8cb83cd6e8a237f70c319bd5c29f78ed1084ba5d50eeac86f7f9" + +[[package]] +name = "iana-time-zone" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad2bfd338099682614d3ee3fe0cd72e0b6a41ca6a87f6a74a3bd593c91650501" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "js-sys", + "wasm-bindgen", + "winapi", +] + +[[package]] +name = "instant" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "io-lifetimes" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ea37f355c05dde75b84bba2d767906ad522e97cd9e2eef2be7a4ab7fb442c06" + +[[package]] +name = "itertools" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9a9d19fa1e79b6215ff29b9d6880b706147f16e9b1dbb1e4e5947b5b02bc5e3" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8af84674fe1f223a982c933a0ee1086ac4d4052aa0fb8060c12c6ad838e754" + +[[package]] +name = "js-sys" +version = "0.3.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "258451ab10b34f8af53416d1fdab72c22e805f0c92a1136d59470ec0b11138b2" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.132" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5" + +[[package]] +name = "libsqlite3-sys" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f0455f2c1bc9a7caa792907026e469c1d91761fb0ea37cbb16427c77280cf35" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d2456c373231a208ad294c33dc5bff30051eafd954cd4caae83a712b12854d" + +[[package]] +name = "lock_api" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mio" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ee1c23c7c63b0c9250c339ffdc69255f110b298b901b9f6c82547b7b87caaf" +dependencies = [ + "libc", + "log", + "wasi 0.11.0+wasi-snapshot-preview1", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-integer" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225d3389fb3509a24c93f5c29eb6bde2586b98d9f016636dff58d7c6f7569cd9" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "578ede34cf02f8924ab9447f50c28075b4d3e5b269972345e7e0372b38c6cdcd" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + +[[package]] +name = "once_cell" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "074864da206b4973b84eb91683020dbefd6a8c3f0f38e054d93954e891935e4e" + +[[package]] +name = "output_vt100" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628223faebab4e3e40667ee0b2336d34a5b960ff60ea743ddfdbcf7770bcfb66" +dependencies = [ + "winapi", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a279cbf25cb0757810394fbc1e359949b59e348145c643a939a525692e6929" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-sys", +] + +[[package]] +name = "pkg-config" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1df8c4ec4b0627e53bdf214615ad287367e482558cf84b109250b37464dc03ae" + +[[package]] +name = "pretty_assertions" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89f989ac94207d048d92db058e4f6ec7342b0971fc58d1271ca148b799b3563" +dependencies = [ + "ansi_term", + "ctor", + "diff", + "output_vt100", +] + +[[package]] +name = "proc-macro2" +version = "1.0.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2ca2c61bc9f3d74d2886294ab7b9853abd9c1ad903a3ac7815c58989bb7bab" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reedline" +version = "0.16.0" +dependencies = [ + "chrono", + "clipboard", + "crossbeam", + "crossterm", + "fd-lock", + "gethostname", + "itertools", + "nu-ansi-term", + "pretty_assertions", + "rstest", + "rusqlite", + "serde", + "serde_json", + "strip-ansi-escapes", + "strum", + "strum_macros", + "tempfile", + "thiserror", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + +[[package]] +name = "rstest" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b07f2d176c472198ec1e6551dc7da28f1c089652f66a7b722676c2238ebc0edf" +dependencies = [ + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7229b505ae0706e64f37ffc54a9c163e11022a6636d58fe1f3f52018257ff9f7" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-ident", +] + +[[package]] +name = "rusqlite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01e213bc3ecb39ac32e81e51ebe31fd888a940515173e3a18a35f8c6e896422a" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.35.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72c825b8aa8010eb9ee99b75f05e10180b9278d161583034d7574c9d617aeada" +dependencies = [ + "bitflags", + "errno", + "io-lifetimes", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97477e48b4cf8603ad5f7aaf897467cf42ab4218a38ef76fb14c2d6773a6d6a8" + +[[package]] +name = "ryu" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4501abdff3ae82a1c1b477a17252eb69cee9e66eb915c1abaa4f44d873df9f09" + +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "semver" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93f6841e709003d68bb2deee8c343572bf446003ec20a583e76f7b15cebf3711" + +[[package]] +name = "serde" +version = "1.0.144" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f747710de3dcd43b88c9168773254e809d8ddbdf9653b84e2554ab219f17860" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.144" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94ed3a816fb1d101812f83e789f888322c34e291f894f19590dc310963e87a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "signal-hook" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a253b5e89e2698464fc26b545c9edceb338e18a89effeeecfea192c3025be29d" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +dependencies = [ + "libc", +] + +[[package]] +name = "smallvec" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1" + +[[package]] +name = "strip-ansi-escapes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "011cbb39cf7c1f62871aea3cc46e5817b0937b49e9447370c93cacbe93a766d8" +dependencies = [ + "vte", +] + +[[package]] +name = "strum" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" + +[[package]] +name = "strum_macros" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "syn" +version = "1.0.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58dbef6ec655055e20b86b15a8cc6d439cca19b667537ac6a1369572d151ab13" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +dependencies = [ + "cfg-if", + "fastrand", + "libc", + "redox_syscall", + "remove_dir_all", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f6586b7f764adc0231f4c79be7b920e766bb2f3e51b3661cdb263828f19994" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bafc5b54507e0149cdf1b145a5d80ab80a90bcd9275df43d4fff68460f6c21" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db9e6914ab8b1ae1c260a4ae7a49b6c5611b40328a735b21862567685e73255" +dependencies = [ + "libc", + "wasi 0.10.0+wasi-snapshot-preview1", + "winapi", +] + +[[package]] +name = "unicode-ident" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84a22b9f218b40614adcb3f4ff08b703773ad44fa9423e4e0d346d5db86e4ebc" + +[[package]] +name = "unicode-segmentation" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" + +[[package]] +name = "unicode-width" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" + +[[package]] +name = "utf8parse" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "936e4b492acfd135421d8dca4b1aa80a7bfc26e702ef3af710e0752684df5372" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "vte" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cbce692ab4ca2f1f3047fcf732430249c0e971bfdd2b234cf2c47ad93af5983" +dependencies = [ + "arrayvec", + "utf8parse", + "vte_generate_state_changes", +] + +[[package]] +name = "vte_generate_state_changes" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d257817081c7dffcdbab24b9e62d2def62e2ff7d00b1c20062551e6cccc145ff" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "wasi" +version = "0.10.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a143597ca7c7793eff794def352d41792a93c481eb1042423ff7ff72ba2c31f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7652e3f6c4706c8d9cd54832c4a4ccb9b5336e2c3bd154d5cccfbf1c1f5f7d" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "662cd44805586bd52971b9586b1df85cdbbd9112e4ef4d8f41559c334dc6ac3f" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b260f13d3012071dfb1512849c033b1925038373aea48ced3012c09df952c602" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be8e654bdd9b79216c2929ab90721aa82faf65c48cdf08bdc4e7f51357b80da" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6598dd0bd3c7d51095ff6531a5b23e02acdc81804e30d8f07afb77b7215a140a" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-sys" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea04155a16a59f9eab786fe12a4a450e75cdb175f9e0d80da1e17db09f55b8d2" +dependencies = [ + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb8c3fd39ade2d67e9874ac4f3db21f0d710bee00fe7cab16949ec184eeaa47" + +[[package]] +name = "windows_i686_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180e6ccf01daf4c426b846dfc66db1fc518f074baa793aa7d9b9aaeffad6a3b6" + +[[package]] +name = "windows_i686_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e7917148b2812d1eeafaeb22a97e4813dfa60a3f8f78ebe204bcc88f12f024" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dcd171b8776c41b97521e5da127a2d86ad280114807d0b2ab1e462bc764d9e1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c811ca4a8c853ef420abd8592ba53ddbbac90410fab6903b3e79972a631f7680" + +[[package]] +name = "x11-clipboard" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89bd49c06c9eb5d98e6ba6536cf64ac9f7ee3a009b2f53996d405b3944f6bcea" +dependencies = [ + "xcb", +] + +[[package]] +name = "xcb" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e917a3f24142e9ff8be2414e36c649d47d6cc2ba81f16201cdef96e533e02de" +dependencies = [ + "libc", + "log", +] diff --git a/reedline/Cargo.toml b/reedline/Cargo.toml new file mode 100644 index 00000000..c1740537 --- /dev/null +++ b/reedline/Cargo.toml @@ -0,0 +1,45 @@ +[package] +name = "reedline" +version = "0.16.0" +authors = ["The Nushell Project Developers", "JT "] +edition = "2021" +description = "A readline-like crate for CLI text input" +license = "MIT" +repository = "https://github.com/nushell/reedline" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html +[lib] +doctest = true + +[dependencies] +chrono = "0.4.19" +clipboard = { version = "0.5.0", optional = true } +crossterm = { version = "0.24.0", features = ["serde"] } +itertools = "0.10.3" +nu-ansi-term = "0.46.0" +serde = { version = "1.0", features = ["derive"] } +unicode-segmentation = "1.9.0" +unicode-width = "0.1.9" +strip-ansi-escapes = "0.1.1" +strum = "0.24" +strum_macros = "0.24" +fd-lock = "3.0.3" +rusqlite = { version = "0.28.0", optional = true } +serde_json = { version = "1.0.79", optional = true } +thiserror = "1.0.31" +crossbeam = { version = "0.8.2", optional = true } + +[dev-dependencies] +tempfile = "3.3.0" +pretty_assertions = "1.1.0" +rstest = { version = "0.16.0", default-features = false } +# For examples/demo.rs +gethostname = "0.2.3" + +[features] +system_clipboard = ["clipboard"] +bashisms = [] +external_printer = ["crossbeam"] +sqlite = ["rusqlite/bundled", "serde_json"] +sqlite-dynlib = ["rusqlite", "serde_json"] + diff --git a/reedline/LICENSE b/reedline/LICENSE new file mode 100644 index 00000000..25623d08 --- /dev/null +++ b/reedline/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Nushell Project + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/reedline/README.md b/reedline/README.md new file mode 100644 index 00000000..0d6b331c --- /dev/null +++ b/reedline/README.md @@ -0,0 +1,246 @@ +# A readline replacement written in Rust + +![GitHub](https://img.shields.io/github/license/nushell/reedline) +[![Crates.io](https://img.shields.io/crates/v/reedline)](https://crates.io/crates/reedline) +[![docs.rs](https://img.shields.io/docsrs/reedline)](https://docs.rs/reedline/) +![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/nushell/reedline/ci.yml?branch=main) +[![Discord](https://img.shields.io/discord/601130461678272522.svg?logo=discord)](https://discord.gg/NtAbbGn) + +## Introduction + +Reedline is a project to create a line editor (like bash's `readline` or zsh's `zle`) that supports many of the modern conveniences of CLIs, including syntax highlighting, completions, multiline support, Unicode support, and more. +It is currently primarily developed as the interactive editor for [nushell](https://github.com/nushell/nushell) (starting with `v0.60`) striving to provide a pleasant interactive experience. + +## Outline + +- [Examples](#examples) + - [Basic example](#basic-example) + - [Integrate with custom keybindings](#integrate-with-custom-keybindings) + - [Integrate with `History`](#integrate-with-history) + - [Integrate with custom syntax `Highlighter`](#integrate-with-custom-syntax-highlighter) + - [Integrate with custom tab completion](#integrate-with-custom-tab-completion) + - [Integrate with `Hinter` for fish-style history autosuggestions](#integrate-with-hinter-for-fish-style-history-autosuggestions) + - [Integrate with custom line completion `Validator`](#integrate-with-custom-line-completion-validator) + - [Use custom `EditMode`](#use-custom-editmode) +- [Crate features](#crate-features) +- [Are we prompt yet? (Development status)](#are-we-prompt-yet-development-status) +- [Contributing](./CONTRIBUTING.md) +- [Alternatives](#alternatives) + +## Examples + +For the full documentation visit . The examples should highlight how you enable the most important features or which traits can be implemented for language-specific behavior. + +### Basic example + +```rust,no_run +// Create a default reedline object to handle user input + +use reedline::{DefaultPrompt, Reedline, Signal}; + +let mut line_editor = Reedline::create(); +let prompt = DefaultPrompt::default(); + +loop { + let sig = line_editor.read_line(&prompt); + match sig { + Ok(Signal::Success(buffer)) => { + println!("We processed: {}", buffer); + } + Ok(Signal::CtrlD) | Ok(Signal::CtrlC) => { + println!("\nAborted!"); + break; + } + x => { + println!("Event: {:?}", x); + } + } +} +``` + +### Integrate with custom keybindings + +```rust +// Configure reedline with custom keybindings + +//Cargo.toml +// [dependencies] +// crossterm = "*" + +use { + crossterm::event::{KeyCode, KeyModifiers}, + reedline::{default_emacs_keybindings, EditCommand, Reedline, Emacs, ReedlineEvent}, +}; + +let mut keybindings = default_emacs_keybindings(); +keybindings.add_binding( + KeyModifiers::ALT, + KeyCode::Char('m'), + ReedlineEvent::Edit(vec![EditCommand::BackspaceWord]), +); +let edit_mode = Box::new(Emacs::new(keybindings)); + +let mut line_editor = Reedline::create().with_edit_mode(edit_mode); +``` + +### Integrate with `History` + +```rust,no_run +// Create a reedline object with history support, including history size limits + +use reedline::{FileBackedHistory, Reedline}; + +let history = Box::new( + FileBackedHistory::with_file(5, "history.txt".into()) + .expect("Error configuring history with file"), +); +let mut line_editor = Reedline::create() + .with_history(history); +``` + +### Integrate with custom syntax `Highlighter` + +```rust +// Create a reedline object with highlighter support + +use reedline::{ExampleHighlighter, Reedline}; + +let commands = vec![ + "test".into(), + "hello world".into(), + "hello world reedline".into(), + "this is the reedline crate".into(), +]; +let mut line_editor = +Reedline::create().with_highlighter(Box::new(ExampleHighlighter::new(commands))); +``` + +### Integrate with custom tab completion + +```rust +// Create a reedline object with tab completions support + +use reedline::{default_emacs_keybindings, ColumnarMenu, DefaultCompleter, Emacs, KeyCode, KeyModifiers, Reedline, ReedlineEvent, ReedlineMenu}; + +let commands = vec![ + "test".into(), + "hello world".into(), + "hello world reedline".into(), + "this is the reedline crate".into(), +]; +let completer = Box::new(DefaultCompleter::new_with_wordlen(commands.clone(), 2)); +// Use the interactive menu to select options from the completer +let completion_menu = Box::new(ColumnarMenu::default().with_name("completion_menu")); +// Set up the required keybindings +let mut keybindings = default_emacs_keybindings(); +keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Tab, + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::Menu("completion_menu".to_string()), + ReedlineEvent::MenuNext, + ]), +); + +let edit_mode = Box::new(Emacs::new(keybindings)); + +let mut line_editor = Reedline::create() + .with_completer(completer) + .with_menu(ReedlineMenu::EngineCompleter(completion_menu)) + .with_edit_mode(edit_mode); +``` + +### Integrate with `Hinter` for fish-style history autosuggestions + +```rust +// Create a reedline object with in-line hint support + +//Cargo.toml +// [dependencies] +// nu-ansi-term = "*" + +use { + nu_ansi_term::{Color, Style}, + reedline::{DefaultHinter, Reedline}, +}; + +let mut line_editor = Reedline::create().with_hinter(Box::new( + DefaultHinter::default() + .with_style(Style::new().italic().fg(Color::LightGray)), +)); +``` + +### Integrate with custom line completion `Validator` + +```rust +// Create a reedline object with line completion validation support + +use reedline::{DefaultValidator, Reedline}; + +let validator = Box::new(DefaultValidator); + +let mut line_editor = Reedline::create().with_validator(validator); +``` + +### Use custom `EditMode` + +```rust +// Create a reedline object with custom edit mode +// This can define a keybinding setting or enable vi-emulation + +use reedline::{ + default_vi_insert_keybindings, default_vi_normal_keybindings, EditMode, Reedline, Vi, +}; + +let mut line_editor = Reedline::create().with_edit_mode(Box::new(Vi::new( + default_vi_insert_keybindings(), + default_vi_normal_keybindings(), +))); +``` + +## Crate features + +- `clipboard`: Enable support to use the `SystemClipboard`. Enabling this feature will return a `SystemClipboard` instead of a local clipboard when calling `get_default_clipboard()`. +- `bashisms`: Enable support for special text sequences that recall components from the history. e.g. `!!` and `!$`. For use in shells like `bash` or [`nushell`](https://nushell.sh). +- `sqlite`: Provides the `SqliteBackedHistory` to store richer information in the history. Statically links the required sqlite version. +- `sqlite-dynlib`: Alternative to the feature `sqlite`. Will not statically link. Requires `sqlite >= 3.38` to link dynamically! +- `external_printer`: **Experimental:** Thread-safe `ExternalPrinter` handle to print lines from concurrently running threads. + +## Are we prompt yet? (Development status) + +Reedline has now all the basic features to become the primary line editor for [nushell](https://github.com/nushell/nushell +) + +- General editing functionality, that should feel familiar coming from other shells (e.g. bash, fish, zsh). +- Configurable keybindings (emacs-style bindings and basic vi-style). +- Configurable prompt +- Content-aware syntax highlighting. +- Autocompletion (With graphical selection menu or simple cycling inline). +- History with interactive search options (optionally persists to file, can support multiple sessions accessing the same file) +- Fish-style history autosuggestion hints +- Undo support. +- Clipboard integration +- Line completeness validation for seamless entry of multiline command sequences. + +### Areas for future improvements + +- [ ] Support for Unicode beyond simple left-to-right scripts +- [ ] Easier keybinding configuration +- [ ] Support for more advanced vi commands +- [ ] Visual selection +- [ ] Smooth experience if completion or prompt content takes long to compute +- [ ] Support for a concurrent output stream from background tasks to be displayed, while the input prompt is active. ("Full duplex" mode) + +For more ideas check out the [feature discussion](https://github.com/nushell/reedline/issues/63) or hop on the `#reedline` channel of the [nushell discord](https://discordapp.com/invite/NtAbbGn). + +### Development history + +If you want to follow along with the history of how reedline got started, you can watch the [recordings](https://youtube.com/playlist?list=PLP2yfE2-FXdQw0I6O4YdIX_mzBeF5TDdv) of [JT](https://github.com/jntrnr)'s [live-coding streams](https://www.twitch.tv/jntrnr). + +[Playlist: Creating a line editor in Rust](https://youtube.com/playlist?list=PLP2yfE2-FXdQw0I6O4YdIX_mzBeF5TDdv) + +### Alternatives + +For currently more mature Rust line editing check out: + +- [rustyline](https://crates.io/crates/rustyline) diff --git a/reedline/UX_TESTING.md b/reedline/UX_TESTING.md new file mode 100644 index 00000000..9e436152 --- /dev/null +++ b/reedline/UX_TESTING.md @@ -0,0 +1,80 @@ +# UX Test Checklist + +As we currently don't have automated tests for the user facing terminal logic, we still have to check a few things manually. +This list does not try to cover every case but tries to catch the most likely breaking points or previous gotchas. +Exhaustiveness should be achieved by covering the components with appropriate unit tests. + +## Do I have to perform all the manual tests? + +Ideally we would validate the user experience for every PR but there are probably some good heuristics for when it is a *really good* idea to run through the manual checklist. + +- Your PR changed the repaint logic. +- You changed how key presses are dispatched. +- You added a completely new component. +- The component you changed is not covered by tests, that uphold a contract for the I/O facing engine. +- You did a large refactoring touching several components at once. + +## Configuration + +To catch potential index overflows etc. running the example binary in debug mode via `cargo run` can be helpful. Yet in some cases the experience might be better/smoother when running the actual release build via `cargo run --release`. This is especially true for resizing. If the slower execution in debug mode causes noticeable issues report them with the checklist. + +> Copy the checklist below, as part of your PR finalization + +## Manual checks + +Relevant features tested (leave open if you did not consider those areas touched by your PR): + +- [ ] core editing and default Emacs keybindings +- [ ] history +- [ ] syntax highlighting +- [ ] completion/hinting +- [ ] vi mode + +### Info + +Build: [ ] debug / [ ] release + +Platform: + +Terminal emulator: + +Inside a [ ] ssh,[ ] tmux or [ ] screen session? + +### Basics + +- [ ] Typing of a short line containing both upper- and lowercase characters. +- [ ] Movement left/right using the arrow keys +- [ ] Word to the left with `Ctrl-b` or `Ctrl-Left`, Word to the right with `Ctrl-f` +- [ ] `Enter` to complete entry + +#### Clearing + +- [ ] Type something and abort the entry with `Ctrl-c`, you should end up on an empty prompt below. +- [ ] Type something and press `Ctrl-l` to clear the screen. Your current entry should still be there and passed through when pressing `Enter` + +#### Unicode and Emojis + +- [ ] Paste the line `Emoji test šŸ˜Š checks šŸ¤¦šŸ¼ā€ā™‚ļø unicode` and move the cursor over the emojis. +- [ ] Are you able to delete the smiley? +- [ ] `Home`/`End` at accurate positions +- [ ] Check that the emoji containing line can be entered + +## History + +- [ ] On the empty line press the `up-arrow` key to see if you can recall the previous entry +- [ ] Press `Enter` to execute this line (it should *not* be duplicated in the history, after checking leave history recall by `down-arrow`) +- [ ] On an empty line start typing the beginning of a line in the history. Hit the `up-arrow` to find the matching entry. +- [ ] After that run `Ctrl-r` to start traditional reverse search. Type your initial search. Can you find more hits by pressing `Ctrl-r` or `up-arrow`? +- [ ] Abort this search by pressing `Ctrl-c` + +## Syntax highlighting + +- [ ] Upon entering `test`, this word is highlighted differently. + +## Completion + +**TODO:** *define desired behavior* + +### VI mode + +**TODO:** *define basic set to test* diff --git a/reedline/examples/basic.rs b/reedline/examples/basic.rs new file mode 100644 index 00000000..8aef71e1 --- /dev/null +++ b/reedline/examples/basic.rs @@ -0,0 +1,27 @@ +// Create a default reedline object to handle user input +// cargo run --example basic +// +// You can browse the local (non persistent) history using Up/Down or Ctrl n/p. + +use std::io; + +use reedline::{DefaultPrompt, Reedline, Signal}; + +fn main() -> io::Result<()> { + // Create a new Reedline engine with a local History that is not synchronized to a file. + let mut line_editor = Reedline::create(); + let prompt = DefaultPrompt::default(); + + loop { + let sig = line_editor.read_line(&prompt)?; + match sig { + Signal::Success(buffer) => { + println!("We processed: {buffer}"); + }, + Signal::CtrlD | Signal::CtrlC => { + println!("\nAborted!"); + break Ok(()); + }, + } + } +} diff --git a/reedline/examples/completions.rs b/reedline/examples/completions.rs new file mode 100644 index 00000000..5dc162f3 --- /dev/null +++ b/reedline/examples/completions.rs @@ -0,0 +1,59 @@ +// Create a reedline object with tab completions support +// cargo run --example completions +// +// "t" [Tab] will allow you to select the completions "test" and "this is the reedline crate" +// [Enter] to select the chosen alternative + +use std::io; + +use reedline::{ + default_emacs_keybindings, ColumnarMenu, DefaultCompleter, DefaultPrompt, Emacs, KeyCode, + KeyModifiers, Keybindings, Reedline, ReedlineEvent, ReedlineMenu, Signal, +}; + +fn add_menu_keybindings(keybindings: &mut Keybindings) { + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Tab, + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::Menu("completion_menu".to_string()), + ReedlineEvent::MenuNext, + ]), + ); +} +fn main() -> io::Result<()> { + let commands = vec![ + "test".into(), + "hello world".into(), + "hello world reedline".into(), + "this is the reedline crate".into(), + ]; + let completer = Box::new(DefaultCompleter::new_with_wordlen(commands, 2)); + // Use the interactive menu to select options from the completer + let completion_menu = Box::new(ColumnarMenu::default().with_name("completion_menu")); + + let mut keybindings = default_emacs_keybindings(); + add_menu_keybindings(&mut keybindings); + + let edit_mode = Box::new(Emacs::new(keybindings)); + + let mut line_editor = Reedline::create() + .with_completer(completer) + .with_menu(ReedlineMenu::EngineCompleter(completion_menu)) + .with_edit_mode(edit_mode); + + let prompt = DefaultPrompt::default(); + + loop { + let sig = line_editor.read_line(&prompt)?; + match sig { + Signal::Success(buffer) => { + println!("We processed: {buffer}"); + }, + Signal::CtrlD | Signal::CtrlC => { + println!("\nAborted!"); + break Ok(()); + }, + } + } +} diff --git a/reedline/examples/custom_prompt.rs b/reedline/examples/custom_prompt.rs new file mode 100644 index 00000000..489fe470 --- /dev/null +++ b/reedline/examples/custom_prompt.rs @@ -0,0 +1,76 @@ +// Create a reedline object with a custom prompt. +// cargo run --example custom_prompt +// +// Pressing keys will increase the right prompt value + +use std::{borrow::Cow, cell::Cell, io}; + +use reedline::{ + Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, Reedline, Signal, +}; + +// For custom prompt, implement the Prompt trait +// +// This example displays the number of keystrokes +// or rather increments each time the prompt is rendered. +#[derive(Clone)] +pub struct CustomPrompt(Cell, &'static str); +pub static DEFAULT_MULTILINE_INDICATOR: &str = "::: "; +impl Prompt for CustomPrompt { + fn render_prompt_left(&self) -> Cow { + { + Cow::Owned(self.1.to_string()) + } + } + + fn render_prompt_right(&self) -> Cow { + { + let old = self.0.get(); + self.0.set(old + 1); + Cow::Owned(format!("[{old}]")) + } + } + + fn render_prompt_indicator(&self, _edit_mode: PromptEditMode) -> Cow { + Cow::Owned(">".to_string()) + } + + fn render_prompt_multiline_indicator(&self) -> Cow { + Cow::Borrowed(DEFAULT_MULTILINE_INDICATOR) + } + + fn render_prompt_history_search_indicator( + &self, + history_search: PromptHistorySearch, + ) -> Cow { + let prefix = match history_search.status { + PromptHistorySearchStatus::Passing => "", + PromptHistorySearchStatus::Failing => "failing ", + }; + + Cow::Owned(format!( + "({}reverse-search: {}) ", + prefix, history_search.term + )) + } +} + +fn main() -> io::Result<()> { + println!("Custom prompt demo:\nAbort with Ctrl-C or Ctrl-D"); + let mut line_editor = Reedline::create(); + + let prompt = CustomPrompt(Cell::new(0), "Custom Prompt"); + + loop { + let sig = line_editor.read_line(&prompt)?; + match sig { + Signal::Success(buffer) => { + println!("We processed: {buffer}"); + }, + Signal::CtrlD | Signal::CtrlC => { + println!("\nAborted!"); + break Ok(()); + }, + } + } +} diff --git a/reedline/examples/demo.rs b/reedline/examples/demo.rs new file mode 100644 index 00000000..f38a737f --- /dev/null +++ b/reedline/examples/demo.rs @@ -0,0 +1,219 @@ +use crossterm::{ + cursor::CursorShape, + event::{KeyCode, KeyModifiers}, + Result, +}; +use nu_ansi_term::{Color, Style}; +#[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))] +use reedline::FileBackedHistory; +use reedline::{ + default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings, + ColumnarMenu, CursorConfig, DefaultCompleter, DefaultHinter, DefaultPrompt, DefaultValidator, + EditCommand, EditMode, Emacs, ExampleHighlighter, Keybindings, ListMenu, Reedline, + ReedlineEvent, ReedlineMenu, Signal, Vi, +}; + +fn main() -> Result<()> { + println!("Ctrl-D to quit"); + // quick command like parameter handling + let vi_mode = matches!(std::env::args().nth(1), Some(x) if x == "--vi"); + + #[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))] + let history = Box::new( + reedline::SqliteBackedHistory::with_file("history.sqlite3".into()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?, + ); + #[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))] + let history = Box::new(FileBackedHistory::with_file(50, "history.txt".into())?); + let commands = vec![ + "test".into(), + "clear".into(), + "exit".into(), + "history 1".into(), + "history 2".into(), + "history 3".into(), + "history 4".into(), + "history 5".into(), + "logout".into(), + "login".into(), + "hello world".into(), + "hello world reedline".into(), + "hello world something".into(), + "hello world another".into(), + "hello world 1".into(), + "hello world 2".into(), + "hello world 3".into(), + "hello world 4".into(), + "hello another very large option for hello word that will force one column".into(), + "this is the reedline crate".into(), + "abaaacas".into(), + "abaaac".into(), + "abaaaxyc".into(), + "abaaarabc".into(), + "恓悓恫恔ćÆäø–ē•Œ".into(), + "恓悓恰悓ćÆäø–ē•Œ".into(), + ]; + + let completer = Box::new(DefaultCompleter::new_with_wordlen(commands.clone(), 2)); + + let cursor_config = CursorConfig { + vi_insert: Some(CursorShape::Line), + vi_normal: Some(CursorShape::Block), + emacs: None, + }; + + let mut line_editor = Reedline::create() + .with_history(history) + .with_completer(completer) + .with_quick_completions(true) + .with_partial_completions(true) + .with_cursor_config(cursor_config) + .with_highlighter(Box::new(ExampleHighlighter::new(commands))) + .with_hinter(Box::new( + DefaultHinter::default().with_style(Style::new().fg(Color::DarkGray)), + )) + .with_validator(Box::new(DefaultValidator)) + .with_ansi_colors(true); + + // Adding default menus for the compiled reedline + line_editor = line_editor + .with_menu(ReedlineMenu::EngineCompleter(Box::new( + ColumnarMenu::default().with_name("completion_menu"), + ))) + .with_menu(ReedlineMenu::HistoryMenu(Box::new( + ListMenu::default().with_name("history_menu"), + ))); + + let edit_mode: Box = if vi_mode { + let mut normal_keybindings = default_vi_normal_keybindings(); + let mut insert_keybindings = default_vi_insert_keybindings(); + + add_menu_keybindings(&mut normal_keybindings); + add_menu_keybindings(&mut insert_keybindings); + + add_newline_keybinding(&mut insert_keybindings); + + Box::new(Vi::new(insert_keybindings, normal_keybindings)) + } else { + let mut keybindings = default_emacs_keybindings(); + add_menu_keybindings(&mut keybindings); + add_newline_keybinding(&mut keybindings); + + Box::new(Emacs::new(keybindings)) + }; + + line_editor = line_editor.with_edit_mode(edit_mode); + + // Adding vi as text editor + line_editor = line_editor.with_buffer_editor("vi".into(), "nu".into()); + + let prompt = DefaultPrompt::default(); + + loop { + let sig = line_editor.read_line(&prompt); + + match sig { + Ok(Signal::CtrlD) => { + break; + }, + Ok(Signal::Success(buffer)) => { + #[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))] + let start = std::time::Instant::now(); + // save timestamp, cwd, hostname to history + #[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))] + if !buffer.is_empty() { + line_editor + .update_last_command_context(&|mut c: reedline::HistoryItem| { + c.start_timestamp = Some(chrono::Utc::now()); + c.hostname = + Some(gethostname::gethostname().to_string_lossy().to_string()); + c.cwd = std::env::current_dir() + .ok() + .map(|e| e.to_string_lossy().to_string()); + c + }) + .expect("todo: error handling"); + } + if (buffer.trim() == "exit") || (buffer.trim() == "logout") { + break; + } + if buffer.trim() == "clear" { + line_editor.clear_scrollback()?; + continue; + } + if buffer.trim() == "history" { + line_editor.print_history()?; + continue; + } + if buffer.trim() == "clear-history" { + let hstry = Box::new(line_editor.history_mut()); + hstry + .clear() + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + continue; + } + println!("Our buffer: {buffer}"); + #[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))] + if !buffer.is_empty() { + line_editor + .update_last_command_context(&|mut c| { + c.duration = Some(start.elapsed()); + c.exit_status = Some(0); + c + }) + .expect("todo: error handling"); + } + }, + Ok(Signal::CtrlC) => { + // Prompt has been cleared and should start on the next line + }, + Err(err) => { + println!("Error: {err:?}"); + }, + } + } + + println!(); + Ok(()) +} + +fn add_menu_keybindings(keybindings: &mut Keybindings) { + keybindings.add_binding( + KeyModifiers::CONTROL, + KeyCode::Char('x'), + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::Menu("history_menu".to_string()), + ReedlineEvent::MenuPageNext, + ]), + ); + + keybindings.add_binding( + KeyModifiers::CONTROL | KeyModifiers::SHIFT, + KeyCode::Char('x'), + ReedlineEvent::MenuPagePrevious, + ); + + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Tab, + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::Menu("completion_menu".to_string()), + ReedlineEvent::Edit(vec![EditCommand::Complete]), + ]), + ); + + keybindings.add_binding( + KeyModifiers::SHIFT, + KeyCode::BackTab, + ReedlineEvent::MenuPrevious, + ); +} + +fn add_newline_keybinding(keybindings: &mut Keybindings) { + // This doesn't work for macOS + keybindings.add_binding( + KeyModifiers::ALT, + KeyCode::Enter, + ReedlineEvent::Edit(vec![EditCommand::InsertNewline]), + ); +} diff --git a/reedline/examples/event_listener.rs b/reedline/examples/event_listener.rs new file mode 100644 index 00000000..07890653 --- /dev/null +++ b/reedline/examples/event_listener.rs @@ -0,0 +1,72 @@ +use std::{ + io::{stdout, Write}, + time::Duration, +}; + +use crossterm::{ + event::{poll, Event, KeyCode, KeyEvent}, + terminal, Result, +}; + +fn main() -> Result<()> { + println!("Ready to print events (Abort with ESC):"); + print_events()?; + println!(); + Ok(()) +} + +/// **For debugging purposes only:** Track the terminal events observed by [`Reedline`] and print them. +pub fn print_events() -> Result<()> { + stdout().flush()?; + terminal::enable_raw_mode()?; + let result = print_events_helper(); + terminal::disable_raw_mode()?; + + result +} + +// this fn is totally ripped off from crossterm's examples +// it's really a diagnostic routine to see if crossterm is +// even seeing the events. if you press a key and no events +// are printed, it's a good chance your terminal is eating +// those events. +fn print_events_helper() -> Result<()> { + loop { + // Wait up to 5s for another event + if poll(Duration::from_millis(5_000))? { + // It's guaranteed that read() wont block if `poll` returns `Ok(true)` + let event = crossterm::event::read()?; + + if let Event::Key(KeyEvent { code, modifiers }) = event { + match code { + KeyCode::Char(c) => { + println!( + "Char: {} code: {:#08x}; Modifier {:?}; Flags {:#08b}\r", + c, + u32::from(c), + modifiers, + modifiers + ); + }, + _ => { + println!( + "Keycode: {code:?}; Modifier {modifiers:?}; Flags {modifiers:#08b}\r" + ); + }, + } + } else { + println!("Event::{event:?}\r"); + } + + // hit the esc key to git out + if event == Event::Key(KeyCode::Esc.into()) { + break; + } + } else { + // Timeout expired, no event for 5s + println!("Waiting for you to type...\r"); + } + } + + Ok(()) +} diff --git a/reedline/examples/external_printer.rs b/reedline/examples/external_printer.rs new file mode 100644 index 00000000..d48d196c --- /dev/null +++ b/reedline/examples/external_printer.rs @@ -0,0 +1,64 @@ +// Create a default reedline object to handle user input +// to run: +// cargo run --example external_printer --features=external_printer + +#[cfg(feature = "external_printer")] +use { + reedline::ExternalPrinter, + reedline::{DefaultPrompt, Reedline, Signal}, + std::thread, + std::thread::sleep, + std::time::Duration, +}; + +#[cfg(feature = "external_printer")] +fn main() { + let printer = ExternalPrinter::default(); + // make a clone to use it in a different thread + let p_clone = printer.clone(); + // get the Sender to have full sending control + let p_sender = printer.sender(); + + // external printer that prints a message every second + thread::spawn(move || { + let mut i = 1; + loop { + sleep(Duration::from_secs(1)); + assert!(p_clone.print(format!("Message {i} delivered.")).is_ok()); + i += 1; + } + }); + + // external printer that prints a bunch of messages after 3 seconds + thread::spawn(move || { + sleep(Duration::from_secs(3)); + for _ in 0..10 { + sleep(Duration::from_millis(1)); + assert!(p_sender.send("Fast Hello !".to_string()).is_ok()); + } + }); + + let mut line_editor = Reedline::create().with_external_printer(printer); + let prompt = DefaultPrompt::default(); + + loop { + if let Ok(sig) = line_editor.read_line(&prompt) { + match sig { + Signal::Success(buffer) => { + println!("We processed: {buffer}"); + }, + Signal::CtrlD | Signal::CtrlC => { + println!("\nAborted!"); + break; + }, + } + continue; + } + break; + } +} + +#[cfg(not(feature = "external_printer"))] +fn main() { + println!("Please enable the feature: ā€˜external_printerā€˜") +} diff --git a/reedline/examples/highlighter.rs b/reedline/examples/highlighter.rs new file mode 100644 index 00000000..7e1e8d66 --- /dev/null +++ b/reedline/examples/highlighter.rs @@ -0,0 +1,32 @@ +// Create a reedline object with highlighter support. +// cargo run --example highlighter +// +// unmatched input is marked red, matched input is marked green +use std::io; + +use reedline::{DefaultPrompt, ExampleHighlighter, Reedline, Signal}; + +fn main() -> io::Result<()> { + let commands = vec![ + "test".into(), + "hello world".into(), + "hello world reedline".into(), + "this is the reedline crate".into(), + ]; + let mut line_editor = + Reedline::create().with_highlighter(Box::new(ExampleHighlighter::new(commands))); + let prompt = DefaultPrompt::default(); + + loop { + let sig = line_editor.read_line(&prompt)?; + match sig { + Signal::Success(buffer) => { + println!("We processed: {buffer}"); + }, + Signal::CtrlD | Signal::CtrlC => { + println!("\nAborted!"); + break Ok(()); + }, + } + } +} diff --git a/reedline/examples/hinter.rs b/reedline/examples/hinter.rs new file mode 100644 index 00000000..fa1d2b44 --- /dev/null +++ b/reedline/examples/hinter.rs @@ -0,0 +1,32 @@ +// Create a reedline object with in-line hint support. +// cargo run --example hinter +// +// Fish-style history based hinting. +// assuming history ["abc", "ade"] +// pressing "a" hints to abc. +// Up/Down or Ctrl p/n, to select next/previous match + +use std::io; + +use nu_ansi_term::{Color, Style}; +use reedline::{DefaultHinter, DefaultPrompt, Reedline, Signal}; + +fn main() -> io::Result<()> { + let mut line_editor = Reedline::create().with_hinter(Box::new( + DefaultHinter::default().with_style(Style::new().italic().fg(Color::LightGray)), + )); + let prompt = DefaultPrompt::default(); + + loop { + let sig = line_editor.read_line(&prompt)?; + match sig { + Signal::Success(buffer) => { + println!("We processed: {buffer}"); + }, + Signal::CtrlD | Signal::CtrlC => { + println!("\nAborted!"); + break Ok(()); + }, + } + } +} diff --git a/reedline/examples/history.rs b/reedline/examples/history.rs new file mode 100644 index 00000000..4b51364a --- /dev/null +++ b/reedline/examples/history.rs @@ -0,0 +1,34 @@ +// Create a reedline object with history support, including history size limits. +// cargo run --example history +// +// A file `history.txt` will be created (or replaced). +// Allows for persistent loading of previous session. +// +// Browse history by Up/Down arrows or Ctrl-n/p + +use std::io; + +use reedline::{DefaultPrompt, FileBackedHistory, Reedline, Signal}; + +fn main() -> io::Result<()> { + let history = Box::new( + FileBackedHistory::with_file(5, "history.txt".into()) + .expect("Error configuring history with file"), + ); + + let mut line_editor = Reedline::create().with_history(history); + let prompt = DefaultPrompt::default(); + + loop { + let sig = line_editor.read_line(&prompt)?; + match sig { + Signal::Success(buffer) => { + println!("We processed: {buffer}"); + }, + Signal::CtrlD | Signal::CtrlC => { + println!("\nAborted!"); + break Ok(()); + }, + } + } +} diff --git a/reedline/examples/list_bindings.rs b/reedline/examples/list_bindings.rs new file mode 100644 index 00000000..5f588a8e --- /dev/null +++ b/reedline/examples/list_bindings.rs @@ -0,0 +1,45 @@ +use crossterm::Result; +use reedline::{ + get_reedline_default_keybindings, get_reedline_edit_commands, + get_reedline_keybinding_modifiers, get_reedline_keycodes, get_reedline_prompt_edit_modes, + get_reedline_reedline_events, +}; + +fn main() -> Result<()> { + get_all_keybinding_info(); + println!(); + Ok(()) +} + +/// List all keybinding information +fn get_all_keybinding_info() { + println!("--Key Modifiers--"); + for mods in get_reedline_keybinding_modifiers().iter() { + println!("{mods}"); + } + + println!("\n--Modes--"); + for modes in get_reedline_prompt_edit_modes().iter() { + println!("{modes}"); + } + + println!("\n--Key Codes--"); + for kcs in get_reedline_keycodes().iter() { + println!("{kcs}"); + } + + println!("\n--Reedline Events--"); + for rle in get_reedline_reedline_events().iter() { + println!("{rle}"); + } + + println!("\n--Edit Commands--"); + for edit in get_reedline_edit_commands().iter() { + println!("{edit}"); + } + + println!("\n--Default Keybindings--"); + for (mode, modifier, code, event) in get_reedline_default_keybindings() { + println!("mode: {mode}, keymodifiers: {modifier}, keycode: {code}, event: {event}"); + } +} diff --git a/reedline/examples/validator.rs b/reedline/examples/validator.rs new file mode 100644 index 00000000..82956163 --- /dev/null +++ b/reedline/examples/validator.rs @@ -0,0 +1,42 @@ +// Create a reedline object with a custom validator to break the line on unfinished input. +// cargo run --example validator +// +// Input "complete" followed by [Enter], will accept the input line (Signal::Succeed will be called) +// Pressing [Enter] will in other cases give you a multi-line prompt. + +use std::io; + +use reedline::{DefaultPrompt, Reedline, Signal, ValidationResult, Validator}; + +struct CustomValidator; + +// For custom validation, implement the Validator trait +impl Validator for CustomValidator { + fn validate(&self, line: &str) -> ValidationResult { + if line == "complete" { + ValidationResult::Complete + } else { + ValidationResult::Incomplete + } + } +} + +fn main() -> io::Result<()> { + println!("Input \"complete\" followed by [Enter], will accept the input line (Signal::Succeed will be called)\nPressing [Enter] will in other cases give you a multi-line prompt.\nAbort with Ctrl-C or Ctrl-D"); + let mut line_editor = Reedline::create().with_validator(Box::new(CustomValidator)); + + let prompt = DefaultPrompt::default(); + + loop { + let sig = line_editor.read_line(&prompt)?; + match sig { + Signal::Success(buffer) => { + println!("We processed: {buffer}"); + }, + Signal::CtrlD | Signal::CtrlC => { + println!("\nAborted!"); + break Ok(()); + }, + } + } +} diff --git a/reedline/src/README.md b/reedline/src/README.md new file mode 100644 index 00000000..7b6efda0 --- /dev/null +++ b/reedline/src/README.md @@ -0,0 +1,49 @@ +# Reedline internal developer documentation + +**Attention** The README files in the source folders are primarily intended to document the current implementation details and quirks as well as requirements we currently strive towards. +It is not intended as the public documentation for library users! +While we try to take care that the primary documentation also shared on stays current with the behavior of the implementation and is doctested accordingly. +The internal documents may get out of sync more easily and require good will by contributors to stay up to date if systems are refactored. + +## Design requirements + +Reedline is currently developed as the bundled line editor for [nushell](https://github.com/nushell/nushell) but tries to remain agnostic of the actual language implementation so it can easily be used for other projects. +As the feedback loop for nushell project contributors is typically the shortest, we might implicitly favour optimizations or changes driven by nushell's requirements. + +### Core requirements + +With general functionality: + +- Support terminals on all major platforms: Windows, macOS, Linux +- Support a variety of terminal emulators: e.g. Terminal.app on macOS, iterm2, Windows terminal, gnome-terminal (and those using its vte backend), xterm derivatives, alacritty, kitty, wezterm, and a bunch more +- Be usable despite the fact that the different platforms and terminal emulators might have restricted support for certain functionalities (core ANSI terminal support or extensions to that) or have some key events mapped to core system functions. +- Have integrations for syntax highlighting, tab completions etc. that are implemented by the using programming language/environment to provide modern comforts. +- have configurable prompts, a history, and some other goodies +- Make the keybindings configurable to some extent +- Support sufficient configuration to allow customization +- Be aware of unicode characters and display them as good as the terminal can. (Currently we only have thought of left to right text flow!) + +## Goals + +- don't `panic!`: as a library we should strive towards a behavior where any panic should reflect a serious bug on our side and errors that result from the reality of a system are reflected as useful result types. +- the most important thing to keep correct: Have consistent display of the currently entered line. The displayed and submitted version should not deviate! +- be a nice citizen: Don't cause display artifacts on the current screen, avoid overwriting previous output and if possible maintain consistent display in the scroll-back buffer. Hard to reach goal: handle resizes of the terminal window gracefully. +- Our defaults should not be surprising, only pleasantly surprising + +## Non-goals + +- General terminal input functionality beyond the use as a programming language REPL or general command box: (for example not as a password prompt or a prompt that only accepts entries in a certain format e.g. number or date input box) +- maximizing the compatibility/similarity with an existing (line) editor at the cost of flexibility to support workflows from other editors. +- be a standalone text editor +- be useful outside of rust as a shared library with a maintained ABI + +## Technical background + +- [ANSI terminal](https://en.wikipedia.org/wiki/ANSI_escape_code): standardized protocol to have control and styling sequences "in-band" with the text. That means on Unix systems most of the terminal control is written to the same buffer as the output and special input events are also encoded with the content that represents user input. The core of this has been standardized many moons ago but has been extended with additional control sequences by various physical terminals (like the [VT-series of terminals](https://vt100.net/docs/vt510-rm/contents.html), some of it accepted spec) and also by terminal emulators (like [xterm](https://invisible-island.net/xterm/xterm.html)). + - In a regular mode the terminal will display user typed input, to respond to it we enable something called raw mode (changing some settings depending on the platform) to listen to the events and handle them ourselves + - Some control is directly handled by the pure ASCII characters (they contain 32 control sequences in the lowest bit values), this has some implications: in raw mode `\n` or `LF` will only move a line down not as expected on unix also return to the beginning of the next line, thus you have to send like on windows `\r\n` `CRLF` for drawing operations with enabled raw mode. Also some keybindings with `Ctrl` are unusable as `CTRL-` can be encoded in shifted bits for some characters like `tab` +- On Windows NT the terminal configuration is managed primarily out of band with calls to Windows functions using a handle to the terminal. Details on that can be found [here](https://docs.microsoft.com/en-us/windows/console/) but we luckily can abstract most of that with `crossterm`. + +## Current design decisions + +- To handle sending styling commands and receiving events we currently use the excellent [crossterm crate](https://github.com/crossterm-rs/crossterm). It not only encodes a variety of ANSI sequences for styling or terminal setup but also hooks into Windows APIs for the non ANSI compliant functions of the Windows terminal. diff --git a/reedline/src/completion/base.rs b/reedline/src/completion/base.rs new file mode 100644 index 00000000..44da56b2 --- /dev/null +++ b/reedline/src/completion/base.rs @@ -0,0 +1,71 @@ +/// A span of source code, with positions in bytes +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash)] +pub struct Span { + /// The starting position of the span, in bytes + pub start: usize, + + /// The ending position of the span, in bytes + pub end: usize, +} + +impl Span { + /// Creates a new `Span` from start and end inputs. + /// The end parameter must be greater than or equal to the start parameter. + /// + /// # Panics + /// If `end < start` + pub fn new(start: usize, end: usize) -> Span { + assert!( + end >= start, + "Can't create a Span whose end < start, start={start}, end={end}" + ); + + Span { start, end } + } +} + +/// A trait that defines how to convert a line and position to a list of potential completions in that position. +pub trait Completer: Send { + /// the action that will take the line and position and convert it to a vector of completions, which include the + /// span to replace and the contents of that replacement + fn complete(&mut self, line: &str, pos: usize) -> Vec; + + /// action that will return a partial section of available completions + /// this command comes handy when trying to avoid to pull all the data at once + /// from the completer + fn partial_complete( + &mut self, + line: &str, + pos: usize, + start: usize, + offset: usize, + ) -> Vec { + self.complete(line, pos) + .into_iter() + .skip(start) + .take(offset) + .collect() + } + + /// number of available completions + fn total_completions(&mut self, line: &str, pos: usize) -> usize { + self.complete(line, pos).len() + } +} + +/// Suggestion returned by the Completer +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct Suggestion { + /// String replacement that will be introduced to the the buffer + pub value: String, + /// Optional description for the replacement + pub description: Option, + /// Optional vector of strings in the suggestion. These can be used to + /// represent examples comming from a suggestion + pub extra: Option>, + /// Replacement span in the buffer + pub span: Span, + /// Whether to append a space after selecting this suggestion. + /// This helps to avoid that a completer repeats the complete suggestion. + pub append_whitespace: bool, +} diff --git a/reedline/src/completion/default.rs b/reedline/src/completion/default.rs new file mode 100644 index 00000000..d1e6c727 --- /dev/null +++ b/reedline/src/completion/default.rs @@ -0,0 +1,396 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + str::Chars, + sync::Arc, +}; + +use crate::{Completer, Span, Suggestion}; + +/// A default completer that can detect keywords +/// +/// # Example +/// +/// ```rust +/// use reedline::{DefaultCompleter, Reedline}; +/// +/// let commands = vec![ +/// "test".into(), +/// "hello world".into(), +/// "hello world reedline".into(), +/// "this is the reedline crate".into(), +/// ]; +/// let completer = Box::new(DefaultCompleter::new_with_wordlen(commands.clone(), 2)); +/// +/// let mut line_editor = Reedline::create().with_completer(completer); +/// ``` +#[derive(Debug, Clone)] +pub struct DefaultCompleter { + root: CompletionNode, + min_word_len: usize, +} + +impl Default for DefaultCompleter { + fn default() -> Self { + let inclusions = Arc::new(BTreeSet::new()); + Self { + root: CompletionNode::new(inclusions), + min_word_len: 2, + } + } +} +impl Completer for DefaultCompleter { + /// Returns a vector of completions and the position in which they must be replaced; + /// based on the provided input. + /// + /// # Arguments + /// + /// * `line` The line to complete + /// * `pos` The cursor position + /// + /// # Example + /// ``` + /// use reedline::{DefaultCompleter,Completer,Span,Suggestion}; + /// + /// let mut completions = DefaultCompleter::default(); + /// completions.insert(vec!["batman","robin","batmobile","batcave","robber"].iter().map(|s| s.to_string()).collect()); + /// assert_eq!( + /// completions.complete("bat",3), + /// vec![ + /// Suggestion {value: "batcave".into(), description: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false}, + /// Suggestion {value: "batman".into(), description: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false}, + /// Suggestion {value: "batmobile".into(), description: None, extra: None, span: Span { start: 0, end: 3 }, append_whitespace: false}, + /// ]); + /// + /// assert_eq!( + /// completions.complete("to the bat",10), + /// vec![ + /// Suggestion {value: "batcave".into(), description: None, extra: None, span: Span { start: 7, end: 10 }, append_whitespace: false}, + /// Suggestion {value: "batman".into(), description: None, extra: None, span: Span { start: 7, end: 10 }, append_whitespace: false}, + /// Suggestion {value: "batmobile".into(), description: None, extra: None, span: Span { start: 7, end: 10 }, append_whitespace: false}, + /// ]); + /// ``` + fn complete(&mut self, line: &str, pos: usize) -> Vec { + let mut span_line_whitespaces = 0; + let mut completions = vec![]; + if !line.is_empty() { + let mut splitted = line[0..pos].split(' ').rev(); + let mut span_line: String = String::new(); + for _ in 0..splitted.clone().count() { + if let Some(s) = splitted.next() { + if s.is_empty() { + span_line_whitespaces += 1; + continue; + } + if span_line.is_empty() { + span_line = s.to_string(); + } else { + span_line = format!("{s} {span_line}"); + } + if let Some(mut extensions) = self.root.complete(span_line.chars()) { + extensions.sort(); + completions.extend( + extensions + .iter() + .map(|ext| { + let span = Span::new( + pos - span_line.len() - span_line_whitespaces, + pos, + ); + + Suggestion { + value: format!("{span_line}{ext}"), + description: None, + extra: None, + span, + append_whitespace: false, + } + }) + .filter(|t| t.value.len() > (t.span.end - t.span.start)) + .collect::>(), + ); + } + } + } + } + completions.dedup(); + completions + } +} +impl DefaultCompleter { + /// Construct the default completer with a list of commands/keywords to highlight + pub fn new(external_commands: Vec) -> Self { + let mut dc = DefaultCompleter::default(); + dc.insert(external_commands); + dc + } + + /// Construct the default completer with a list of commands/keywords to highlight, given a minimum word length + pub fn new_with_wordlen(external_commands: Vec, min_word_len: usize) -> Self { + let mut dc = DefaultCompleter::default().set_min_word_len(min_word_len); + dc.insert(external_commands); + dc + } + + /// Insert `external_commands` list in the object root + /// + /// # Arguments + /// + /// * `line` A vector of `String` containing the external commands + /// + /// # Example + /// ``` + /// use reedline::{DefaultCompleter,Completer}; + /// + /// let mut completions = DefaultCompleter::default(); + /// + /// // Insert multiple words + /// completions.insert(vec!["a","line","with","many","words"].iter().map(|s| s.to_string()).collect()); + /// + /// // The above line is equal to the following: + /// completions.insert(vec!["a","line","with"].iter().map(|s| s.to_string()).collect()); + /// completions.insert(vec!["many","words"].iter().map(|s| s.to_string()).collect()); + /// ``` + pub fn insert(&mut self, words: Vec) { + for word in words { + if word.len() >= self.min_word_len { + self.root.insert(word.chars()); + } + } + } + + /// Create a new `DefaultCompleter` with provided non alphabet characters whitelisted. + /// The default `DefaultCompleter` will only parse alphabet characters (a-z, A-Z). Use this to + /// introduce additional accepted special characters. + /// + /// # Arguments + /// + /// * `incl` An array slice with allowed characters + /// + /// # Example + /// ``` + /// use reedline::{DefaultCompleter,Completer,Span,Suggestion}; + /// + /// let mut completions = DefaultCompleter::default(); + /// completions.insert(vec!["test-hyphen","test_underscore"].iter().map(|s| s.to_string()).collect()); + /// assert_eq!( + /// completions.complete("te",2), + /// vec![Suggestion {value: "test".into(), description: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false}]); + /// + /// let mut completions = DefaultCompleter::with_inclusions(&['-', '_']); + /// completions.insert(vec!["test-hyphen","test_underscore"].iter().map(|s| s.to_string()).collect()); + /// assert_eq!( + /// completions.complete("te",2), + /// vec![ + /// Suggestion {value: "test-hyphen".into(), description: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false}, + /// Suggestion {value: "test_underscore".into(), description: None, extra: None, span: Span { start: 0, end: 2 }, append_whitespace: false}, + /// ]); + /// ``` + pub fn with_inclusions(incl: &[char]) -> Self { + let mut set = BTreeSet::new(); + set.extend(incl.iter()); + let inclusions = Arc::new(set); + Self { + root: CompletionNode::new(inclusions), + ..Self::default() + } + } + + /// Clears all the data from the tree + /// # Example + /// ``` + /// use reedline::{DefaultCompleter,Completer}; + /// + /// let mut completions = DefaultCompleter::default(); + /// completions.insert(vec!["batman","robin","batmobile","batcave","robber"].iter().map(|s| s.to_string()).collect()); + /// assert_eq!(completions.word_count(), 5); + /// assert_eq!(completions.size(), 24); + /// completions.clear(); + /// assert_eq!(completions.size(), 1); + /// assert_eq!(completions.word_count(), 0); + /// ``` + pub fn clear(&mut self) { + self.root.clear(); + } + + /// Returns a count of how many words that exist in the tree + /// # Example + /// ``` + /// use reedline::{DefaultCompleter,Completer}; + /// + /// let mut completions = DefaultCompleter::default(); + /// completions.insert(vec!["batman","robin","batmobile","batcave","robber"].iter().map(|s| s.to_string()).collect()); + /// assert_eq!(completions.word_count(), 5); + /// ``` + pub fn word_count(&self) -> u32 { + self.root.word_count() + } + + /// Returns the size of the tree, the amount of nodes, not words + /// # Example + /// ``` + /// use reedline::{DefaultCompleter,Completer}; + /// + /// let mut completions = DefaultCompleter::default(); + /// completions.insert(vec!["batman","robin","batmobile","batcave","robber"].iter().map(|s| s.to_string()).collect()); + /// assert_eq!(completions.size(), 24); + /// ``` + pub fn size(&self) -> u32 { + self.root.subnode_count() + } + + /// Returns the minimum word length to complete. This allows you + /// to pass full sentences to `insert()` and not worry about + /// pruning out small words like "a" or "to", because they will be + /// ignored. + /// # Example + /// ``` + /// use reedline::{DefaultCompleter,Completer}; + /// + /// let mut completions = DefaultCompleter::default().set_min_word_len(4); + /// completions.insert(vec!["one","two","three","four","five"].iter().map(|s| s.to_string()).collect()); + /// assert_eq!(completions.word_count(), 3); + /// + /// let mut completions = DefaultCompleter::default().set_min_word_len(1); + /// completions.insert(vec!["one","two","three","four","five"].iter().map(|s| s.to_string()).collect()); + /// assert_eq!(completions.word_count(), 5); + /// ``` + pub fn min_word_len(&self) -> usize { + self.min_word_len + } + + /// Sets the minimum word length to complete on. Smaller words are + /// ignored. This only affects future calls to `insert()` - + /// changing this won't start completing on smaller words that + /// were added in the past, nor will it exclude larger words + /// already inserted into the completion tree. + #[must_use] + pub fn set_min_word_len(mut self, len: usize) -> Self { + self.min_word_len = len; + self + } +} + +#[derive(Debug, Clone)] +struct CompletionNode { + subnodes: BTreeMap, + leaf: bool, + inclusions: Arc>, +} + +impl CompletionNode { + fn new(incl: Arc>) -> Self { + Self { + subnodes: BTreeMap::new(), + leaf: false, + inclusions: incl, + } + } + + fn clear(&mut self) { + self.subnodes.clear(); + } + + fn word_count(&self) -> u32 { + let mut count = self.subnodes.values().map(CompletionNode::word_count).sum(); + if self.leaf { + count += 1; + } + count + } + + fn subnode_count(&self) -> u32 { + self.subnodes + .values() + .map(CompletionNode::subnode_count) + .sum::() + + 1 + } + + fn insert(&mut self, mut iter: Chars) { + if let Some(c) = iter.next() { + if self.inclusions.contains(&c) || c.is_alphanumeric() || c.is_whitespace() { + let inclusions = self.inclusions.clone(); + let subnode = self + .subnodes + .entry(c) + .or_insert_with(|| CompletionNode::new(inclusions)); + subnode.insert(iter); + } else { + self.leaf = true; + } + } else { + self.leaf = true; + } + } + + fn complete(&self, mut iter: Chars) -> Option> { + if let Some(c) = iter.next() { + if let Some(subnode) = self.subnodes.get(&c) { + subnode.complete(iter) + } else { + None + } + } else { + Some(self.collect("")) + } + } + + fn collect(&self, partial: &str) -> Vec { + let mut completions = vec![]; + if self.leaf { + completions.push(partial.to_string()); + } + + if !self.subnodes.is_empty() { + for (c, node) in &self.subnodes { + let mut partial = partial.to_string(); + partial.push(*c); + completions.append(&mut node.collect(&partial)); + } + } + completions + } +} + +#[cfg(test)] +mod tests { + #[test] + fn default_completer_with_non_ansi() { + use super::*; + + let mut completions = DefaultCompleter::default(); + completions.insert( + vec!["ļ½Žļ½•ļ½“ļ½ˆļ½…ļ½Œļ½Œ", "ļ½Žļ½•ļ½Œļ½Œ", "ļ½Žļ½•ļ½ļ½‚ļ½…ļ½’"] + .iter() + .map(|s| s.to_string()) + .collect(), + ); + + assert_eq!( + completions.complete("ļ½Ž", 3), + vec![ + Suggestion { + value: "ļ½Žļ½•ļ½Œļ½Œ".into(), + description: None, + extra: None, + span: Span { start: 0, end: 3 }, + append_whitespace: false, + }, + Suggestion { + value: "ļ½Žļ½•ļ½ļ½‚ļ½…ļ½’".into(), + description: None, + extra: None, + span: Span { start: 0, end: 3 }, + append_whitespace: false, + }, + Suggestion { + value: "ļ½Žļ½•ļ½“ļ½ˆļ½…ļ½Œļ½Œ".into(), + description: None, + extra: None, + span: Span { start: 0, end: 3 }, + append_whitespace: false, + }, + ] + ); + } +} diff --git a/reedline/src/completion/history.rs b/reedline/src/completion/history.rs new file mode 100644 index 00000000..ea6aedb6 --- /dev/null +++ b/reedline/src/completion/history.rs @@ -0,0 +1,67 @@ +use std::ops::Deref; + +use crate::{ + history::SearchQuery, menu_functions::parse_selection_char, Completer, History, Span, + Suggestion, +}; + +const SELECTION_CHAR: char = '!'; + +// The HistoryCompleter is created just before updating the menu +// It pulls data from the object that contains access to the History +pub(crate) struct HistoryCompleter<'menu>(&'menu dyn History); + +// Safe to implement Send since the Historycompleter should only be used when +// updating the menu and that must happen in the same thread +unsafe impl<'menu> Send for HistoryCompleter<'menu> {} + +impl<'menu> Completer for HistoryCompleter<'menu> { + fn complete(&mut self, line: &str, pos: usize) -> Vec { + let parsed = parse_selection_char(line, SELECTION_CHAR); + let values = self + .0 + .search(SearchQuery::all_that_contain_rev( + parsed.remainder.to_string(), + )) + .expect("todo: error handling"); + + values + .into_iter() + .map(|value| self.create_suggestion(line, pos, value.command_line.deref())) + .collect() + } + + // TODO: Implement `fn partial_complete()` + + fn total_completions(&mut self, line: &str, _pos: usize) -> usize { + let parsed = parse_selection_char(line, SELECTION_CHAR); + let count = self + .0 + .count(SearchQuery::all_that_contain_rev( + parsed.remainder.to_string(), + )) + .expect("todo: error handling"); + count as usize + } +} + +impl<'menu> HistoryCompleter<'menu> { + pub fn new(history: &'menu dyn History) -> Self { + Self(history) + } + + fn create_suggestion(&self, line: &str, pos: usize, value: &str) -> Suggestion { + let span = Span { + start: pos, + end: pos + line.len(), + }; + + Suggestion { + value: value.to_string(), + description: None, + extra: None, + span, + append_whitespace: false, + } + } +} diff --git a/reedline/src/completion/mod.rs b/reedline/src/completion/mod.rs new file mode 100644 index 00000000..10f196eb --- /dev/null +++ b/reedline/src/completion/mod.rs @@ -0,0 +1,6 @@ +mod base; +mod default; +pub(crate) mod history; + +pub use base::{Completer, Span, Suggestion}; +pub use default::DefaultCompleter; diff --git a/reedline/src/core_editor/clip_buffer.rs b/reedline/src/core_editor/clip_buffer.rs new file mode 100644 index 00000000..7ef3926d --- /dev/null +++ b/reedline/src/core_editor/clip_buffer.rs @@ -0,0 +1,147 @@ +/// Defines an interface to interact with a Clipboard for cut and paste. +/// +/// Mutable reference requirements are stricter than always necessary, but the currently used system clipboard API demands them for exclusive access. +pub trait Clipboard: Send { + fn set(&mut self, content: &str, mode: ClipboardMode); + + fn get(&mut self) -> (String, ClipboardMode); + + fn clear(&mut self) { + self.set("", ClipboardMode::Normal); + } + + fn len(&mut self) -> usize { + self.get().0.len() + } +} + +/// Determines how the content in the clipboard should be inserted +#[derive(Copy, Clone, Debug)] +pub enum ClipboardMode { + /// As direct content at the current cursor position + Normal, + /// As new lines below or above + Lines, +} + +impl Default for ClipboardMode { + fn default() -> Self { + ClipboardMode::Normal + } +} + +/// Simple buffer that provides a clipboard only usable within the application/library. +#[derive(Default)] +pub struct LocalClipboard { + content: String, + mode: ClipboardMode, +} + +impl LocalClipboard { + #[allow(dead_code)] + pub fn new() -> Self { + Self::default() + } +} + +impl Clipboard for LocalClipboard { + fn set(&mut self, content: &str, mode: ClipboardMode) { + self.content = content.to_owned(); + self.mode = mode; + } + + fn get(&mut self) -> (String, ClipboardMode) { + (self.content.clone(), self.mode) + } +} + +#[cfg(feature = "system_clipboard")] +pub use system_clipboard::SystemClipboard; + +#[cfg(feature = "system_clipboard")] +/// Helper to get a clipboard based on the `system_clipboard` feature flag: +/// +/// Enabled -> [`SystemClipboard`], which talks to the system +/// +/// Disabled -> [`LocalClipboard`], which supports cutting and pasting limited to the [`crate::Reedline`] instance +pub fn get_default_clipboard() -> SystemClipboard { + SystemClipboard::new() +} + +#[cfg(not(feature = "system_clipboard"))] +/// Helper to get a clipboard based on the `system_clipboard` feature flag: +/// +/// Enabled -> `SystemClipboard`, which talks to the system +/// +/// Disabled -> [`LocalClipboard`], which supports cutting and pasting limited to the [`crate::Reedline`] instance +pub fn get_default_clipboard() -> LocalClipboard { + LocalClipboard::new() +} + +#[cfg(feature = "system_clipboard")] +mod system_clipboard { + use clipboard::{ClipboardContext, ClipboardProvider}; + + use super::*; + + /// Wrapper around [`clipboard`](https://docs.rs/clipboard) crate + /// + /// Requires that the feature `system_clipboard` is enabled + pub struct SystemClipboard { + cb: ClipboardContext, + local_copy: String, + mode: ClipboardMode, + } + + impl SystemClipboard { + pub fn new() -> Self { + let cb = ClipboardProvider::new().unwrap(); + SystemClipboard { + cb, + local_copy: String::new(), + mode: ClipboardMode::Normal, + } + } + } + + impl Clipboard for SystemClipboard { + fn set(&mut self, content: &str, mode: ClipboardMode) { + self.local_copy = content.to_owned(); + let _ = self.cb.set_contents(content.to_owned()); + self.mode = mode; + } + + fn get(&mut self) -> (String, ClipboardMode) { + let system_content = self.cb.get_contents().unwrap_or_default(); + if system_content == self.local_copy { + // We assume the content was yanked inside the line editor and the last yank determined the mode. + (system_content, self.mode) + } else { + // Content has changed, default to direct insertion. + (system_content, ClipboardMode::Normal) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{get_default_clipboard, Clipboard, ClipboardMode}; + #[test] + fn reads_back() { + let mut cb = get_default_clipboard(); + // If the system clipboard is used we want to persist it for the user + let previous_state = cb.get().0; + + // Actual test + cb.set("test", ClipboardMode::Normal); + assert_eq!(cb.len(), 4); + assert_eq!(cb.get().0, "test".to_owned()); + cb.clear(); + assert_eq!(cb.get().0, String::new()); + + // Restore! + + cb.set(&previous_state, ClipboardMode::Normal); + } +} diff --git a/reedline/src/core_editor/edit_stack.rs b/reedline/src/core_editor/edit_stack.rs new file mode 100644 index 00000000..de87b29f --- /dev/null +++ b/reedline/src/core_editor/edit_stack.rs @@ -0,0 +1,114 @@ +#[derive(Debug, PartialEq, Eq)] +pub struct EditStack { + internal_list: Vec, + index: usize, +} + +impl EditStack { + pub fn new() -> Self + where + T: Default, + { + EditStack { + internal_list: vec![T::default()], + index: 0, + } + } +} + +impl EditStack +where + T: Default + Clone + Send, +{ + /// Go back one point in the undo stack. If present on first edit do nothing + pub(super) fn undo(&mut self) -> &T { + self.index = if self.index == 0 { 0 } else { self.index - 1 }; + &self.internal_list[self.index] + } + + /// Go forward one point in the undo stack. If present on the last edit do nothing + pub(super) fn redo(&mut self) -> &T { + self.index = if self.index == self.internal_list.len() - 1 { + self.index + } else { + self.index + 1 + }; + &self.internal_list[self.index] + } + + /// Insert a new entry to the undo stack. + /// NOTE: (IMP): If we have hit undo a few times then discard all the other values that come + /// after the current point + pub(super) fn insert(&mut self, value: T) { + if self.index < self.internal_list.len() - 1 { + self.internal_list.resize_with(self.index + 1, || { + panic!("Impossible state reached: Bug in UndoStack logic") + }); + } + self.internal_list.push(value); + self.index += 1; + } + + /// Reset the stack to the initial state + pub(super) fn reset(&mut self) { + self.index = 0; + self.internal_list = vec![T::default()]; + } + + /// Return the entry currently being pointed to + pub(super) fn current(&mut self) -> &T { + &self.internal_list[self.index] + } +} + +#[cfg(test)] +mod test { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + fn edit_stack(values: &[T], index: usize) -> EditStack + where + T: Clone, + { + EditStack { + internal_list: values.to_vec(), + index, + } + } + + #[rstest] + #[case(edit_stack(&[1, 2, 3][..], 2), 2)] + #[case(edit_stack(&[1][..], 0), 1)] + fn undo_works(#[case] stack: EditStack, #[case] value_after_undo: isize) { + let mut stack = stack; + + let value = stack.undo(); + assert_eq!(*value, value_after_undo); + } + + #[rstest] + #[case(edit_stack(&[1, 2, 3][..], 1), 3)] + #[case(edit_stack(&[1][..], 0), 1)] + fn redo_works(#[case] stack: EditStack, #[case] value_after_undo: isize) { + let mut stack = stack; + + let value = stack.redo(); + assert_eq!(*value, value_after_undo); + } + + #[rstest] + #[case(edit_stack(&[1, 2, 3][..], 1), 4, edit_stack(&[1, 2, 4], 2))] + #[case(edit_stack(&[1, 2, 3][..], 2), 3, edit_stack(&[1, 2, 3, 3], 3))] + fn insert_works( + #[case] old_stack: EditStack, + #[case] value_to_insert: isize, + #[case] expected_stack: EditStack, + ) { + let mut stack = old_stack; + + stack.insert(value_to_insert); + assert_eq!(stack, expected_stack); + } +} diff --git a/reedline/src/core_editor/editor.rs b/reedline/src/core_editor/editor.rs new file mode 100644 index 00000000..151f389b --- /dev/null +++ b/reedline/src/core_editor/editor.rs @@ -0,0 +1,678 @@ +use super::{edit_stack::EditStack, Clipboard, ClipboardMode, LineBuffer}; +use crate::{ + core_editor::get_default_clipboard, + enums::{EditType, UndoBehavior}, + EditCommand, +}; + +/// Stateful editor executing changes to the underlying [`LineBuffer`] +/// +/// In comparison to the state-less [`LineBuffer`] the [`Editor`] keeps track of +/// the undo/redo history and has facilities for cut/copy/yank/paste +pub struct Editor { + line_buffer: LineBuffer, + cut_buffer: Box, + + edit_stack: EditStack, + last_undo_behavior: UndoBehavior, +} + +impl Default for Editor { + fn default() -> Self { + Editor { + line_buffer: LineBuffer::new(), + cut_buffer: Box::new(get_default_clipboard()), + edit_stack: EditStack::new(), + last_undo_behavior: UndoBehavior::CreateUndoPoint, + } + } +} + +impl Editor { + /// Get the current [`LineBuffer`] + pub fn line_buffer(&self) -> &LineBuffer { + &self.line_buffer + } + + /// Set the current [`LineBuffer`]. + /// [`UndoBehavior`] specifies how this change should be reflected on the undo stack. + pub(crate) fn set_line_buffer(&mut self, line_buffer: LineBuffer, undo_behavior: UndoBehavior) { + self.line_buffer = line_buffer; + self.update_undo_state(undo_behavior); + } + + pub(crate) fn run_edit_command(&mut self, command: &EditCommand) { + match command { + EditCommand::MoveToStart => self.line_buffer.move_to_start(), + EditCommand::MoveToLineStart => self.line_buffer.move_to_line_start(), + EditCommand::MoveToEnd => self.line_buffer.move_to_end(), + EditCommand::MoveToLineEnd => self.line_buffer.move_to_line_end(), + EditCommand::MoveToPosition(pos) => self.line_buffer.set_insertion_point(*pos), + EditCommand::MoveLeft => self.line_buffer.move_left(), + EditCommand::MoveRight => self.line_buffer.move_right(), + EditCommand::MoveWordLeft => self.line_buffer.move_word_left(), + EditCommand::MoveBigWordLeft => self.line_buffer.move_big_word_left(), + EditCommand::MoveWordRight => self.line_buffer.move_word_right(), + EditCommand::MoveWordRightStart => self.line_buffer.move_word_right_start(), + EditCommand::MoveBigWordRightStart => self.line_buffer.move_big_word_right_start(), + EditCommand::MoveWordRightEnd => self.line_buffer.move_word_right_end(), + EditCommand::MoveBigWordRightEnd => self.line_buffer.move_big_word_right_end(), + EditCommand::InsertChar(c) => self.line_buffer.insert_char(*c), + EditCommand::Complete => {}, + EditCommand::InsertString(str) => self.line_buffer.insert_str(str), + EditCommand::InsertNewline => self.line_buffer.insert_newline(), + EditCommand::ReplaceChar(chr) => self.replace_char(*chr), + EditCommand::ReplaceChars(n_chars, str) => self.replace_chars(*n_chars, str), + EditCommand::Backspace => self.line_buffer.delete_left_grapheme(), + EditCommand::Delete => self.line_buffer.delete_right_grapheme(), + EditCommand::CutChar => self.cut_char(), + EditCommand::BackspaceWord => self.line_buffer.delete_word_left(), + EditCommand::DeleteWord => self.line_buffer.delete_word_right(), + EditCommand::Clear => self.line_buffer.clear(), + EditCommand::ClearToLineEnd => self.line_buffer.clear_to_line_end(), + EditCommand::CutCurrentLine => self.cut_current_line(), + EditCommand::CutFromStart => self.cut_from_start(), + EditCommand::CutFromLineStart => self.cut_from_line_start(), + EditCommand::CutToEnd => self.cut_from_end(), + EditCommand::CutToLineEnd => self.cut_to_line_end(), + EditCommand::CutWordLeft => self.cut_word_left(), + EditCommand::CutBigWordLeft => self.cut_big_word_left(), + EditCommand::CutWordRight => self.cut_word_right(), + EditCommand::CutBigWordRight => self.cut_big_word_right(), + EditCommand::CutWordRightToNext => self.cut_word_right_to_next(), + EditCommand::CutBigWordRightToNext => self.cut_big_word_right_to_next(), + EditCommand::PasteCutBufferBefore => self.insert_cut_buffer_before(), + EditCommand::PasteCutBufferAfter => self.insert_cut_buffer_after(), + EditCommand::UppercaseWord => self.line_buffer.uppercase_word(), + EditCommand::LowercaseWord => self.line_buffer.lowercase_word(), + EditCommand::SwitchcaseChar => self.line_buffer.switchcase_char(), + EditCommand::CapitalizeChar => self.line_buffer.capitalize_char(), + EditCommand::SwapWords => self.line_buffer.swap_words(), + EditCommand::SwapGraphemes => self.line_buffer.swap_graphemes(), + EditCommand::Undo => self.undo(), + EditCommand::Redo => self.redo(), + EditCommand::CutRightUntil(c) => self.cut_right_until_char(*c, false, true), + EditCommand::CutRightBefore(c) => self.cut_right_until_char(*c, true, true), + EditCommand::MoveRightUntil(c) => self.move_right_until_char(*c, false, true), + EditCommand::MoveRightBefore(c) => self.move_right_until_char(*c, true, true), + EditCommand::CutLeftUntil(c) => self.cut_left_until_char(*c, false, true), + EditCommand::CutLeftBefore(c) => self.cut_left_until_char(*c, true, true), + EditCommand::MoveLeftUntil(c) => self.move_left_until_char(*c, false, true), + EditCommand::MoveLeftBefore(c) => self.move_left_until_char(*c, true, true), + } + + let new_undo_behavior = match (command, command.edit_type()) { + (_, EditType::MoveCursor) => UndoBehavior::MoveCursor, + (EditCommand::InsertChar(c), EditType::EditText) => UndoBehavior::InsertCharacter(*c), + (EditCommand::Delete, EditType::EditText) => { + let deleted_char = self.edit_stack.current().grapheme_right().chars().next(); + UndoBehavior::Delete(deleted_char) + }, + (EditCommand::Backspace, EditType::EditText) => { + let deleted_char = self.edit_stack.current().grapheme_left().chars().next(); + UndoBehavior::Backspace(deleted_char) + }, + (_, EditType::UndoRedo) => UndoBehavior::UndoRedo, + (_, _) => UndoBehavior::CreateUndoPoint, + }; + self.update_undo_state(new_undo_behavior); + } + + pub(crate) fn move_line_up(&mut self) { + self.line_buffer.move_line_up(); + self.update_undo_state(UndoBehavior::MoveCursor); + } + + pub(crate) fn move_line_down(&mut self) { + self.line_buffer.move_line_down(); + self.update_undo_state(UndoBehavior::MoveCursor); + } + + /// Get the text of the current [`LineBuffer`] + pub fn get_buffer(&self) -> &str { + self.line_buffer.get_buffer() + } + + /// Edit the [`LineBuffer`] in an undo-safe manner. + pub fn edit_buffer(&mut self, func: F, undo_behavior: UndoBehavior) + where + F: FnOnce(&mut LineBuffer), + { + self.update_undo_state(undo_behavior); + func(&mut self.line_buffer); + } + + /// Set the text of the current [`LineBuffer`] given the specified [`UndoBehavior`] + /// Insertion point update to the end of the buffer. + pub(crate) fn set_buffer(&mut self, buffer: String, undo_behavior: UndoBehavior) { + self.line_buffer.set_buffer(buffer); + self.update_undo_state(undo_behavior); + } + + pub(crate) fn insertion_point(&self) -> usize { + self.line_buffer.insertion_point() + } + + pub(crate) fn is_empty(&self) -> bool { + self.line_buffer.is_empty() + } + + pub(crate) fn is_cursor_at_first_line(&self) -> bool { + self.line_buffer.is_cursor_at_first_line() + } + + pub(crate) fn is_cursor_at_last_line(&self) -> bool { + self.line_buffer.is_cursor_at_last_line() + } + + pub(crate) fn is_cursor_at_buffer_end(&self) -> bool { + self.line_buffer.insertion_point() == self.get_buffer().len() + } + + pub(crate) fn reset_undo_stack(&mut self) { + self.edit_stack.reset(); + } + + pub(crate) fn move_to_start(&mut self, undo_behavior: UndoBehavior) { + self.line_buffer.move_to_start(); + self.update_undo_state(undo_behavior); + } + + pub(crate) fn move_to_end(&mut self, undo_behavior: UndoBehavior) { + self.line_buffer.move_to_end(); + self.update_undo_state(undo_behavior); + } + + #[allow(dead_code)] + pub(crate) fn move_to_line_start(&mut self, undo_behavior: UndoBehavior) { + self.line_buffer.move_to_line_start(); + self.update_undo_state(undo_behavior); + } + + pub(crate) fn move_to_line_end(&mut self, undo_behavior: UndoBehavior) { + self.line_buffer.move_to_line_end(); + self.update_undo_state(undo_behavior); + } + + fn undo(&mut self) { + let val = self.edit_stack.undo(); + self.line_buffer = val.clone(); + } + + fn redo(&mut self) { + let val = self.edit_stack.redo(); + self.line_buffer = val.clone(); + } + + fn update_undo_state(&mut self, undo_behavior: UndoBehavior) { + if matches!(undo_behavior, UndoBehavior::UndoRedo) { + self.last_undo_behavior = UndoBehavior::UndoRedo; + return; + } + if !undo_behavior.create_undo_point_after(&self.last_undo_behavior) { + self.edit_stack.undo(); + } + self.edit_stack.insert(self.line_buffer.clone()); + self.last_undo_behavior = undo_behavior; + } + + fn cut_current_line(&mut self) { + let deletion_range = self.line_buffer.current_line_range(); + + let cut_slice = &self.line_buffer.get_buffer()[deletion_range.clone()]; + if !cut_slice.is_empty() { + self.cut_buffer.set(cut_slice, ClipboardMode::Lines); + self.line_buffer.set_insertion_point(deletion_range.start); + self.line_buffer.clear_range(deletion_range); + } + } + + fn cut_from_start(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + if insertion_offset > 0 { + self.cut_buffer.set( + &self.line_buffer.get_buffer()[..insertion_offset], + ClipboardMode::Normal, + ); + self.line_buffer.clear_to_insertion_point(); + } + } + + fn cut_from_line_start(&mut self) { + let previous_offset = self.line_buffer.insertion_point(); + self.line_buffer.move_to_line_start(); + let deletion_range = self.line_buffer.insertion_point()..previous_offset; + let cut_slice = &self.line_buffer.get_buffer()[deletion_range.clone()]; + if !cut_slice.is_empty() { + self.cut_buffer.set(cut_slice, ClipboardMode::Normal); + self.line_buffer.clear_range(deletion_range); + } + } + + fn cut_from_end(&mut self) { + let cut_slice = &self.line_buffer.get_buffer()[self.line_buffer.insertion_point()..]; + if !cut_slice.is_empty() { + self.cut_buffer.set(cut_slice, ClipboardMode::Normal); + self.line_buffer.clear_to_end(); + } + } + + fn cut_to_line_end(&mut self) { + let cut_slice = &self.line_buffer.get_buffer() + [self.line_buffer.insertion_point()..self.line_buffer.find_current_line_end()]; + if !cut_slice.is_empty() { + self.cut_buffer.set(cut_slice, ClipboardMode::Normal); + self.line_buffer.clear_to_line_end(); + } + } + + fn cut_word_left(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let left_index = self.line_buffer.word_left_index(); + if left_index < insertion_offset { + let cut_range = left_index..insertion_offset; + self.cut_buffer.set( + &self.line_buffer.get_buffer()[cut_range.clone()], + ClipboardMode::Normal, + ); + self.line_buffer.clear_range(cut_range); + self.line_buffer.set_insertion_point(left_index); + } + } + + fn cut_big_word_left(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let left_index = self.line_buffer.big_word_left_index(); + if left_index < insertion_offset { + let cut_range = left_index..insertion_offset; + self.cut_buffer.set( + &self.line_buffer.get_buffer()[cut_range.clone()], + ClipboardMode::Normal, + ); + self.line_buffer.clear_range(cut_range); + self.line_buffer.set_insertion_point(left_index); + } + } + + fn cut_word_right(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let right_index = self.line_buffer.word_right_index(); + if right_index > insertion_offset { + let cut_range = insertion_offset..right_index; + self.cut_buffer.set( + &self.line_buffer.get_buffer()[cut_range.clone()], + ClipboardMode::Normal, + ); + self.line_buffer.clear_range(cut_range); + } + } + + fn cut_big_word_right(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let right_index = self.line_buffer.next_whitespace(); + if right_index > insertion_offset { + let cut_range = insertion_offset..right_index; + self.cut_buffer.set( + &self.line_buffer.get_buffer()[cut_range.clone()], + ClipboardMode::Normal, + ); + self.line_buffer.clear_range(cut_range); + } + } + + fn cut_word_right_to_next(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let right_index = self.line_buffer.word_right_start_index(); + if right_index > insertion_offset { + let cut_range = insertion_offset..right_index; + self.cut_buffer.set( + &self.line_buffer.get_buffer()[cut_range.clone()], + ClipboardMode::Normal, + ); + self.line_buffer.clear_range(cut_range); + } + } + + fn cut_big_word_right_to_next(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let right_index = self.line_buffer.big_word_right_start_index(); + if right_index > insertion_offset { + let cut_range = insertion_offset..right_index; + self.cut_buffer.set( + &self.line_buffer.get_buffer()[cut_range.clone()], + ClipboardMode::Normal, + ); + self.line_buffer.clear_range(cut_range); + } + } + + fn cut_char(&mut self) { + let insertion_offset = self.line_buffer.insertion_point(); + let right_index = self.line_buffer.grapheme_right_index(); + if right_index > insertion_offset { + let cut_range = insertion_offset..right_index; + self.cut_buffer.set( + &self.line_buffer.get_buffer()[cut_range.clone()], + ClipboardMode::Normal, + ); + self.line_buffer.clear_range(cut_range); + } + } + + fn insert_cut_buffer_before(&mut self) { + match self.cut_buffer.get() { + (content, ClipboardMode::Normal) => { + self.line_buffer.insert_str(&content); + }, + (mut content, ClipboardMode::Lines) => { + // TODO: Simplify that? + self.line_buffer.move_to_line_start(); + self.line_buffer.move_line_up(); + if !content.ends_with('\n') { + // TODO: Make sure platform requirements are met + content.push('\n'); + } + self.line_buffer.insert_str(&content); + }, + } + } + + fn insert_cut_buffer_after(&mut self) { + match self.cut_buffer.get() { + (content, ClipboardMode::Normal) => { + self.line_buffer.move_right(); + self.line_buffer.insert_str(&content); + }, + (mut content, ClipboardMode::Lines) => { + // TODO: Simplify that? + self.line_buffer.move_to_line_start(); + self.line_buffer.move_line_down(); + if !content.ends_with('\n') { + // TODO: Make sure platform requirements are met + content.push('\n'); + } + self.line_buffer.insert_str(&content); + }, + } + } + + fn move_right_until_char(&mut self, c: char, before_char: bool, current_line: bool) { + if before_char { + self.line_buffer.move_right_before(c, current_line); + } else { + self.line_buffer.move_right_until(c, current_line); + } + } + + fn move_left_until_char(&mut self, c: char, before_char: bool, current_line: bool) { + if before_char { + self.line_buffer.move_left_before(c, current_line); + } else { + self.line_buffer.move_left_until(c, current_line); + } + } + + fn cut_right_until_char(&mut self, c: char, before_char: bool, current_line: bool) { + if let Some(index) = self.line_buffer.find_char_right(c, current_line) { + // Saving the section of the string that will be deleted to be + // stored into the buffer + let extra = if before_char { 0 } else { c.len_utf8() }; + let cut_slice = + &self.line_buffer.get_buffer()[self.line_buffer.insertion_point()..index + extra]; + + if !cut_slice.is_empty() { + self.cut_buffer.set(cut_slice, ClipboardMode::Normal); + + if before_char { + self.line_buffer.delete_right_before_char(c, current_line); + } else { + self.line_buffer.delete_right_until_char(c, current_line); + } + } + } + } + + fn cut_left_until_char(&mut self, c: char, before_char: bool, current_line: bool) { + if let Some(index) = self.line_buffer.find_char_left(c, current_line) { + // Saving the section of the string that will be deleted to be + // stored into the buffer + let extra = if before_char { c.len_utf8() } else { 0 }; + let cut_slice = + &self.line_buffer.get_buffer()[index + extra..self.line_buffer.insertion_point()]; + + if !cut_slice.is_empty() { + self.cut_buffer.set(cut_slice, ClipboardMode::Normal); + + if before_char { + self.line_buffer.delete_left_before_char(c, current_line); + } else { + self.line_buffer.delete_left_until_char(c, current_line); + } + } + } + } + + fn replace_char(&mut self, character: char) { + self.line_buffer.delete_right_grapheme(); + + self.line_buffer.insert_char(character); + } + + fn replace_chars(&mut self, n_chars: usize, string: &str) { + for _ in 0..n_chars { + self.line_buffer.delete_right_grapheme(); + } + + self.line_buffer.insert_str(string); + } +} + +#[cfg(test)] +mod test { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + fn editor_with(buffer: &str) -> Editor { + let mut editor = Editor::default(); + editor.set_buffer(buffer.to_string(), UndoBehavior::CreateUndoPoint); + editor + } + + #[rstest] + #[case("abc def ghi", 11, "abc def ")] + #[case("abc def-ghi", 11, "abc def-")] + #[case("abc def.ghi", 11, "abc ")] + fn test_cut_word_left(#[case] input: &str, #[case] position: usize, #[case] expected: &str) { + let mut editor = editor_with(input); + editor.line_buffer.set_insertion_point(position); + + editor.cut_word_left(); + + assert_eq!(editor.get_buffer(), expected); + } + + #[rstest] + #[case("abc def ghi", 11, "abc def ")] + #[case("abc def-ghi", 11, "abc ")] + #[case("abc def.ghi", 11, "abc ")] + fn test_cut_big_word_left( + #[case] input: &str, + #[case] position: usize, + #[case] expected: &str, + ) { + let mut editor = editor_with(input); + editor.line_buffer.set_insertion_point(position); + + editor.cut_big_word_left(); + + assert_eq!(editor.get_buffer(), expected); + } + + #[rstest] + #[case("hello world", 0, 'l', 1, false, "lo world")] + #[case("hello world", 0, 'l', 1, true, "llo world")] + #[ignore = "Deleting two consecutives is not implemented correctly and needs the multiplier explicitly."] + #[case("hello world", 0, 'l', 2, false, "o world")] + #[case("hello world", 0, 'h', 1, false, "hello world")] + #[case("hello world", 0, 'l', 3, true, "ld")] + #[case("hello world", 4, 'o', 1, true, "hellorld")] + #[case("hello world", 4, 'w', 1, false, "hellorld")] + #[case("hello world", 4, 'o', 1, false, "hellrld")] + fn test_cut_right_until_char( + #[case] input: &str, + #[case] position: usize, + #[case] search_char: char, + #[case] repeat: usize, + #[case] before_char: bool, + #[case] expected: &str, + ) { + let mut editor = editor_with(input); + editor.line_buffer.set_insertion_point(position); + for _ in 0..repeat { + editor.cut_right_until_char(search_char, before_char, true); + } + assert_eq!(editor.get_buffer(), expected); + } + + #[rstest] + #[case("abc", 1, 'X', "aXc")] + #[case("abc", 1, 'šŸ”„', "ašŸ”„c")] + #[case("ašŸ”„c", 1, 'X', "aXc")] + #[case("ašŸ”„c", 1, 'šŸ”€', "ašŸ”€c")] + fn test_replace_char( + #[case] input: &str, + #[case] position: usize, + #[case] replacement: char, + #[case] expected: &str, + ) { + let mut editor = editor_with(input); + editor.line_buffer.set_insertion_point(position); + + editor.replace_char(replacement); + + assert_eq!(editor.get_buffer(), expected); + } + + fn str_to_edit_commands(s: &str) -> Vec { + s.chars().map(EditCommand::InsertChar).collect() + } + + #[test] + fn test_undo_insert_works_on_work_boundries() { + let mut editor = editor_with("This is a"); + for cmd in str_to_edit_commands(" test") { + editor.run_edit_command(&cmd); + } + assert_eq!(editor.get_buffer(), "This is a test"); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "This is a"); + editor.run_edit_command(&EditCommand::Redo); + assert_eq!(editor.get_buffer(), "This is a test"); + } + + #[test] + fn test_undo_backspace_works_on_word_boundaries() { + let mut editor = editor_with("This is a test"); + for _ in 0..6 { + editor.run_edit_command(&EditCommand::Backspace); + } + assert_eq!(editor.get_buffer(), "This is "); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "This is a"); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "This is a test"); + } + + #[test] + fn test_undo_delete_works_on_word_boundaries() { + let mut editor = editor_with("This is a test"); + editor.line_buffer.set_insertion_point(0); + for _ in 0..7 { + editor.run_edit_command(&EditCommand::Delete); + } + assert_eq!(editor.get_buffer(), "s a test"); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "is a test"); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "This is a test"); + } + + #[test] + fn test_undo_insert_with_newline() { + let mut editor = editor_with("This is a"); + for cmd in str_to_edit_commands(" \n test") { + editor.run_edit_command(&cmd); + } + assert_eq!(editor.get_buffer(), "This is a \n test"); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "This is a \n"); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "This is a"); + } + + #[test] + fn test_undo_backspace_with_newline() { + let mut editor = editor_with("This is a \n test"); + for _ in 0..8 { + editor.run_edit_command(&EditCommand::Backspace); + } + assert_eq!(editor.get_buffer(), "This is "); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "This is a"); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "This is a \n"); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "This is a \n test"); + } + + #[test] + fn test_undo_backspace_with_crlf() { + let mut editor = editor_with("This is a \r\n test"); + for _ in 0..8 { + editor.run_edit_command(&EditCommand::Backspace); + } + assert_eq!(editor.get_buffer(), "This is "); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "This is a"); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "This is a \r\n"); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "This is a \r\n test"); + } + + #[test] + fn test_undo_delete_with_newline() { + let mut editor = editor_with("This \n is a test"); + editor.line_buffer.set_insertion_point(0); + for _ in 0..8 { + editor.run_edit_command(&EditCommand::Delete); + } + assert_eq!(editor.get_buffer(), "s a test"); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "is a test"); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "\n is a test"); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "This \n is a test"); + } + + #[test] + fn test_undo_delete_with_crlf() { + // CLRF delete is a special case, since the first character of the + // grapheme is \r rather than \n + let mut editor = editor_with("This \r\n is a test"); + editor.line_buffer.set_insertion_point(0); + for _ in 0..8 { + editor.run_edit_command(&EditCommand::Delete); + } + assert_eq!(editor.get_buffer(), "s a test"); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "is a test"); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "\r\n is a test"); + editor.run_edit_command(&EditCommand::Undo); + assert_eq!(editor.get_buffer(), "This \r\n is a test"); + } +} diff --git a/reedline/src/core_editor/line_buffer.rs b/reedline/src/core_editor/line_buffer.rs new file mode 100644 index 00000000..e7185347 --- /dev/null +++ b/reedline/src/core_editor/line_buffer.rs @@ -0,0 +1,1572 @@ +use std::{convert::From, ops::Range}; + +use itertools::Itertools; +use unicode_segmentation::UnicodeSegmentation; + +/// In memory representation of the entered line(s) including a cursor position to facilitate cursor based editing. +#[derive(Debug, PartialEq, Eq, Clone, Default)] +pub struct LineBuffer { + lines: String, + insertion_point: usize, +} + +impl From<&str> for LineBuffer { + fn from(input: &str) -> Self { + let mut line_buffer = LineBuffer::new(); + line_buffer.insert_str(input); + line_buffer + } +} + +impl LineBuffer { + /// Create a line buffer instance + pub fn new() -> LineBuffer { + Self::default() + } + + /// Check to see if the line buffer is empty + pub fn is_empty(&self) -> bool { + self.lines.is_empty() + } + + /// Check if the line buffer is valid utf-8 and the cursor sits on a valid grapheme boundary + pub fn is_valid(&self) -> bool { + self.lines.is_char_boundary(self.insertion_point()) + && (self + .lines + .grapheme_indices(true) + .any(|(i, _)| i == self.insertion_point()) + || self.insertion_point() == self.lines.len()) + && std::str::from_utf8(self.lines.as_bytes()).is_ok() + } + + #[cfg(test)] + fn assert_valid(&self) { + assert!( + self.lines.is_char_boundary(self.insertion_point()), + "Not on valid char boundary" + ); + assert!( + self.lines + .grapheme_indices(true) + .any(|(i, _)| i == self.insertion_point()) + || self.insertion_point() == self.lines.len(), + "Not on valid grapheme" + ); + assert!( + std::str::from_utf8(self.lines.as_bytes()).is_ok(), + "Not valid utf-8" + ); + } + + /// Gets the current edit position + pub fn insertion_point(&self) -> usize { + self.insertion_point + } + + /// Sets the current edit position + /// ## Unicode safety: + /// Not checked, inproper use may cause panics in following operations + pub fn set_insertion_point(&mut self, offset: usize) { + self.insertion_point = offset; + } + + /// Output the current line in the multiline buffer + pub fn get_buffer(&self) -> &str { + &self.lines + } + + /// Set to a single line of `buffer` and reset the `InsertionPoint` cursor to the end + pub fn set_buffer(&mut self, buffer: String) { + self.lines = buffer; + self.insertion_point = self.lines.len(); + } + + /// Calculates the current the user is on + /// + /// Zero-based index + pub fn line(&self) -> usize { + self.lines[..self.insertion_point].matches('\n').count() + } + + /// Counts the number of lines in the buffer + pub fn num_lines(&self) -> usize { + self.lines.split('\n').count() + } + + /// Checks to see if the buffer ends with a given character + pub fn ends_with(&self, c: char) -> bool { + self.lines.ends_with(c) + } + + /// Reset the insertion point to the start of the buffer + pub fn move_to_start(&mut self) { + self.insertion_point = 0; + } + + /// Move the cursor before the first character of the line + pub fn move_to_line_start(&mut self) { + self.insertion_point = self.lines[..self.insertion_point] + .rfind('\n') + .map_or(0, |offset| offset + 1); + // str is guaranteed to be utf8, thus \n is safe to assume 1 byte long + } + + /// Move cursor position to the end of the line + /// + /// Insertion will append to the line. + /// Cursor on top of the potential `\n` or `\r` of `\r\n` + pub fn move_to_line_end(&mut self) { + self.insertion_point = self.find_current_line_end(); + } + + /// Set the insertion point *behind* the last character. + pub fn move_to_end(&mut self) { + self.insertion_point = self.lines.len(); + } + + /// Get the length of the buffer + pub fn len(&self) -> usize { + self.lines.len() + } + + /// Returns where the current line terminates + /// + /// Either: + /// - end of buffer (`len()`) + /// - `\n` or `\r\n` (on the first byte) + pub fn find_current_line_end(&self) -> usize { + self.lines[self.insertion_point..].find('\n').map_or_else( + || self.lines.len(), + |i| { + let absolute_index = i + self.insertion_point; + if absolute_index > 0 && self.lines.as_bytes()[absolute_index - 1] == b'\r' { + absolute_index - 1 + } else { + absolute_index + } + }, + ) + } + + /// Cursor position *behind* the next unicode grapheme to the right + pub fn grapheme_right_index(&self) -> usize { + self.lines[self.insertion_point..] + .grapheme_indices(true) + .nth(1) + .map(|(i, _)| self.insertion_point + i) + .unwrap_or_else(|| self.lines.len()) + } + + /// Cursor position *in front of* the next unicode grapheme to the left + pub fn grapheme_left_index(&self) -> usize { + self.lines[..self.insertion_point] + .grapheme_indices(true) + .last() + .map(|(i, _)| i) + .unwrap_or(0) + } + + /// Cursor position *behind* the next word to the right + pub fn word_right_index(&self) -> usize { + self.lines[self.insertion_point..] + .split_word_bound_indices() + .find(|(_, word)| !is_whitespace_str(word)) + .map(|(i, word)| self.insertion_point + i + word.len()) + .unwrap_or_else(|| self.lines.len()) + } + + /// Cursor position *behind* the next WORD to the right + pub fn big_word_right_index(&self) -> usize { + let mut found_ws = false; + + self.lines[self.insertion_point..] + .split_word_bound_indices() + .find(|(_, word)| { + found_ws = found_ws || is_whitespace_str(word); + found_ws && !is_whitespace_str(word) + }) + .map(|(i, word)| self.insertion_point + i + word.len()) + .unwrap_or_else(|| self.lines.len()) + } + + /// Cursor position *at end of* the next word to the right + pub fn word_right_end_index(&self) -> usize { + self.lines[self.insertion_point..] + .split_word_bound_indices() + .find_map(|(i, word)| { + word.grapheme_indices(true) + .next_back() + .map(|x| self.insertion_point + x.0 + i) + .filter(|x| !is_whitespace_str(word) && *x != self.insertion_point) + }) + .unwrap_or_else(|| { + self.lines + .grapheme_indices(true) + .last() + .map(|x| x.0) + .unwrap_or(0) + }) + } + + /// Cursor position *at end of* the next WORD to the right + pub fn big_word_right_end_index(&self) -> usize { + self.lines[self.insertion_point..] + .split_word_bound_indices() + .tuple_windows() + .find_map(|((prev_i, prev_word), (_, word))| { + if is_whitespace_str(word) { + prev_word + .grapheme_indices(true) + .next_back() + .map(|x| self.insertion_point + x.0 + prev_i) + .filter(|x| *x != self.insertion_point) + } else { + None + } + }) + .unwrap_or_else(|| { + self.lines + .grapheme_indices(true) + .last() + .map(|x| x.0) + .unwrap_or(0) + }) + } + + /// Cursor position *in front of* the next word to the right + pub fn word_right_start_index(&self) -> usize { + self.lines[self.insertion_point..] + .split_word_bound_indices() + .find(|(i, word)| *i != 0 && !is_whitespace_str(word)) + .map(|(i, _)| self.insertion_point + i) + .unwrap_or_else(|| self.lines.len()) + } + + /// Cursor position *in front of* the next WORD to the right + pub fn big_word_right_start_index(&self) -> usize { + let mut found_ws = false; + + self.lines[self.insertion_point..] + .split_word_bound_indices() + .find(|(i, word)| { + found_ws = found_ws || *i != 0 && is_whitespace_str(word); + found_ws && *i != 0 && !is_whitespace_str(word) + }) + .map(|(i, _)| self.insertion_point + i) + .unwrap_or_else(|| self.lines.len()) + } + + /// Cursor position *in front of* the next word to the left + pub fn word_left_index(&self) -> usize { + self.lines[..self.insertion_point] + .split_word_bound_indices() + .filter(|(_, word)| !is_whitespace_str(word)) + .last() + .map(|(i, _)| i) + .unwrap_or(0) + } + + /// Cursor position *in front of* the next WORD to the left + pub fn big_word_left_index(&self) -> usize { + self.lines[..self.insertion_point] + .split_word_bound_indices() + .fold(None, |last_word_index, (i, word)| { + match (last_word_index, is_whitespace_str(word)) { + (None, true) => None, + (None, false) => Some(i), + (Some(_), true) => None, + (Some(v), false) => Some(v), + } + }) + .unwrap_or(0) + } + + /// Cursor position on the next whitespace + pub fn next_whitespace(&self) -> usize { + self.lines[self.insertion_point..] + .split_word_bound_indices() + .find(|(i, word)| *i != 0 && is_whitespace_str(word)) + .map(|(i, _)| self.insertion_point + i) + .unwrap_or_else(|| self.lines.len()) + } + + /// Move cursor position *behind* the next unicode grapheme to the right + pub fn move_right(&mut self) { + self.insertion_point = self.grapheme_right_index(); + } + + /// Move cursor position *in front of* the next unicode grapheme to the left + pub fn move_left(&mut self) { + self.insertion_point = self.grapheme_left_index(); + } + + /// Move cursor position *in front of* the next word to the left + pub fn move_word_left(&mut self) { + self.insertion_point = self.word_left_index(); + } + + /// Move cursor position *in front of* the next WORD to the left + pub fn move_big_word_left(&mut self) { + self.insertion_point = self.big_word_left_index(); + } + + /// Move cursor position *behind* the next word to the right + pub fn move_word_right(&mut self) { + self.insertion_point = self.word_right_index(); + } + + /// Move cursor position to the start of the next word + pub fn move_word_right_start(&mut self) { + self.insertion_point = self.word_right_start_index(); + } + + /// Move cursor position to the start of the next WORD + pub fn move_big_word_right_start(&mut self) { + self.insertion_point = self.big_word_right_start_index(); + } + + /// Move cursor position to the end of the next word + pub fn move_word_right_end(&mut self) { + self.insertion_point = self.word_right_end_index(); + } + + /// Move cursor position to the end of the next WORD + pub fn move_big_word_right_end(&mut self) { + self.insertion_point = self.big_word_right_end_index(); + } + + ///Insert a single character at the insertion point and move right + pub fn insert_char(&mut self, c: char) { + self.lines.insert(self.insertion_point, c); + self.move_right(); + } + + /// Insert `&str` at the cursor position in the current line. + /// + /// Sets cursor to end of inserted string + /// + /// ## Unicode safety: + /// Does not validate the incoming string or the current cursor position + pub fn insert_str(&mut self, string: &str) { + self.lines.insert_str(self.insertion_point(), string); + self.insertion_point = self.insertion_point() + string.len(); + } + + /// Inserts the system specific new line character + /// + /// - On Unix systems LF (`"\n"`) + /// - On Windows CRLF (`"\r\n"`) + pub fn insert_newline(&mut self) { + #[cfg(target_os = "windows")] + self.insert_str("\r\n"); + #[cfg(not(target_os = "windows"))] + self.insert_char('\n'); + } + + /// Empty buffer and reset cursor + pub fn clear(&mut self) { + self.lines = String::new(); + self.insertion_point = 0; + } + + /// Clear everything beginning at the cursor to the right/end. + /// Keeps the cursor at the end. + pub fn clear_to_end(&mut self) { + self.lines.truncate(self.insertion_point); + } + + /// Clear beginning at the cursor up to the end of the line. + /// Newline character at the end remains. + pub fn clear_to_line_end(&mut self) { + self.clear_range(self.insertion_point..self.find_current_line_end()); + } + + /// Clear from the start of the buffer to the cursor. + /// Keeps the cursor at the beginning of the line/buffer. + pub fn clear_to_insertion_point(&mut self) { + self.clear_range(..self.insertion_point); + self.insertion_point = 0; + } + + /// Clear text covered by `range` in the current line + /// + /// Safety: Does not change the insertion point/offset and is thus not unicode safe! + pub(crate) fn clear_range(&mut self, range: R) + where + R: std::ops::RangeBounds, + { + self.replace_range(range, ""); + } + + /// Substitute text covered by `range` in the current line + /// + /// Safety: Does not change the insertion point/offset and is thus not unicode safe! + pub fn replace_range(&mut self, range: R, replace_with: &str) + where + R: std::ops::RangeBounds, + { + self.lines.replace_range(range, replace_with); + } + + /// Checks to see if the current edit position is pointing to whitespace + pub fn on_whitespace(&self) -> bool { + self.lines[self.insertion_point..] + .chars() + .next() + .map(char::is_whitespace) + .unwrap_or(false) + } + + /// Get the grapheme immediately to the right of the cursor, if any + pub fn grapheme_right(&self) -> &str { + &self.lines[self.insertion_point..self.grapheme_right_index()] + } + + /// Get the grapheme immediately to the left of the cursor, if any + pub fn grapheme_left(&self) -> &str { + &self.lines[self.grapheme_left_index()..self.insertion_point] + } + + /// Gets the range of the word the current edit position is pointing to + pub fn current_word_range(&self) -> Range { + let right_index = self.word_right_index(); + let left_index = self.lines[..right_index] + .split_word_bound_indices() + .filter(|(_, word)| !is_whitespace_str(word)) + .last() + .map(|(i, _)| i) + .unwrap_or(0); + + left_index..right_index + } + + /// Range over the current line + /// + /// Starts on the first non-newline character and is an exclusive range + /// extending beyond the potential carriage return and line feed characters + /// terminating the line + pub fn current_line_range(&self) -> Range { + let left_index = self.lines[..self.insertion_point] + .rfind('\n') + .map_or(0, |offset| offset + 1); + let right_index = self.lines[self.insertion_point..] + .find('\n') + .map_or_else(|| self.lines.len(), |i| i + self.insertion_point + 1); + + left_index..right_index + } + + /// Uppercases the current word + pub fn uppercase_word(&mut self) { + let change_range = self.current_word_range(); + let uppercased = self.get_buffer()[change_range.clone()].to_uppercase(); + self.replace_range(change_range, &uppercased); + self.move_word_right(); + } + + /// Lowercases the current word + pub fn lowercase_word(&mut self) { + let change_range = self.current_word_range(); + let uppercased = self.get_buffer()[change_range.clone()].to_lowercase(); + self.replace_range(change_range, &uppercased); + self.move_word_right(); + } + + /// Switches the ASCII case of the current char + pub fn switchcase_char(&mut self) { + let insertion_offset = self.insertion_point(); + let right_index = self.grapheme_right_index(); + + if right_index > insertion_offset { + let change_range = insertion_offset..right_index; + let swapped = self.get_buffer()[change_range.clone()] + .chars() + .map(|c| { + if c.is_ascii_uppercase() { + c.to_ascii_lowercase() + } else { + c.to_ascii_uppercase() + } + }) + .collect::(); + self.replace_range(change_range, &swapped); + self.move_right(); + } + } + + /// Capitalize the character at insertion point (or the first character + /// following the whitespace at the insertion point) and move the insertion + /// point right one grapheme. + pub fn capitalize_char(&mut self) { + if self.on_whitespace() { + self.move_word_right(); + self.move_word_left(); + } + let insertion_offset = self.insertion_point(); + let right_index = self.grapheme_right_index(); + + if right_index > insertion_offset { + let change_range = insertion_offset..right_index; + let uppercased = self.get_buffer()[change_range.clone()].to_uppercase(); + self.replace_range(change_range, &uppercased); + self.move_right(); + } + } + + /// Deletes on grapheme to the left + pub fn delete_left_grapheme(&mut self) { + let left_index = self.grapheme_left_index(); + let insertion_offset = self.insertion_point(); + if left_index < insertion_offset { + self.clear_range(left_index..insertion_offset); + self.insertion_point = left_index; + } + } + + /// Deletes one grapheme to the right + pub fn delete_right_grapheme(&mut self) { + let right_index = self.grapheme_right_index(); + let insertion_offset = self.insertion_point(); + if right_index > insertion_offset { + self.clear_range(insertion_offset..right_index); + } + } + + /// Deletes one word to the left + pub fn delete_word_left(&mut self) { + let left_word_index = self.word_left_index(); + self.clear_range(left_word_index..self.insertion_point()); + self.insertion_point = left_word_index; + } + + /// Deletes one word to the right + pub fn delete_word_right(&mut self) { + let right_word_index = self.word_right_index(); + self.clear_range(self.insertion_point()..right_word_index); + } + + /// Swaps current word with word on right + pub fn swap_words(&mut self) { + let word_1_range = self.current_word_range(); + self.move_word_right(); + let word_2_range = self.current_word_range(); + + if word_1_range != word_2_range { + self.move_word_left(); + let insertion_line = self.get_buffer(); + let word_1 = insertion_line[word_1_range.clone()].to_string(); + let word_2 = insertion_line[word_2_range.clone()].to_string(); + self.replace_range(word_2_range, &word_1); + self.replace_range(word_1_range, &word_2); + } + } + + /// Swaps current grapheme with grapheme on right + pub fn swap_graphemes(&mut self) { + let initial_offset = self.insertion_point(); + + if initial_offset == 0 { + self.move_right(); + } else if initial_offset == self.get_buffer().len() { + self.move_left(); + } + + let updated_offset = self.insertion_point(); + let grapheme_1_start = self.grapheme_left_index(); + let grapheme_2_end = self.grapheme_right_index(); + + if grapheme_1_start < updated_offset && grapheme_2_end > updated_offset { + let grapheme_1 = self.get_buffer()[grapheme_1_start..updated_offset].to_string(); + let grapheme_2 = self.get_buffer()[updated_offset..grapheme_2_end].to_string(); + self.replace_range(updated_offset..grapheme_2_end, &grapheme_1); + self.replace_range(grapheme_1_start..updated_offset, &grapheme_2); + self.insertion_point = grapheme_2_end; + } else { + self.insertion_point = updated_offset; + } + } + + /// Moves one line up + pub fn move_line_up(&mut self) { + if !self.is_cursor_at_first_line() { + let old_range = self.current_line_range(); + + let grapheme_col = self.lines[old_range.start..self.insertion_point()] + .graphemes(true) + .count(); + + // Platform independent way to jump to the previous line. + // Doesn't matter if `\n` or `\r\n` terminated line. + // Maybe replace with more explicit implementation. + self.set_insertion_point(old_range.start); + self.move_left(); + + let new_range = self.current_line_range(); + let new_line = &self.lines[new_range.clone()]; + + self.insertion_point = new_line + .grapheme_indices(true) + .take(grapheme_col + 1) + .last() + .map_or(new_range.start, |(i, _)| i + new_range.start); + } + } + + /// Moves one line down + pub fn move_line_down(&mut self) { + if !self.is_cursor_at_last_line() { + let old_range = self.current_line_range(); + + let grapheme_col = self.lines[old_range.start..self.insertion_point()] + .graphemes(true) + .count(); + + // Exclusive range, thus guaranteed to be in the next line + self.set_insertion_point(old_range.end); + + let new_range = self.current_line_range(); + let new_line = &self.lines[new_range.clone()]; + + // Slightly different to move_line_up to account for the special + // case of the last line without newline char at the end. + // -> use `self.find_current_line_end()` + self.insertion_point = new_line + .grapheme_indices(true) + .nth(grapheme_col) + .map_or_else( + || self.find_current_line_end(), + |(i, _)| i + new_range.start, + ); + } + } + + /// Checks to see if the cursor is on the first line of the buffer + pub fn is_cursor_at_first_line(&self) -> bool { + !self.get_buffer()[0..self.insertion_point()].contains('\n') + } + + /// Checks to see if the cursor is on the last line of the buffer + pub fn is_cursor_at_last_line(&self) -> bool { + !self.get_buffer()[self.insertion_point()..].contains('\n') + } + + /// Finds index for the first occurrence of a char to the right of offset + pub fn find_char_right(&self, c: char, current_line: bool) -> Option { + // Skip current grapheme + let char_offset = self.grapheme_right_index(); + let range = if current_line { + char_offset..self.current_line_range().end + } else { + char_offset..self.lines.len() + }; + self.lines[range].find(c).map(|index| index + char_offset) + } + + /// Finds index for the first occurrence of a char to the left of offset + pub fn find_char_left(&self, c: char, current_line: bool) -> Option { + let range = if current_line { + self.current_line_range().start..self.insertion_point() + } else { + 0..self.insertion_point() + }; + self.lines[range.clone()].rfind(c).map(|i| i + range.start) + } + + /// Moves the insertion point until the next char to the right + pub fn move_right_until(&mut self, c: char, current_line: bool) -> usize { + if let Some(index) = self.find_char_right(c, current_line) { + self.insertion_point = index; + } + + self.insertion_point + } + + /// Moves the insertion point before the next char to the right + pub fn move_right_before(&mut self, c: char, current_line: bool) -> usize { + if let Some(index) = self.find_char_right(c, current_line) { + self.insertion_point = index; + self.insertion_point = self.grapheme_left_index(); + } + + self.insertion_point + } + + /// Moves the insertion point until the next char to the left of offset + pub fn move_left_until(&mut self, c: char, current_line: bool) -> usize { + if let Some(index) = self.find_char_left(c, current_line) { + self.insertion_point = index; + } + + self.insertion_point + } + + /// Moves the insertion point before the next char to the left of offset + pub fn move_left_before(&mut self, c: char, current_line: bool) -> usize { + if let Some(index) = self.find_char_left(c, current_line) { + self.insertion_point = index + c.len_utf8(); + } + + self.insertion_point + } + + /// Deletes until first character to the right of offset + pub fn delete_right_until_char(&mut self, c: char, current_line: bool) { + if let Some(index) = self.find_char_right(c, current_line) { + self.clear_range(self.insertion_point()..index + c.len_utf8()); + } + } + + /// Deletes before first character to the right of offset + pub fn delete_right_before_char(&mut self, c: char, current_line: bool) { + if let Some(index) = self.find_char_right(c, current_line) { + self.clear_range(self.insertion_point()..index); + } + } + + /// Deletes until first character to the left of offset + pub fn delete_left_until_char(&mut self, c: char, current_line: bool) { + if let Some(index) = self.find_char_left(c, current_line) { + self.clear_range(index..self.insertion_point()); + self.insertion_point = index; + } + } + + /// Deletes before first character to the left of offset + pub fn delete_left_before_char(&mut self, c: char, current_line: bool) { + if let Some(index) = self.find_char_left(c, current_line) { + self.clear_range(index + c.len_utf8()..self.insertion_point()); + self.insertion_point = index + c.len_utf8(); + } + } +} + +/// Match any sequence of characters that are considered a word boundary +fn is_whitespace_str(s: &str) -> bool { + s.chars().all(char::is_whitespace) +} + +#[cfg(test)] +mod test { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + fn buffer_with(content: &str) -> LineBuffer { + let mut line_buffer = LineBuffer::new(); + line_buffer.insert_str(content); + + line_buffer + } + + #[test] + fn test_new_buffer_is_empty() { + let line_buffer = LineBuffer::new(); + assert!(line_buffer.is_empty()); + line_buffer.assert_valid(); + } + + #[test] + fn test_clearing_line_buffer_resets_buffer_and_insertion_point() { + let mut line_buffer = buffer_with("this is a command"); + line_buffer.clear(); + let empty_buffer = LineBuffer::new(); + + assert_eq!(line_buffer, empty_buffer); + line_buffer.assert_valid(); + } + + #[test] + fn insert_str_updates_insertion_point_point_correctly() { + let mut line_buffer = LineBuffer::new(); + line_buffer.insert_str("this is a command"); + + let expected_updated_insertion_point = 17; + + assert_eq!( + expected_updated_insertion_point, + line_buffer.insertion_point() + ); + line_buffer.assert_valid(); + } + + #[test] + fn insert_char_updates_insertion_point_point_correctly() { + let mut line_buffer = LineBuffer::new(); + line_buffer.insert_char('c'); + + let expected_updated_insertion_point = 1; + + assert_eq!( + expected_updated_insertion_point, + line_buffer.insertion_point() + ); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("new string", 10)] + #[case("new line1\nnew line 2", 20)] + fn set_buffer_updates_insertion_point_to_new_buffer_length( + #[case] string_to_set: &str, + #[case] expected_insertion_point: usize, + ) { + let mut line_buffer = buffer_with("test string"); + let before_operation_location = 11; + assert_eq!(before_operation_location, line_buffer.insertion_point()); + + line_buffer.set_buffer(string_to_set.to_string()); + + assert_eq!(expected_insertion_point, line_buffer.insertion_point()); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("This is a test", "This is a tes")] + #[case("This is a test šŸ˜Š", "This is a test ")] + #[case("", "")] + fn delete_left_grapheme_works(#[case] input: &str, #[case] expected: &str) { + let mut line_buffer = buffer_with(input); + line_buffer.delete_left_grapheme(); + + let expected_line_buffer = buffer_with(expected); + + assert_eq!(expected_line_buffer, line_buffer); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("This is a test", "This is a tes")] + #[case("This is a test šŸ˜Š", "This is a test ")] + #[case("", "")] + fn delete_right_grapheme_works(#[case] input: &str, #[case] expected: &str) { + let mut line_buffer = buffer_with(input); + line_buffer.move_left(); + line_buffer.delete_right_grapheme(); + + let expected_line_buffer = buffer_with(expected); + + assert_eq!(expected_line_buffer, line_buffer); + line_buffer.assert_valid(); + } + + #[test] + fn delete_word_left_works() { + let mut line_buffer = buffer_with("This is a test"); + line_buffer.delete_word_left(); + + let expected_line_buffer = buffer_with("This is a "); + + assert_eq!(expected_line_buffer, line_buffer); + line_buffer.assert_valid(); + } + + #[test] + fn delete_word_right_works() { + let mut line_buffer = buffer_with("This is a test"); + line_buffer.move_word_left(); + line_buffer.delete_word_right(); + + let expected_line_buffer = buffer_with("This is a "); + + assert_eq!(expected_line_buffer, line_buffer); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("", 0, 0)] // Basecase + #[case("word", 0, 3)] // Cursor on top of the last grapheme of the word + #[case("word and another one", 0, 3)] + #[case("word and another one", 3, 7)] // repeat calling will move + #[case("word and another one", 4, 7)] // Starting from whitespace works + #[case("word\nline two", 0, 3)] // Multiline... + #[case("word\nline two", 3, 8)] // ... contineus to next word end + #[case("weirdƶ characters", 0, 5)] // Multibyte unicode at the word end (latin UTF-8 should be two bytes long) + #[case("weirdƶ characters", 5, 17)] // continue with unicode (latin UTF-8 should be two bytes long) + #[case("weirdƶ", 0, 5)] // Multibyte unicode at the buffer end is fine as well + #[case("weirdƶ", 5, 5)] // Multibyte unicode at the buffer end is fine as well + #[case("wordšŸ˜‡ with emoji", 0, 3)] // (Emojis are a separate word) + #[case("wordšŸ˜‡ with emoji", 3, 4)] // Moves to end of "emoji word" as it is one grapheme, on top of the first byte + #[case("šŸ˜‡", 0, 0)] // More UTF-8 shenanigans + fn test_move_word_right_end( + #[case] input: &str, + #[case] in_location: usize, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(in_location); + + line_buffer.move_word_right_end(); + + assert_eq!(line_buffer.insertion_point(), expected); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("This is a test", 13, "This is a tesT", 14)] + #[case("This is a test", 10, "This is a Test", 11)] + #[case("This is a test", 9, "This is a Test", 11)] + fn capitalize_char_works( + #[case] input: &str, + #[case] in_location: usize, + #[case] output: &str, + #[case] out_location: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(in_location); + line_buffer.capitalize_char(); + + let mut expected = buffer_with(output); + expected.set_insertion_point(out_location); + + assert_eq!(expected, line_buffer); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("This is a test", 13, "This is a TEST", 14)] + #[case("This is a test", 10, "This is a TEST", 14)] + #[case("", 0, "", 0)] + #[case("This", 0, "THIS", 4)] + #[case("This", 4, "THIS", 4)] + fn uppercase_word_works( + #[case] input: &str, + #[case] in_location: usize, + #[case] output: &str, + #[case] out_location: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(in_location); + line_buffer.uppercase_word(); + + let mut expected = buffer_with(output); + expected.set_insertion_point(out_location); + + assert_eq!(expected, line_buffer); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("This is a TEST", 13, "This is a test", 14)] + #[case("This is a TEST", 10, "This is a test", 14)] + #[case("", 0, "", 0)] + #[case("THIS", 0, "this", 4)] + #[case("THIS", 4, "this", 4)] + fn lowercase_word_works( + #[case] input: &str, + #[case] in_location: usize, + #[case] output: &str, + #[case] out_location: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(in_location); + line_buffer.lowercase_word(); + + let mut expected = buffer_with(output); + expected.set_insertion_point(out_location); + + assert_eq!(expected, line_buffer); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("", 0, "", 0)] + #[case("a test", 2, "a Test", 3)] + #[case("a Test", 2, "a test", 3)] + #[case("test", 0, "Test", 1)] + #[case("Test", 0, "test", 1)] + #[case("test", 3, "tesT", 4)] + #[case("tesT", 3, "test", 4)] + #[case("Ɵ", 0, "Ɵ", 2)] + fn switchcase_char( + #[case] input: &str, + #[case] in_location: usize, + #[case] output: &str, + #[case] out_location: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(in_location); + line_buffer.switchcase_char(); + + let mut expected = buffer_with(output); + expected.set_insertion_point(out_location); + + assert_eq!(expected, line_buffer); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("This is a test", 13, "This is a tets", 14)] + #[case("This is a test", 14, "This is a tets", 14)] // NOTE: Swaping works in opposite direction at last index + #[case("This is a test", 4, "Thi sis a test", 5)] // NOTE: Swaps space, moves right + #[case("This is a test", 0, "hTis is a test", 2)] + fn swap_graphemes_work( + #[case] input: &str, + #[case] in_location: usize, + #[case] output: &str, + #[case] out_location: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(in_location); + + line_buffer.swap_graphemes(); + + let mut expected = buffer_with(output); + expected.set_insertion_point(out_location); + + assert_eq!(line_buffer, expected); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("This is a test", 8, "This is test a", 8)] + #[case("This is a test", 0, "is This a test", 0)] + #[case("This is a test", 14, "This is a test", 14)] + fn swap_words_works( + #[case] input: &str, + #[case] in_location: usize, + #[case] output: &str, + #[case] out_location: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(in_location); + + line_buffer.swap_words(); + + let mut expected = buffer_with(output); + expected.set_insertion_point(out_location); + + assert_eq!(line_buffer, expected); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("line 1\nline 2", 7, 0)] + #[case("line 1\nline 2", 8, 1)] + #[case("line 1\nline 2", 0, 0)] + #[case("line\nlong line", 14, 4)] + #[case("line\nlong line", 8, 3)] + #[case("line 1\nšŸ˜‡line 2", 11, 1)] + #[case("line\n\nline", 8, 5)] + fn moving_up_works( + #[case] input: &str, + #[case] in_location: usize, + #[case] out_location: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(in_location); + + line_buffer.move_line_up(); + + let mut expected = buffer_with(input); + expected.set_insertion_point(out_location); + + assert_eq!(line_buffer, expected); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("line 1", 0, 0)] + #[case("line 1\nline 2", 0, 7)] + #[case("line 1\nšŸ˜‡line 2", 1, 11)] + #[case("line šŸ˜‡ 1\nline 2 long", 9, 18)] + #[case("line 1\nline 2", 7, 7)] + #[case("long line\nline", 8, 14)] + #[case("long line\nline", 4, 14)] + #[case("long line\nline", 3, 13)] + #[case("long line\nline\nline", 8, 14)] + #[case("line\n\nline", 3, 5)] + fn moving_down_works( + #[case] input: &str, + #[case] in_location: usize, + #[case] out_location: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(in_location); + + line_buffer.move_line_down(); + + let mut expected = buffer_with(input); + expected.set_insertion_point(out_location); + + assert_eq!(line_buffer, expected); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("line", 4, true)] + #[case("line 1\nline 2\nline 3", 0, true)] + #[case("line 1\nline 2\nline 3", 6, true)] + #[case("line 1\nline 2\nline 3", 8, false)] + fn test_first_line_detection( + #[case] input: &str, + #[case] in_location: usize, + #[case] expected: bool, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(in_location); + line_buffer.assert_valid(); + + assert_eq!(line_buffer.is_cursor_at_first_line(), expected); + } + + #[rstest] + #[case("line", 4, true)] + #[case("line\nline", 9, true)] + #[case("line 1\nline 2\nline 3", 8, false)] + #[case("line 1\nline 2\nline 3", 13, false)] + #[case("line 1\nline 2\nline 3", 14, true)] + #[case("line 1\nline 2\nline 3", 20, true)] + #[case("line 1\nline 2\nline 3\n", 20, false)] + #[case("line 1\nline 2\nline 3\n", 21, true)] + fn test_last_line_detection( + #[case] input: &str, + #[case] in_location: usize, + #[case] expected: bool, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(in_location); + line_buffer.assert_valid(); + + assert_eq!(line_buffer.is_cursor_at_last_line(), expected); + } + + #[rstest] + #[case("abc def ghi", 0, 'c', true, 2)] + #[case("abc def ghi", 0, 'a', true, 0)] + #[case("abc def ghi", 0, 'z', true, 0)] + #[case("ašŸ˜‡c", 0, 'c', true, 5)] + #[case("šŸ˜‡bc", 0, 'c', true, 5)] + #[case("abc\ndef", 0, 'f', true, 0)] + #[case("abc\ndef", 3, 'f', true, 3)] + #[case("abc\ndef", 0, 'f', false, 6)] + #[case("abc\ndef", 3, 'f', false, 6)] + fn test_move_right_until( + #[case] input: &str, + #[case] position: usize, + #[case] c: char, + #[case] current_line: bool, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + + line_buffer.move_right_until(c, current_line); + + assert_eq!(line_buffer.insertion_point(), expected); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("abc def ghi", 0, 'd', true, 3)] + #[case("abc def ghi", 3, 'd', true, 3)] + #[case("ašŸ˜‡c", 0, 'c', true, 1)] + #[case("šŸ˜‡bc", 0, 'c', true, 4)] + fn test_move_right_before( + #[case] input: &str, + #[case] position: usize, + #[case] c: char, + #[case] current_line: bool, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + + line_buffer.move_right_before(c, current_line); + + assert_eq!(line_buffer.insertion_point(), expected); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("abc def ghi", 0, 'd', true, "ef ghi")] + #[case("abc def ghi", 0, 'i', true, "")] + #[case("abc def ghi", 0, 'z', true, "abc def ghi")] + #[case("abc def ghi", 0, 'a', true, "abc def ghi")] + fn test_delete_until( + #[case] input: &str, + #[case] position: usize, + #[case] c: char, + #[case] current_line: bool, + #[case] expected: &str, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + + line_buffer.delete_right_until_char(c, current_line); + + assert_eq!(line_buffer.lines, expected); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("abc def ghi", 0, 'b', true, "bc def ghi")] + #[case("abc def ghi", 0, 'i', true, "i")] + #[case("abc def ghi", 0, 'z', true, "abc def ghi")] + fn test_delete_before( + #[case] input: &str, + #[case] position: usize, + #[case] c: char, + #[case] current_line: bool, + #[case] expected: &str, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + + line_buffer.delete_right_before_char(c, current_line); + + assert_eq!(line_buffer.lines, expected); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("abc def ghi", 4, 'c', true, 2)] + #[case("abc def ghi", 0, 'a', true, 0)] + #[case("abc def ghi", 6, 'a', true, 0)] + fn test_move_left_until( + #[case] input: &str, + #[case] position: usize, + #[case] c: char, + #[case] current_line: bool, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + + line_buffer.move_left_until(c, current_line); + + assert_eq!(line_buffer.insertion_point(), expected); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("abc def ghi", 4, 'c', true, 3)] + #[case("abc def ghi", 0, 'a', true, 0)] + #[case("abc def ghi", 6, 'a', true, 1)] + fn test_move_left_before( + #[case] input: &str, + #[case] position: usize, + #[case] c: char, + #[case] current_line: bool, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + + line_buffer.move_left_before(c, current_line); + + assert_eq!(line_buffer.insertion_point(), expected); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("abc def ghi", 5, 'b', true, "aef ghi")] + #[case("abc def ghi", 5, 'e', true, "abc def ghi")] + #[case("abc def ghi", 10, 'a', true, "i")] + #[case("z\nabc def ghi", 10, 'z', true, "z\nabc def ghi")] + #[case("z\nabc def ghi", 12, 'z', false, "i")] + fn test_delete_until_left( + #[case] input: &str, + #[case] position: usize, + #[case] c: char, + #[case] current_line: bool, + #[case] expected: &str, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + + line_buffer.delete_left_until_char(c, current_line); + + assert_eq!(line_buffer.lines, expected); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("abc def ghi", 5, 'b', true, "abef ghi")] + #[case("abc def ghi", 5, 'e', true, "abc def ghi")] + #[case("abc def ghi", 10, 'a', true, "ai")] + fn test_delete_before_left( + #[case] input: &str, + #[case] position: usize, + #[case] c: char, + #[case] current_line: bool, + #[case] expected: &str, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + + line_buffer.delete_left_before_char(c, current_line); + + assert_eq!(line_buffer.lines, expected); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("line", 0, 4)] + #[case("line\nline", 1, 4)] + #[case("line\nline", 7, 9)] + // TODO: Check if this behavior is desired for full vi consistency + #[case("line\n", 4, 4)] + #[case("line\n", 5, 5)] + // Platform agnostic + #[case("\n", 0, 0)] + #[case("\r\n", 0, 0)] + #[case("line\r\nword", 1, 4)] + #[case("line\r\nword", 7, 10)] + fn test_find_current_line_end( + #[case] input: &str, + #[case] in_location: usize, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(in_location); + line_buffer.assert_valid(); + + assert_eq!(line_buffer.find_current_line_end(), expected); + } + + #[rstest] + #[case("", 0, 0)] + #[case("\n", 0, 0)] + #[case("\n", 1, 1)] + #[case("a\nb", 0, 0)] + #[case("a\nb", 1, 0)] + #[case("a\nb", 2, 1)] + #[case("a\nbc", 3, 1)] + #[case("a\r\nb", 3, 1)] + #[case("a\r\nbc", 4, 1)] + fn test_current_line_num( + #[case] input: &str, + #[case] in_location: usize, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(in_location); + line_buffer.assert_valid(); + + assert_eq!(line_buffer.line(), expected); + } + + #[rstest] + #[case("", 0, 1)] + #[case("line", 0, 1)] + #[case("\n", 0, 2)] + #[case("line\n", 0, 2)] + #[case("a\nb", 0, 2)] + fn test_num_lines(#[case] input: &str, #[case] in_location: usize, #[case] expected: usize) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(in_location); + line_buffer.assert_valid(); + + assert_eq!(line_buffer.num_lines(), expected); + } + + #[rstest] + #[case("", 0, 0)] + #[case("line", 0, 4)] + #[case("\n", 0, 0)] + #[case("line\n", 0, 4)] + #[case("a\nb", 2, 3)] + #[case("a\nb", 0, 1)] + #[case("a\r\nb", 0, 1)] + fn test_move_to_line_end( + #[case] input: &str, + #[case] in_location: usize, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(in_location); + + line_buffer.move_to_line_end(); + + assert_eq!(line_buffer.insertion_point(), expected); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("", 0, 0)] + #[case("line", 3, 0)] + #[case("\n", 1, 1)] + #[case("\n", 0, 0)] + #[case("\nline", 3, 1)] + #[case("a\nb", 2, 2)] + #[case("a\nb", 3, 2)] + #[case("a\r\nb", 3, 3)] + fn test_move_to_line_start( + #[case] input: &str, + #[case] in_location: usize, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(in_location); + + line_buffer.move_to_line_start(); + + assert_eq!(line_buffer.insertion_point(), expected); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("", 0, 0..0)] + #[case("line", 0, 0..4)] + #[case("line\n", 0, 0..5)] + #[case("line\n", 4, 0..5)] + #[case("line\r\n", 0, 0..6)] + #[case("line\r\n", 4, 0..6)] // Position 5 would be invalid from a grapheme perspective + #[case("line\nsecond", 5, 5..11)] + #[case("line\r\nsecond", 7, 6..12)] + fn test_current_line_range( + #[case] input: &str, + #[case] in_location: usize, + #[case] expected: Range, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(in_location); + line_buffer.assert_valid(); + + assert_eq!(line_buffer.current_line_range(), expected); + } + + #[rstest] + #[case("This is a test", 7, "This is", 7)] + #[case("This is a test\nunrelated", 7, "This is\nunrelated", 7)] + #[case("This is a test\r\nunrelated", 7, "This is\r\nunrelated", 7)] + fn test_clear_to_line_end( + #[case] input: &str, + #[case] in_location: usize, + #[case] output: &str, + #[case] out_location: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(in_location); + + line_buffer.clear_to_line_end(); + + let mut expected = buffer_with(output); + expected.set_insertion_point(out_location); + + assert_eq!(expected, line_buffer); + line_buffer.assert_valid(); + } + + #[rstest] + #[case("abc def ghi", 10, 8)] + #[case("abc def-ghi", 10, 8)] + #[case("abc def.ghi", 10, 4)] + fn test_word_left_index(#[case] input: &str, #[case] position: usize, #[case] expected: usize) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + + let index = line_buffer.word_left_index(); + + assert_eq!(index, expected); + } + + #[rstest] + #[case("abc def ghi", 10, 8)] + #[case("abc def-ghi", 10, 4)] + #[case("abc def.ghi", 10, 4)] + fn test_big_word_left_index( + #[case] input: &str, + #[case] position: usize, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + + let index = line_buffer.big_word_left_index(); + + assert_eq!(index, expected,); + } + + #[rstest] + #[case("abc def ghi", 0, 4)] + #[case("abc-def ghi", 0, 3)] + #[case("abc.def ghi", 0, 8)] + fn test_word_right_start_index( + #[case] input: &str, + #[case] position: usize, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + + let index = line_buffer.word_right_start_index(); + + assert_eq!(index, expected); + } + + #[rstest] + #[case("abc def ghi", 0, 4)] + #[case("abc-def ghi", 0, 8)] + #[case("abc.def ghi", 0, 8)] + fn test_big_word_right_start_index( + #[case] input: &str, + #[case] position: usize, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + + let index = line_buffer.big_word_right_start_index(); + + assert_eq!(index, expected); + } + + #[rstest] + #[case("abc def ghi", 0, 2)] + #[case("abc-def ghi", 0, 2)] + #[case("abc.def ghi", 0, 6)] + #[case("abc", 1, 2)] + #[case("abc", 2, 2)] + #[case("abc def", 2, 6)] + fn test_word_right_end_index( + #[case] input: &str, + #[case] position: usize, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + + let index = line_buffer.word_right_end_index(); + + assert_eq!(index, expected); + } + + #[rstest] + #[case("abc def ghi", 0, 2)] + #[case("abc-def ghi", 0, 6)] + #[case("abc-def ghi", 5, 6)] + #[case("abc-def ghi", 6, 10)] + #[case("abc.def ghi", 0, 6)] + #[case("abc", 1, 2)] + #[case("abc", 2, 2)] + #[case("abc def", 2, 6)] + #[case("abc-def", 6, 6)] + fn test_big_word_right_end_index( + #[case] input: &str, + #[case] position: usize, + #[case] expected: usize, + ) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + + let index = line_buffer.big_word_right_end_index(); + + assert_eq!(index, expected); + } + + #[rstest] + #[case("abc def", 0, 3)] + #[case("abc def ghi", 3, 7)] + #[case("abc", 1, 3)] + fn test_next_whitespace(#[case] input: &str, #[case] position: usize, #[case] expected: usize) { + let mut line_buffer = buffer_with(input); + line_buffer.set_insertion_point(position); + + let index = line_buffer.next_whitespace(); + + assert_eq!(index, expected); + } +} diff --git a/reedline/src/core_editor/mod.rs b/reedline/src/core_editor/mod.rs new file mode 100644 index 00000000..2bdd7994 --- /dev/null +++ b/reedline/src/core_editor/mod.rs @@ -0,0 +1,8 @@ +mod clip_buffer; +mod edit_stack; +mod editor; +mod line_buffer; + +pub(crate) use clip_buffer::{get_default_clipboard, Clipboard, ClipboardMode}; +pub use editor::Editor; +pub use line_buffer::LineBuffer; diff --git a/reedline/src/edit_mode/base.rs b/reedline/src/edit_mode/base.rs new file mode 100644 index 00000000..c9c5ea78 --- /dev/null +++ b/reedline/src/edit_mode/base.rs @@ -0,0 +1,15 @@ +use crossterm::event::Event; + +use crate::{enums::ReedlineEvent, PromptEditMode}; + +/// Define the style of parsing for the edit events +/// Available default options: +/// - Emacs +/// - Vi +pub trait EditMode: Send { + /// Translate the given user input event into what the `LineEditor` understands + fn parse_event(&mut self, event: Event) -> ReedlineEvent; + + /// What to display in the prompt indicator + fn edit_mode(&self) -> PromptEditMode; +} diff --git a/reedline/src/edit_mode/cursors.rs b/reedline/src/edit_mode/cursors.rs new file mode 100644 index 00000000..b975d5c6 --- /dev/null +++ b/reedline/src/edit_mode/cursors.rs @@ -0,0 +1,13 @@ +use crossterm::cursor::CursorShape; + +/// Maps cursor shapes to each edit mode (emacs, vi normal & vi insert). +/// If any of the fields is `None`, the cursor won't get changed by Reedline for that mode. +#[derive(Default)] +pub struct CursorConfig { + /// The cursor to be used when in vi insert mode + pub vi_insert: Option, + /// The cursor to be used when in vi normal mode + pub vi_normal: Option, + /// The cursor to be used when in emacs mode + pub emacs: Option, +} diff --git a/reedline/src/edit_mode/emacs.rs b/reedline/src/edit_mode/emacs.rs new file mode 100644 index 00000000..d3b465dd --- /dev/null +++ b/reedline/src/edit_mode/emacs.rs @@ -0,0 +1,268 @@ +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; + +use crate::{ + edit_mode::{ + keybindings::{ + add_common_control_bindings, add_common_edit_bindings, add_common_navigation_bindings, + edit_bind, Keybindings, + }, + EditMode, + }, + enums::{EditCommand, ReedlineEvent}, + PromptEditMode, +}; + +/// Returns the current default emacs keybindings +pub fn default_emacs_keybindings() -> Keybindings { + use EditCommand as EC; + use KeyCode as KC; + use KeyModifiers as KM; + + let mut kb = Keybindings::new(); + add_common_control_bindings(&mut kb); + add_common_navigation_bindings(&mut kb); + add_common_edit_bindings(&mut kb); + + // This could be in common, but in Vi it also changes the mode + kb.add_binding(KM::NONE, KC::Enter, ReedlineEvent::Enter); + + // *** CTRL *** + // Moves + kb.add_binding( + KM::CONTROL, + KC::Char('b'), + ReedlineEvent::UntilFound(vec![ReedlineEvent::MenuLeft, ReedlineEvent::Left]), + ); + kb.add_binding( + KM::CONTROL, + KC::Char('f'), + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintComplete, + ReedlineEvent::MenuRight, + ReedlineEvent::Right, + ]), + ); + // Undo/Redo + kb.add_binding(KM::CONTROL, KC::Char('g'), edit_bind(EC::Redo)); + kb.add_binding(KM::CONTROL, KC::Char('z'), edit_bind(EC::Undo)); + // Cutting + kb.add_binding( + KM::CONTROL, + KC::Char('y'), + edit_bind(EC::PasteCutBufferBefore), + ); + kb.add_binding(KM::CONTROL, KC::Char('w'), edit_bind(EC::CutWordLeft)); + kb.add_binding(KM::CONTROL, KC::Char('k'), edit_bind(EC::CutToEnd)); + kb.add_binding(KM::CONTROL, KC::Char('u'), edit_bind(EC::CutFromStart)); + // Edits + kb.add_binding(KM::CONTROL, KC::Char('t'), edit_bind(EC::SwapGraphemes)); + + // *** ALT *** + // Moves + kb.add_binding(KM::ALT, KC::Left, edit_bind(EC::MoveWordLeft)); + kb.add_binding( + KM::ALT, + KC::Right, + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintWordComplete, + edit_bind(EC::MoveWordRight), + ]), + ); + kb.add_binding(KM::ALT, KC::Char('b'), edit_bind(EC::MoveWordLeft)); + kb.add_binding( + KM::ALT, + KC::Char('f'), + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintWordComplete, + edit_bind(EC::MoveWordRight), + ]), + ); + // Edits + kb.add_binding(KM::ALT, KC::Delete, edit_bind(EC::DeleteWord)); + kb.add_binding(KM::ALT, KC::Backspace, edit_bind(EC::BackspaceWord)); + kb.add_binding( + KM::ALT, + KC::Char('m'), + ReedlineEvent::Edit(vec![EditCommand::BackspaceWord]), + ); + // Cutting + kb.add_binding(KM::ALT, KC::Char('d'), edit_bind(EC::CutWordRight)); + // Case changes + kb.add_binding(KM::ALT, KC::Char('u'), edit_bind(EC::UppercaseWord)); + kb.add_binding(KM::ALT, KC::Char('l'), edit_bind(EC::LowercaseWord)); + kb.add_binding(KM::ALT, KC::Char('c'), edit_bind(EC::CapitalizeChar)); + + kb +} + +/// This parses the incoming Events like a emacs style-editor +pub struct Emacs { + keybindings: Keybindings, +} + +impl Default for Emacs { + fn default() -> Self { + Emacs { + keybindings: default_emacs_keybindings(), + } + } +} + +impl EditMode for Emacs { + fn parse_event(&mut self, event: Event) -> ReedlineEvent { + match event { + Event::Key(KeyEvent { code, modifiers }) => match (modifiers, code) { + (modifier, KeyCode::Char(c)) => { + // Note. The modifier can also be a combination of modifiers, for + // example: + // KeyModifiers::CONTROL | KeyModifiers::ALT + // KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT + // + // Mixed modifiers are used by non american keyboards that have extra + // keys like 'alt gr'. Keep this in mind if in the future there are + // cases where an event is not being captured + let c = match modifier { + KeyModifiers::NONE => c, + _ => c.to_ascii_lowercase(), + }; + + if modifier == KeyModifiers::NONE + || modifier == KeyModifiers::SHIFT + || modifier == KeyModifiers::CONTROL | KeyModifiers::ALT + || modifier + == KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT + { + ReedlineEvent::Edit(vec![EditCommand::InsertChar( + if modifier == KeyModifiers::SHIFT { + c.to_ascii_uppercase() + } else { + c + }, + )]) + } else { + self.keybindings + .find_binding(modifier, KeyCode::Char(c)) + .unwrap_or(ReedlineEvent::None) + } + }, + _ => self + .keybindings + .find_binding(modifiers, code) + .unwrap_or(ReedlineEvent::None), + }, + + Event::Mouse(_) => ReedlineEvent::Mouse, + Event::Resize(width, height) => ReedlineEvent::Resize(width, height), + } + } + + fn edit_mode(&self) -> PromptEditMode { + PromptEditMode::Emacs + } +} + +impl Emacs { + /// Emacs style input parsing constructor if you want to use custom keybindings + pub fn new(keybindings: Keybindings) -> Self { + Emacs { keybindings } + } +} + +#[cfg(test)] +mod test { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn ctrl_l_leads_to_clear_screen_event() { + let mut emacs = Emacs::default(); + let ctrl_l = Event::Key(KeyEvent { + modifiers: KeyModifiers::CONTROL, + code: KeyCode::Char('l'), + }); + let result = emacs.parse_event(ctrl_l); + + assert_eq!(result, ReedlineEvent::ClearScreen); + } + + #[test] + fn overriding_default_keybindings_works() { + let mut keybindings = default_emacs_keybindings(); + keybindings.add_binding( + KeyModifiers::CONTROL, + KeyCode::Char('l'), + ReedlineEvent::HistoryHintComplete, + ); + + let mut emacs = Emacs::new(keybindings); + let ctrl_l = Event::Key(KeyEvent { + modifiers: KeyModifiers::CONTROL, + code: KeyCode::Char('l'), + }); + let result = emacs.parse_event(ctrl_l); + + assert_eq!(result, ReedlineEvent::HistoryHintComplete); + } + + #[test] + fn inserting_character_works() { + let mut emacs = Emacs::default(); + let l = Event::Key(KeyEvent { + modifiers: KeyModifiers::NONE, + code: KeyCode::Char('l'), + }); + let result = emacs.parse_event(l); + + assert_eq!( + result, + ReedlineEvent::Edit(vec![EditCommand::InsertChar('l')]) + ); + } + + #[test] + fn inserting_capital_character_works() { + let mut emacs = Emacs::default(); + + let uppercase_l = Event::Key(KeyEvent { + modifiers: KeyModifiers::SHIFT, + code: KeyCode::Char('l'), + }); + let result = emacs.parse_event(uppercase_l); + + assert_eq!( + result, + ReedlineEvent::Edit(vec![EditCommand::InsertChar('L')]) + ); + } + + #[test] + fn return_none_reedline_event_when_keybinding_is_not_found() { + let keybindings = Keybindings::default(); + + let mut emacs = Emacs::new(keybindings); + let ctrl_l = Event::Key(KeyEvent { + modifiers: KeyModifiers::CONTROL, + code: KeyCode::Char('l'), + }); + let result = emacs.parse_event(ctrl_l); + + assert_eq!(result, ReedlineEvent::None); + } + + #[test] + fn inserting_capital_character_for_non_ascii_remains_as_is() { + let mut emacs = Emacs::default(); + + let uppercase_l = Event::Key(KeyEvent { + modifiers: KeyModifiers::SHIFT, + code: KeyCode::Char('šŸ˜€'), + }); + let result = emacs.parse_event(uppercase_l); + + assert_eq!( + result, + ReedlineEvent::Edit(vec![EditCommand::InsertChar('šŸ˜€')]) + ); + } +} diff --git a/reedline/src/edit_mode/keybindings.rs b/reedline/src/edit_mode/keybindings.rs new file mode 100644 index 00000000..3527abbc --- /dev/null +++ b/reedline/src/edit_mode/keybindings.rs @@ -0,0 +1,197 @@ +use std::collections::HashMap; + +use crossterm::event::{KeyCode, KeyModifiers}; +use serde::{Deserialize, Serialize}; + +use crate::{enums::ReedlineEvent, EditCommand}; + +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Hash, Debug)] +pub struct KeyCombination { + pub modifier: KeyModifiers, + pub key_code: KeyCode, +} + +/// Main definition of editor keybindings +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Keybindings { + /// Defines a keybinding for a reedline event + pub bindings: HashMap, +} + +impl Default for Keybindings { + fn default() -> Self { + Self::new() + } +} + +impl Keybindings { + /// New keybining + pub fn new() -> Self { + Self { + bindings: HashMap::new(), + } + } + + /// Defines an empty keybinding object + pub fn empty() -> Self { + Self::new() + } + + /// Adds a keybinding + /// + /// # Panics + /// + /// If `comamnd` is an empty [`ReedlineEvent::UntilFound`] + pub fn add_binding( + &mut self, + modifier: KeyModifiers, + key_code: KeyCode, + command: ReedlineEvent, + ) { + if let ReedlineEvent::UntilFound(subcommands) = &command { + assert!( + !subcommands.is_empty(), + "UntilFound should contain a series of potential events to handle" + ); + } + + let key_combo = KeyCombination { modifier, key_code }; + self.bindings.insert(key_combo, command); + } + + /// Find a keybinding based on the modifier and keycode + pub fn find_binding(&self, modifier: KeyModifiers, key_code: KeyCode) -> Option { + let key_combo = KeyCombination { modifier, key_code }; + self.bindings.get(&key_combo).cloned() + } + + /// Remove a keybinding + /// + /// Returns `Some(ReedlineEvent)` if the keycombination was previously bound to a particular [`ReedlineEvent`] + pub fn remove_binding( + &mut self, + modifier: KeyModifiers, + key_code: KeyCode, + ) -> Option { + let key_combo = KeyCombination { modifier, key_code }; + self.bindings.remove(&key_combo) + } + + /// Get assigned keybindings + pub fn get_keybindings(&self) -> &HashMap { + &self.bindings + } +} + +pub fn edit_bind(command: EditCommand) -> ReedlineEvent { + ReedlineEvent::Edit(vec![command]) +} + +/// Add the basic special keybindings +/// +/// `Ctrl-C`, `Ctrl-D`, `Ctrl-O`, `Ctrl-R` +/// + `Esc` +/// + `Ctrl-O` to open the external editor +pub fn add_common_control_bindings(kb: &mut Keybindings) { + use KeyCode as KC; + use KeyModifiers as KM; + + kb.add_binding(KM::NONE, KC::Esc, ReedlineEvent::Esc); + kb.add_binding(KM::CONTROL, KC::Char('c'), ReedlineEvent::CtrlC); + kb.add_binding(KM::CONTROL, KC::Char('d'), ReedlineEvent::CtrlD); + kb.add_binding(KM::CONTROL, KC::Char('l'), ReedlineEvent::ClearScreen); + kb.add_binding(KM::CONTROL, KC::Char('r'), ReedlineEvent::SearchHistory); + kb.add_binding(KM::CONTROL, KC::Char('o'), ReedlineEvent::OpenEditor); +} +/// Add the arrow navigation and its `Ctrl` variants +pub fn add_common_navigation_bindings(kb: &mut Keybindings) { + use EditCommand as EC; + use KeyCode as KC; + use KeyModifiers as KM; + + // Arrow keys without modifier + kb.add_binding( + KM::NONE, + KC::Up, + ReedlineEvent::UntilFound(vec![ReedlineEvent::MenuUp, ReedlineEvent::Up]), + ); + kb.add_binding( + KM::NONE, + KC::Down, + ReedlineEvent::UntilFound(vec![ReedlineEvent::MenuDown, ReedlineEvent::Down]), + ); + kb.add_binding( + KM::NONE, + KC::Left, + ReedlineEvent::UntilFound(vec![ReedlineEvent::MenuLeft, ReedlineEvent::Left]), + ); + kb.add_binding( + KM::NONE, + KC::Right, + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintComplete, + ReedlineEvent::MenuRight, + ReedlineEvent::Right, + ]), + ); + + // Ctrl Left and Right + kb.add_binding(KM::CONTROL, KC::Left, edit_bind(EC::MoveWordLeft)); + kb.add_binding( + KM::CONTROL, + KC::Right, + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintWordComplete, + edit_bind(EC::MoveWordRight), + ]), + ); + // Home/End & ctrl+a/ctrl+e + kb.add_binding(KM::NONE, KC::Home, edit_bind(EC::MoveToLineStart)); + kb.add_binding(KM::CONTROL, KC::Char('a'), edit_bind(EC::MoveToLineStart)); + kb.add_binding( + KM::NONE, + KC::End, + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintComplete, + edit_bind(EC::MoveToLineEnd), + ]), + ); + kb.add_binding( + KM::CONTROL, + KC::Char('e'), + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintComplete, + edit_bind(EC::MoveToLineEnd), + ]), + ); + // Ctrl Home/End + kb.add_binding(KM::CONTROL, KC::Home, edit_bind(EC::MoveToStart)); + kb.add_binding(KM::CONTROL, KC::End, edit_bind(EC::MoveToEnd)); + // EMACS arrows + kb.add_binding( + KM::CONTROL, + KC::Char('p'), + ReedlineEvent::UntilFound(vec![ReedlineEvent::MenuUp, ReedlineEvent::Up]), + ); + kb.add_binding( + KM::CONTROL, + KC::Char('n'), + ReedlineEvent::UntilFound(vec![ReedlineEvent::MenuDown, ReedlineEvent::Down]), + ); +} + +/// Add basic functionality to edit +/// +/// `Delete`, `Backspace` and the basic variants do delete words +pub fn add_common_edit_bindings(kb: &mut Keybindings) { + use EditCommand as EC; + use KeyCode as KC; + use KeyModifiers as KM; + kb.add_binding(KM::NONE, KC::Backspace, edit_bind(EC::Backspace)); + kb.add_binding(KM::NONE, KC::Delete, edit_bind(EC::Delete)); + kb.add_binding(KM::CONTROL, KC::Backspace, edit_bind(EC::BackspaceWord)); + kb.add_binding(KM::CONTROL, KC::Delete, edit_bind(EC::DeleteWord)); + // Base commands should not affect cut buffer + kb.add_binding(KM::CONTROL, KC::Char('h'), edit_bind(EC::Backspace)); + kb.add_binding(KM::CONTROL, KC::Char('w'), edit_bind(EC::BackspaceWord)); +} diff --git a/reedline/src/edit_mode/mod.rs b/reedline/src/edit_mode/mod.rs new file mode 100644 index 00000000..38e1456f --- /dev/null +++ b/reedline/src/edit_mode/mod.rs @@ -0,0 +1,11 @@ +mod base; +mod cursors; +mod emacs; +mod keybindings; +mod vi; + +pub use base::EditMode; +pub use cursors::CursorConfig; +pub use emacs::{default_emacs_keybindings, Emacs}; +pub use keybindings::Keybindings; +pub use vi::{default_vi_insert_keybindings, default_vi_normal_keybindings, Vi}; diff --git a/reedline/src/edit_mode/vi/command.rs b/reedline/src/edit_mode/vi/command.rs new file mode 100644 index 00000000..48acee8b --- /dev/null +++ b/reedline/src/edit_mode/vi/command.rs @@ -0,0 +1,277 @@ +use std::iter::Peekable; + +use super::{ + motion::{Motion, ViCharSearch}, + parser::ReedlineOption, +}; +use crate::{EditCommand, ReedlineEvent, Vi}; + +pub fn parse_command<'iter, I>(input: &mut Peekable) -> Option +where + I: Iterator, +{ + match input.peek() { + Some('d') => { + let _ = input.next(); + Some(Command::Delete) + }, + Some('p') => { + let _ = input.next(); + Some(Command::PasteAfter) + }, + Some('P') => { + let _ = input.next(); + Some(Command::PasteBefore) + }, + Some('i') => { + let _ = input.next(); + Some(Command::EnterViInsert) + }, + Some('a') => { + let _ = input.next(); + Some(Command::EnterViAppend) + }, + Some('u') => { + let _ = input.next(); + Some(Command::Undo) + }, + Some('c') => { + let _ = input.next(); + Some(Command::Change) + }, + Some('x') => { + let _ = input.next(); + Some(Command::DeleteChar) + }, + Some('r') => { + let _ = input.next(); + match input.next() { + Some(c) => Some(Command::ReplaceChar(*c)), + None => Some(Command::Incomplete), + } + }, + Some('s') => { + let _ = input.next(); + Some(Command::SubstituteCharWithInsert) + }, + Some('?') => { + let _ = input.next(); + Some(Command::HistorySearch) + }, + Some('C') => { + let _ = input.next(); + Some(Command::ChangeToLineEnd) + }, + Some('D') => { + let _ = input.next(); + Some(Command::DeleteToEnd) + }, + Some('I') => { + let _ = input.next(); + Some(Command::PrependToStart) + }, + Some('A') => { + let _ = input.next(); + Some(Command::AppendToEnd) + }, + Some('S') => { + let _ = input.next(); + Some(Command::RewriteCurrentLine) + }, + Some('~') => { + let _ = input.next(); + Some(Command::Switchcase) + }, + Some('.') => { + let _ = input.next(); + Some(Command::RepeatLastAction) + }, + _ => None, + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Command { + Incomplete, + Delete, + DeleteChar, + ReplaceChar(char), + SubstituteCharWithInsert, + PasteAfter, + PasteBefore, + EnterViAppend, + EnterViInsert, + Undo, + ChangeToLineEnd, + DeleteToEnd, + AppendToEnd, + PrependToStart, + RewriteCurrentLine, + Change, + HistorySearch, + Switchcase, + RepeatLastAction, +} + +impl Command { + pub fn whole_line_char(&self) -> Option { + match self { + Command::Delete => Some('d'), + Command::Change => Some('c'), + _ => None, + } + } + + pub fn requires_motion(&self) -> bool { + matches!(self, Command::Delete | Command::Change) + } + + pub fn to_reedline(&self, vi_state: &mut Vi) -> Vec { + match self { + Self::EnterViInsert => vec![ReedlineOption::Event(ReedlineEvent::Repaint)], + Self::EnterViAppend => vec![ReedlineOption::Edit(EditCommand::MoveRight)], + Self::PasteAfter => vec![ReedlineOption::Edit(EditCommand::PasteCutBufferAfter)], + Self::PasteBefore => vec![ReedlineOption::Edit(EditCommand::PasteCutBufferBefore)], + Self::Undo => vec![ReedlineOption::Edit(EditCommand::Undo)], + Self::ChangeToLineEnd => vec![ReedlineOption::Edit(EditCommand::ClearToLineEnd)], + Self::DeleteToEnd => vec![ReedlineOption::Edit(EditCommand::CutToLineEnd)], + Self::AppendToEnd => vec![ReedlineOption::Edit(EditCommand::MoveToLineEnd)], + Self::PrependToStart => vec![ReedlineOption::Edit(EditCommand::MoveToLineStart)], + Self::RewriteCurrentLine => vec![ReedlineOption::Edit(EditCommand::CutCurrentLine)], + Self::DeleteChar => vec![ReedlineOption::Edit(EditCommand::CutChar)], + Self::ReplaceChar(c) => { + vec![ReedlineOption::Edit(EditCommand::ReplaceChar(*c))] + }, + Self::SubstituteCharWithInsert => vec![ReedlineOption::Edit(EditCommand::CutChar)], + Self::HistorySearch => vec![ReedlineOption::Event(ReedlineEvent::SearchHistory)], + Self::Switchcase => vec![ReedlineOption::Edit(EditCommand::SwitchcaseChar)], + // Mark a command as incomplete whenever a motion is required to finish the command + Self::Delete | Self::Change | Self::Incomplete => vec![ReedlineOption::Incomplete], + Command::RepeatLastAction => match &vi_state.previous { + Some(event) => vec![ReedlineOption::Event(event.clone())], + None => vec![], + }, + } + } + + pub fn to_reedline_with_motion( + &self, + motion: &Motion, + vi_state: &mut Vi, + ) -> Option> { + match self { + Self::Delete => match motion { + Motion::End => Some(vec![ReedlineOption::Edit(EditCommand::CutToLineEnd)]), + Motion::Line => Some(vec![ReedlineOption::Edit(EditCommand::CutCurrentLine)]), + Motion::NextWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutWordRightToNext)]) + }, + Motion::NextBigWord => Some(vec![ReedlineOption::Edit( + EditCommand::CutBigWordRightToNext, + )]), + Motion::NextWordEnd => Some(vec![ReedlineOption::Edit(EditCommand::CutWordRight)]), + Motion::NextBigWordEnd => { + Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordRight)]) + }, + Motion::PreviousWord => Some(vec![ReedlineOption::Edit(EditCommand::CutWordLeft)]), + Motion::PreviousBigWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordLeft)]) + }, + Motion::RightUntil(c) => { + vi_state.last_char_search = Some(ViCharSearch::ToRight(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutRightUntil(*c))]) + }, + Motion::RightBefore(c) => { + vi_state.last_char_search = Some(ViCharSearch::TillRight(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutRightBefore(*c))]) + }, + Motion::LeftUntil(c) => { + vi_state.last_char_search = Some(ViCharSearch::ToLeft(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutLeftUntil(*c))]) + }, + Motion::LeftBefore(c) => { + vi_state.last_char_search = Some(ViCharSearch::TillLeft(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutLeftBefore(*c))]) + }, + Motion::Start => Some(vec![ReedlineOption::Edit(EditCommand::CutFromLineStart)]), + Motion::Left => Some(vec![ReedlineOption::Edit(EditCommand::Backspace)]), + Motion::Right => Some(vec![ReedlineOption::Edit(EditCommand::Delete)]), + Motion::Up => None, + Motion::Down => None, + Motion::ReplayCharSearch => vi_state + .last_char_search + .as_ref() + .map(|char_search| vec![ReedlineOption::Edit(char_search.to_cut())]), + Motion::ReverseCharSearch => vi_state + .last_char_search + .as_ref() + .map(|char_search| vec![ReedlineOption::Edit(char_search.reverse().to_cut())]), + }, + Self::Change => { + let op = match motion { + Motion::End => Some(vec![ReedlineOption::Edit(EditCommand::ClearToLineEnd)]), + Motion::Line => Some(vec![ + ReedlineOption::Edit(EditCommand::MoveToStart), + ReedlineOption::Edit(EditCommand::ClearToLineEnd), + ]), + Motion::NextWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutWordRightToNext)]) + }, + Motion::NextBigWord => Some(vec![ReedlineOption::Edit( + EditCommand::CutBigWordRightToNext, + )]), + Motion::NextWordEnd => { + Some(vec![ReedlineOption::Edit(EditCommand::CutWordRight)]) + }, + Motion::NextBigWordEnd => { + Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordRight)]) + }, + Motion::PreviousWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutWordLeft)]) + }, + Motion::PreviousBigWord => { + Some(vec![ReedlineOption::Edit(EditCommand::CutBigWordLeft)]) + }, + Motion::RightUntil(c) => { + vi_state.last_char_search = Some(ViCharSearch::ToRight(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutRightUntil(*c))]) + }, + Motion::RightBefore(c) => { + vi_state.last_char_search = Some(ViCharSearch::TillRight(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutRightBefore(*c))]) + }, + Motion::LeftUntil(c) => { + vi_state.last_char_search = Some(ViCharSearch::ToLeft(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutLeftUntil(*c))]) + }, + Motion::LeftBefore(c) => { + vi_state.last_char_search = Some(ViCharSearch::TillLeft(*c)); + Some(vec![ReedlineOption::Edit(EditCommand::CutLeftBefore(*c))]) + }, + Motion::Start => { + Some(vec![ReedlineOption::Edit(EditCommand::CutFromLineStart)]) + }, + Motion::Left => Some(vec![ReedlineOption::Edit(EditCommand::Backspace)]), + Motion::Right => Some(vec![ReedlineOption::Edit(EditCommand::Delete)]), + Motion::Up => None, + Motion::Down => None, + Motion::ReplayCharSearch => vi_state + .last_char_search + .as_ref() + .map(|char_search| vec![ReedlineOption::Edit(char_search.to_cut())]), + Motion::ReverseCharSearch => { + vi_state.last_char_search.as_ref().map(|char_search| { + vec![ReedlineOption::Edit(char_search.reverse().to_cut())] + }) + }, + }; + // Semihack: Append `Repaint` to ensure the mode change gets displayed + op.map(|mut vec| { + vec.push(ReedlineOption::Event(ReedlineEvent::Repaint)); + vec + }) + }, + _ => None, + } + } +} diff --git a/reedline/src/edit_mode/vi/mod.rs b/reedline/src/edit_mode/vi/mod.rs new file mode 100644 index 00000000..c9074f90 --- /dev/null +++ b/reedline/src/edit_mode/vi/mod.rs @@ -0,0 +1,252 @@ +mod command; +mod motion; +mod parser; +mod vi_keybindings; + +use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers}; +pub use vi_keybindings::{default_vi_insert_keybindings, default_vi_normal_keybindings}; + +use self::motion::ViCharSearch; +use super::EditMode; +use crate::{ + edit_mode::{keybindings::Keybindings, vi::parser::parse}, + enums::{EditCommand, ReedlineEvent}, + PromptEditMode, PromptViMode, +}; + +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +enum ViMode { + Normal, + Insert, +} + +/// This parses incoming input `Event`s like a Vi-Style editor +pub struct Vi { + cache: Vec, + insert_keybindings: Keybindings, + normal_keybindings: Keybindings, + mode: ViMode, + previous: Option, + // last f, F, t, T motion for ; and , + last_char_search: Option, +} + +impl Default for Vi { + fn default() -> Self { + Vi { + insert_keybindings: default_vi_insert_keybindings(), + normal_keybindings: default_vi_normal_keybindings(), + cache: Vec::new(), + mode: ViMode::Insert, + previous: None, + last_char_search: None, + } + } +} + +impl Vi { + /// Creates Vi editor using defined keybindings + pub fn new(insert_keybindings: Keybindings, normal_keybindings: Keybindings) -> Self { + Self { + insert_keybindings, + normal_keybindings, + ..Default::default() + } + } +} + +impl EditMode for Vi { + fn parse_event(&mut self, event: Event) -> ReedlineEvent { + match event { + Event::Key(KeyEvent { code, modifiers }) => match (self.mode, modifiers, code) { + (ViMode::Normal, modifier, KeyCode::Char(c)) => { + let c = c.to_ascii_lowercase(); + + if let Some(event) = self + .normal_keybindings + .find_binding(modifiers, KeyCode::Char(c)) + { + event + } else if modifier == KeyModifiers::NONE || modifier == KeyModifiers::SHIFT { + self.cache.push(if modifier == KeyModifiers::SHIFT { + c.to_ascii_uppercase() + } else { + c + }); + + let res = parse(&mut self.cache.iter().peekable()); + + if !res.is_valid() { + self.cache.clear(); + ReedlineEvent::None + } else if res.is_complete() { + if res.enters_insert_mode() { + self.mode = ViMode::Insert; + } + + let event = res.to_reedline_event(self); + self.cache.clear(); + event + } else { + ReedlineEvent::None + } + } else { + ReedlineEvent::None + } + }, + (ViMode::Insert, modifier, KeyCode::Char(c)) => { + // Note. The modifier can also be a combination of modifiers, for + // example: + // KeyModifiers::CONTROL | KeyModifiers::ALT + // KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT + // + // Mixed modifiers are used by non american keyboards that have extra + // keys like 'alt gr'. Keep this in mind if in the future there are + // cases where an event is not being captured + let c = match modifier { + KeyModifiers::NONE => c, + _ => c.to_ascii_lowercase(), + }; + + if modifier == KeyModifiers::NONE + || modifier == KeyModifiers::SHIFT + || modifier == KeyModifiers::CONTROL | KeyModifiers::ALT + || modifier + == KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SHIFT + { + ReedlineEvent::Edit(vec![EditCommand::InsertChar( + if modifier == KeyModifiers::SHIFT { + c.to_ascii_uppercase() + } else { + c + }, + )]) + } else { + self.insert_keybindings + .find_binding(modifier, KeyCode::Char(c)) + .unwrap_or(ReedlineEvent::None) + } + }, + (_, KeyModifiers::NONE, KeyCode::Esc) => { + self.cache.clear(); + self.mode = ViMode::Normal; + ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) + }, + (_, KeyModifiers::NONE, KeyCode::Enter) => { + self.mode = ViMode::Insert; + ReedlineEvent::Enter + }, + (ViMode::Normal, _, _) => self + .normal_keybindings + .find_binding(modifiers, code) + .unwrap_or(ReedlineEvent::None), + (ViMode::Insert, _, _) => self + .insert_keybindings + .find_binding(modifiers, code) + .unwrap_or(ReedlineEvent::None), + }, + + Event::Mouse(_) => ReedlineEvent::Mouse, + Event::Resize(width, height) => ReedlineEvent::Resize(width, height), + } + } + + fn edit_mode(&self) -> PromptEditMode { + match self.mode { + ViMode::Normal => PromptEditMode::Vi(PromptViMode::Normal), + ViMode::Insert => PromptEditMode::Vi(PromptViMode::Insert), + } + } +} + +#[cfg(test)] +mod test { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn esc_leads_to_normal_mode_test() { + let mut vi = Vi::default(); + let esc = Event::Key(KeyEvent { + modifiers: KeyModifiers::NONE, + code: KeyCode::Esc, + }); + let result = vi.parse_event(esc); + + assert_eq!( + result, + ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Repaint]) + ); + assert!(matches!(vi.mode, ViMode::Normal)); + } + + #[test] + fn keybinding_without_modifier_test() { + let mut keybindings = default_vi_normal_keybindings(); + keybindings.add_binding( + KeyModifiers::NONE, + KeyCode::Char('e'), + ReedlineEvent::ClearScreen, + ); + + let mut vi = Vi { + insert_keybindings: default_vi_insert_keybindings(), + normal_keybindings: keybindings, + mode: ViMode::Normal, + ..Default::default() + }; + + let esc = Event::Key(KeyEvent { + modifiers: KeyModifiers::NONE, + code: KeyCode::Char('e'), + }); + let result = vi.parse_event(esc); + + assert_eq!(result, ReedlineEvent::ClearScreen); + } + + #[test] + fn keybinding_with_shift_modifier_test() { + let mut keybindings = default_vi_normal_keybindings(); + keybindings.add_binding( + KeyModifiers::SHIFT, + KeyCode::Char('$'), + ReedlineEvent::CtrlD, + ); + + let mut vi = Vi { + insert_keybindings: default_vi_insert_keybindings(), + normal_keybindings: keybindings, + mode: ViMode::Normal, + ..Default::default() + }; + + let esc = Event::Key(KeyEvent { + modifiers: KeyModifiers::SHIFT, + code: KeyCode::Char('$'), + }); + let result = vi.parse_event(esc); + + assert_eq!(result, ReedlineEvent::CtrlD); + } + + #[test] + fn non_register_modifier_test() { + let keybindings = default_vi_normal_keybindings(); + let mut vi = Vi { + insert_keybindings: default_vi_insert_keybindings(), + normal_keybindings: keybindings, + mode: ViMode::Normal, + ..Default::default() + }; + + let esc = Event::Key(KeyEvent { + modifiers: KeyModifiers::NONE, + code: KeyCode::Char('q'), + }); + let result = vi.parse_event(esc); + + assert_eq!(result, ReedlineEvent::None); + } +} diff --git a/reedline/src/edit_mode/vi/motion.rs b/reedline/src/edit_mode/vi/motion.rs new file mode 100644 index 00000000..a4a5fe72 --- /dev/null +++ b/reedline/src/edit_mode/vi/motion.rs @@ -0,0 +1,246 @@ +use std::iter::Peekable; + +use super::parser::{ParseResult, ReedlineOption}; +use crate::{EditCommand, ReedlineEvent, Vi}; + +pub fn parse_motion<'iter, I>( + input: &mut Peekable, + command_char: Option, +) -> ParseResult +where + I: Iterator, +{ + match input.peek() { + Some('h') => { + let _ = input.next(); + ParseResult::Valid(Motion::Left) + }, + Some('l') => { + let _ = input.next(); + ParseResult::Valid(Motion::Right) + }, + Some('j') => { + let _ = input.next(); + ParseResult::Valid(Motion::Down) + }, + Some('k') => { + let _ = input.next(); + ParseResult::Valid(Motion::Up) + }, + Some('b') => { + let _ = input.next(); + ParseResult::Valid(Motion::PreviousWord) + }, + Some('B') => { + let _ = input.next(); + ParseResult::Valid(Motion::PreviousBigWord) + }, + Some('w') => { + let _ = input.next(); + ParseResult::Valid(Motion::NextWord) + }, + Some('W') => { + let _ = input.next(); + ParseResult::Valid(Motion::NextBigWord) + }, + Some('e') => { + let _ = input.next(); + ParseResult::Valid(Motion::NextWordEnd) + }, + Some('E') => { + let _ = input.next(); + ParseResult::Valid(Motion::NextBigWordEnd) + }, + Some('0' | '^') => { + let _ = input.next(); + ParseResult::Valid(Motion::Start) + }, + Some('$') => { + let _ = input.next(); + ParseResult::Valid(Motion::End) + }, + Some('f') => { + let _ = input.next(); + match input.peek() { + Some(&x) => { + input.next(); + ParseResult::Valid(Motion::RightUntil(*x)) + }, + None => ParseResult::Incomplete, + } + }, + Some('t') => { + let _ = input.next(); + match input.peek() { + Some(&x) => { + input.next(); + ParseResult::Valid(Motion::RightBefore(*x)) + }, + None => ParseResult::Incomplete, + } + }, + Some('F') => { + let _ = input.next(); + match input.peek() { + Some(&x) => { + input.next(); + ParseResult::Valid(Motion::LeftUntil(*x)) + }, + None => ParseResult::Incomplete, + } + }, + Some('T') => { + let _ = input.next(); + match input.peek() { + Some(&x) => { + input.next(); + ParseResult::Valid(Motion::LeftBefore(*x)) + }, + None => ParseResult::Incomplete, + } + }, + Some(';') => { + let _ = input.next(); + ParseResult::Valid(Motion::ReplayCharSearch) + }, + Some(',') => { + let _ = input.next(); + ParseResult::Valid(Motion::ReverseCharSearch) + }, + ch if ch == command_char.as_ref().as_ref() && command_char.is_some() => { + let _ = input.next(); + ParseResult::Valid(Motion::Line) + }, + None => ParseResult::Incomplete, + _ => ParseResult::Invalid, + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Motion { + Left, + Right, + Up, + Down, + NextWord, + NextBigWord, + NextWordEnd, + NextBigWordEnd, + PreviousWord, + PreviousBigWord, + Line, + Start, + End, + RightUntil(char), + RightBefore(char), + LeftUntil(char), + LeftBefore(char), + ReplayCharSearch, + ReverseCharSearch, +} + +impl Motion { + pub fn to_reedline(&self, vi_state: &mut Vi) -> Vec { + match self { + Motion::Left => vec![ReedlineOption::Event(ReedlineEvent::UntilFound(vec![ + ReedlineEvent::MenuLeft, + ReedlineEvent::Left, + ]))], + Motion::Right => vec![ReedlineOption::Event(ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintComplete, + ReedlineEvent::MenuRight, + ReedlineEvent::Right, + ]))], + Motion::Up => vec![ReedlineOption::Event(ReedlineEvent::UntilFound(vec![ + ReedlineEvent::MenuUp, + ReedlineEvent::Up, + ]))], + Motion::Down => vec![ReedlineOption::Event(ReedlineEvent::UntilFound(vec![ + ReedlineEvent::MenuDown, + ReedlineEvent::Down, + ]))], + Motion::NextWord => vec![ReedlineOption::Edit(EditCommand::MoveWordRightStart)], + Motion::NextBigWord => vec![ReedlineOption::Edit(EditCommand::MoveBigWordRightStart)], + Motion::NextWordEnd => vec![ReedlineOption::Edit(EditCommand::MoveWordRightEnd)], + Motion::NextBigWordEnd => vec![ReedlineOption::Edit(EditCommand::MoveBigWordRightEnd)], + Motion::PreviousWord => vec![ReedlineOption::Edit(EditCommand::MoveWordLeft)], + Motion::PreviousBigWord => vec![ReedlineOption::Edit(EditCommand::MoveBigWordLeft)], + Motion::Line => vec![], // Placeholder as unusable standalone motion + Motion::Start => vec![ReedlineOption::Edit(EditCommand::MoveToLineStart)], + Motion::End => vec![ReedlineOption::Edit(EditCommand::MoveToLineEnd)], + Motion::RightUntil(ch) => { + vi_state.last_char_search = Some(ViCharSearch::ToRight(*ch)); + vec![ReedlineOption::Edit(EditCommand::MoveRightUntil(*ch))] + }, + Motion::RightBefore(ch) => { + vi_state.last_char_search = Some(ViCharSearch::TillRight(*ch)); + vec![ReedlineOption::Edit(EditCommand::MoveRightBefore(*ch))] + }, + Motion::LeftUntil(ch) => { + vi_state.last_char_search = Some(ViCharSearch::ToLeft(*ch)); + vec![ReedlineOption::Edit(EditCommand::MoveLeftUntil(*ch))] + }, + Motion::LeftBefore(ch) => { + vi_state.last_char_search = Some(ViCharSearch::TillLeft(*ch)); + vec![ReedlineOption::Edit(EditCommand::MoveLeftBefore(*ch))] + }, + Motion::ReplayCharSearch => { + if let Some(char_search) = vi_state.last_char_search.as_ref() { + vec![ReedlineOption::Edit(char_search.to_move())] + } else { + vec![] + } + }, + Motion::ReverseCharSearch => { + if let Some(char_search) = vi_state.last_char_search.as_ref() { + vec![ReedlineOption::Edit(char_search.reverse().to_move())] + } else { + vec![] + } + }, + } + } +} + +/// Vi left-right motions to or till a character. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum ViCharSearch { + /// f + ToRight(char), + /// F + ToLeft(char), + /// t + TillRight(char), + /// T + TillLeft(char), +} + +impl ViCharSearch { + /// Swap the direction of the to or till for ',' + pub fn reverse(&self) -> Self { + match self { + ViCharSearch::ToRight(c) => ViCharSearch::ToLeft(*c), + ViCharSearch::ToLeft(c) => ViCharSearch::ToRight(*c), + ViCharSearch::TillRight(c) => ViCharSearch::TillLeft(*c), + ViCharSearch::TillLeft(c) => ViCharSearch::TillRight(*c), + } + } + + pub fn to_move(&self) -> EditCommand { + match self { + ViCharSearch::ToRight(c) => EditCommand::MoveRightUntil(*c), + ViCharSearch::ToLeft(c) => EditCommand::MoveLeftUntil(*c), + ViCharSearch::TillRight(c) => EditCommand::MoveRightBefore(*c), + ViCharSearch::TillLeft(c) => EditCommand::MoveLeftBefore(*c), + } + } + + pub fn to_cut(&self) -> EditCommand { + match self { + ViCharSearch::ToRight(c) => EditCommand::CutRightUntil(*c), + ViCharSearch::ToLeft(c) => EditCommand::CutLeftUntil(*c), + ViCharSearch::TillRight(c) => EditCommand::CutRightBefore(*c), + ViCharSearch::TillLeft(c) => EditCommand::CutLeftBefore(*c), + } + } +} diff --git a/reedline/src/edit_mode/vi/parser.rs b/reedline/src/edit_mode/vi/parser.rs new file mode 100644 index 00000000..82882ba3 --- /dev/null +++ b/reedline/src/edit_mode/vi/parser.rs @@ -0,0 +1,470 @@ +use std::iter::Peekable; + +use super::{ + command::{parse_command, Command}, + motion::{parse_motion, Motion}, +}; +use crate::{EditCommand, ReedlineEvent, Vi}; + +#[derive(Debug, Clone)] +pub enum ReedlineOption { + Event(ReedlineEvent), + Edit(EditCommand), + Incomplete, +} + +impl ReedlineOption { + pub fn into_reedline_event(self) -> Option { + match self { + ReedlineOption::Event(event) => Some(event), + ReedlineOption::Edit(edit) => Some(ReedlineEvent::Edit(vec![edit])), + ReedlineOption::Incomplete => None, + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ParseResult { + Valid(T), + Incomplete, + Invalid, +} + +impl ParseResult { + fn is_invalid(&self) -> bool { + match self { + ParseResult::Valid(_) => false, + ParseResult::Incomplete => false, + ParseResult::Invalid => true, + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ParsedViSequence { + multiplier: Option, + command: Option, + count: Option, + motion: ParseResult, +} + +impl ParsedViSequence { + pub fn is_valid(&self) -> bool { + !self.motion.is_invalid() + } + + pub fn is_complete(&self) -> bool { + match (&self.command, &self.motion) { + (None, ParseResult::Valid(_)) => true, + (Some(Command::Incomplete), _) => false, + (Some(cmd), ParseResult::Incomplete) if !cmd.requires_motion() => true, + (Some(_), ParseResult::Valid(_)) => true, + (Some(cmd), ParseResult::Incomplete) if cmd.requires_motion() => false, + _ => false, + } + } + + /// Combine `multiplier` and `count` as vim only considers the product + /// + /// Default return value: 1 + /// + /// ### Note: + /// + /// https://github.com/vim/vim/blob/140f6d0eda7921f2f0b057ec38ed501240903fc3/runtime/doc/motion.txt#L64-L70 + fn total_multiplier(&self) -> usize { + self.multiplier.unwrap_or(1) * self.count.unwrap_or(1) + } + + fn apply_multiplier(&self, raw_events: Option>) -> ReedlineEvent { + if let Some(raw_events) = raw_events { + let events = std::iter::repeat(raw_events) + .take(self.total_multiplier()) + .flatten() + .filter_map(ReedlineOption::into_reedline_event) + .collect::>(); + + if events.is_empty() || events.contains(&ReedlineEvent::None) { + // TODO: Clarify if the `contains(ReedlineEvent::None)` path is relevant + ReedlineEvent::None + } else { + ReedlineEvent::Multiple(events) + } + } else { + ReedlineEvent::None + } + } + + pub fn enters_insert_mode(&self) -> bool { + matches!( + (&self.command, &self.motion), + (Some(Command::EnterViInsert), ParseResult::Incomplete) + | (Some(Command::EnterViAppend), ParseResult::Incomplete) + | (Some(Command::ChangeToLineEnd), ParseResult::Incomplete) + | (Some(Command::AppendToEnd), ParseResult::Incomplete) + | (Some(Command::PrependToStart), ParseResult::Incomplete) + | (Some(Command::RewriteCurrentLine), ParseResult::Incomplete) + | ( + Some(Command::SubstituteCharWithInsert), + ParseResult::Incomplete + ) + | (Some(Command::HistorySearch), ParseResult::Incomplete) + | (Some(Command::Change), ParseResult::Valid(_)) + ) + } + + pub fn to_reedline_event(&self, vi_state: &mut Vi) -> ReedlineEvent { + match (&self.multiplier, &self.command, &self.count, &self.motion) { + (_, Some(command), None, ParseResult::Incomplete) => { + let events = self.apply_multiplier(Some(command.to_reedline(vi_state))); + match &events { + ReedlineEvent::None => {}, + event => vi_state.previous = Some(event.clone()), + } + events + }, + // This case handles all combinations of commands and motions that could exist + (_, Some(command), _, ParseResult::Valid(motion)) => { + let events = + self.apply_multiplier(command.to_reedline_with_motion(motion, vi_state)); + match &events { + ReedlineEvent::None => {}, + event => vi_state.previous = Some(event.clone()), + } + events + }, + (_, None, _, ParseResult::Valid(motion)) => { + self.apply_multiplier(Some(motion.to_reedline(vi_state))) + }, + _ => ReedlineEvent::None, + } + } +} + +fn parse_number<'iter, I>(input: &mut Peekable) -> Option +where + I: Iterator, +{ + match input.peek() { + Some('0') => None, + Some(x) if x.is_ascii_digit() => { + let mut count: usize = 0; + while let Some(&c) = input.peek() { + if c.is_ascii_digit() { + let c = c.to_digit(10).expect("already checked if is a digit"); + let _ = input.next(); + count *= 10; + count += c as usize; + } else { + return Some(count); + } + } + Some(count) + }, + _ => None, + } +} + +pub fn parse<'iter, I>(input: &mut Peekable) -> ParsedViSequence +where + I: Iterator, +{ + let multiplier = parse_number(input); + let command = parse_command(input); + let count = parse_number(input); + let motion = parse_motion(input, command.as_ref().and_then(Command::whole_line_char)); + + ParsedViSequence { + multiplier, + command, + count, + motion, + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + fn vi_parse(input: &[char]) -> ParsedViSequence { + parse(&mut input.iter().peekable()) + } + + #[test] + fn test_delete_word() { + let input = ['d', 'w']; + let output = vi_parse(&input); + + assert_eq!( + output, + ParsedViSequence { + multiplier: None, + command: Some(Command::Delete), + count: None, + motion: ParseResult::Valid(Motion::NextWord), + } + ); + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), true); + } + + #[test] + fn test_two_delete_word() { + let input = ['2', 'd', 'w']; + let output = vi_parse(&input); + + assert_eq!( + output, + ParsedViSequence { + multiplier: Some(2), + command: Some(Command::Delete), + count: None, + motion: ParseResult::Valid(Motion::NextWord), + } + ); + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), true); + } + + #[test] + fn test_two_delete_two_word() { + let input = ['2', 'd', '2', 'w']; + let output = vi_parse(&input); + + assert_eq!( + output, + ParsedViSequence { + multiplier: Some(2), + command: Some(Command::Delete), + count: Some(2), + motion: ParseResult::Valid(Motion::NextWord), + } + ); + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), true); + } + + #[test] + fn test_two_delete_twenty_word() { + let input = ['2', 'd', '2', '0', 'w']; + let output = vi_parse(&input); + + assert_eq!( + output, + ParsedViSequence { + multiplier: Some(2), + command: Some(Command::Delete), + count: Some(20), + motion: ParseResult::Valid(Motion::NextWord), + } + ); + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), true); + } + + #[test] + fn test_two_delete_two_lines() { + let input = ['2', 'd', 'd']; + let output = vi_parse(&input); + + assert_eq!( + output, + ParsedViSequence { + multiplier: Some(2), + command: Some(Command::Delete), + count: None, + motion: ParseResult::Valid(Motion::Line), + } + ); + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), true); + } + + #[test] + fn test_find_action() { + let input = ['d', 't', 'd']; + let output = vi_parse(&input); + + assert_eq!( + output, + ParsedViSequence { + multiplier: None, + command: Some(Command::Delete), + count: None, + motion: ParseResult::Valid(Motion::RightBefore('d')), + } + ); + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), true); + } + + #[test] + fn test_has_garbage() { + let input = ['2', 'd', 'm']; + let output = vi_parse(&input); + + assert_eq!( + output, + ParsedViSequence { + multiplier: Some(2), + command: Some(Command::Delete), + count: None, + motion: ParseResult::Invalid, + } + ); + assert_eq!(output.is_valid(), false); + } + + #[test] + fn test_partial_action() { + let input = ['r']; + let output = vi_parse(&input); + + assert_eq!( + output, + ParsedViSequence { + multiplier: None, + command: Some(Command::Incomplete), + count: None, + motion: ParseResult::Incomplete, + } + ); + + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), false); + } + + #[test] + fn test_partial_motion() { + let input = ['f']; + let output = vi_parse(&input); + + assert_eq!( + output, + ParsedViSequence { + multiplier: None, + command: None, + count: None, + motion: ParseResult::Incomplete, + } + ); + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), false); + } + + #[test] + fn test_two_char_action_replace() { + let input = ['r', 'k']; + let output = vi_parse(&input); + + assert_eq!( + output, + ParsedViSequence { + multiplier: None, + command: Some(Command::ReplaceChar('k')), + count: None, + motion: ParseResult::Incomplete, + } + ); + + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), true); + } + + #[test] + fn test_find_motion() { + let input = ['2', 'f', 'f']; + let output = vi_parse(&input); + + assert_eq!( + output, + ParsedViSequence { + multiplier: Some(2), + command: None, + count: None, + motion: ParseResult::Valid(Motion::RightUntil('f')), + } + ); + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), true); + } + + #[test] + fn test_two_up() { + let input = ['2', 'k']; + let output = vi_parse(&input); + + assert_eq!( + output, + ParsedViSequence { + multiplier: Some(2), + command: None, + count: None, + motion: ParseResult::Valid(Motion::Up), + } + ); + assert_eq!(output.is_valid(), true); + assert_eq!(output.is_complete(), true); + } + + #[rstest] + #[case(&['2', 'k'], ReedlineEvent::Multiple(vec![ReedlineEvent::UntilFound(vec![ + ReedlineEvent::MenuUp, + ReedlineEvent::Up, + ]), ReedlineEvent::UntilFound(vec![ + ReedlineEvent::MenuUp, + ReedlineEvent::Up, + ])]))] + #[case(&['k'], ReedlineEvent::Multiple(vec![ReedlineEvent::UntilFound(vec![ + ReedlineEvent::MenuUp, + ReedlineEvent::Up, + ])]))] + #[case(&['w'], + ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveWordRightStart])]))] + #[case(&['W'], + ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveBigWordRightStart])]))] + #[case(&['2', 'l'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintComplete, + ReedlineEvent::MenuRight, + ReedlineEvent::Right, + ]),ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintComplete, + ReedlineEvent::MenuRight, + ReedlineEvent::Right, + ]) ]))] + #[case(&['l'], ReedlineEvent::Multiple(vec![ReedlineEvent::UntilFound(vec![ + ReedlineEvent::HistoryHintComplete, + ReedlineEvent::MenuRight, + ReedlineEvent::Right, + ])]))] + #[case(&['0'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveToLineStart])]))] + #[case(&['$'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::MoveToLineEnd])]))] + #[case(&['i'], ReedlineEvent::Multiple(vec![ReedlineEvent::Repaint]))] + #[case(&['p'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter])]))] + #[case(&['2', 'p'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]), + ReedlineEvent::Edit(vec![EditCommand::PasteCutBufferAfter]) + ]))] + #[case(&['u'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::Undo])]))] + #[case(&['2', 'u'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::Undo]), + ReedlineEvent::Edit(vec![EditCommand::Undo]) + ]))] + #[case(&['d', 'd'], ReedlineEvent::Multiple(vec![ + ReedlineEvent::Edit(vec![EditCommand::CutCurrentLine])]))] + #[case(&['d', 'w'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRightToNext])]))] + #[case(&['d', 'W'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordRightToNext])]))] + #[case(&['d', 'e'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordRight])]))] + #[case(&['d', 'b'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutWordLeft])]))] + #[case(&['d', 'B'], ReedlineEvent::Multiple(vec![ReedlineEvent::Edit(vec![EditCommand::CutBigWordLeft])]))] + fn test_reedline_move(#[case] input: &[char], #[case] expected: ReedlineEvent) { + let mut vi = Vi::default(); + let res = vi_parse(input); + let output = res.to_reedline_event(&mut vi); + + assert_eq!(output, expected); + } +} diff --git a/reedline/src/edit_mode/vi/vi_keybindings.rs b/reedline/src/edit_mode/vi/vi_keybindings.rs new file mode 100644 index 00000000..1498f3ac --- /dev/null +++ b/reedline/src/edit_mode/vi/vi_keybindings.rs @@ -0,0 +1,39 @@ +use crossterm::event::{KeyCode, KeyModifiers}; + +use crate::{ + edit_mode::{ + keybindings::{ + add_common_control_bindings, add_common_edit_bindings, add_common_navigation_bindings, + edit_bind, + }, + Keybindings, + }, + EditCommand, +}; + +/// Default Vi normal keybindings +pub fn default_vi_normal_keybindings() -> Keybindings { + let mut kb = Keybindings::new(); + use EditCommand as EC; + use KeyCode as KC; + use KeyModifiers as KM; + + add_common_control_bindings(&mut kb); + add_common_navigation_bindings(&mut kb); + // Replicate vi's default behavior for Backspace and delete + kb.add_binding(KM::NONE, KC::Backspace, edit_bind(EC::MoveLeft)); + kb.add_binding(KM::NONE, KC::Delete, edit_bind(EC::Delete)); + + kb +} + +/// Default Vi insert keybindings +pub fn default_vi_insert_keybindings() -> Keybindings { + let mut kb = Keybindings::new(); + + add_common_control_bindings(&mut kb); + add_common_navigation_bindings(&mut kb); + add_common_edit_bindings(&mut kb); + + kb +} diff --git a/reedline/src/engine.rs b/reedline/src/engine.rs new file mode 100644 index 00000000..1d776d3f --- /dev/null +++ b/reedline/src/engine.rs @@ -0,0 +1,1545 @@ +use std::{ + fs::File, + io, + io::Write, + process::Command, + time::{Duration, SystemTime}, +}; + +use crossterm::{ + event, + event::{Event, KeyCode, KeyEvent, KeyModifiers}, + terminal, Result, +}; +#[cfg(feature = "external_printer")] +use { + crate::external_printer::ExternalPrinter, + crossbeam::channel::TryRecvError, + std::io::{Error, ErrorKind}, +}; + +use crate::{ + completion::{Completer, DefaultCompleter}, + core_editor::Editor, + edit_mode::{EditMode, Emacs}, + enums::{EventStatus, ReedlineEvent}, + highlighter::SimpleMatchHighlighter, + hinter::Hinter, + history::{ + FileBackedHistory, History, HistoryCursor, HistoryItem, HistoryItemId, + HistoryNavigationQuery, HistorySessionId, SearchDirection, SearchQuery, + }, + painting::{Painter, PromptLines}, + prompt::{PromptEditMode, PromptHistorySearchStatus}, + result::{ReedlineError, ReedlineErrorVariants}, + utils::text_manipulation, + CursorConfig, EditCommand, ExampleHighlighter, Highlighter, LineBuffer, Menu, MenuEvent, + Prompt, PromptHistorySearch, ReedlineMenu, Signal, UndoBehavior, ValidationResult, Validator, +}; +#[cfg(feature = "bashisms")] +use crate::{ + history::SearchFilter, + menu_functions::{parse_selection_char, ParseAction}, +}; + +// The POLL_WAIT is used to specify for how long the POLL should wait for +// events, to accelerate the handling of paste or compound resize events. Having +// a POLL_WAIT of zero means that every single event is treated as soon as it +// arrives. This doesn't allow for the possibility of more than 1 event +// happening at the same time. +const POLL_WAIT: u64 = 10; +// Since a paste event is multiple Event::Key events happening at the same time, we specify +// how many events should be in the crossterm_events vector before it is considered +// a paste. 10 events in 10 milliseconds is conservative enough (unlikely somebody +// will type more than 10 characters in 10 milliseconds) +const EVENTS_THRESHOLD: usize = 10; + +/// Determines if inputs should be used to extend the regular line buffer, +/// traverse the history in the standard prompt or edit the search string in the +/// reverse search +#[derive(Debug, PartialEq, Eq)] +enum InputMode { + /// Regular input by user typing or previous insertion. + /// Undo tracking is active + Regular, + /// Full reverse search mode with different prompt, + /// editing affects the search string, + /// suggestions are provided to be inserted in the line buffer + HistorySearch, + /// Hybrid mode indicating that history is walked through in the standard prompt + /// Either bash style up/down history or fish style prefix search, + /// Edits directly switch to [`InputMode::Regular`] + HistoryTraversal, +} + +/// Line editor engine +/// +/// ## Example usage +/// ```no_run +/// use reedline::{Reedline, Signal, DefaultPrompt}; +/// let mut line_editor = Reedline::create(); +/// let prompt = DefaultPrompt::default(); +/// +/// let out = line_editor.read_line(&prompt).unwrap(); +/// match out { +/// Signal::Success(content) => { +/// // process content +/// } +/// _ => { +/// eprintln!("Entry aborted!"); +/// +/// } +/// } +/// ``` +pub struct Reedline { + editor: Editor, + + // History + history: Box, + history_cursor: HistoryCursor, + history_session_id: Option, + // none if history doesn't support this + history_last_run_id: Option, + input_mode: InputMode, + + // Validator + validator: Option>, + + // Stdout + painter: Painter, + + // Edit Mode: Vi, Emacs + edit_mode: Box, + + // Provides the tab completions + completer: Box, + quick_completions: bool, + partial_completions: bool, + + // Highlight the edit buffer + highlighter: Box, + + // Showcase hints based on various strategies (history, language-completion, spellcheck, etc) + hinter: Option>, + hide_hints: bool, + + // Use ansi coloring or not + use_ansi_coloring: bool, + + // Engine Menus + menus: Vec, + + // Text editor used to open the line buffer for editing + buffer_editor: Option, + + // Use different cursors depending on the current edit mode + cursor_shapes: Option, + + #[cfg(feature = "external_printer")] + external_printer: Option>, +} + +struct BufferEditor { + editor: String, + extension: String, +} + +impl Drop for Reedline { + fn drop(&mut self) { + // Ensures that the terminal is in a good state if we panic semigracefully + // Calling `disable_raw_mode()` twice is fine with Linux + let _ignore = terminal::disable_raw_mode(); + } +} + +impl Reedline { + /// Create a new [`Reedline`] engine with a local [`History`] that is not synchronized to a file. + #[must_use] + pub fn create() -> Self { + let history = Box::::default(); + let painter = Painter::new(std::io::BufWriter::new(std::io::stderr())); + let buffer_highlighter = Box::::default(); + let completer = Box::::default(); + let hinter = None; + let validator = None; + let edit_mode = Box::::default(); + let hist_session_id = Self::create_history_session_id(); + + Reedline { + editor: Editor::default(), + history, + history_cursor: HistoryCursor::new(HistoryNavigationQuery::Normal( + LineBuffer::default(), + )), + history_session_id: hist_session_id, + history_last_run_id: None, + input_mode: InputMode::Regular, + painter, + edit_mode, + completer, + quick_completions: false, + partial_completions: false, + highlighter: buffer_highlighter, + hinter, + hide_hints: false, + validator, + use_ansi_coloring: true, + menus: Vec::new(), + buffer_editor: None, + cursor_shapes: None, + #[cfg(feature = "external_printer")] + external_printer: None, + } + } + + /// Get a new history session id based on the current time and the first commit datetime of reedline + fn create_history_session_id() -> Option { + let nanos = match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { + Ok(n) => n.as_nanos() as i64, + Err(_) => 0, + }; + + Some(HistorySessionId::new(nanos)) + } + + /// Return the previously generated history session id + pub fn get_history_session_id(&self) -> Option { + self.history_session_id + } + + /// A builder to include a [`Hinter`] in your instance of the Reedline engine + /// # Example + /// ```rust + /// //Cargo.toml + /// //[dependencies] + /// //nu-ansi-term = "*" + /// use { + /// nu_ansi_term::{Color, Style}, + /// reedline::{DefaultHinter, Reedline}, + /// }; + /// + /// let mut line_editor = Reedline::create().with_hinter(Box::new( + /// DefaultHinter::default() + /// .with_style(Style::new().italic().fg(Color::LightGray)), + /// )); + /// ``` + #[must_use] + pub fn with_hinter(mut self, hinter: Box) -> Self { + self.hinter = Some(hinter); + self + } + + /// Remove current [`Hinter`] + #[must_use] + pub fn disable_hints(mut self) -> Self { + self.hinter = None; + self + } + + /// A builder to configure the tab completion + /// # Example + /// ```rust + /// // Create a reedline object with tab completions support + /// + /// use reedline::{DefaultCompleter, Reedline}; + /// + /// let commands = vec![ + /// "test".into(), + /// "hello world".into(), + /// "hello world reedline".into(), + /// "this is the reedline crate".into(), + /// ]; + /// let completer = Box::new(DefaultCompleter::new_with_wordlen(commands.clone(), 2)); + /// + /// let mut line_editor = Reedline::create().with_completer(completer); + /// ``` + #[must_use] + pub fn with_completer(mut self, completer: Box) -> Self { + self.completer = completer; + self + } + + /// Turn on quick completions. These completions will auto-select if the completer + /// ever narrows down to a single entry. + #[must_use] + pub fn with_quick_completions(mut self, quick_completions: bool) -> Self { + self.quick_completions = quick_completions; + self + } + + /// Turn on partial completions. These completions will fill the buffer with the + /// smallest common string from all the options + #[must_use] + pub fn with_partial_completions(mut self, partial_completions: bool) -> Self { + self.partial_completions = partial_completions; + self + } + + /// A builder which enables or disables the use of ansi coloring in the prompt + /// and in the command line syntax highlighting. + #[must_use] + pub fn with_ansi_colors(mut self, use_ansi_coloring: bool) -> Self { + self.use_ansi_coloring = use_ansi_coloring; + self + } + + /// A builder that configures the highlighter for your instance of the Reedline engine + /// # Example + /// ```rust + /// // Create a reedline object with highlighter support + /// + /// use reedline::{ExampleHighlighter, Reedline}; + /// + /// let commands = vec![ + /// "test".into(), + /// "hello world".into(), + /// "hello world reedline".into(), + /// "this is the reedline crate".into(), + /// ]; + /// let mut line_editor = + /// Reedline::create().with_highlighter(Box::new(ExampleHighlighter::new(commands))); + /// ``` + #[must_use] + pub fn with_highlighter(mut self, highlighter: Box) -> Self { + self.highlighter = highlighter; + self + } + + /// A builder which configures the history for your instance of the Reedline engine + /// # Example + /// ```rust,no_run + /// // Create a reedline object with history support, including history size limits + /// + /// use reedline::{FileBackedHistory, Reedline}; + /// + /// let history = Box::new( + /// FileBackedHistory::with_file(5, "history.txt".into()) + /// .expect("Error configuring history with file"), + /// ); + /// let mut line_editor = Reedline::create() + /// .with_history(history); + /// ``` + #[must_use] + pub fn with_history(mut self, history: Box) -> Self { + self.history = history; + self + } + + /// A builder that configures the validator for your instance of the Reedline engine + /// # Example + /// ```rust + /// // Create a reedline object with validator support + /// + /// use reedline::{DefaultValidator, Reedline}; + /// + /// let mut line_editor = + /// Reedline::create().with_validator(Box::new(DefaultValidator)); + /// ``` + #[must_use] + pub fn with_validator(mut self, validator: Box) -> Self { + self.validator = Some(validator); + self + } + + /// A builder that configures the text editor used to edit the line buffer + /// # Example + /// ```rust,no_run + /// // Create a reedline object with vim as editor + /// + /// use reedline::{DefaultValidator, Reedline}; + /// + /// let mut line_editor = + /// Reedline::create().with_buffer_editor("vim".into(), "nu".into()); + /// ``` + #[must_use] + pub fn with_buffer_editor(mut self, editor: String, extension: String) -> Self { + self.buffer_editor = Some(BufferEditor { editor, extension }); + self + } + + /// Remove the current [`Validator`] + #[must_use] + pub fn disable_validator(mut self) -> Self { + self.validator = None; + self + } + + /// A builder which configures the edit mode for your instance of the Reedline engine + #[must_use] + pub fn with_edit_mode(mut self, edit_mode: Box) -> Self { + self.edit_mode = edit_mode; + self + } + + /// A builder that appends a menu to the engine + #[must_use] + pub fn with_menu(mut self, menu: ReedlineMenu) -> Self { + self.menus.push(menu); + self + } + + /// A builder that clears the list of menus added to the engine + #[must_use] + pub fn clear_menus(mut self) -> Self { + self.menus = Vec::new(); + self + } + + /// A builder that enables reedline changing the cursor shape based on the current edit mode. + /// The current implementation sets the cursor shape when drawing the prompt. + /// Do not use this if the cursor shape is set elsewhere, e.g. in the terminal settings or by ansi escape sequences. + pub fn with_cursor_config(mut self, cursor_shapes: CursorConfig) -> Self { + self.cursor_shapes = Some(cursor_shapes); + self + } + + /// Returns the corresponding expected prompt style for the given edit mode + pub fn prompt_edit_mode(&self) -> PromptEditMode { + self.edit_mode.edit_mode() + } + + /// Output the complete [`History`] chronologically with numbering to the terminal + pub fn print_history(&mut self) -> Result<()> { + let history: Vec<_> = self + .history + .search(SearchQuery::everything(SearchDirection::Forward)) + .expect("todo: error handling"); + + for (i, entry) in history.iter().enumerate() { + self.print_line(&format!("{}\t{}", i, entry.command_line))?; + } + Ok(()) + } + + /// Read-only view of the history + pub fn history(&self) -> &dyn History { + &*self.history + } + + /// Mutable view of the history + pub fn history_mut(&mut self) -> &mut dyn History { + &mut *self.history + } + + /// Update the underlying [`History`] to/from disk + pub fn sync_history(&mut self) -> std::io::Result<()> { + // TODO: check for interactions in the non-submitting events + self.history.sync() + } + + /// Check if any commands have been run. + /// + /// When no commands have been run, calling [`Self::update_last_command_context`] + /// does not make sense and is guaranteed to fail with a "No command run" error. + pub fn has_last_command_context(&self) -> bool { + self.history_last_run_id.is_some() + } + + /// update the last history item with more information + pub fn update_last_command_context( + &mut self, + f: &dyn Fn(HistoryItem) -> HistoryItem, + ) -> crate::Result<()> { + if let Some(r) = &self.history_last_run_id { + self.history.update(*r, f)?; + } else { + return Err(ReedlineError(ReedlineErrorVariants::OtherHistoryError( + "No command run", + ))); + } + Ok(()) + } + + /// Wait for input and provide the user with a specified [`Prompt`]. + /// + /// Returns a [`crossterm::Result`] in which the `Err` type is [`crossterm::ErrorKind`] + /// to distinguish I/O errors and the `Ok` variant wraps a [`Signal`] which + /// handles user inputs. + pub fn read_line(&mut self, prompt: &dyn Prompt) -> Result { + terminal::enable_raw_mode()?; + + let result = self.read_line_helper(prompt); + + terminal::disable_raw_mode()?; + + result + } + + /// Returns the current contents of the input buffer. + pub fn current_buffer_contents(&self) -> &str { + self.editor.get_buffer() + } + + /// Writes `msg` to the terminal with a following carriage return and newline + fn print_line(&mut self, msg: &str) -> Result<()> { + self.painter.paint_line(msg) + } + + /// Clear the screen by printing enough whitespace to start the prompt or + /// other output back at the first line of the terminal. + pub fn clear_screen(&mut self) -> Result<()> { + self.painter.clear_screen()?; + + Ok(()) + } + + /// Clear the screen and the scollback buffer of the terminal + pub fn clear_scrollback(&mut self) -> Result<()> { + self.painter.clear_scrollback()?; + + Ok(()) + } + + /// Helper implementing the logic for [`Reedline::read_line()`] to be wrapped + /// in a `raw_mode` context. + fn read_line_helper(&mut self, prompt: &dyn Prompt) -> Result { + self.painter.initialize_prompt_position()?; + self.hide_hints = false; + + self.repaint(prompt)?; + + let mut crossterm_events: Vec = vec![]; + let mut reedline_events: Vec = vec![]; + + loop { + let mut paste_enter_state = false; + + #[cfg(feature = "external_printer")] + if let Some(ref external_printer) = self.external_printer { + // get messages from printer as crlf separated "lines" + let messages = Self::external_messages(external_printer)?; + if !messages.is_empty() { + // print the message(s) + self.painter.print_external_message( + messages, + self.editor.line_buffer(), + prompt, + )?; + self.repaint(prompt)?; + } + } + + if event::poll(Duration::from_millis(100))? { + let mut latest_resize = None; + + // There could be multiple events queued up! + // pasting text, resizes, blocking this thread (e.g. during debugging) + // We should be able to handle all of them as quickly as possible without causing unnecessary output steps. + while event::poll(Duration::from_millis(POLL_WAIT))? { + match event::read()? { + Event::Resize(x, y) => { + latest_resize = Some((x, y)); + }, + enter @ Event::Key(KeyEvent { + code: KeyCode::Enter, + modifiers: KeyModifiers::NONE, + }) => { + crossterm_events.push(enter); + // Break early to check if the input is complete and + // can be send to the hosting application. If + // multiple complete entries are submitted, events + // are still in the crossterm queue for us to + // process. + paste_enter_state = crossterm_events.len() > EVENTS_THRESHOLD; + break; + }, + x => { + crossterm_events.push(x); + }, + } + } + + if let Some((x, y)) = latest_resize { + reedline_events.push(ReedlineEvent::Resize(x, y)); + } + + // Accelerate pasted text by fusing `EditCommand`s + // + // (Text should only be `EditCommand::InsertChar`s) + let mut last_edit_commands = None; + for event in crossterm_events.drain(..) { + match (&mut last_edit_commands, self.edit_mode.parse_event(event)) { + (None, ReedlineEvent::Edit(ec)) => { + last_edit_commands = Some(ec); + }, + (None, other_event) => { + reedline_events.push(other_event); + }, + (Some(ref mut last_ecs), ReedlineEvent::Edit(ec)) => { + last_ecs.extend(ec); + }, + (ref mut a @ Some(_), other_event) => { + reedline_events.push(ReedlineEvent::Edit(a.take().unwrap())); + + reedline_events.push(other_event); + }, + } + } + if let Some(ec) = last_edit_commands { + reedline_events.push(ReedlineEvent::Edit(ec)); + } + }; + + for event in reedline_events.drain(..) { + match self.handle_event(prompt, event)? { + EventStatus::Exits(signal) => { + // Move the cursor below the input area, for external commands or new read_line call + self.painter.move_cursor_to_end()?; + return Ok(signal); + }, + EventStatus::Handled => { + if !paste_enter_state { + self.repaint(prompt)?; + } + }, + EventStatus::Inapplicable => { + // Nothing changed, no need to repaint + }, + } + } + } + } + + fn handle_event(&mut self, prompt: &dyn Prompt, event: ReedlineEvent) -> Result { + if self.input_mode == InputMode::HistorySearch { + self.handle_history_search_event(event) + } else { + self.handle_editor_event(prompt, event) + } + } + + fn handle_history_search_event(&mut self, event: ReedlineEvent) -> io::Result { + match event { + ReedlineEvent::UntilFound(events) => { + for event in events { + match self.handle_history_search_event(event)? { + EventStatus::Inapplicable => { + // Try again with the next event handler + }, + success => { + return Ok(success); + }, + } + } + // Exhausting the event handlers is still considered handled + Ok(EventStatus::Handled) + }, + ReedlineEvent::CtrlD => { + if self.editor.is_empty() { + self.input_mode = InputMode::Regular; + self.editor.reset_undo_stack(); + Ok(EventStatus::Exits(Signal::CtrlD)) + } else { + self.run_history_commands(&[EditCommand::Delete]); + Ok(EventStatus::Handled) + } + }, + ReedlineEvent::CtrlC => { + self.input_mode = InputMode::Regular; + Ok(EventStatus::Exits(Signal::CtrlC)) + }, + ReedlineEvent::ClearScreen => { + self.painter.clear_screen()?; + Ok(EventStatus::Handled) + }, + ReedlineEvent::ClearScrollback => { + self.painter.clear_scrollback()?; + Ok(EventStatus::Handled) + }, + ReedlineEvent::Enter + | ReedlineEvent::HistoryHintComplete + | ReedlineEvent::Submit + | ReedlineEvent::SubmitOrNewline => { + if let Some(string) = self.history_cursor.string_at_cursor() { + self.editor + .set_buffer(string, UndoBehavior::CreateUndoPoint); + } + + self.input_mode = InputMode::Regular; + Ok(EventStatus::Handled) + }, + ReedlineEvent::ExecuteHostCommand(host_command) => { + // TODO: Decide if we need to do something special to have a nicer painter state on the next go + Ok(EventStatus::Exits(Signal::Success(host_command))) + }, + ReedlineEvent::Edit(commands) => { + self.run_history_commands(&commands); + Ok(EventStatus::Handled) + }, + ReedlineEvent::Mouse => Ok(EventStatus::Handled), + ReedlineEvent::Resize(width, height) => { + self.painter.handle_resize(width, height); + Ok(EventStatus::Inapplicable) + }, + ReedlineEvent::Repaint => { + // A handled Event causes a repaint + Ok(EventStatus::Handled) + }, + ReedlineEvent::PreviousHistory | ReedlineEvent::Up | ReedlineEvent::SearchHistory => { + self.history_cursor + .back(self.history.as_ref()) + .expect("todo: error handling"); + Ok(EventStatus::Handled) + }, + ReedlineEvent::NextHistory | ReedlineEvent::Down => { + self.history_cursor + .forward(self.history.as_ref()) + .expect("todo: error handling"); + // Hacky way to ensure that we don't fall of into failed search going forward + if self.history_cursor.string_at_cursor().is_none() { + self.history_cursor + .back(self.history.as_ref()) + .expect("todo: error handling"); + } + Ok(EventStatus::Handled) + }, + ReedlineEvent::Esc => { + self.input_mode = InputMode::Regular; + Ok(EventStatus::Handled) + }, + // TODO: Check if events should be handled + ReedlineEvent::Right + | ReedlineEvent::Left + | ReedlineEvent::Multiple(_) + | ReedlineEvent::None + | ReedlineEvent::HistoryHintWordComplete + | ReedlineEvent::OpenEditor + | ReedlineEvent::Menu(_) + | ReedlineEvent::MenuNext + | ReedlineEvent::MenuPrevious + | ReedlineEvent::MenuUp + | ReedlineEvent::MenuDown + | ReedlineEvent::MenuLeft + | ReedlineEvent::MenuRight + | ReedlineEvent::MenuPageNext + | ReedlineEvent::MenuPagePrevious => Ok(EventStatus::Inapplicable), + } + } + + fn handle_editor_event( + &mut self, + prompt: &dyn Prompt, + event: ReedlineEvent, + ) -> io::Result { + match event { + ReedlineEvent::Menu(name) => { + if self.active_menu().is_none() { + if let Some(menu) = self.menus.iter_mut().find(|menu| menu.name() == name) { + menu.menu_event(MenuEvent::Activate(self.quick_completions)); + + if self.quick_completions && menu.can_quick_complete() { + menu.update_values( + &mut self.editor, + self.completer.as_mut(), + self.history.as_ref(), + ); + + if menu.get_values().len() == 1 { + return self.handle_editor_event(prompt, ReedlineEvent::Enter); + } + } + + if self.partial_completions + && menu.can_partially_complete( + self.quick_completions, + &mut self.editor, + self.completer.as_mut(), + self.history.as_ref(), + ) + { + return Ok(EventStatus::Handled); + } + + return Ok(EventStatus::Handled); + } + } + Ok(EventStatus::Inapplicable) + }, + ReedlineEvent::MenuNext => { + self.active_menu() + .map_or(Ok(EventStatus::Inapplicable), |menu| { + menu.menu_event(MenuEvent::NextElement); + Ok(EventStatus::Handled) + }) + }, + ReedlineEvent::MenuPrevious => { + self.active_menu() + .map_or(Ok(EventStatus::Inapplicable), |menu| { + menu.menu_event(MenuEvent::PreviousElement); + Ok(EventStatus::Handled) + }) + }, + ReedlineEvent::MenuUp => { + self.active_menu() + .map_or(Ok(EventStatus::Inapplicable), |menu| { + menu.menu_event(MenuEvent::MoveUp); + Ok(EventStatus::Handled) + }) + }, + ReedlineEvent::MenuDown => { + self.active_menu() + .map_or(Ok(EventStatus::Inapplicable), |menu| { + menu.menu_event(MenuEvent::MoveDown); + Ok(EventStatus::Handled) + }) + }, + ReedlineEvent::MenuLeft => { + self.active_menu() + .map_or(Ok(EventStatus::Inapplicable), |menu| { + menu.menu_event(MenuEvent::MoveLeft); + Ok(EventStatus::Handled) + }) + }, + ReedlineEvent::MenuRight => { + self.active_menu() + .map_or(Ok(EventStatus::Inapplicable), |menu| { + menu.menu_event(MenuEvent::MoveRight); + Ok(EventStatus::Handled) + }) + }, + ReedlineEvent::MenuPageNext => { + self.active_menu() + .map_or(Ok(EventStatus::Inapplicable), |menu| { + menu.menu_event(MenuEvent::NextPage); + Ok(EventStatus::Handled) + }) + }, + ReedlineEvent::MenuPagePrevious => { + self.active_menu() + .map_or(Ok(EventStatus::Inapplicable), |menu| { + menu.menu_event(MenuEvent::PreviousPage); + Ok(EventStatus::Handled) + }) + }, + ReedlineEvent::HistoryHintComplete => { + if let Some(hinter) = self.hinter.as_mut() { + let current_hint = hinter.complete_hint(); + if self.hints_active() + && self.editor.is_cursor_at_buffer_end() + && !current_hint.is_empty() + && self.active_menu().is_none() + { + self.run_edit_commands(&[EditCommand::InsertString(current_hint)]); + return Ok(EventStatus::Handled); + } + } + Ok(EventStatus::Inapplicable) + }, + ReedlineEvent::HistoryHintWordComplete => { + if let Some(hinter) = self.hinter.as_mut() { + let current_hint_part = hinter.next_hint_token(); + if self.hints_active() + && self.editor.is_cursor_at_buffer_end() + && !current_hint_part.is_empty() + && self.active_menu().is_none() + { + self.run_edit_commands(&[EditCommand::InsertString(current_hint_part)]); + return Ok(EventStatus::Handled); + } + } + Ok(EventStatus::Inapplicable) + }, + ReedlineEvent::Esc => { + self.deactivate_menus(); + Ok(EventStatus::Handled) + }, + ReedlineEvent::CtrlD => { + if self.editor.is_empty() { + self.editor.reset_undo_stack(); + Ok(EventStatus::Exits(Signal::CtrlD)) + } else { + self.run_edit_commands(&[EditCommand::Delete]); + Ok(EventStatus::Handled) + } + }, + ReedlineEvent::CtrlC => { + self.deactivate_menus(); + self.run_edit_commands(&[EditCommand::Clear]); + self.editor.reset_undo_stack(); + Ok(EventStatus::Exits(Signal::CtrlC)) + }, + ReedlineEvent::ClearScreen => { + self.deactivate_menus(); + self.painter.clear_screen()?; + Ok(EventStatus::Handled) + }, + ReedlineEvent::ClearScrollback => { + self.deactivate_menus(); + self.painter.clear_scrollback()?; + Ok(EventStatus::Handled) + }, + ReedlineEvent::Enter | ReedlineEvent::Submit | ReedlineEvent::SubmitOrNewline + if self.menus.iter().any(|menu| menu.is_active()) => + { + for menu in self.menus.iter_mut() { + if menu.is_active() { + menu.replace_in_buffer(&mut self.editor); + menu.menu_event(MenuEvent::Deactivate); + + return Ok(EventStatus::Handled); + } + } + unreachable!() + }, + ReedlineEvent::Enter => { + #[cfg(feature = "bashisms")] + if let Some(event) = self.parse_bang_command() { + return self.handle_editor_event(prompt, event); + } + + let buffer = self.editor.get_buffer().to_string(); + match self.validator.as_mut().map(|v| v.validate(&buffer)) { + None | Some(ValidationResult::Complete) => Ok(self.submit_buffer(prompt)?), + Some(ValidationResult::Incomplete) => { + self.run_edit_commands(&[EditCommand::InsertNewline]); + + Ok(EventStatus::Handled) + }, + } + }, + ReedlineEvent::Submit => { + #[cfg(feature = "bashisms")] + if let Some(event) = self.parse_bang_command() { + return self.handle_editor_event(prompt, event); + } + Ok(self.submit_buffer(prompt)?) + }, + ReedlineEvent::SubmitOrNewline => { + #[cfg(feature = "bashisms")] + if let Some(event) = self.parse_bang_command() { + return self.handle_editor_event(prompt, event); + } + let cursor_position_in_buffer = self.editor.insertion_point(); + let buffer = self.editor.get_buffer().to_string(); + if cursor_position_in_buffer < buffer.len() { + self.run_edit_commands(&[EditCommand::InsertNewline]); + return Ok(EventStatus::Handled); + } + match self.validator.as_mut().map(|v| v.validate(&buffer)) { + None | Some(ValidationResult::Complete) => Ok(self.submit_buffer(prompt)?), + Some(ValidationResult::Incomplete) => { + self.run_edit_commands(&[EditCommand::InsertNewline]); + + Ok(EventStatus::Handled) + }, + } + }, + ReedlineEvent::ExecuteHostCommand(host_command) => { + // TODO: Decide if we need to do something special to have a nicer painter state on the next go + Ok(EventStatus::Exits(Signal::Success(host_command))) + }, + ReedlineEvent::Edit(commands) => { + self.run_edit_commands(&commands); + if let Some(menu) = self.menus.iter_mut().find(|men| men.is_active()) { + if self.quick_completions && menu.can_quick_complete() { + match commands.first() { + Some(&EditCommand::Backspace) + | Some(&EditCommand::BackspaceWord) + | Some(&EditCommand::MoveToLineStart) => { + menu.menu_event(MenuEvent::Deactivate) + }, + _ => { + menu.menu_event(MenuEvent::Edit(self.quick_completions)); + menu.update_values( + &mut self.editor, + self.completer.as_mut(), + self.history.as_ref(), + ); + if let Some(&EditCommand::Complete) = commands.first() { + if menu.get_values().len() == 1 { + return self + .handle_editor_event(prompt, ReedlineEvent::Enter); + } else if self.partial_completions + && menu.can_partially_complete( + self.quick_completions, + &mut self.editor, + self.completer.as_mut(), + self.history.as_ref(), + ) + { + return Ok(EventStatus::Handled); + } + } + }, + } + } + if self.editor.line_buffer().get_buffer().is_empty() { + menu.menu_event(MenuEvent::Deactivate); + } else { + menu.menu_event(MenuEvent::Edit(self.quick_completions)); + } + } + Ok(EventStatus::Handled) + }, + ReedlineEvent::OpenEditor => self.open_editor().map(|_| EventStatus::Handled), + ReedlineEvent::Resize(width, height) => { + self.painter.handle_resize(width, height); + Ok(EventStatus::Inapplicable) + }, + ReedlineEvent::Repaint => { + // A handled Event causes a repaint + Ok(EventStatus::Handled) + }, + ReedlineEvent::PreviousHistory => { + self.previous_history(); + Ok(EventStatus::Handled) + }, + ReedlineEvent::NextHistory => { + self.next_history(); + Ok(EventStatus::Handled) + }, + ReedlineEvent::Up => { + self.up_command(); + Ok(EventStatus::Handled) + }, + ReedlineEvent::Down => { + self.down_command(); + Ok(EventStatus::Handled) + }, + ReedlineEvent::Left => { + self.run_edit_commands(&[EditCommand::MoveLeft]); + Ok(EventStatus::Handled) + }, + ReedlineEvent::Right => { + self.run_edit_commands(&[EditCommand::MoveRight]); + Ok(EventStatus::Handled) + }, + ReedlineEvent::SearchHistory => { + self.enter_history_search(); + Ok(EventStatus::Handled) + }, + ReedlineEvent::Multiple(events) => { + let mut latest_signal = EventStatus::Inapplicable; + for event in events { + match self.handle_editor_event(prompt, event)? { + EventStatus::Handled => { + latest_signal = EventStatus::Handled; + }, + EventStatus::Inapplicable => { + // NO OP + }, + EventStatus::Exits(signal) => { + // TODO: Check if we want to allow execution to + // proceed if there are more events after the + // terminating + return Ok(EventStatus::Exits(signal)); + }, + } + } + + Ok(latest_signal) + }, + ReedlineEvent::UntilFound(events) => { + for event in events { + match self.handle_editor_event(prompt, event)? { + EventStatus::Inapplicable => { + // Try again with the next event handler + }, + success => { + return Ok(success); + }, + } + } + // Exhausting the event handlers is still considered handled + Ok(EventStatus::Inapplicable) + }, + ReedlineEvent::None | ReedlineEvent::Mouse => Ok(EventStatus::Inapplicable), + } + } + + fn active_menu(&mut self) -> Option<&mut ReedlineMenu> { + self.menus.iter_mut().find(|menu| menu.is_active()) + } + + fn deactivate_menus(&mut self) { + self.menus + .iter_mut() + .for_each(|menu| menu.menu_event(MenuEvent::Deactivate)); + } + + fn previous_history(&mut self) { + if self.input_mode != InputMode::HistoryTraversal { + self.input_mode = InputMode::HistoryTraversal; + self.history_cursor = + HistoryCursor::new(self.get_history_navigation_based_on_line_buffer()); + } + + self.history_cursor + .back(self.history.as_ref()) + .expect("todo: error handling"); + self.update_buffer_from_history(); + self.editor.move_to_start(UndoBehavior::HistoryNavigation); + self.editor + .move_to_line_end(UndoBehavior::HistoryNavigation); + } + + fn next_history(&mut self) { + if self.input_mode != InputMode::HistoryTraversal { + self.input_mode = InputMode::HistoryTraversal; + self.history_cursor = + HistoryCursor::new(self.get_history_navigation_based_on_line_buffer()); + } + + self.history_cursor + .forward(self.history.as_ref()) + .expect("todo: error handling"); + self.update_buffer_from_history(); + self.editor.move_to_end(UndoBehavior::HistoryNavigation); + } + + /// Enable the search and navigation through the history from the line buffer prompt + /// + /// Enables either prefix search with output in the line buffer or simple traversal + fn get_history_navigation_based_on_line_buffer(&self) -> HistoryNavigationQuery { + if self.editor.is_empty() || !self.editor.is_cursor_at_buffer_end() { + // Perform bash-style basic up/down entry walking + HistoryNavigationQuery::Normal( + // Hack: Tight coupling point to be able to restore previously typed input + self.editor.line_buffer().clone(), + ) + } else { + // Prefix search like found in fish, zsh, etc. + // Search string is set once from the current buffer + // Current setup (code in other methods) + // Continuing with typing will leave the search + // but next invocation of this method will start the next search + let buffer = self.editor.get_buffer().to_string(); + HistoryNavigationQuery::PrefixSearch(buffer) + } + } + + /// Switch into reverse history search mode + /// + /// This mode uses a separate prompt and handles keybindings slightly differently! + fn enter_history_search(&mut self) { + self.history_cursor = + HistoryCursor::new(HistoryNavigationQuery::SubstringSearch("".to_string())); + self.input_mode = InputMode::HistorySearch; + } + + /// Dispatches the applicable [`EditCommand`] actions for editing the history search string. + /// + /// Only modifies internal state, does not perform regular output! + fn run_history_commands(&mut self, commands: &[EditCommand]) { + for command in commands { + match command { + EditCommand::InsertChar(c) => { + let navigation = self.history_cursor.get_navigation(); + if let HistoryNavigationQuery::SubstringSearch(mut substring) = navigation { + substring.push(*c); + self.history_cursor = + HistoryCursor::new(HistoryNavigationQuery::SubstringSearch(substring)); + } else { + self.history_cursor = HistoryCursor::new( + HistoryNavigationQuery::SubstringSearch(String::from(*c)), + ); + } + self.history_cursor + .back(self.history.as_mut()) + .expect("todo: error handling"); + }, + EditCommand::Backspace => { + let navigation = self.history_cursor.get_navigation(); + + if let HistoryNavigationQuery::SubstringSearch(substring) = navigation { + let new_substring = text_manipulation::remove_last_grapheme(&substring); + + self.history_cursor = HistoryCursor::new( + HistoryNavigationQuery::SubstringSearch(new_substring.to_string()), + ); + self.history_cursor + .back(self.history.as_mut()) + .expect("todo: error handling"); + } + }, + _ => { + self.input_mode = InputMode::Regular; + }, + } + } + } + + /// Set the buffer contents for history traversal/search in the standard prompt + /// + /// When using the up/down traversal or fish/zsh style prefix search update the main line buffer accordingly. + /// Not used for the separate modal reverse search! + fn update_buffer_from_history(&mut self) { + match self.history_cursor.get_navigation() { + HistoryNavigationQuery::Normal(original) => { + if let Some(buffer_to_paint) = self.history_cursor.string_at_cursor() { + self.editor + .set_buffer(buffer_to_paint, UndoBehavior::HistoryNavigation); + } else { + // Hack + self.editor + .set_line_buffer(original, UndoBehavior::HistoryNavigation); + } + }, + HistoryNavigationQuery::PrefixSearch(prefix) => { + if let Some(prefix_result) = self.history_cursor.string_at_cursor() { + self.editor + .set_buffer(prefix_result, UndoBehavior::HistoryNavigation); + } else { + self.editor + .set_buffer(prefix, UndoBehavior::HistoryNavigation); + } + }, + HistoryNavigationQuery::SubstringSearch(_) => todo!(), + } + } + + /// Executes [`EditCommand`] actions by modifying the internal state appropriately. Does not output itself. + pub fn run_edit_commands(&mut self, commands: &[EditCommand]) { + if self.input_mode == InputMode::HistoryTraversal { + if matches!( + self.history_cursor.get_navigation(), + HistoryNavigationQuery::Normal(_) + ) { + if let Some(string) = self.history_cursor.string_at_cursor() { + self.editor + .set_buffer(string, UndoBehavior::HistoryNavigation); + } + } + self.input_mode = InputMode::Regular; + } + + // Run the commands over the edit buffer + for command in commands { + self.editor.run_edit_command(command); + } + } + + fn up_command(&mut self) { + // If we're at the top, then: + if self.editor.is_cursor_at_first_line() { + // If we're at the top, move to previous history + self.previous_history(); + } else { + self.editor.move_line_up(); + } + } + + fn down_command(&mut self) { + // If we're at the top, then: + if self.editor.is_cursor_at_last_line() { + // If we're at the top, move to previous history + self.next_history(); + } else { + self.editor.move_line_down(); + } + } + + /// Checks if hints should be displayed and are able to be completed + fn hints_active(&self) -> bool { + !self.hide_hints && matches!(self.input_mode, InputMode::Regular) + } + + /// Repaint of either the buffer or the parts for reverse history search + fn repaint(&mut self, prompt: &dyn Prompt) -> io::Result<()> { + // Repainting + if self.input_mode == InputMode::HistorySearch { + self.history_search_paint(prompt) + } else { + self.buffer_paint(prompt) + } + } + + #[cfg(feature = "bashisms")] + /// Parses the ! command to replace entries from the history + fn parse_bang_command(&mut self) -> Option { + let buffer = self.editor.get_buffer(); + let parsed = parse_selection_char(buffer, '!'); + + if let Some(last) = parsed.remainder.chars().last() { + if last != ' ' { + return None; + } + } + + let history_result = parsed + .index + .zip(parsed.marker) + .and_then(|(index, indicator)| match parsed.action { + ParseAction::LastCommand => self + .history + .search(SearchQuery { + direction: SearchDirection::Backward, + start_time: None, + end_time: None, + start_id: None, + end_id: None, + limit: Some(1), // fetch the latest one entries + filter: SearchFilter::anything(), + }) + .unwrap_or_else(|_| Vec::new()) + .get(index.saturating_sub(1)) + .map(|history| { + ( + parsed.remainder.len(), + indicator.len(), + history.command_line.clone(), + ) + }), + ParseAction::BackwardSearch => self + .history + .search(SearchQuery { + direction: SearchDirection::Backward, + start_time: None, + end_time: None, + start_id: None, + end_id: None, + limit: Some(index as i64), // fetch the latest n entries + filter: SearchFilter::anything(), + }) + .unwrap_or_else(|_| Vec::new()) + .get(index.saturating_sub(1)) + .map(|history| { + ( + parsed.remainder.len(), + indicator.len(), + history.command_line.clone(), + ) + }), + ParseAction::ForwardSearch => self + .history + .search(SearchQuery { + direction: SearchDirection::Forward, + start_time: None, + end_time: None, + start_id: None, + end_id: None, + limit: Some((index + 1) as i64), // fetch the oldest n entries + filter: SearchFilter::anything(), + }) + .unwrap_or_else(|_| Vec::new()) + .get(index) + .map(|history| { + ( + parsed.remainder.len(), + indicator.len(), + history.command_line.clone(), + ) + }), + ParseAction::LastToken => self + .history + .search(SearchQuery::last_with_search(SearchFilter::anything())) + .unwrap_or_else(|_| Vec::new()) + .get(0) + .and_then(|history| history.command_line.split_whitespace().rev().next()) + .map(|token| (parsed.remainder.len(), indicator.len(), token.to_string())), + }); + + if let Some((start, size, history)) = history_result { + let edits = vec![ + EditCommand::MoveToPosition(start), + EditCommand::ReplaceChars(size, history), + ]; + + Some(ReedlineEvent::Edit(edits)) + } else { + None + } + } + + fn open_editor(&mut self) -> Result<()> { + match &self.buffer_editor { + None => Ok(()), + Some(BufferEditor { editor, extension }) => { + let temp_directory = std::env::temp_dir(); + let temp_file = temp_directory.join(format!("reedline_buffer.{extension}")); + + { + let mut file = File::create(temp_file.clone())?; + write!(file, "{}", self.editor.get_buffer())?; + } + + let mut ed = editor.split(' '); + let command = ed.next(); + + { + let mut process = Command::new(command.unwrap_or(editor)); + process.args(ed); + process.arg(temp_file.as_path()); + + let mut child = process.spawn()?; + child.wait()?; + } + + let res = std::fs::read_to_string(temp_file)?; + let res = res.trim_end().to_string(); + + self.editor.set_buffer(res, UndoBehavior::CreateUndoPoint); + + Ok(()) + }, + } + } + + /// Repaint logic for the history reverse search + /// + /// Overwrites the prompt indicator and highlights the search string + /// separately from the result buffer. + fn history_search_paint(&mut self, prompt: &dyn Prompt) -> Result<()> { + let navigation = self.history_cursor.get_navigation(); + + if let HistoryNavigationQuery::SubstringSearch(substring) = navigation { + let status = + if !substring.is_empty() && self.history_cursor.string_at_cursor().is_none() { + PromptHistorySearchStatus::Failing + } else { + PromptHistorySearchStatus::Passing + }; + + let prompt_history_search = PromptHistorySearch::new(status, substring.clone()); + + let res_string = self.history_cursor.string_at_cursor().unwrap_or_default(); + + // Highlight matches + let res_string = if self.use_ansi_coloring { + let match_highlighter = SimpleMatchHighlighter::new(substring); + let styled = match_highlighter.highlight(&res_string, 0); + styled.render_simple() + } else { + res_string + }; + + let lines = PromptLines::new( + prompt, + self.prompt_edit_mode(), + Some(prompt_history_search), + &res_string, + "", + "", + ); + + self.painter.repaint_buffer( + prompt, + &lines, + self.prompt_edit_mode(), + None, + self.use_ansi_coloring, + &self.cursor_shapes, + )?; + } + + Ok(()) + } + + /// Triggers a full repaint including the prompt parts + /// + /// Includes the highlighting and hinting calls. + fn buffer_paint(&mut self, prompt: &dyn Prompt) -> Result<()> { + let cursor_position_in_buffer = self.editor.insertion_point(); + let buffer_to_paint = self.editor.get_buffer(); + + let (before_cursor, after_cursor) = self + .highlighter + .highlight(buffer_to_paint, cursor_position_in_buffer) + .render_around_insertion_point( + cursor_position_in_buffer, + prompt, + self.use_ansi_coloring, + ); + + let hint: String = if self.hints_active() { + self.hinter.as_mut().map_or_else(String::new, |hinter| { + hinter.handle( + buffer_to_paint, + cursor_position_in_buffer, + self.history.as_ref(), + self.use_ansi_coloring, + ) + }) + } else { + String::new() + }; + + // Needs to add return carriage to newlines because when not in raw mode + // some OS don't fully return the carriage + + let lines = PromptLines::new( + prompt, + self.prompt_edit_mode(), + None, + &before_cursor, + &after_cursor, + &hint, + ); + + // Updating the working details of the active menu + for menu in self.menus.iter_mut() { + if menu.is_active() { + menu.update_working_details( + &mut self.editor, + self.completer.as_mut(), + self.history.as_ref(), + &self.painter, + ); + } + } + + let menu = self.menus.iter().find(|menu| menu.is_active()); + + self.painter.repaint_buffer( + prompt, + &lines, + self.prompt_edit_mode(), + menu, + self.use_ansi_coloring, + &self.cursor_shapes, + ) + } + + /// Adds an external printer + #[cfg(feature = "external_printer")] + pub fn with_external_printer(mut self, printer: ExternalPrinter) -> Self { + self.external_printer = Some(printer); + self + } + + #[cfg(feature = "external_printer")] + fn external_messages(external_printer: &ExternalPrinter) -> Result> { + let mut messages = Vec::new(); + loop { + let result = external_printer.receiver().try_recv(); + match result { + Ok(line) => { + messages.push(line); + }, + Err(TryRecvError::Empty) => { + break; + }, + Err(TryRecvError::Disconnected) => { + return Err(Error::new( + ErrorKind::NotConnected, + TryRecvError::Disconnected, + )); + }, + } + } + Ok(messages) + } + + fn submit_buffer(&mut self, prompt: &dyn Prompt) -> io::Result { + let buffer = self.editor.get_buffer().to_string(); + self.hide_hints = true; + // Additional repaint to show the content without hints etc. + self.repaint(prompt)?; + if !buffer.is_empty() { + let mut entry = HistoryItem::from_command_line(&buffer); + entry.session_id = self.history_session_id; + let entry = self.history.save(entry).expect("todo: error handling"); + self.history_last_run_id = entry.id; + } + self.run_edit_commands(&[EditCommand::Clear]); + self.editor.reset_undo_stack(); + + Ok(EventStatus::Exits(Signal::Success(buffer))) + } +} + +#[test] +fn thread_safe() { + fn f(_: S) {} + f(Reedline::create()); +} diff --git a/reedline/src/enums.rs b/reedline/src/enums.rs new file mode 100644 index 00000000..88f98664 --- /dev/null +++ b/reedline/src/enums.rs @@ -0,0 +1,563 @@ +use std::fmt::{Display, Formatter}; + +use serde::{Deserialize, Serialize}; +use strum_macros::EnumIter; + +/// Valid ways how `Reedline::read_line()` can return +#[derive(Debug)] +pub enum Signal { + /// Entry succeeded with the provided content + Success(String), + /// Entry was aborted with `Ctrl+C` + CtrlC, // Interrupt current editing + /// Abort with `Ctrl+D` signalling `EOF` or abort of a whole interactive session + CtrlD, // End terminal session +} + +/// Editing actions which can be mapped to key bindings. +/// +/// Executed by `Reedline::run_edit_commands()` +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, EnumIter)] +pub enum EditCommand { + /// Move to the start of the buffer + MoveToStart, + + /// Move to the start of the current line + MoveToLineStart, + + /// Move to the end of the buffer + MoveToEnd, + + /// Move to the end of the current line + MoveToLineEnd, + + /// Move one character to the left + MoveLeft, + + /// Move one character to the right + MoveRight, + + /// Move one word to the left + MoveWordLeft, + + /// Move one WORD to the left + MoveBigWordLeft, + + /// Move one word to the right + MoveWordRight, + + /// Move one word to the right, stop at start of word + MoveWordRightStart, + + /// Move one WORD to the right, stop at start of WORD + MoveBigWordRightStart, + + /// Move one word to the right, stop at end of word + MoveWordRightEnd, + + /// Move one WORD to the right, stop at end of WORD + MoveBigWordRightEnd, + + /// Move to position + MoveToPosition(usize), + + /// Insert a character at the current insertion point + InsertChar(char), + + /// Insert a string at the current insertion point + InsertString(String), + + /// Inserts the system specific new line character + /// + /// - On Unix systems LF (`"\n"`) + /// - On Windows CRLF (`"\r\n"`) + InsertNewline, + + /// Replace a character + ReplaceChar(char), + + /// Replace characters with string + ReplaceChars(usize, String), + + /// Backspace delete from the current insertion point + Backspace, + + /// Delete in-place from the current insertion point + Delete, + + /// Cut the grapheme right from the current insertion point + CutChar, + + /// Backspace delete a word from the current insertion point + BackspaceWord, + + /// Delete in-place a word from the current insertion point + DeleteWord, + + /// Clear the current buffer + Clear, + + /// Clear to the end of the current line + ClearToLineEnd, + + /// Insert completion: entire completion if there is only one possibility, or else up to shared prefix. + Complete, + + /// Cut the current line + CutCurrentLine, + + /// Cut from the start of the buffer to the insertion point + CutFromStart, + + /// Cut from the start of the current line to the insertion point + CutFromLineStart, + + /// Cut from the insertion point to the end of the buffer + CutToEnd, + + /// Cut from the insertion point to the end of the current line + CutToLineEnd, + + /// Cut the word left of the insertion point + CutWordLeft, + + /// Cut the WORD left of the insertion point + CutBigWordLeft, + + /// Cut the word right of the insertion point + CutWordRight, + + /// Cut the word right of the insertion point + CutBigWordRight, + + /// Cut the word right of the insertion point and any following space + CutWordRightToNext, + + /// Cut the WORD right of the insertion point and any following space + CutBigWordRightToNext, + + /// Paste the cut buffer in front of the insertion point (Emacs, vi `P`) + PasteCutBufferBefore, + + /// Paste the cut buffer in front of the insertion point (vi `p`) + PasteCutBufferAfter, + + /// Upper case the current word + UppercaseWord, + + /// Lower case the current word + LowercaseWord, + + /// Capitalize the current character + CapitalizeChar, + + /// Switch the case of the current character + SwitchcaseChar, + + /// Swap the current word with the word to the right + SwapWords, + + /// Swap the current grapheme/character with the one to the right + SwapGraphemes, + + /// Undo the previous edit command + Undo, + + /// Redo an edit command from the undo history + Redo, + + /// CutUntil right until char + CutRightUntil(char), + + /// CutUntil right before char + CutRightBefore(char), + + /// CutUntil right until char + MoveRightUntil(char), + + /// CutUntil right before char + MoveRightBefore(char), + + /// CutUntil left until char + CutLeftUntil(char), + + /// CutUntil left before char + CutLeftBefore(char), + + /// CutUntil left until char + MoveLeftUntil(char), + + /// CutUntil left before char + MoveLeftBefore(char), +} + +impl Display for EditCommand { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + match self { + EditCommand::MoveToStart => write!(f, "MoveToStart"), + EditCommand::MoveToLineStart => write!(f, "MoveToLineStart"), + EditCommand::MoveToEnd => write!(f, "MoveToEnd"), + EditCommand::MoveToLineEnd => write!(f, "MoveToLineEnd"), + EditCommand::MoveLeft => write!(f, "MoveLeft"), + EditCommand::MoveRight => write!(f, "MoveRight"), + EditCommand::MoveWordLeft => write!(f, "MoveWordLeft"), + EditCommand::MoveBigWordLeft => write!(f, "MoveBigWordLeft"), + EditCommand::MoveWordRight => write!(f, "MoveWordRight"), + EditCommand::MoveWordRightEnd => write!(f, "MoveWordRightEnd"), + EditCommand::MoveBigWordRightEnd => write!(f, "MoveBigWordRightEnd"), + EditCommand::MoveWordRightStart => write!(f, "MoveWordRightStart"), + EditCommand::MoveBigWordRightStart => write!(f, "MoveBigWordRightStart"), + EditCommand::MoveToPosition(_) => write!(f, "MoveToPosition Value: "), + EditCommand::InsertChar(_) => write!(f, "InsertChar Value: "), + EditCommand::InsertString(_) => write!(f, "InsertString Value: "), + EditCommand::InsertNewline => write!(f, "InsertNewline"), + EditCommand::ReplaceChar(_) => write!(f, "ReplaceChar "), + EditCommand::ReplaceChars(_, _) => write!(f, "ReplaceChars "), + EditCommand::Backspace => write!(f, "Backspace"), + EditCommand::Delete => write!(f, "Delete"), + EditCommand::CutChar => write!(f, "CutChar"), + EditCommand::BackspaceWord => write!(f, "BackspaceWord"), + EditCommand::DeleteWord => write!(f, "DeleteWord"), + EditCommand::Clear => write!(f, "Clear"), + EditCommand::ClearToLineEnd => write!(f, "ClearToLineEnd"), + EditCommand::Complete => write!(f, "Complete"), + EditCommand::CutCurrentLine => write!(f, "CutCurrentLine"), + EditCommand::CutFromStart => write!(f, "CutFromStart"), + EditCommand::CutFromLineStart => write!(f, "CutFromLineStart"), + EditCommand::CutToEnd => write!(f, "CutToEnd"), + EditCommand::CutToLineEnd => write!(f, "CutToLineEnd"), + EditCommand::CutWordLeft => write!(f, "CutWordLeft"), + EditCommand::CutBigWordLeft => write!(f, "CutBigWordLeft"), + EditCommand::CutWordRight => write!(f, "CutWordRight"), + EditCommand::CutBigWordRight => write!(f, "CutBigWordRight"), + EditCommand::CutWordRightToNext => write!(f, "CutWordRightToNext"), + EditCommand::CutBigWordRightToNext => write!(f, "CutBigWordRightToNext"), + EditCommand::PasteCutBufferBefore => write!(f, "PasteCutBufferBefore"), + EditCommand::PasteCutBufferAfter => write!(f, "PasteCutBufferAfter"), + EditCommand::UppercaseWord => write!(f, "UppercaseWord"), + EditCommand::LowercaseWord => write!(f, "LowercaseWord"), + EditCommand::SwitchcaseChar => write!(f, "SwitchcaseChar"), + EditCommand::CapitalizeChar => write!(f, "CapitalizeChar"), + EditCommand::SwapWords => write!(f, "SwapWords"), + EditCommand::SwapGraphemes => write!(f, "SwapGraphemes"), + EditCommand::Undo => write!(f, "Undo"), + EditCommand::Redo => write!(f, "Redo"), + EditCommand::CutRightUntil(_) => write!(f, "CutRightUntil Value: "), + EditCommand::CutRightBefore(_) => write!(f, "CutRightBefore Value: "), + EditCommand::MoveRightUntil(_) => write!(f, "MoveRightUntil Value: "), + EditCommand::MoveRightBefore(_) => write!(f, "MoveRightBefore Value: "), + EditCommand::CutLeftUntil(_) => write!(f, "CutLeftUntil Value: "), + EditCommand::CutLeftBefore(_) => write!(f, "CutLeftBefore Value: "), + EditCommand::MoveLeftUntil(_) => write!(f, "MoveLeftUntil Value: "), + EditCommand::MoveLeftBefore(_) => write!(f, "MoveLeftBefore Value: "), + } + } +} + +impl EditCommand { + /// Determine if a certain operation should be undoable + /// or if the operations should be coalesced for undoing + pub fn edit_type(&self) -> EditType { + match self { + // Cursor moves + EditCommand::MoveToStart + | EditCommand::MoveToEnd + | EditCommand::MoveToLineStart + | EditCommand::MoveToLineEnd + | EditCommand::MoveToPosition(_) + | EditCommand::MoveLeft + | EditCommand::MoveRight + | EditCommand::MoveWordLeft + | EditCommand::MoveBigWordLeft + | EditCommand::MoveWordRight + | EditCommand::MoveWordRightStart + | EditCommand::MoveBigWordRightStart + | EditCommand::MoveWordRightEnd + | EditCommand::MoveBigWordRightEnd + | EditCommand::MoveRightUntil(_) + | EditCommand::MoveRightBefore(_) + | EditCommand::MoveLeftUntil(_) + | EditCommand::MoveLeftBefore(_) => EditType::MoveCursor, + + // Text edits + EditCommand::InsertChar(_) + | EditCommand::Backspace + | EditCommand::Delete + | EditCommand::CutChar + | EditCommand::InsertString(_) + | EditCommand::InsertNewline + | EditCommand::ReplaceChar(_) + | EditCommand::ReplaceChars(_, _) + | EditCommand::BackspaceWord + | EditCommand::DeleteWord + | EditCommand::Clear + | EditCommand::ClearToLineEnd + | EditCommand::Complete + | EditCommand::CutCurrentLine + | EditCommand::CutFromStart + | EditCommand::CutFromLineStart + | EditCommand::CutToLineEnd + | EditCommand::CutToEnd + | EditCommand::CutWordLeft + | EditCommand::CutBigWordLeft + | EditCommand::CutWordRight + | EditCommand::CutBigWordRight + | EditCommand::CutWordRightToNext + | EditCommand::CutBigWordRightToNext + | EditCommand::PasteCutBufferBefore + | EditCommand::PasteCutBufferAfter + | EditCommand::UppercaseWord + | EditCommand::LowercaseWord + | EditCommand::SwitchcaseChar + | EditCommand::CapitalizeChar + | EditCommand::SwapWords + | EditCommand::SwapGraphemes + | EditCommand::CutRightUntil(_) + | EditCommand::CutRightBefore(_) + | EditCommand::CutLeftUntil(_) + | EditCommand::CutLeftBefore(_) => EditType::EditText, + + EditCommand::Undo | EditCommand::Redo => EditType::UndoRedo, + } + } +} + +/// Specifies the types of edit commands, used to simplify grouping edits +/// to mark undo behavior +#[derive(PartialEq, Eq)] +pub enum EditType { + /// Cursor movement commands + MoveCursor, + /// Undo/Redo commands + UndoRedo, + /// Text editing commands + EditText, +} + +/// Every line change should come with an `UndoBehavior` tag, which can be used to +/// calculate how the change should be reflected on the undo stack +#[derive(Debug)] +pub enum UndoBehavior { + /// Character insertion, tracking the character inserted + InsertCharacter(char), + /// Backspace command, tracking the deleted character (left of cursor) + /// Warning: this does not track the whole grapheme, just the character + Backspace(Option), + /// Delete command, tracking the deleted character (right of cursor) + /// Warning: this does not track the whole grapheme, just the character + Delete(Option), + /// Move the cursor position + MoveCursor, + /// Navigated the history using up or down arrows + HistoryNavigation, + /// Catch-all for actions that should always form a unique undo point and never be + /// grouped with later edits + CreateUndoPoint, + /// Undo/Redo actions shouldn't be reflected on the edit stack + UndoRedo, +} + +impl UndoBehavior { + /// Return if the current operation should start a new undo set, or be + /// combined with the previous operation + pub fn create_undo_point_after(&self, previous: &UndoBehavior) -> bool { + use UndoBehavior as UB; + match (previous, self) { + // Never start an undo set with cursor movement + (_, UB::MoveCursor) => false, + (UB::HistoryNavigation, UB::HistoryNavigation) => false, + // When inserting/deleting repeatedly, each undo set should encompass + // inserting/deleting a complete word and the associated whitespace + (UB::InsertCharacter(c_prev), UB::InsertCharacter(c_new)) => { + (*c_prev == '\n' || *c_prev == '\r') + || (!c_prev.is_whitespace() && c_new.is_whitespace()) + }, + (UB::Backspace(Some(c_prev)), UB::Backspace(Some(c_new))) => { + (*c_new == '\n' || *c_new == '\r') + || (c_prev.is_whitespace() && !c_new.is_whitespace()) + }, + (UB::Backspace(_), UB::Backspace(_)) => false, + (UB::Delete(Some(c_prev)), UB::Delete(Some(c_new))) => { + (*c_new == '\n' || *c_new == '\r') + || (c_prev.is_whitespace() && !c_new.is_whitespace()) + }, + (UB::Delete(_), UB::Delete(_)) => false, + (_, _) => true, + } + } +} + +/// Reedline supported actions. +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug, EnumIter)] +pub enum ReedlineEvent { + /// No op event + None, + + /// Complete history hint (default in full) + HistoryHintComplete, + + /// Complete a single token/word of the history hint + HistoryHintWordComplete, + + /// Handle EndOfLine event + /// + /// Expected Behavior: + /// + /// - On empty line breaks execution to exit with [`Signal::CtrlD`] + /// - Secondary behavior [`EditCommand::Delete`] + CtrlD, + + /// Handle SIGTERM key input + /// + /// Expected behavior: + /// + /// Abort entry + /// Run [`EditCommand::Clear`] + /// Clear the current undo + /// Bubble up [`Signal::CtrlC`] + CtrlC, + + /// Clears the screen and sets prompt to first line + ClearScreen, + + /// Clears the screen and the scrollback buffer + /// + /// Sets the prompt back to the first line + ClearScrollback, + + /// Handle enter event + Enter, + + /// Handle unconditional submit event + Submit, + + /// Submit at the end of the *complete* text, otherwise newline + SubmitOrNewline, + + /// Esc event + Esc, + + /// Mouse + Mouse, // Fill in details later + + /// trigger termimal resize + Resize(u16, u16), + + /// Run these commands in the editor + Edit(Vec), + + /// Trigger full repaint + Repaint, + + /// Navigate to the previous historic buffer + PreviousHistory, + + /// Move up to the previous line, if multiline, or up into the historic buffers + Up, + + /// Move down to the next line, if multiline, or down through the historic buffers + Down, + + /// Move right to the next column, completion entry, or complete hint + Right, + + /// Move left to the next column, or completion entry + Left, + + /// Navigate to the next historic buffer + NextHistory, + + /// Search the history for a string + SearchHistory, + + /// In vi mode multiple reedline events can be chained while parsing the + /// command or movement characters + Multiple(Vec), + + /// Test + UntilFound(Vec), + + /// Trigger a menu event. It activates a menu with the event name + Menu(String), + + /// Next element in the menu + MenuNext, + + /// Previous element in the menu + MenuPrevious, + + /// Moves up in the menu + MenuUp, + + /// Moves down in the menu + MenuDown, + + /// Moves left in the menu + MenuLeft, + + /// Moves right in the menu + MenuRight, + + /// Move to the next history page + MenuPageNext, + + /// Move to the previous history page + MenuPagePrevious, + + /// Way to bind the execution of a whole command (directly returning from [`crate::Reedline::read_line()`]) to a keybinding + ExecuteHostCommand(String), + + /// Open text editor + OpenEditor, +} + +impl Display for ReedlineEvent { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + match self { + ReedlineEvent::None => write!(f, "None"), + ReedlineEvent::HistoryHintComplete => write!(f, "HistoryHintComplete"), + ReedlineEvent::HistoryHintWordComplete => write!(f, "HistoryHintWordComplete"), + ReedlineEvent::CtrlD => write!(f, "CtrlD"), + ReedlineEvent::CtrlC => write!(f, "CtrlC"), + ReedlineEvent::ClearScreen => write!(f, "ClearScreen"), + ReedlineEvent::ClearScrollback => write!(f, "ClearScrollback"), + ReedlineEvent::Enter => write!(f, "Enter"), + ReedlineEvent::Submit => write!(f, "Submit"), + ReedlineEvent::SubmitOrNewline => write!(f, "SubmitOrNewline"), + ReedlineEvent::Esc => write!(f, "Esc"), + ReedlineEvent::Mouse => write!(f, "Mouse"), + ReedlineEvent::Resize(_, _) => write!(f, "Resize "), + ReedlineEvent::Edit(_) => write!( + f, + "Edit: or Edit: value: " + ), + ReedlineEvent::Repaint => write!(f, "Repaint"), + ReedlineEvent::PreviousHistory => write!(f, "PreviousHistory"), + ReedlineEvent::Up => write!(f, "Up"), + ReedlineEvent::Down => write!(f, "Down"), + ReedlineEvent::Right => write!(f, "Right"), + ReedlineEvent::Left => write!(f, "Left"), + ReedlineEvent::NextHistory => write!(f, "NextHistory"), + ReedlineEvent::SearchHistory => write!(f, "SearchHistory"), + ReedlineEvent::Multiple(_) => write!(f, "Multiple[ {{ ReedLineEvents, }} ]"), + ReedlineEvent::UntilFound(_) => write!(f, "UntilFound [ {{ ReedLineEvents, }} ]"), + ReedlineEvent::Menu(_) => write!(f, "Menu Name: "), + ReedlineEvent::MenuNext => write!(f, "MenuNext"), + ReedlineEvent::MenuPrevious => write!(f, "MenuPrevious"), + ReedlineEvent::MenuUp => write!(f, "MenuUp"), + ReedlineEvent::MenuDown => write!(f, "MenuDown"), + ReedlineEvent::MenuLeft => write!(f, "MenuLeft"), + ReedlineEvent::MenuRight => write!(f, "MenuRight"), + ReedlineEvent::MenuPageNext => write!(f, "MenuPageNext"), + ReedlineEvent::MenuPagePrevious => write!(f, "MenuPagePrevious"), + ReedlineEvent::ExecuteHostCommand(_) => write!(f, "ExecuteHostCommand"), + ReedlineEvent::OpenEditor => write!(f, "OpenEditor"), + } + } +} + +pub(crate) enum EventStatus { + Handled, + Inapplicable, + Exits(Signal), +} diff --git a/reedline/src/external_printer.rs b/reedline/src/external_printer.rs new file mode 100644 index 00000000..c2cdf707 --- /dev/null +++ b/reedline/src/external_printer.rs @@ -0,0 +1,69 @@ +//! To print messages while editing a line +//! +//! See example: +//! +//! ``` shell +//! cargo run --example external_printer --features=external_printer +//! ``` +#[cfg(feature = "external_printer")] +use { + crossbeam::channel::{bounded, Receiver, SendError, Sender}, + std::fmt::Display, +}; + +#[cfg(feature = "external_printer")] +pub const EXTERNAL_PRINTER_DEFAULT_CAPACITY: usize = 20; + +/// An ExternalPrinter allows to print messages of text while editing a line. +/// The message is printed as a new line, the line-edit will continue below the +/// output. +#[cfg(feature = "external_printer")] +#[derive(Debug, Clone)] +pub struct ExternalPrinter +where + T: Display, +{ + sender: Sender, + receiver: Receiver, +} + +#[cfg(feature = "external_printer")] +impl ExternalPrinter +where + T: Display, +{ + /// Creates an ExternalPrinter to store lines with a max_cap + pub fn new(max_cap: usize) -> Self { + let (sender, receiver) = bounded::(max_cap); + Self { sender, receiver } + } + /// Gets a Sender to use the printer externally by sending lines to it + pub fn sender(&self) -> Sender { + self.sender.clone() + } + /// Receiver to get messages if any + pub fn receiver(&self) -> &Receiver { + &self.receiver + } + + /// Convenience method if the whole Printer is cloned, blocks if max_cap is reached. + /// + pub fn print(&self, line: T) -> Result<(), SendError> { + self.sender.send(line) + } + + /// Convenience method to get a line if any, doesnĀ“t block. + pub fn get_line(&self) -> Option { + self.receiver.try_recv().ok() + } +} + +#[cfg(feature = "external_printer")] +impl Default for ExternalPrinter +where + T: Display, +{ + fn default() -> Self { + Self::new(EXTERNAL_PRINTER_DEFAULT_CAPACITY) + } +} diff --git a/reedline/src/highlighter/example.rs b/reedline/src/highlighter/example.rs new file mode 100644 index 00000000..0ac5828e --- /dev/null +++ b/reedline/src/highlighter/example.rs @@ -0,0 +1,87 @@ +use nu_ansi_term::{Color, Style}; + +use crate::{highlighter::Highlighter, StyledText}; + +pub static DEFAULT_BUFFER_MATCH_COLOR: Color = Color::Green; +pub static DEFAULT_BUFFER_NEUTRAL_COLOR: Color = Color::White; +pub static DEFAULT_BUFFER_NOTMATCH_COLOR: Color = Color::Red; + +/// A simple, example highlighter that shows how to highlight keywords +pub struct ExampleHighlighter { + external_commands: Vec, + match_color: Color, + notmatch_color: Color, + neutral_color: Color, +} + +impl Highlighter for ExampleHighlighter { + fn highlight(&self, line: &str, _cursor: usize) -> StyledText { + let mut styled_text = StyledText::new(); + + if self + .external_commands + .clone() + .iter() + .any(|x| line.contains(x)) + { + let matches: Vec<&str> = self + .external_commands + .iter() + .filter(|c| line.contains(*c)) + .map(std::ops::Deref::deref) + .collect(); + let longest_match = matches.iter().fold("".to_string(), |acc, &item| { + if item.len() > acc.len() { + item.to_string() + } else { + acc + } + }); + let buffer_split: Vec<&str> = line.splitn(2, &longest_match).collect(); + + styled_text.push(( + Style::new().fg(self.neutral_color), + buffer_split[0].to_string(), + )); + styled_text.push((Style::new().fg(self.match_color), longest_match)); + styled_text.push(( + Style::new().bold().fg(self.neutral_color), + buffer_split[1].to_string(), + )); + } else if self.external_commands.is_empty() { + styled_text.push((Style::new().fg(self.neutral_color), line.to_string())); + } else { + styled_text.push((Style::new().fg(self.notmatch_color), line.to_string())); + } + + styled_text + } +} +impl ExampleHighlighter { + /// Construct the default highlighter with a given set of extern commands/keywords to detect and highlight + pub fn new(external_commands: Vec) -> ExampleHighlighter { + ExampleHighlighter { + external_commands, + match_color: DEFAULT_BUFFER_MATCH_COLOR, + notmatch_color: DEFAULT_BUFFER_NOTMATCH_COLOR, + neutral_color: DEFAULT_BUFFER_NEUTRAL_COLOR, + } + } + + /// Configure the highlighter to use different colors + pub fn change_colors( + &mut self, + match_color: Color, + notmatch_color: Color, + neutral_color: Color, + ) { + self.match_color = match_color; + self.notmatch_color = notmatch_color; + self.neutral_color = neutral_color; + } +} +impl Default for ExampleHighlighter { + fn default() -> Self { + ExampleHighlighter::new(vec![]) + } +} diff --git a/reedline/src/highlighter/mod.rs b/reedline/src/highlighter/mod.rs new file mode 100644 index 00000000..14345c91 --- /dev/null +++ b/reedline/src/highlighter/mod.rs @@ -0,0 +1,15 @@ +mod example; +mod simple_match; + +pub use example::ExampleHighlighter; +pub use simple_match::SimpleMatchHighlighter; + +use crate::StyledText; +/// The syntax highlighting trait. Implementers of this trait will take in the current string and then +/// return a `StyledText` object, which represents the contents of the original line as styled strings +pub trait Highlighter: Send { + /// The action that will handle the current buffer as a line and return the corresponding `StyledText` for the buffer + /// + /// Cursor position as byte offsets in the string + fn highlight(&self, line: &str, cursor: usize) -> StyledText; +} diff --git a/reedline/src/highlighter/simple_match.rs b/reedline/src/highlighter/simple_match.rs new file mode 100644 index 00000000..8f37f617 --- /dev/null +++ b/reedline/src/highlighter/simple_match.rs @@ -0,0 +1,79 @@ +use nu_ansi_term::{Color, Style}; + +use crate::{highlighter::Highlighter, StyledText}; + +/// Highlight all matches for a given search string in a line +/// +/// Default style: +/// +/// - non-matching text: Default style +/// - matching text: Green foreground color +pub struct SimpleMatchHighlighter { + neutral_style: Style, + match_style: Style, + query: String, +} + +impl Default for SimpleMatchHighlighter { + fn default() -> Self { + Self { + neutral_style: Style::default(), + match_style: Style::new().fg(Color::Green), + query: String::default(), + } + } +} + +impl Highlighter for SimpleMatchHighlighter { + fn highlight(&self, line: &str, _cursor: usize) -> StyledText { + let mut styled_text = StyledText::new(); + if self.query.is_empty() { + styled_text.push((self.neutral_style, line.to_owned())); + } else { + let mut next_idx: usize = 0; + + for (idx, mat) in line.match_indices(&self.query) { + if idx != next_idx { + styled_text.push((self.neutral_style, line[next_idx..idx].to_owned())); + } + styled_text.push((self.match_style, mat.to_owned())); + next_idx = idx + mat.len(); + } + if next_idx != line.len() { + styled_text.push((self.neutral_style, line[next_idx..].to_owned())); + } + } + styled_text + } +} + +impl SimpleMatchHighlighter { + /// Create a simple highlighter that styles every exact match of `query`. + pub fn new(query: String) -> Self { + Self { + query, + ..Self::default() + } + } + + /// Update query string to match + #[must_use] + pub fn with_query(mut self, query: String) -> Self { + self.query = query; + self + } + + /// Set style for the matches found + #[must_use] + pub fn with_match_style(mut self, match_style: Style) -> Self { + self.match_style = match_style; + self + } + + /// Set style for the text that does not match the query + #[must_use] + pub fn with_neutral_style(mut self, neutral_style: Style) -> Self { + self.neutral_style = neutral_style; + self + } +} diff --git a/reedline/src/hinter/default.rs b/reedline/src/hinter/default.rs new file mode 100644 index 00000000..c3a98e09 --- /dev/null +++ b/reedline/src/hinter/default.rs @@ -0,0 +1,92 @@ +use nu_ansi_term::{Color, Style}; + +use crate::{history::SearchQuery, Hinter, History}; + +/// A hinter that use the completions or the history to show a hint to the user +/// +/// Similar to `fish` autosuggestins +pub struct DefaultHinter { + style: Style, + current_hint: String, + min_chars: usize, +} + +impl Hinter for DefaultHinter { + fn handle( + &mut self, + line: &str, + #[allow(unused_variables)] pos: usize, + history: &dyn History, + use_ansi_coloring: bool, + ) -> String { + self.current_hint = if line.chars().count() >= self.min_chars { + history + .search(SearchQuery::last_with_prefix(line.to_string())) + .expect("todo: error handling") + .get(0) + .map_or_else(String::new, |entry| { + entry + .command_line + .get(line.len()..) + .unwrap_or_default() + .to_string() + }) + } else { + String::new() + }; + + if use_ansi_coloring && !self.current_hint.is_empty() { + self.style.paint(&self.current_hint).to_string() + } else { + self.current_hint.clone() + } + } + + fn complete_hint(&self) -> String { + self.current_hint.clone() + } + + fn next_hint_token(&self) -> String { + let mut reached_content = false; + let result: String = self + .current_hint + .chars() + .take_while(|c| match (c.is_whitespace(), reached_content) { + (true, true) => false, + (true, false) => true, + (false, true) => true, + (false, false) => { + reached_content = true; + true + }, + }) + .collect(); + result + } +} + +impl Default for DefaultHinter { + fn default() -> Self { + DefaultHinter { + style: Style::new().fg(Color::LightGray), + current_hint: String::new(), + min_chars: 1, + } + } +} + +impl DefaultHinter { + /// A builder that sets the style applied to the hint as part of the buffer + #[must_use] + pub fn with_style(mut self, style: Style) -> Self { + self.style = style; + self + } + + /// A builder that sets the number of characters that have to be present to enable history hints + #[must_use] + pub fn with_min_chars(mut self, min_chars: usize) -> Self { + self.min_chars = min_chars; + self + } +} diff --git a/reedline/src/hinter/mod.rs b/reedline/src/hinter/mod.rs new file mode 100644 index 00000000..4b63b5d3 --- /dev/null +++ b/reedline/src/hinter/mod.rs @@ -0,0 +1,25 @@ +mod default; +pub use default::DefaultHinter; + +use crate::History; +/// A trait that's responsible for returning the hint for the current line and position +/// Hints are often shown in-line as part of the buffer, showing the user text they can accept or ignore +pub trait Hinter: Send { + /// Handle the hinting duty by using the line, position, and current history + /// + /// Returns the formatted output to show the user + fn handle( + &mut self, + line: &str, + pos: usize, + history: &dyn History, + use_ansi_coloring: bool, + ) -> String; + + /// Return the current hint unformatted to perform the completion of the full hint + fn complete_hint(&self) -> String; + + /// Return the first semantic token of the hint + /// for incremental completion + fn next_hint_token(&self) -> String; +} diff --git a/reedline/src/history/base.rs b/reedline/src/history/base.rs new file mode 100644 index 00000000..2d8f9770 --- /dev/null +++ b/reedline/src/history/base.rs @@ -0,0 +1,374 @@ +use chrono::Utc; + +use super::HistoryItemId; +use crate::{core_editor::LineBuffer, HistoryItem, Result}; + +/// Browsing modes for a [`History`] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HistoryNavigationQuery { + /// `bash` style browsing through the history. Contained `LineBuffer` is used to store the state of manual entry before browsing through the history + Normal(LineBuffer), + /// Search for entries starting with a particular string. + PrefixSearch(String), + /// Full exact search for all entries containing a string. + SubstringSearch(String), + // Suffix Search + // Fuzzy Search +} + +/// Ways to search for a particular command line in the [`History`] +// todo: merge with [HistoryNavigationQuery] +pub enum CommandLineSearch { + /// Command line starts with the same string + Prefix(String), + /// Command line contains the string + Substring(String), + /// Command line is the string. + /// + /// Useful to gather statistics + Exact(String), +} + +/// Defines how to traverse the history when executing a [`SearchQuery`] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum SearchDirection { + /// From the most recent entry backward + Backward, + /// From the least recent entry forward + Forward, +} + +/// Defines additional filters for querying the [`History`] +pub struct SearchFilter { + /// Query for the command line content + pub command_line: Option, + /// Considered implementation detail for now + pub(crate) not_command_line: Option, // to skip the currently shown value in up-arrow navigation + /// Filter based on the executing systems hostname + pub hostname: Option, + /// Exact filter for the working directory + pub cwd_exact: Option, + /// Prefix filter for the working directory + pub cwd_prefix: Option, + /// Filter whether the command completed + pub exit_successful: Option, +} +impl SearchFilter { + /// Create a search filter with a [`CommandLineSearch`] + pub fn from_text_search(cmd: CommandLineSearch) -> SearchFilter { + let mut s = SearchFilter::anything(); + s.command_line = Some(cmd); + s + } + /// No filter constraint + pub fn anything() -> SearchFilter { + SearchFilter { + command_line: None, + not_command_line: None, + hostname: None, + cwd_exact: None, + cwd_prefix: None, + exit_successful: None, + } + } +} + +/// Query for search in the potentially rich [`History`] +pub struct SearchQuery { + /// Direction to search in + pub direction: SearchDirection, + /// if given, only get results after/before this time (depending on direction) + pub start_time: Option>, + /// if given, only get results after/before this time (depending on direction) + pub end_time: Option>, + /// if given, only get results after/before this id (depending on direction) + pub start_id: Option, + /// if given, only get results after/before this id (depending on direction) + pub end_id: Option, + /// How many results to get + pub limit: Option, + /// Additional filters defined with [`SearchFilter`] + pub filter: SearchFilter, +} + +/// Currently `pub` ways to construct a query +impl SearchQuery { + /// all that contain string in reverse chronological order + pub fn all_that_contain_rev(contains: String) -> SearchQuery { + SearchQuery { + direction: SearchDirection::Backward, + start_time: None, + end_time: None, + start_id: None, + end_id: None, + limit: None, + filter: SearchFilter::from_text_search(CommandLineSearch::Substring(contains)), + } + } + /// Get the most recent entry matching [`SearchFilter`] + pub fn last_with_search(filter: SearchFilter) -> SearchQuery { + SearchQuery { + direction: SearchDirection::Backward, + start_time: None, + end_time: None, + start_id: None, + end_id: None, + limit: Some(1), + filter, + } + } + /// Get the most recent entry starting with the `prefix` + pub fn last_with_prefix(prefix: String) -> SearchQuery { + SearchQuery::last_with_search(SearchFilter::from_text_search(CommandLineSearch::Prefix( + prefix, + ))) + } + /// Query to get all entries in the given [`SearchDirection`] + pub fn everything(direction: SearchDirection) -> SearchQuery { + SearchQuery { + direction, + start_time: None, + end_time: None, + start_id: None, + end_id: None, + limit: None, + filter: SearchFilter::anything(), + } + } +} + +/// Represents a history file or database +/// Data could be stored e.g. in a plain text file, in a `JSONL` file, in a `SQLite` database +pub trait History: Send { + /// save a history item to the database + /// if given id is None, a new id is created and set in the return value + /// if given id is Some, the existing entry is updated + fn save(&mut self, h: HistoryItem) -> Result; + /// load a history item by its id + fn load(&self, id: HistoryItemId) -> Result; + + /// retrieves the next unused session id + + /// count the results of a query + fn count(&self, query: SearchQuery) -> Result; + /// return the total number of history items + fn count_all(&self) -> Result { + self.count(SearchQuery::everything(SearchDirection::Forward)) + } + /// return the results of a query + fn search(&self, query: SearchQuery) -> Result>; + + /// update an item atomically + fn update( + &mut self, + id: HistoryItemId, + updater: &dyn Fn(HistoryItem) -> HistoryItem, + ) -> Result<()>; + /// delete all history items + fn clear(&mut self) -> Result<()>; + /// remove an item from this history + fn delete(&mut self, h: HistoryItemId) -> Result<()>; + /// ensure that this history is written to disk + fn sync(&mut self) -> std::io::Result<()>; +} + +#[cfg(test)] +mod test { + #[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))] + const IS_FILE_BASED: bool = false; + #[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))] + const IS_FILE_BASED: bool = true; + + use crate::HistorySessionId; + + fn create_item(session: i64, cwd: &str, cmd: &str, exit_status: i64) -> HistoryItem { + HistoryItem { + id: None, + start_timestamp: None, + command_line: cmd.to_string(), + session_id: Some(HistorySessionId::new(session)), + hostname: Some("foohost".to_string()), + cwd: Some(cwd.to_string()), + duration: Some(Duration::from_millis(1000)), + exit_status: Some(exit_status), + more_info: None, + } + } + use std::time::Duration; + + use super::*; + fn create_filled_example_history() -> Result> { + #[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))] + let mut history = crate::SqliteBackedHistory::in_memory()?; + #[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))] + let mut history = crate::FileBackedHistory::default(); + #[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))] + history.save(create_item(1, "/", "dummy", 0))?; // add dummy item so ids start with 1 + history.save(create_item(1, "/home/me", "cd ~/Downloads", 0))?; // 1 + history.save(create_item(1, "/home/me/Downloads", "unzp foo.zip", 1))?; // 2 + history.save(create_item(1, "/home/me/Downloads", "unzip foo.zip", 0))?; // 3 + history.save(create_item(1, "/home/me/Downloads", "cd foo", 0))?; // 4 + history.save(create_item(1, "/home/me/Downloads/foo", "ls", 0))?; // 5 + history.save(create_item(1, "/home/me/Downloads/foo", "ls -alh", 0))?; // 6 + history.save(create_item(1, "/home/me/Downloads/foo", "cat x.txt", 0))?; // 7 + + history.save(create_item(1, "/home/me", "cd /etc/nginx", 0))?; // 8 + history.save(create_item(1, "/etc/nginx", "ls -l", 0))?; // 9 + history.save(create_item(1, "/etc/nginx", "vim nginx.conf", 0))?; // 10 + history.save(create_item(1, "/etc/nginx", "vim htpasswd", 0))?; // 11 + history.save(create_item(1, "/etc/nginx", "cat nginx.conf", 0))?; // 12 + Ok(Box::new(history)) + } + + #[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))] + #[test] + fn update_item() -> Result<()> { + let mut history = create_filled_example_history()?; + let id = HistoryItemId::new(2); + let before = history.load(id)?; + history.update(id, &|mut e| { + e.exit_status = Some(1); + e + })?; + let after = history.load(id)?; + assert_eq!( + after, + HistoryItem { + exit_status: Some(1), + ..before + } + ); + Ok(()) + } + + fn search_returned( + history: &dyn History, + res: Vec, + wanted: Vec, + ) -> Result<()> { + let wanted = wanted + .iter() + .map(|id| history.load(HistoryItemId::new(*id))) + .collect::>>()?; + assert_eq!(res, wanted); + Ok(()) + } + + #[test] + fn count_all() -> Result<()> { + let history = create_filled_example_history()?; + println!( + "{:#?}", + history.search(SearchQuery::everything(SearchDirection::Forward)) + ); + + assert_eq!(history.count_all()?, if IS_FILE_BASED { 13 } else { 12 }); + Ok(()) + } + + #[test] + fn get_latest() -> Result<()> { + let history = create_filled_example_history()?; + let res = history.search(SearchQuery::last_with_search(SearchFilter::anything()))?; + + search_returned(&*history, res, vec![12])?; + Ok(()) + } + + #[test] + fn get_earliest() -> Result<()> { + let history = create_filled_example_history()?; + let res = history.search(SearchQuery { + limit: Some(1), + ..SearchQuery::everything(SearchDirection::Forward) + })?; + search_returned(&*history, res, vec![if IS_FILE_BASED { 0 } else { 1 }])?; + Ok(()) + } + + #[test] + fn search_prefix() -> Result<()> { + let history = create_filled_example_history()?; + let res = history.search(SearchQuery { + filter: SearchFilter::from_text_search(CommandLineSearch::Prefix("ls ".to_string())), + ..SearchQuery::everything(SearchDirection::Backward) + })?; + search_returned(&*history, res, vec![9, 6])?; + + Ok(()) + } + + #[test] + fn search_includes() -> Result<()> { + let history = create_filled_example_history()?; + let res = history.search(SearchQuery { + filter: SearchFilter::from_text_search(CommandLineSearch::Substring( + "foo.zip".to_string(), + )), + ..SearchQuery::everything(SearchDirection::Forward) + })?; + search_returned(&*history, res, vec![2, 3])?; + Ok(()) + } + + #[test] + fn search_includes_limit() -> Result<()> { + let history = create_filled_example_history()?; + let res = history.search(SearchQuery { + filter: SearchFilter::from_text_search(CommandLineSearch::Substring("c".to_string())), + limit: Some(2), + ..SearchQuery::everything(SearchDirection::Forward) + })?; + search_returned(&*history, res, vec![1, 4])?; + + Ok(()) + } + + #[test] + fn clear_history() -> Result<()> { + let mut history = create_filled_example_history()?; + assert_ne!(history.count_all()?, 0); + history.clear().unwrap(); + assert_eq!(history.count_all()?, 0); + + Ok(()) + } + + // test that clear() works as expected across multiple instances of History + #[test] + fn clear_history_with_backing_file() -> Result<()> { + #[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))] + fn open_history() -> Box { + Box::new( + crate::SqliteBackedHistory::with_file("target/test-history.db".into()).unwrap(), + ) + } + + #[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))] + fn open_history() -> Box { + Box::new( + crate::FileBackedHistory::with_file(100, "target/test-history.txt".into()).unwrap(), + ) + } + + // create history, add a few entries + let mut history = open_history(); + history.save(create_item(1, "/home/me", "cd ~/Downloads", 0))?; // 1 + history.save(create_item(1, "/home/me/Downloads", "unzp foo.zip", 1))?; // 2 + assert_eq!(history.count_all()?, 2); + drop(history); + + // open it again and clear it + let mut history = open_history(); + assert_eq!(history.count_all()?, 2); + history.clear().unwrap(); + assert_eq!(history.count_all()?, 0); + drop(history); + + // open it once more and confirm that the cleared data is gone forever + let history = open_history(); + assert_eq!(history.count_all()?, 0); + + Ok(()) + } +} diff --git a/reedline/src/history/cursor.rs b/reedline/src/history/cursor.rs new file mode 100644 index 00000000..6fc36ee7 --- /dev/null +++ b/reedline/src/history/cursor.rs @@ -0,0 +1,585 @@ +use super::{ + base::{CommandLineSearch, SearchDirection, SearchFilter}, + HistoryItem, SearchQuery, +}; +use crate::{History, HistoryNavigationQuery, Result}; + +/// Interface of a stateful navigation via [`HistoryNavigationQuery`]. +#[derive(Debug)] +pub struct HistoryCursor { + query: HistoryNavigationQuery, + current: Option, + skip_dupes: bool, +} + +impl HistoryCursor { + pub fn new(query: HistoryNavigationQuery) -> HistoryCursor { + HistoryCursor { + query, + current: None, + skip_dupes: true, + } + } + + /// This moves the cursor backwards respecting the navigation query that is set + /// - Results in a no-op if the cursor is at the initial point + pub fn back(&mut self, history: &dyn History) -> Result<()> { + self.navigate_in_direction(history, SearchDirection::Backward) + } + + /// This moves the cursor forwards respecting the navigation-query that is set + /// - Results in a no-op if the cursor is at the latest point + pub fn forward(&mut self, history: &dyn History) -> Result<()> { + self.navigate_in_direction(history, SearchDirection::Forward) + } + + fn get_search_filter(&self) -> SearchFilter { + let filter = match self.query.clone() { + HistoryNavigationQuery::Normal(_) => SearchFilter::anything(), + HistoryNavigationQuery::PrefixSearch(prefix) => { + SearchFilter::from_text_search(CommandLineSearch::Prefix(prefix)) + }, + HistoryNavigationQuery::SubstringSearch(substring) => { + SearchFilter::from_text_search(CommandLineSearch::Substring(substring)) + }, + }; + if let (true, Some(current)) = (self.skip_dupes, &self.current) { + SearchFilter { + not_command_line: Some(current.command_line.clone()), + ..filter + } + } else { + filter + } + } + fn navigate_in_direction( + &mut self, + history: &dyn History, + direction: SearchDirection, + ) -> Result<()> { + if direction == SearchDirection::Forward && self.current.is_none() { + // if searching forward but we don't have a starting point, assume we are at the end + return Ok(()); + } + let start_id = self.current.as_ref().and_then(|e| e.id); + let mut next = history.search(SearchQuery { + start_id, + end_id: None, + start_time: None, + end_time: None, + direction, + limit: Some(1), + filter: self.get_search_filter(), + })?; + if next.len() == 1 { + self.current = Some(next.swap_remove(0)); + } else if direction == SearchDirection::Forward { + // no result and searching forward: we are at the end + self.current = None; + } + Ok(()) + } + + /// Returns the string (if present) at the cursor + pub fn string_at_cursor(&self) -> Option { + self.current.as_ref().map(|e| e.command_line.to_string()) + } + + /// Poll the current [`HistoryNavigationQuery`] mode + pub fn get_navigation(&self) -> HistoryNavigationQuery { + self.query.clone() + } +} + +#[cfg(test)] +mod tests { + use std::path::Path; + + use pretty_assertions::assert_eq; + + use super::{super::*, *}; + use crate::LineBuffer; + + fn create_history() -> (Box, HistoryCursor) { + #[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))] + let hist = Box::new(SqliteBackedHistory::in_memory().unwrap()); + #[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))] + let hist = Box::new(FileBackedHistory::default()); + ( + hist, + HistoryCursor::new(HistoryNavigationQuery::Normal(LineBuffer::default())), + ) + } + fn create_history_at(cap: usize, path: &Path) -> (Box, HistoryCursor) { + let hist = Box::new(FileBackedHistory::with_file(cap, path.to_owned()).unwrap()); + ( + hist, + HistoryCursor::new(HistoryNavigationQuery::Normal(LineBuffer::default())), + ) + } + + fn get_all_entry_texts(hist: &dyn History) -> Vec { + let res = hist + .search(SearchQuery::everything(SearchDirection::Forward)) + .unwrap(); + let actual: Vec<_> = res.iter().map(|e| e.command_line.to_string()).collect(); + actual + } + fn add_text_entries(hist: &mut dyn History, entries: &[impl AsRef]) { + entries.iter().for_each(|e| { + hist.save(HistoryItem::from_command_line(e.as_ref())) + .unwrap(); + }); + } + + #[test] + fn accessing_empty_history_returns_nothing() -> Result<()> { + let (_hist, cursor) = create_history(); + assert_eq!(cursor.string_at_cursor(), None); + Ok(()) + } + + #[test] + fn going_forward_in_empty_history_does_not_error_out() -> Result<()> { + let (hist, mut cursor) = create_history(); + cursor.forward(&*hist)?; + assert_eq!(cursor.string_at_cursor(), None); + Ok(()) + } + + #[test] + fn going_backwards_in_empty_history_does_not_error_out() -> Result<()> { + let (hist, mut cursor) = create_history(); + cursor.back(&*hist)?; + assert_eq!(cursor.string_at_cursor(), None); + Ok(()) + } + + #[test] + fn going_backwards_bottoms_out() -> Result<()> { + let (mut hist, mut cursor) = create_history(); + hist.save(HistoryItem::from_command_line("command1"))?; + hist.save(HistoryItem::from_command_line("command2"))?; + cursor.back(&*hist)?; + cursor.back(&*hist)?; + cursor.back(&*hist)?; + cursor.back(&*hist)?; + cursor.back(&*hist)?; + assert_eq!(cursor.string_at_cursor(), Some("command1".to_string())); + Ok(()) + } + + #[test] + fn going_forwards_bottoms_out() -> Result<()> { + let (mut hist, mut cursor) = create_history(); + hist.save(HistoryItem::from_command_line("command1"))?; + hist.save(HistoryItem::from_command_line("command2"))?; + cursor.forward(&*hist)?; + cursor.forward(&*hist)?; + cursor.forward(&*hist)?; + cursor.forward(&*hist)?; + cursor.forward(&*hist)?; + assert_eq!(cursor.string_at_cursor(), None); + Ok(()) + } + + #[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))] + #[test] + fn appends_only_unique() -> Result<()> { + let (mut hist, _) = create_history(); + hist.save(HistoryItem::from_command_line("unique_old"))?; + hist.save(HistoryItem::from_command_line("test"))?; + hist.save(HistoryItem::from_command_line("test"))?; + hist.save(HistoryItem::from_command_line("unique"))?; + assert_eq!(hist.count_all()?, 3); + Ok(()) + } + + #[test] + fn prefix_search_works() -> Result<()> { + let (mut hist, _) = create_history(); + hist.save(HistoryItem::from_command_line("find me as well"))?; + hist.save(HistoryItem::from_command_line("test"))?; + hist.save(HistoryItem::from_command_line("find me"))?; + + let mut cursor = + HistoryCursor::new(HistoryNavigationQuery::PrefixSearch("find".to_string())); + + cursor.back(&*hist)?; + assert_eq!(cursor.string_at_cursor(), Some("find me".to_string())); + cursor.back(&*hist)?; + assert_eq!( + cursor.string_at_cursor(), + Some("find me as well".to_string()) + ); + Ok(()) + } + + #[test] + fn prefix_search_bottoms_out() -> Result<()> { + let (mut hist, _) = create_history(); + hist.save(HistoryItem::from_command_line("find me as well"))?; + hist.save(HistoryItem::from_command_line("test"))?; + hist.save(HistoryItem::from_command_line("find me"))?; + + let mut cursor = + HistoryCursor::new(HistoryNavigationQuery::PrefixSearch("find".to_string())); + cursor.back(&*hist)?; + assert_eq!(cursor.string_at_cursor(), Some("find me".to_string())); + cursor.back(&*hist)?; + assert_eq!( + cursor.string_at_cursor(), + Some("find me as well".to_string()) + ); + cursor.back(&*hist)?; + cursor.back(&*hist)?; + cursor.back(&*hist)?; + cursor.back(&*hist)?; + assert_eq!( + cursor.string_at_cursor(), + Some("find me as well".to_string()) + ); + Ok(()) + } + #[test] + fn prefix_search_returns_to_none() -> Result<()> { + let (mut hist, _) = create_history(); + hist.save(HistoryItem::from_command_line("find me as well"))?; + hist.save(HistoryItem::from_command_line("test"))?; + hist.save(HistoryItem::from_command_line("find me"))?; + + let mut cursor = + HistoryCursor::new(HistoryNavigationQuery::PrefixSearch("find".to_string())); + cursor.back(&*hist)?; + assert_eq!(cursor.string_at_cursor(), Some("find me".to_string())); + cursor.back(&*hist)?; + assert_eq!( + cursor.string_at_cursor(), + Some("find me as well".to_string()) + ); + cursor.forward(&*hist)?; + assert_eq!(cursor.string_at_cursor(), Some("find me".to_string())); + cursor.forward(&*hist)?; + assert_eq!(cursor.string_at_cursor(), None); + cursor.forward(&*hist)?; + assert_eq!(cursor.string_at_cursor(), None); + Ok(()) + } + + #[test] + fn prefix_search_ignores_consecutive_equivalent_entries_going_backwards() -> Result<()> { + let (mut hist, _) = create_history(); + hist.save(HistoryItem::from_command_line("find me as well"))?; + hist.save(HistoryItem::from_command_line("find me once"))?; + hist.save(HistoryItem::from_command_line("test"))?; + hist.save(HistoryItem::from_command_line("find me once"))?; + + let mut cursor = + HistoryCursor::new(HistoryNavigationQuery::PrefixSearch("find".to_string())); + cursor.back(&*hist)?; + assert_eq!(cursor.string_at_cursor(), Some("find me once".to_string())); + cursor.back(&*hist)?; + assert_eq!( + cursor.string_at_cursor(), + Some("find me as well".to_string()) + ); + Ok(()) + } + + #[test] + fn prefix_search_ignores_consecutive_equivalent_entries_going_forwards() -> Result<()> { + let (mut hist, _) = create_history(); + hist.save(HistoryItem::from_command_line("find me once"))?; + hist.save(HistoryItem::from_command_line("test"))?; + hist.save(HistoryItem::from_command_line("find me once"))?; + hist.save(HistoryItem::from_command_line("find me as well"))?; + + let mut cursor = + HistoryCursor::new(HistoryNavigationQuery::PrefixSearch("find".to_string())); + cursor.back(&*hist)?; + assert_eq!( + cursor.string_at_cursor(), + Some("find me as well".to_string()) + ); + cursor.back(&*hist)?; + cursor.back(&*hist)?; + assert_eq!(cursor.string_at_cursor(), Some("find me once".to_string())); + cursor.forward(&*hist)?; + assert_eq!( + cursor.string_at_cursor(), + Some("find me as well".to_string()) + ); + cursor.forward(&*hist)?; + assert_eq!(cursor.string_at_cursor(), None); + Ok(()) + } + + #[test] + fn substring_search_works() -> Result<()> { + let (mut hist, _) = create_history(); + hist.save(HistoryItem::from_command_line("substring"))?; + hist.save(HistoryItem::from_command_line("don't find me either"))?; + hist.save(HistoryItem::from_command_line("prefix substring"))?; + hist.save(HistoryItem::from_command_line("don't find me"))?; + hist.save(HistoryItem::from_command_line("prefix substring suffix"))?; + + let mut cursor = HistoryCursor::new(HistoryNavigationQuery::SubstringSearch( + "substring".to_string(), + )); + cursor.back(&*hist)?; + assert_eq!( + cursor.string_at_cursor(), + Some("prefix substring suffix".to_string()) + ); + cursor.back(&*hist)?; + assert_eq!( + cursor.string_at_cursor(), + Some("prefix substring".to_string()) + ); + cursor.back(&*hist)?; + assert_eq!(cursor.string_at_cursor(), Some("substring".to_string())); + Ok(()) + } + + #[test] + fn substring_search_with_empty_value_returns_none() -> Result<()> { + let (mut hist, _) = create_history(); + hist.save(HistoryItem::from_command_line("substring"))?; + + let cursor = HistoryCursor::new(HistoryNavigationQuery::SubstringSearch("".to_string())); + + assert_eq!(cursor.string_at_cursor(), None); + Ok(()) + } + + #[test] + fn writes_to_new_file() -> Result<()> { + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + // check that it also works for a path where the directory has not been created yet + let histfile = tmp.path().join("nested_path").join(".history"); + + let entries = vec!["test", "text", "more test text"]; + + { + let (mut hist, _) = create_history_at(5, &histfile); + + add_text_entries(hist.as_mut(), &entries); + // As `hist` goes out of scope and get's dropped, its contents are flushed to disk + } + + let (reading_hist, _) = create_history_at(5, &histfile); + let actual = get_all_entry_texts(reading_hist.as_ref()); + assert_eq!(entries, actual); + + tmp.close().unwrap(); + Ok(()) + } + + #[test] + fn persists_newlines_in_entries() -> Result<()> { + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let histfile = tmp.path().join(".history"); + + let entries = vec![ + "test", + "multiline\nentry\nunix", + "multiline\r\nentry\r\nwindows", + "more test text", + ]; + + { + let (mut writing_hist, _) = create_history_at(5, &histfile); + add_text_entries(writing_hist.as_mut(), &entries); + // As `hist` goes out of scope and get's dropped, its contents are flushed to disk + } + + let (reading_hist, _) = create_history_at(5, &histfile); + + let actual: Vec<_> = get_all_entry_texts(reading_hist.as_ref()); + assert_eq!(entries, actual); + + tmp.close().unwrap(); + Ok(()) + } + + #[cfg(not(any(feature = "sqlite", feature = "sqlite-dynlib")))] + #[test] + fn truncates_file_to_capacity() -> Result<()> { + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let histfile = tmp.path().join(".history"); + + let capacity = 5; + let initial_entries = vec!["test 1", "test 2"]; + let appending_entries = vec!["test 3", "test 4"]; + let expected_appended_entries = vec!["test 1", "test 2", "test 3", "test 4"]; + let truncating_entries = vec!["test 5", "test 6", "test 7", "test 8"]; + let expected_truncated_entries = vec!["test 4", "test 5", "test 6", "test 7", "test 8"]; + + { + let (mut writing_hist, _) = create_history_at(capacity, &histfile); + add_text_entries(writing_hist.as_mut(), &initial_entries); + // As `hist` goes out of scope and get's dropped, its contents are flushed to disk + } + + { + let (mut appending_hist, _) = create_history_at(capacity, &histfile); + add_text_entries(appending_hist.as_mut(), &appending_entries); + // As `hist` goes out of scope and get's dropped, its contents are flushed to disk + let actual: Vec<_> = get_all_entry_texts(appending_hist.as_ref()); + assert_eq!(expected_appended_entries, actual); + } + + { + let (mut truncating_hist, _) = create_history_at(capacity, &histfile); + add_text_entries(truncating_hist.as_mut(), &truncating_entries); + let actual: Vec<_> = get_all_entry_texts(truncating_hist.as_ref()); + assert_eq!(expected_truncated_entries, actual); + // As `hist` goes out of scope and get's dropped, its contents are flushed to disk + } + + let (reading_hist, _) = create_history_at(capacity, &histfile); + + let actual: Vec<_> = get_all_entry_texts(reading_hist.as_ref()); + assert_eq!(expected_truncated_entries, actual); + + tmp.close().unwrap(); + Ok(()) + } + + #[test] + fn truncates_too_large_file() -> Result<()> { + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let histfile = tmp.path().join(".history"); + + let overly_large_previous_entries = vec![ + "test 1", "test 2", "test 3", "test 4", "test 5", "test 6", "test 7", "test 8", + ]; + let expected_truncated_entries = vec!["test 4", "test 5", "test 6", "test 7", "test 8"]; + + { + let (mut writing_hist, _) = create_history_at(10, &histfile); + add_text_entries(writing_hist.as_mut(), &overly_large_previous_entries); + // As `hist` goes out of scope and get's dropped, its contents are flushed to disk + } + + { + let (truncating_hist, _) = create_history_at(5, &histfile); + + let actual: Vec<_> = get_all_entry_texts(truncating_hist.as_ref()); + assert_eq!(expected_truncated_entries, actual); + // As `hist` goes out of scope and get's dropped, its contents are flushed to disk + } + + let (reading_hist, _) = create_history_at(5, &histfile); + + let actual: Vec<_> = get_all_entry_texts(reading_hist.as_ref()); + assert_eq!(expected_truncated_entries, actual); + + tmp.close().unwrap(); + Ok(()) + } + + #[test] + fn concurrent_histories_dont_erase_eachother() -> Result<()> { + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let histfile = tmp.path().join(".history"); + + let capacity = 7; + let initial_entries = vec!["test 1", "test 2", "test 3", "test 4", "test 5"]; + let entries_a = vec!["A1", "A2", "A3"]; + let entries_b = vec!["B1", "B2", "B3"]; + let expected_entries = vec!["test 5", "B1", "B2", "B3", "A1", "A2", "A3"]; + + { + let (mut writing_hist, _) = create_history_at(capacity, &histfile); + add_text_entries(writing_hist.as_mut(), &initial_entries); + // As `hist` goes out of scope and get's dropped, its contents are flushed to disk + } + + { + let (mut hist_a, _) = create_history_at(capacity, &histfile); + + { + let (mut hist_b, _) = create_history_at(capacity, &histfile); + + add_text_entries(hist_b.as_mut(), &entries_b); + // As `hist` goes out of scope and get's dropped, its contents are flushed to disk + } + add_text_entries(hist_a.as_mut(), &entries_a); + // As `hist` goes out of scope and get's dropped, its contents are flushed to disk + } + + let (reading_hist, _) = create_history_at(capacity, &histfile); + + let actual: Vec<_> = get_all_entry_texts(reading_hist.as_ref()); + assert_eq!(expected_entries, actual); + + tmp.close().unwrap(); + Ok(()) + } + + #[test] + fn concurrent_histories_are_threadsafe() -> Result<()> { + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let histfile = tmp.path().join(".history"); + + let num_threads = 16; + let capacity = 2 * num_threads + 1; + + let initial_entries: Vec<_> = (0..capacity).map(|i| format!("initial {i}")).collect(); + + { + let (mut writing_hist, _) = create_history_at(capacity, &histfile); + add_text_entries(writing_hist.as_mut(), &initial_entries); + // As `hist` goes out of scope and get's dropped, its contents are flushed to disk + } + + let threads = (0..num_threads) + .map(|i| { + let cap = capacity; + let hfile = histfile.clone(); + std::thread::spawn(move || { + let (mut hist, _) = create_history_at(cap, &hfile); + hist.save(HistoryItem::from_command_line(format!("A{i}"))) + .unwrap(); + hist.sync().unwrap(); + hist.save(HistoryItem::from_command_line(format!("B{i}"))) + .unwrap(); + }) + }) + .collect::>(); + + for t in threads { + t.join().unwrap(); + } + + let (reading_hist, _) = create_history_at(capacity, &histfile); + + let actual: Vec<_> = get_all_entry_texts(reading_hist.as_ref()); + + assert!( + actual.contains(&format!("initial {}", capacity - 1)), + "Overwrote entry from before threading test" + ); + + for i in 0..num_threads { + assert!(actual.contains(&format!("A{i}")),); + assert!(actual.contains(&format!("B{i}")),); + } + + tmp.close().unwrap(); + Ok(()) + } +} diff --git a/reedline/src/history/file_backed.rs b/reedline/src/history/file_backed.rs new file mode 100644 index 00000000..098cd3f7 --- /dev/null +++ b/reedline/src/history/file_backed.rs @@ -0,0 +1,330 @@ +use std::{ + collections::VecDeque, + fs::OpenOptions, + io::{BufRead, BufReader, BufWriter, Seek, SeekFrom, Write}, + ops::{Deref, DerefMut}, + path::PathBuf, +}; + +use super::{ + base::CommandLineSearch, History, HistoryItem, HistoryItemId, SearchDirection, SearchQuery, +}; +use crate::{ + result::{ReedlineError, ReedlineErrorVariants}, + Result, +}; + +/// Default size of the [`FileBackedHistory`] used when calling [`FileBackedHistory::default()`] +pub const HISTORY_SIZE: usize = 1000; +pub const NEWLINE_ESCAPE: &str = "<\\n>"; + +/// Stateful history that allows up/down-arrow browsing with an internal cursor. +/// +/// Can optionally be associated with a newline separated history file using the [`FileBackedHistory::with_file()`] constructor. +/// Similar to bash's behavior without HISTTIMEFORMAT. +/// (See ) +/// If the history is associated to a file all new changes within a given history capacity will be written to disk when History is dropped. +#[derive(Debug)] +pub struct FileBackedHistory { + capacity: usize, + entries: VecDeque, + file: Option, + len_on_disk: usize, // Keep track what was previously written to disk +} + +impl Default for FileBackedHistory { + /// Creates an in-memory [`History`] with a maximal capacity of [`HISTORY_SIZE`]. + /// + /// To create a [`History`] that is synchronized with a file use [`FileBackedHistory::with_file()`] + fn default() -> Self { + Self::new(HISTORY_SIZE) + } +} + +fn encode_entry(s: &str) -> String { + s.replace('\n', NEWLINE_ESCAPE) +} + +fn decode_entry(s: &str) -> String { + s.replace(NEWLINE_ESCAPE, "\n") +} + +impl History for FileBackedHistory { + /// only saves a value if it's different than the last value + fn save(&mut self, h: HistoryItem) -> Result { + let entry = h.command_line; + // Don't append if the preceding value is identical or the string empty + let entry_id = if self + .entries + .back() + .map_or(true, |previous| previous != &entry) + && !entry.is_empty() + { + if self.entries.len() == self.capacity { + // History is "full", so we delete the oldest entry first, + // before adding a new one. + self.entries.pop_front(); + self.len_on_disk = self.len_on_disk.saturating_sub(1); + } + self.entries.push_back(entry.to_string()); + Some(HistoryItemId::new((self.entries.len() - 1) as i64)) + } else { + None + }; + Ok(FileBackedHistory::construct_entry(entry_id, entry)) + } + + fn load(&self, id: HistoryItemId) -> Result { + Ok(FileBackedHistory::construct_entry( + Some(id), + self.entries[id.0 as usize].clone(), + )) + } + + fn count(&self, query: SearchQuery) -> Result { + // todo: this could be done cheaper + Ok(self.search(query)?.len() as i64) + } + + fn search(&self, query: SearchQuery) -> Result> { + if query.start_time.is_some() || query.end_time.is_some() { + return Err(ReedlineError( + ReedlineErrorVariants::HistoryFeatureUnsupported { + history: "FileBackedHistory", + feature: "filtering by time", + }, + )); + } + + if query.filter.hostname.is_some() + || query.filter.cwd_exact.is_some() + || query.filter.cwd_prefix.is_some() + || query.filter.exit_successful.is_some() + { + return Err(ReedlineError( + ReedlineErrorVariants::HistoryFeatureUnsupported { + history: "FileBackedHistory", + feature: "filtering by extra info", + }, + )); + } + let (min_id, max_id) = { + let start = query.start_id.map(|e| e.0); + let end = query.end_id.map(|e| e.0); + if let SearchDirection::Backward = query.direction { + (end, start) + } else { + (start, end) + } + }; + // add one to make it inclusive + let min_id = min_id.map(|e| e + 1).unwrap_or(0); + // subtract one to make it inclusive + let max_id = max_id + .map(|e| e - 1) + .unwrap_or(self.entries.len() as i64 - 1); + if max_id < 0 || min_id > self.entries.len() as i64 - 1 { + return Ok(vec![]); + } + let intrinsic_limit = max_id - min_id + 1; + let limit = if let Some(given_limit) = query.limit { + std::cmp::min(intrinsic_limit, given_limit) as usize + } else { + intrinsic_limit as usize + }; + let filter = |(idx, cmd): (usize, &String)| { + if !match &query.filter.command_line { + Some(CommandLineSearch::Prefix(p)) => cmd.starts_with(p), + Some(CommandLineSearch::Substring(p)) => cmd.contains(p), + Some(CommandLineSearch::Exact(p)) => cmd == p, + None => true, + } { + return None; + } + if let Some(str) = &query.filter.not_command_line { + if cmd == str { + return None; + } + } + Some(FileBackedHistory::construct_entry( + Some(HistoryItemId::new(idx as i64)), + cmd.to_string(), // todo: this copy might be a perf bottleneck + )) + }; + + let iter = self + .entries + .iter() + .enumerate() + .skip(min_id as usize) + .take(intrinsic_limit as usize); + if let SearchDirection::Backward = query.direction { + Ok(iter.rev().filter_map(filter).take(limit).collect()) + } else { + Ok(iter.filter_map(filter).take(limit).collect()) + } + } + + fn update( + &mut self, + _id: super::HistoryItemId, + _updater: &dyn Fn(super::HistoryItem) -> super::HistoryItem, + ) -> Result<()> { + Err(ReedlineError( + ReedlineErrorVariants::HistoryFeatureUnsupported { + history: "FileBackedHistory", + feature: "updating entries", + }, + )) + } + + fn clear(&mut self) -> Result<()> { + self.entries.clear(); + self.len_on_disk = 0; + + if let Some(file) = &self.file { + if let Err(err) = std::fs::remove_file(file) { + return Err(ReedlineError(ReedlineErrorVariants::IOError(err))); + } + } + + Ok(()) + } + + fn delete(&mut self, _h: super::HistoryItemId) -> Result<()> { + Err(ReedlineError( + ReedlineErrorVariants::HistoryFeatureUnsupported { + history: "FileBackedHistory", + feature: "removing entries", + }, + )) + } + + /// Writes unwritten history contents to disk. + /// + /// If file would exceed `capacity` truncates the oldest entries. + fn sync(&mut self) -> std::io::Result<()> { + if let Some(fname) = &self.file { + // The unwritten entries + let own_entries = self.entries.range(self.len_on_disk..); + + if let Some(base_dir) = fname.parent() { + std::fs::create_dir_all(base_dir)?; + } + + let mut f_lock = fd_lock::RwLock::new( + OpenOptions::new() + .create(true) + .write(true) + .read(true) + .open(fname)?, + ); + let mut writer_guard = f_lock.write()?; + let (mut foreign_entries, truncate) = { + let reader = BufReader::new(writer_guard.deref()); + let mut from_file = reader + .lines() + .map(|o| o.map(|i| decode_entry(&i))) + .collect::>>()?; + if from_file.len() + own_entries.len() > self.capacity { + ( + from_file.split_off(from_file.len() - (self.capacity - own_entries.len())), + true, + ) + } else { + (from_file, false) + } + }; + + { + let mut writer = BufWriter::new(writer_guard.deref_mut()); + if truncate { + writer.rewind()?; + + for line in &foreign_entries { + writer.write_all(encode_entry(line).as_bytes())?; + writer.write_all("\n".as_bytes())?; + } + } else { + writer.seek(SeekFrom::End(0))?; + } + for line in own_entries { + writer.write_all(encode_entry(line).as_bytes())?; + writer.write_all("\n".as_bytes())?; + } + writer.flush()?; + } + if truncate { + let file = writer_guard.deref_mut(); + let file_len = file.stream_position()?; + file.set_len(file_len)?; + } + + let own_entries = self.entries.drain(self.len_on_disk..); + foreign_entries.extend(own_entries); + self.entries = foreign_entries; + + self.len_on_disk = self.entries.len(); + } + Ok(()) + } +} + +impl FileBackedHistory { + /// Creates a new in-memory history that remembers `n <= capacity` elements + /// + /// # Panics + /// + /// If `capacity == usize::MAX` + pub fn new(capacity: usize) -> Self { + if capacity == usize::MAX { + panic!("History capacity too large to be addressed safely"); + } + FileBackedHistory { + capacity, + entries: VecDeque::new(), + file: None, + len_on_disk: 0, + } + } + + /// Creates a new history with an associated history file. + /// + /// History file format: commands separated by new lines. + /// If file exists file will be read otherwise empty file will be created. + /// + /// + /// **Side effects:** creates all nested directories to the file + /// + pub fn with_file(capacity: usize, file: PathBuf) -> std::io::Result { + let mut hist = Self::new(capacity); + if let Some(base_dir) = file.parent() { + std::fs::create_dir_all(base_dir)?; + } + hist.file = Some(file); + hist.sync()?; + Ok(hist) + } + + // this history doesn't store any info except command line + fn construct_entry(id: Option, command_line: String) -> HistoryItem { + HistoryItem { + id, + start_timestamp: None, + command_line, + session_id: None, + hostname: None, + cwd: None, + duration: None, + exit_status: None, + more_info: None, + } + } +} + +impl Drop for FileBackedHistory { + /// On drop the content of the [`History`] will be written to the file if specified via [`FileBackedHistory::with_file()`]. + fn drop(&mut self) { + let _res = self.sync(); + } +} diff --git a/reedline/src/history/item.rs b/reedline/src/history/item.rs new file mode 100644 index 00000000..2533edbb --- /dev/null +++ b/reedline/src/history/item.rs @@ -0,0 +1,108 @@ +use std::{fmt::Display, time::Duration}; + +use chrono::Utc; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +/// Unique ID for the [`HistoryItem`] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct HistoryItemId(pub(crate) i64); +impl HistoryItemId { + pub(crate) fn new(i: i64) -> HistoryItemId { + HistoryItemId(i) + } +} + +impl Display for HistoryItemId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Unique ID for the session in which reedline was run to disambiguate different sessions +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct HistorySessionId(pub(crate) i64); +impl HistorySessionId { + pub(crate) fn new(i: i64) -> HistorySessionId { + HistorySessionId(i) + } +} + +impl Display for HistorySessionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for i64 { + fn from(id: HistorySessionId) -> Self { + id.0 + } +} + +/// This trait represents additional arbitrary context to be added to a history (optional, see [`HistoryItem`]) +pub trait HistoryItemExtraInfo: Serialize + DeserializeOwned + Default + Send {} + +#[derive(Default, Debug, PartialEq, Eq)] +/// something that is serialized as null and deserialized by ignoring everything +pub struct IgnoreAllExtraInfo; + +impl Serialize for IgnoreAllExtraInfo { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + Option::::None.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for IgnoreAllExtraInfo { + fn deserialize(d: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + serde::de::IgnoredAny::deserialize(d).map(|_| IgnoreAllExtraInfo) + } +} + +impl HistoryItemExtraInfo for IgnoreAllExtraInfo {} + +/// Represents one run command with some optional additional context +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HistoryItem { + /// primary key, unique across one history + pub id: Option, + /// date-time when this command was started + pub start_timestamp: Option>, + /// the full command line as text + pub command_line: String, + /// a unique id for one shell session. + /// used so the history can be filtered to a single session + pub session_id: Option, + /// the hostname the commands were run in + pub hostname: Option, + /// the current working directory + pub cwd: Option, + /// the duration the command took to complete + pub duration: Option, + /// the exit status of the command + pub exit_status: Option, + /// arbitrary additional information that might be interesting + pub more_info: Option, +} + +impl HistoryItem { + /// create a history item purely from the command line with everything else set to None + pub fn from_command_line(cmd: impl Into) -> HistoryItem { + HistoryItem { + id: None, + start_timestamp: None, + command_line: cmd.into(), + session_id: None, + hostname: None, + cwd: None, + duration: None, + exit_status: None, + more_info: None, + } + } +} diff --git a/reedline/src/history/mod.rs b/reedline/src/history/mod.rs new file mode 100644 index 00000000..84ea2ee4 --- /dev/null +++ b/reedline/src/history/mod.rs @@ -0,0 +1,14 @@ +mod base; +mod cursor; +mod file_backed; +mod item; +#[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))] +mod sqlite_backed; +pub use base::{ + CommandLineSearch, History, HistoryNavigationQuery, SearchDirection, SearchFilter, SearchQuery, +}; +pub use cursor::HistoryCursor; +pub use file_backed::{FileBackedHistory, HISTORY_SIZE}; +pub use item::{HistoryItem, HistoryItemId, HistorySessionId}; +#[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))] +pub use sqlite_backed::SqliteBackedHistory; diff --git a/reedline/src/history/sqlite_backed.rs b/reedline/src/history/sqlite_backed.rs new file mode 100644 index 00000000..63f4838e --- /dev/null +++ b/reedline/src/history/sqlite_backed.rs @@ -0,0 +1,348 @@ +use std::{path::PathBuf, time::Duration}; + +use chrono::{TimeZone, Utc}; +use rusqlite::{named_params, params, Connection, ToSql}; + +use super::{ + base::{CommandLineSearch, SearchDirection, SearchQuery}, + History, HistoryItem, HistoryItemId, HistorySessionId, +}; +use crate::{ + result::{ReedlineError, ReedlineErrorVariants}, + Result, +}; +const SQLITE_APPLICATION_ID: i32 = 1151497937; + +/// A history that stores the values to an SQLite database. +/// In addition to storing the command, the history can store an additional arbitrary HistoryEntryContext, +/// to add information such as a timestamp, running directory, result... +pub struct SqliteBackedHistory { + db: rusqlite::Connection, +} + +fn deserialize_history_item(row: &rusqlite::Row) -> rusqlite::Result { + let x: Option = row.get("more_info")?; + Ok(HistoryItem { + id: Some(HistoryItemId::new(row.get("id")?)), + start_timestamp: row + .get::<&str, Option>("start_timestamp")? + .map(|e| Utc.timestamp_millis(e)), + command_line: row.get("command_line")?, + session_id: row + .get::<&str, Option>("session_id")? + .map(HistorySessionId::new), + hostname: row.get("hostname")?, + cwd: row.get("cwd")?, + duration: row + .get::<&str, Option>("duration_ms")? + .map(|e| Duration::from_millis(e as u64)), + exit_status: row.get("exit_status")?, + more_info: x + .map(|x| { + serde_json::from_str(&x).map_err(|e| { + // hack + rusqlite::Error::InvalidColumnType( + 0, + format!("could not deserialize more_info: {e}"), + rusqlite::types::Type::Text, + ) + }) + }) + .transpose()?, + }) +} + +impl History for SqliteBackedHistory { + fn save(&mut self, mut entry: HistoryItem) -> Result { + let ret: i64 = self + .db + .prepare( + "insert into history + (id, start_timestamp, command_line, session_id, hostname, cwd, duration_ms, exit_status, more_info) + values (:id, :start_timestamp, :command_line, :session_id, :hostname, :cwd, :duration_ms, :exit_status, :more_info) + on conflict (history.id) do update set + start_timestamp = excluded.start_timestamp, + command_line = excluded.command_line, + session_id = excluded.session_id, + hostname = excluded.hostname, + cwd = excluded.cwd, + duration_ms = excluded.duration_ms, + exit_status = excluded.exit_status, + more_info = excluded.more_info + returning id", + ) + .map_err(map_sqlite_err)? + .query_row( + named_params! { + ":id": entry.id.map(|id| id.0), + ":start_timestamp": entry.start_timestamp.map(|e| e.timestamp_millis()), + ":command_line": entry.command_line, + ":session_id": entry.session_id.map(|e| e.0), + ":hostname": entry.hostname, + ":cwd": entry.cwd, + ":duration_ms": entry.duration.map(|e| e.as_millis() as i64), + ":exit_status": entry.exit_status, + ":more_info": entry.more_info.as_ref().map(|e| serde_json::to_string(e).unwrap()) + }, + |row| row.get(0), + ) + .map_err(map_sqlite_err)?; + entry.id = Some(HistoryItemId::new(ret)); + Ok(entry) + } + + fn load(&self, id: HistoryItemId) -> Result { + let entry = self + .db + .prepare("select * from history where id = :id") + .map_err(map_sqlite_err)? + .query_row(named_params! { ":id": id.0 }, deserialize_history_item) + .map_err(map_sqlite_err)?; + Ok(entry) + } + + fn count(&self, query: SearchQuery) -> Result { + let (query, params) = self.construct_query(&query, "coalesce(count(*), 0)"); + let params_borrow: Vec<(&str, &dyn ToSql)> = params.iter().map(|e| (e.0, &*e.1)).collect(); + let result: i64 = self + .db + .prepare(&query) + .unwrap() + .query_row(¶ms_borrow[..], |r| r.get(0)) + .map_err(map_sqlite_err)?; + Ok(result) + } + + fn search(&self, query: SearchQuery) -> Result> { + let (query, params) = self.construct_query(&query, "*"); + let params_borrow: Vec<(&str, &dyn ToSql)> = params.iter().map(|e| (e.0, &*e.1)).collect(); + let results: Vec = self + .db + .prepare(&query) + .unwrap() + .query_map(¶ms_borrow[..], deserialize_history_item) + .map_err(map_sqlite_err)? + .collect::>>() + .map_err(map_sqlite_err)?; + Ok(results) + } + + fn update( + &mut self, + id: HistoryItemId, + updater: &dyn Fn(HistoryItem) -> HistoryItem, + ) -> Result<()> { + // in theory this should run in a transaction + let item = self.load(id)?; + self.save(updater(item))?; + Ok(()) + } + + fn clear(&mut self) -> Result<()> { + self.db + .execute("delete from history", params![]) + .map_err(map_sqlite_err)?; + + // VACUUM to ensure that sensitive data is completely erased + // instead of being marked as available for reuse + self.db + .execute("VACUUM", params![]) + .map_err(map_sqlite_err)?; + + Ok(()) + } + + fn delete(&mut self, h: HistoryItemId) -> Result<()> { + let changed = self + .db + .execute("delete from history where id = ?", params![h.0]) + .map_err(map_sqlite_err)?; + if changed == 0 { + return Err(ReedlineError(ReedlineErrorVariants::HistoryDatabaseError( + "Could not find item".to_string(), + ))); + } + Ok(()) + } + + fn sync(&mut self) -> std::io::Result<()> { + // no-op (todo?) + Ok(()) + } +} +fn map_sqlite_err(err: rusqlite::Error) -> ReedlineError { + // TODO: better error mapping + ReedlineError(ReedlineErrorVariants::HistoryDatabaseError(format!( + "{err:?}" + ))) +} + +type BoxedNamedParams<'a> = Vec<(&'static str, Box)>; + +impl SqliteBackedHistory { + /// Creates a new history with an associated history file. + /// + /// + /// **Side effects:** creates all nested directories to the file + /// + pub fn with_file(file: PathBuf) -> Result { + if let Some(base_dir) = file.parent() { + std::fs::create_dir_all(base_dir).map_err(|e| { + ReedlineError(ReedlineErrorVariants::HistoryDatabaseError(format!("{e}"))) + })?; + } + let db = Connection::open(&file).map_err(map_sqlite_err)?; + Self::from_connection(db) + } + /// Creates a new history in memory + pub fn in_memory() -> Result { + Self::from_connection(Connection::open_in_memory().map_err(map_sqlite_err)?) + } + /// initialize a new database / migrate an existing one + fn from_connection(db: Connection) -> Result { + // https://phiresky.github.io/blog/2020/sqlite-performance-tuning/ + db.pragma_update(None, "journal_mode", "wal") + .map_err(map_sqlite_err)?; + db.pragma_update(None, "synchronous", "normal") + .map_err(map_sqlite_err)?; + db.pragma_update(None, "mmap_size", "1000000000") + .map_err(map_sqlite_err)?; + db.pragma_update(None, "foreign_keys", "on") + .map_err(map_sqlite_err)?; + db.pragma_update(None, "application_id", SQLITE_APPLICATION_ID) + .map_err(map_sqlite_err)?; + let db_version: i32 = db + .query_row( + "SELECT user_version FROM pragma_user_version", + params![], + |r| r.get(0), + ) + .map_err(map_sqlite_err)?; + if db_version != 0 { + return Err(ReedlineError(ReedlineErrorVariants::HistoryDatabaseError( + format!("Unknown database version {db_version}"), + ))); + } + db.execute_batch( + " + create table if not exists history ( + id integer primary key autoincrement, + command_line text not null, + start_timestamp integer, + session_id integer, + hostname text, + cwd text, + duration_ms integer, + exit_status integer, + more_info text + ) strict; + create index if not exists idx_history_time on history(start_timestamp); + create index if not exists idx_history_cwd on history(cwd); -- suboptimal for many hosts + create index if not exists idx_history_exit_status on history(exit_status); + create index if not exists idx_history_cmd on history(command_line); + create index if not exists idx_history_cmd on history(session_id); + -- todo: better indexes + ", + ) + .map_err(map_sqlite_err)?; + Ok(SqliteBackedHistory { db }) + } + fn construct_query<'a>( + &self, + query: &'a SearchQuery, + select_expression: &str, + ) -> (String, BoxedNamedParams<'a>) { + // TODO: this whole function could be done with less allocs + let (is_asc, asc) = match query.direction { + SearchDirection::Forward => (true, "asc"), + SearchDirection::Backward => (false, "desc"), + }; + let mut wheres: Vec<&str> = vec![]; + let mut params: BoxedNamedParams = vec![]; + if let Some(start) = query.start_time { + wheres.push(if is_asc { + "timestamp_start > :start_time" + } else { + "timestamp_start < :start_time" + }); + params.push((":start_time", Box::new(start.timestamp_millis()))) + } + if let Some(end) = query.end_time { + wheres.push(if is_asc { + ":end_time >= timestamp_start" + } else { + ":end_time <= timestamp_start" + }); + params.push((":end_time", Box::new(end.timestamp_millis()))); + } + if let Some(start) = query.start_id { + wheres.push(if is_asc { + "id > :start_id" + } else { + "id < :start_id" + }); + params.push((":start_id", Box::new(start.0))) + } + if let Some(end) = query.end_id { + wheres.push(if is_asc { + ":end_id >= id" + } else { + ":end_id <= id" + }); + params.push((":end_id", Box::new(end.0))); + } + let limit = match query.limit { + Some(l) => { + params.push((":limit", Box::new(l))); + "limit :limit" + }, + None => "", + }; + if let Some(command_line) = &query.filter.command_line { + // TODO: escape % + let command_line_like = match command_line { + CommandLineSearch::Exact(e) => e.to_string(), + CommandLineSearch::Prefix(prefix) => format!("{prefix}%"), + CommandLineSearch::Substring(cont) => format!("%{cont}%"), + }; + wheres.push("command_line like :command_line"); + params.push((":command_line", Box::new(command_line_like))); + } + + if let Some(str) = &query.filter.not_command_line { + wheres.push("command_line != :not_cmd"); + params.push((":not_cmd", Box::new(str))); + } + if let Some(hostname) = &query.filter.hostname { + wheres.push("hostname = :hostname"); + params.push((":hostname", Box::new(hostname))); + } + if let Some(cwd_exact) = &query.filter.cwd_exact { + wheres.push("cwd = :cwd"); + params.push((":cwd", Box::new(cwd_exact))); + } + if let Some(cwd_prefix) = &query.filter.cwd_prefix { + wheres.push("cwd like :cwd_like"); + let cwd_like = format!("{cwd_prefix}%"); + params.push((":cwd_like", Box::new(cwd_like))); + } + if let Some(exit_successful) = query.filter.exit_successful { + if exit_successful { + wheres.push("exit_status = 0"); + } else { + wheres.push("exit_status != 0"); + } + } + let mut wheres = wheres.join(" and "); + if wheres.is_empty() { + wheres = "true".to_string(); + } + let query = format!( + "select {select_expression} from history + where + {wheres} + order by id {asc} {limit}" + ); + (query, params) + } +} diff --git a/reedline/src/lib.rs b/reedline/src/lib.rs new file mode 100644 index 00000000..2314c073 --- /dev/null +++ b/reedline/src/lib.rs @@ -0,0 +1,292 @@ +//! # reedline `\|/` +//! # A readline replacement written in Rust +//! +//! Reedline is a project to create a line editor (like bash's `readline` or +//! zsh's `zle`) that supports many of the modern conveniences of CLIs, +//! including syntax highlighting, completions, multiline support, Unicode +//! support, and more. It is currently primarily developed as the interactive +//! editor for [nushell](https://github.com/nushell/nushell) (starting with +//! `v0.60`) striving to provide a pleasant interactive experience. +//! +//! ## Basic example +//! +//! ```rust,no_run +//! // Create a default reedline object to handle user input +//! +//! use reedline::{DefaultPrompt, Reedline, Signal}; +//! +//! let mut line_editor = Reedline::create(); +//! let prompt = DefaultPrompt::default(); +//! +//! loop { +//! let sig = line_editor.read_line(&prompt); +//! match sig { +//! Ok(Signal::Success(buffer)) => { +//! println!("We processed: {}", buffer); +//! } +//! Ok(Signal::CtrlD) | Ok(Signal::CtrlC) => { +//! println!("\nAborted!"); +//! break; +//! } +//! x => { +//! println!("Event: {:?}", x); +//! } +//! } +//! } +//! ``` +//! ## Integrate with custom keybindings +//! +//! ```rust +//! // Configure reedline with custom keybindings +//! +//! //Cargo.toml +//! // [dependencies] +//! // crossterm = "*" +//! +//! use { +//! crossterm::event::{KeyCode, KeyModifiers}, +//! reedline::{default_emacs_keybindings, EditCommand, Reedline, Emacs, ReedlineEvent}, +//! }; +//! +//! let mut keybindings = default_emacs_keybindings(); +//! keybindings.add_binding( +//! KeyModifiers::ALT, +//! KeyCode::Char('m'), +//! ReedlineEvent::Edit(vec![EditCommand::BackspaceWord]), +//! ); +//! let edit_mode = Box::new(Emacs::new(keybindings)); +//! +//! let mut line_editor = Reedline::create().with_edit_mode(edit_mode); +//! ``` +//! +//! ## Integrate with [`History`] +//! +//! ```rust,no_run +//! // Create a reedline object with history support, including history size limits +//! +//! use reedline::{FileBackedHistory, Reedline}; +//! +//! let history = Box::new( +//! FileBackedHistory::with_file(5, "history.txt".into()) +//! .expect("Error configuring history with file"), +//! ); +//! let mut line_editor = Reedline::create() +//! .with_history(history); +//! ``` +//! +//! ## Integrate with custom syntax [`Highlighter`] +//! +//! ```rust +//! // Create a reedline object with highlighter support +//! +//! use reedline::{ExampleHighlighter, Reedline}; +//! +//! let commands = vec![ +//! "test".into(), +//! "hello world".into(), +//! "hello world reedline".into(), +//! "this is the reedline crate".into(), +//! ]; +//! let mut line_editor = +//! Reedline::create().with_highlighter(Box::new(ExampleHighlighter::new(commands))); +//! ``` +//! +//! ## Integrate with custom tab completion +//! +//! ```rust +//! // Create a reedline object with tab completions support +//! +//! use reedline::{default_emacs_keybindings, ColumnarMenu, DefaultCompleter, Emacs, KeyCode, KeyModifiers, Reedline, ReedlineEvent, ReedlineMenu}; +//! +//! let commands = vec![ +//! "test".into(), +//! "hello world".into(), +//! "hello world reedline".into(), +//! "this is the reedline crate".into(), +//! ]; +//! let completer = Box::new(DefaultCompleter::new_with_wordlen(commands.clone(), 2)); +//! // Use the interactive menu to select options from the completer +//! let completion_menu = Box::new(ColumnarMenu::default().with_name("completion_menu")); +//! // Set up the required keybindings +//! let mut keybindings = default_emacs_keybindings(); +//! keybindings.add_binding( +//! KeyModifiers::NONE, +//! KeyCode::Tab, +//! ReedlineEvent::UntilFound(vec![ +//! ReedlineEvent::Menu("completion_menu".to_string()), +//! ReedlineEvent::MenuNext, +//! ]), +//! ); +//! +//! let edit_mode = Box::new(Emacs::new(keybindings)); +//! +//! let mut line_editor = Reedline::create() +//! .with_completer(completer) +//! .with_menu(ReedlineMenu::EngineCompleter(completion_menu)) +//! .with_edit_mode(edit_mode); +//! ``` +//! +//! ## Integrate with [`Hinter`] for fish-style history autosuggestions +//! +//! ```rust +//! // Create a reedline object with in-line hint support +//! +//! //Cargo.toml +//! // [dependencies] +//! // nu-ansi-term = "*" +//! +//! use { +//! nu_ansi_term::{Color, Style}, +//! reedline::{DefaultHinter, Reedline}, +//! }; +//! +//! +//! let mut line_editor = Reedline::create().with_hinter(Box::new( +//! DefaultHinter::default() +//! .with_style(Style::new().italic().fg(Color::LightGray)), +//! )); +//! ``` +//! +//! +//! ## Integrate with custom line completion [`Validator`] +//! +//! ```rust +//! // Create a reedline object with line completion validation support +//! +//! use reedline::{DefaultValidator, Reedline}; +//! +//! let validator = Box::new(DefaultValidator); +//! +//! let mut line_editor = Reedline::create().with_validator(validator); +//! ``` +//! +//! ## Use custom [`EditMode`] +//! +//! ```rust +//! // Create a reedline object with custom edit mode +//! // This can define a keybinding setting or enable vi-emulation +//! use reedline::{ +//! default_vi_insert_keybindings, default_vi_normal_keybindings, EditMode, Reedline, Vi, +//! }; +//! +//! let mut line_editor = Reedline::create().with_edit_mode(Box::new(Vi::new( +//! default_vi_insert_keybindings(), +//! default_vi_normal_keybindings(), +//! ))); +//! ``` +//! +//! ## Crate features +//! +//! - `clipboard`: Enable support to use the `SystemClipboard`. Enabling this feature will return a `SystemClipboard` instead of a local clipboard when calling `get_default_clipboard()`. +//! - `bashisms`: Enable support for special text sequences that recall components from the history. e.g. `!!` and `!$`. For use in shells like `bash` or [`nushell`](https://nushell.sh). +//! - `sqlite`: Provides the `SqliteBackedHistory` to store richer information in the history. Statically links the required sqlite version. +//! - `sqlite-dynlib`: Alternative to the feature `sqlite`. Will not statically link. Requires `sqlite >= 3.38` to link dynamically! +//! - `external_printer`: **Experimental:** Thread-safe `ExternalPrinter` handle to print lines from concurrently running threads. +//! +//! ## Are we prompt yet? (Development status) +//! +//! Nushell has now all the basic features to become the primary line editor for [nushell](https://github.com/nushell/nushell +//! ) +//! +//! - General editing functionality, that should feel familiar coming from other shells (e.g. bash, fish, zsh). +//! - Configurable keybindings (emacs-style bindings and basic vi-style). +//! - Configurable prompt +//! - Content-aware syntax highlighting. +//! - Autocompletion (With graphical selection menu or simple cycling inline). +//! - History with interactive search options (optionally persists to file, can support multilple sessions accessing the same file) +//! - Fish-style history autosuggestion hints +//! - Undo support. +//! - Clipboard integration +//! - Line completeness validation for seamless entry of multiline command sequences. +//! +//! ### Areas for future improvements +//! +//! - [ ] Support for Unicode beyond simple left-to-right scripts +//! - [ ] Easier keybinding configuration +//! - [ ] Support for more advanced vi commands +//! - [ ] Visual selection +//! - [ ] Smooth experience if completion or prompt content takes long to compute +//! - [ ] Support for a concurrent output stream from background tasks to be displayed, while the input prompt is active. ("Full duplex" mode) +//! +//! For more ideas check out the [feature discussion](https://github.com/nushell/reedline/issues/63) or hop on the `#reedline` channel of the [nushell discord](https://discordapp.com/invite/NtAbbGn). +//! +//! ### Development history +//! +//! If you want to follow along with the history how reedline got started, you can watch the [recordings](https://youtube.com/playlist?list=PLP2yfE2-FXdQw0I6O4YdIX_mzBeF5TDdv) of [JT](https://github.com/jntrnr)'s [live-coding streams](https://www.twitch.tv/jntrnr). +//! +//! [Playlist: Creating a line editor in Rust](https://youtube.com/playlist?list=PLP2yfE2-FXdQw0I6O4YdIX_mzBeF5TDdv) +//! +//! ### Alternatives +//! +//! For currently more mature Rust line editing check out: +//! +//! - [rustyline](https://crates.io/crates/rustyline) +//! +#![warn(rustdoc::missing_crate_level_docs)] +#![warn(missing_docs)] +// #![deny(warnings)] +mod core_editor; +pub use core_editor::{Editor, LineBuffer}; + +mod enums; +pub use enums::{EditCommand, ReedlineEvent, Signal, UndoBehavior}; + +mod painting; +pub use painting::{Painter, StyledText}; + +mod engine; +pub use engine::Reedline; + +mod result; +pub use result::{ReedlineError, Result}; + +mod history; +#[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))] +pub use history::SqliteBackedHistory; +pub use history::{ + CommandLineSearch, FileBackedHistory, History, HistoryItem, HistoryItemId, + HistoryNavigationQuery, HistorySessionId, SearchDirection, SearchFilter, SearchQuery, + HISTORY_SIZE, +}; + +mod prompt; +pub use prompt::{ + DefaultPrompt, DefaultPromptSegment, Prompt, PromptEditMode, PromptHistorySearch, + PromptHistorySearchStatus, PromptViMode, +}; + +mod edit_mode; +pub use edit_mode::{ + default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings, + CursorConfig, EditMode, Emacs, Keybindings, Vi, +}; + +mod highlighter; +pub use highlighter::{ExampleHighlighter, Highlighter, SimpleMatchHighlighter}; + +mod completion; +pub use completion::{Completer, DefaultCompleter, Span, Suggestion}; + +mod hinter; +pub use hinter::{DefaultHinter, Hinter}; + +mod validator; +pub use validator::{DefaultValidator, ValidationResult, Validator}; + +mod menu; +pub use menu::{ + menu_functions, ColumnarMenu, ListMenu, Menu, MenuEvent, MenuTextStyle, ReedlineMenu, +}; + +mod utils; + +mod external_printer; +// Reexport the key types to be independent from an explicit crossterm dependency. +pub use crossterm::event::{KeyCode, KeyModifiers}; +#[cfg(feature = "external_printer")] +pub use external_printer::ExternalPrinter; +pub use utils::{ + get_reedline_default_keybindings, get_reedline_edit_commands, + get_reedline_keybinding_modifiers, get_reedline_keycodes, get_reedline_prompt_edit_modes, + get_reedline_reedline_events, +}; diff --git a/reedline/src/menu/columnar_menu.rs b/reedline/src/menu/columnar_menu.rs new file mode 100644 index 00000000..bb9f3f34 --- /dev/null +++ b/reedline/src/menu/columnar_menu.rs @@ -0,0 +1,835 @@ +use nu_ansi_term::{ansi::RESET, Style}; + +use super::{menu_functions::find_common_string, Menu, MenuEvent, MenuTextStyle}; +use crate::{ + core_editor::Editor, menu_functions::string_difference, painting::Painter, Completer, + Suggestion, UndoBehavior, +}; + +/// Default values used as reference for the menu. These values are set during +/// the initial declaration of the menu and are always kept as reference for the +/// changeable [`ColumnDetails`] +struct DefaultColumnDetails { + /// Number of columns that the menu will have + pub columns: u16, + /// Column width + pub col_width: Option, + /// Column padding + pub col_padding: usize, +} + +impl Default for DefaultColumnDetails { + fn default() -> Self { + Self { + columns: 4, + col_width: None, + col_padding: 2, + } + } +} + +/// Represents the actual column conditions of the menu. These conditions change +/// since they need to accommodate possible different line sizes for the column values +#[derive(Default)] +struct ColumnDetails { + /// Number of columns that the menu will have + pub columns: u16, + /// Column width + pub col_width: usize, +} + +/// Menu to present suggestions in a columnar fashion +/// It presents a description of the suggestion if available +pub struct ColumnarMenu { + /// Menu name + name: String, + /// Columnar menu active status + active: bool, + /// Menu coloring + color: MenuTextStyle, + /// Default column details that are set when creating the menu + /// These values are the reference for the working details + default_details: DefaultColumnDetails, + /// Number of minimum rows that are displayed when + /// the required lines is larger than the available lines + min_rows: u16, + /// Working column details keep changing based on the collected values + working_details: ColumnDetails, + /// Menu cached values + values: Vec, + /// column position of the cursor. Starts from 0 + col_pos: u16, + /// row position in the menu. Starts from 0 + row_pos: u16, + /// Menu marker when active + marker: String, + /// Event sent to the menu + event: Option, + /// Longest suggestion found in the values + longest_suggestion: usize, + /// String collected after the menu is activated + input: Option, + /// Calls the completer using only the line buffer difference difference + /// after the menu was activated + only_buffer_difference: bool, +} + +impl Default for ColumnarMenu { + fn default() -> Self { + Self { + name: "columnar_menu".to_string(), + active: false, + color: MenuTextStyle::default(), + default_details: DefaultColumnDetails::default(), + min_rows: 3, + working_details: ColumnDetails::default(), + values: Vec::new(), + col_pos: 0, + row_pos: 0, + marker: "| ".to_string(), + event: None, + longest_suggestion: 0, + input: None, + only_buffer_difference: false, + } + } +} + +// Menu configuration functions +impl ColumnarMenu { + /// Menu builder with new name + #[must_use] + pub fn with_name(mut self, name: &str) -> Self { + self.name = name.into(); + self + } + + /// Menu builder with new value for text style + #[must_use] + pub fn with_text_style(mut self, text_style: Style) -> Self { + self.color.text_style = text_style; + self + } + + /// Menu builder with new value for text style + #[must_use] + pub fn with_selected_text_style(mut self, selected_text_style: Style) -> Self { + self.color.selected_text_style = selected_text_style; + self + } + + /// Menu builder with new value for text style + #[must_use] + pub fn with_description_text_style(mut self, description_text_style: Style) -> Self { + self.color.description_style = description_text_style; + self + } + + /// Menu builder with new columns value + #[must_use] + pub fn with_columns(mut self, columns: u16) -> Self { + self.default_details.columns = columns; + self + } + + /// Menu builder with new column width value + #[must_use] + pub fn with_column_width(mut self, col_width: Option) -> Self { + self.default_details.col_width = col_width; + self + } + + /// Menu builder with new column width value + #[must_use] + pub fn with_column_padding(mut self, col_padding: usize) -> Self { + self.default_details.col_padding = col_padding; + self + } + + /// Menu builder with marker + #[must_use] + pub fn with_marker(mut self, marker: String) -> Self { + self.marker = marker; + self + } + + /// Menu builder with new only buffer difference + #[must_use] + pub fn with_only_buffer_difference(mut self, only_buffer_difference: bool) -> Self { + self.only_buffer_difference = only_buffer_difference; + self + } +} + +// Menu functionality +impl ColumnarMenu { + /// Move menu cursor to the next element + fn move_next(&mut self) { + let mut new_col = self.col_pos + 1; + let mut new_row = self.row_pos; + + if new_col >= self.get_cols() { + new_row += 1; + new_col = 0; + } + + if new_row >= self.get_rows() { + new_row = 0; + new_col = 0; + } + + let position = new_row * self.get_cols() + new_col; + if position >= self.get_values().len() as u16 { + self.reset_position(); + } else { + self.col_pos = new_col; + self.row_pos = new_row; + } + } + + /// Move menu cursor to the previous element + fn move_previous(&mut self) { + let new_col = self.col_pos.checked_sub(1); + + let (new_col, new_row) = match new_col { + Some(col) => (col, self.row_pos), + None => match self.row_pos.checked_sub(1) { + Some(row) => (self.get_cols().saturating_sub(1), row), + None => ( + self.get_cols().saturating_sub(1), + self.get_rows().saturating_sub(1), + ), + }, + }; + + let position = new_row * self.get_cols() + new_col; + if position >= self.get_values().len() as u16 { + self.col_pos = (self.get_values().len() as u16 % self.get_cols()).saturating_sub(1); + self.row_pos = self.get_rows().saturating_sub(1); + } else { + self.col_pos = new_col; + self.row_pos = new_row; + } + } + + /// Move menu cursor up + fn move_up(&mut self) { + self.row_pos = if let Some(new_row) = self.row_pos.checked_sub(1) { + new_row + } else { + let new_row = self.get_rows().saturating_sub(1); + let index = new_row * self.get_cols() + self.col_pos; + if index >= self.values.len() as u16 { + new_row.saturating_sub(1) + } else { + new_row + } + } + } + + /// Move menu cursor left + fn move_down(&mut self) { + let new_row = self.row_pos + 1; + self.row_pos = if new_row >= self.get_rows() { + 0 + } else { + let index = new_row * self.get_cols() + self.col_pos; + if index >= self.values.len() as u16 { + 0 + } else { + new_row + } + } + } + + /// Move menu cursor left + fn move_left(&mut self) { + self.col_pos = if let Some(row) = self.col_pos.checked_sub(1) { + row + } else if self.index() + 1 == self.values.len() { + 0 + } else { + self.get_cols().saturating_sub(1) + } + } + + /// Move menu cursor element + fn move_right(&mut self) { + let new_col = self.col_pos + 1; + self.col_pos = if new_col >= self.get_cols() || self.index() + 2 > self.values.len() { + 0 + } else { + new_col + } + } + + /// Menu index based on column and row position + fn index(&self) -> usize { + let index = self.row_pos * self.get_cols() + self.col_pos; + index as usize + } + + /// Get selected value from the menu + fn get_value(&self) -> Option { + self.get_values().get(self.index()).cloned() + } + + /// Calculates how many rows the Menu will use + fn get_rows(&self) -> u16 { + let values = self.get_values().len() as u16; + + if values == 0 { + // When the values are empty the no_records_msg is shown, taking 1 line + return 1; + } + + let rows = values / self.get_cols(); + if values % self.get_cols() != 0 { + rows + 1 + } else { + rows + } + } + + /// Returns working details col width + fn get_width(&self) -> usize { + self.working_details.col_width + } + + /// Reset menu position + fn reset_position(&mut self) { + self.col_pos = 0; + self.row_pos = 0; + } + + fn no_records_msg(&self, use_ansi_coloring: bool) -> String { + let msg = "NO RECORDS FOUND"; + if use_ansi_coloring { + format!( + "{}{}{}", + self.color.selected_text_style.prefix(), + msg, + RESET + ) + } else { + msg.to_string() + } + } + + /// Returns working details columns + fn get_cols(&self) -> u16 { + self.working_details.columns.max(1) + } + + /// End of line for menu + fn end_of_line(&self, column: u16) -> &str { + if column == self.get_cols().saturating_sub(1) { + "\r\n" + } else { + "" + } + } + + /// Creates default string that represents one suggestion from the menu + fn create_string( + &self, + suggestion: &Suggestion, + index: usize, + column: u16, + empty_space: usize, + use_ansi_coloring: bool, + ) -> String { + if use_ansi_coloring { + if index == self.index() { + if let Some(description) = &suggestion.description { + let left_text_size = self.longest_suggestion + self.default_details.col_padding; + let right_text_size = self.get_width().saturating_sub(left_text_size); + format!( + "{}{:max$}{}{}{}", + self.color.selected_text_style.prefix(), + &suggestion.value, + description + .chars() + .take(right_text_size) + .collect::() + .replace('\n', " "), + RESET, + self.end_of_line(column), + max = left_text_size, + ) + } else { + format!( + "{}{}{}{:>empty$}{}", + self.color.selected_text_style.prefix(), + &suggestion.value, + RESET, + "", + self.end_of_line(column), + empty = empty_space, + ) + } + } else if let Some(description) = &suggestion.description { + let left_text_size = self.longest_suggestion + self.default_details.col_padding; + let right_text_size = self.get_width().saturating_sub(left_text_size); + format!( + "{}{:max$}{}{}{}{}{}", + self.color.text_style.prefix(), + &suggestion.value, + RESET, + self.color.description_style.prefix(), + description + .chars() + .take(right_text_size) + .collect::() + .replace('\n', " "), + RESET, + self.end_of_line(column), + max = left_text_size, + ) + } else { + format!( + "{}{}{}{}{:>empty$}{}{}", + self.color.text_style.prefix(), + &suggestion.value, + RESET, + self.color.description_style.prefix(), + "", + RESET, + self.end_of_line(column), + empty = empty_space, + ) + } + } else { + // If no ansi coloring is found, then the selection word is the line in uppercase + let marker = if index == self.index() { ">" } else { "" }; + + let line = if let Some(description) = &suggestion.description { + format!( + "{}{:max$}{}{}", + marker, + &suggestion.value, + description + .chars() + .take(empty_space) + .collect::() + .replace('\n', " "), + self.end_of_line(column), + max = self.longest_suggestion + + self + .default_details + .col_padding + .saturating_sub(marker.len()), + ) + } else { + format!( + "{}{}{:>empty$}{}", + marker, + &suggestion.value, + "", + self.end_of_line(column), + empty = empty_space.saturating_sub(marker.len()), + ) + }; + + if index == self.index() { + line.to_uppercase() + } else { + line + } + } + } +} + +impl Menu for ColumnarMenu { + /// Menu name + fn name(&self) -> &str { + self.name.as_str() + } + + /// Menu indicator + fn indicator(&self) -> &str { + self.marker.as_str() + } + + /// Deactivates context menu + fn is_active(&self) -> bool { + self.active + } + + /// The columnar menu can to quick complete if there is only one element + fn can_quick_complete(&self) -> bool { + true + } + + /// The columnar menu can try to find the common string and replace it + /// in the given line buffer + fn can_partially_complete( + &mut self, + values_updated: bool, + editor: &mut Editor, + completer: &mut dyn Completer, + ) -> bool { + // If the values were already updated (e.g. quick completions are true) + // there is no need to update the values from the menu + if !values_updated { + self.update_values(editor, completer); + } + + let values = self.get_values(); + if let (Some(Suggestion { value, span, .. }), Some(index)) = find_common_string(values) { + let index = index.min(value.len()); + let matching = &value[0..index]; + + // make sure that the partial completion does not overwrite user entered input + let extends_input = matching.starts_with(&editor.get_buffer()[span.start..span.end]); + + if !matching.is_empty() && extends_input { + let mut line_buffer = editor.line_buffer().clone(); + line_buffer.replace_range(span.start..span.end, matching); + + let offset = if matching.len() < (span.end - span.start) { + line_buffer + .insertion_point() + .saturating_sub((span.end - span.start) - matching.len()) + } else { + line_buffer.insertion_point() + matching.len() - (span.end - span.start) + }; + + line_buffer.set_insertion_point(offset); + editor.set_line_buffer(line_buffer, UndoBehavior::CreateUndoPoint); + + // The values need to be updated because the spans need to be + // recalculated for accurate replacement in the string + self.update_values(editor, completer); + + true + } else { + false + } + } else { + false + } + } + + /// Selects what type of event happened with the menu + fn menu_event(&mut self, event: MenuEvent) { + match &event { + MenuEvent::Activate(_) => self.active = true, + MenuEvent::Deactivate => { + self.active = false; + self.input = None; + }, + _ => {}, + } + + self.event = Some(event); + } + + /// Updates menu values + fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) { + if self.only_buffer_difference { + if let Some(old_string) = &self.input { + let (start, input) = string_difference(editor.get_buffer(), old_string); + if !input.is_empty() { + self.values = completer.complete(input, start); + self.reset_position(); + } + } + } else { + // If there is a new line character in the line buffer, the completer + // doesn't calculate the suggested values correctly. This happens when + // editing a multiline buffer. + // Also, by replacing the new line character with a space, the insert + // position is maintain in the line buffer. + let trimmed_buffer = editor.get_buffer().replace('\n', " "); + self.values = completer.complete(trimmed_buffer.as_str(), editor.insertion_point()); + self.reset_position(); + } + } + + /// The working details for the menu changes based on the size of the lines + /// collected from the completer + fn update_working_details( + &mut self, + editor: &mut Editor, + completer: &mut dyn Completer, + painter: &Painter, + ) { + if let Some(event) = self.event.take() { + // The working value for the menu are updated first before executing any of the + // menu events + // + // If there is at least one suggestion that contains a description, then the layout + // is changed to one column to fit the description + let exist_description = self + .get_values() + .iter() + .any(|suggestion| suggestion.description.is_some()); + + if exist_description { + self.working_details.columns = 1; + self.working_details.col_width = painter.screen_width() as usize; + + self.longest_suggestion = self.get_values().iter().fold(0, |prev, suggestion| { + if prev >= suggestion.value.len() { + prev + } else { + suggestion.value.len() + } + }); + } else { + let max_width = self.get_values().iter().fold(0, |acc, suggestion| { + let str_len = suggestion.value.len() + self.default_details.col_padding; + if str_len > acc { + str_len + } else { + acc + } + }); + + // If no default width is found, then the total screen width is used to estimate + // the column width based on the default number of columns + let default_width = if let Some(col_width) = self.default_details.col_width { + col_width + } else { + let col_width = painter.screen_width() / self.default_details.columns; + col_width as usize + }; + + // Adjusting the working width of the column based the max line width found + // in the menu values + if max_width > default_width { + self.working_details.col_width = max_width; + } else { + self.working_details.col_width = default_width; + }; + + // The working columns is adjusted based on possible number of columns + // that could be fitted in the screen with the calculated column width + let possible_cols = painter.screen_width() / self.working_details.col_width as u16; + if possible_cols > self.default_details.columns { + self.working_details.columns = self.default_details.columns.max(1); + } else { + self.working_details.columns = possible_cols; + } + } + + match event { + MenuEvent::Activate(updated) => { + self.active = true; + self.reset_position(); + + self.input = if self.only_buffer_difference { + Some(editor.get_buffer().to_string()) + } else { + None + }; + + if !updated { + self.update_values(editor, completer); + } + }, + MenuEvent::Deactivate => self.active = false, + MenuEvent::Edit(updated) => { + self.reset_position(); + + if !updated { + self.update_values(editor, completer); + } + }, + MenuEvent::NextElement => self.move_next(), + MenuEvent::PreviousElement => self.move_previous(), + MenuEvent::MoveUp => self.move_up(), + MenuEvent::MoveDown => self.move_down(), + MenuEvent::MoveLeft => self.move_left(), + MenuEvent::MoveRight => self.move_right(), + MenuEvent::PreviousPage | MenuEvent::NextPage => { + // The columnar menu doest have the concept of pages, yet + }, + } + } + } + + /// The buffer gets replaced in the Span location + fn replace_in_buffer(&self, editor: &mut Editor) { + if let Some(Suggestion { + mut value, + span, + append_whitespace, + .. + }) = self.get_value() + { + let start = span.start.min(editor.line_buffer().len()); + let end = span.end.min(editor.line_buffer().len()); + if append_whitespace { + value.push(' '); + } + let mut line_buffer = editor.line_buffer().clone(); + line_buffer.replace_range(start..end, &value); + + let mut offset = line_buffer.insertion_point(); + offset = offset.saturating_add(value.len()); + offset = offset.saturating_sub(end.saturating_sub(start)); + line_buffer.set_insertion_point(offset); + editor.set_line_buffer(line_buffer, UndoBehavior::CreateUndoPoint); + } + } + + /// Minimum rows that should be displayed by the menu + fn min_rows(&self) -> u16 { + self.get_rows().min(self.min_rows) + } + + /// Gets values from filler that will be displayed in the menu + fn get_values(&self) -> &[Suggestion] { + &self.values + } + + fn menu_required_lines(&self, _terminal_columns: u16) -> u16 { + self.get_rows() + } + + fn menu_string(&self, available_lines: u16, use_ansi_coloring: bool) -> String { + if self.get_values().is_empty() { + self.no_records_msg(use_ansi_coloring) + } else { + // The skip values represent the number of lines that should be skipped + // while printing the menu + let skip_values = if self.row_pos >= available_lines { + let skip_lines = self.row_pos.saturating_sub(available_lines) + 1; + (skip_lines * self.get_cols()) as usize + } else { + 0 + }; + + // It seems that crossterm prefers to have a complete string ready to be printed + // rather than looping through the values and printing multiple things + // This reduces the flickering when printing the menu + let available_values = (available_lines * self.get_cols()) as usize; + self.get_values() + .iter() + .skip(skip_values) + .take(available_values) + .enumerate() + .map(|(index, suggestion)| { + // Correcting the enumerate index based on the number of skipped values + let index = index + skip_values; + let column = index as u16 % self.get_cols(); + let empty_space = self.get_width().saturating_sub(suggestion.value.len()); + + self.create_string(suggestion, index, column, empty_space, use_ansi_coloring) + }) + .collect() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Span; + + macro_rules! partial_completion_tests { + (name: $test_group_name:ident, completions: $completions:expr, test_cases: $($name:ident: $value:expr,)*) => { + mod $test_group_name { + use crate::{menu::Menu, ColumnarMenu, core_editor::Editor, enums::UndoBehavior}; + use super::FakeCompleter; + + $( + #[test] + fn $name() { + let (input, expected) = $value; + let mut menu = ColumnarMenu::default(); + let mut editor = Editor::default(); + editor.set_buffer(input.to_string(), UndoBehavior::CreateUndoPoint); + let mut completer = FakeCompleter::new(&$completions); + + menu.can_partially_complete(false, &mut editor, &mut completer); + + assert_eq!(editor.get_buffer(), expected); + } + )* + } + } + } + + partial_completion_tests! { + name: partial_completion_prefix_matches, + completions: ["build.rs", "build-all.sh"], + + test_cases: + empty_completes_prefix: ("", "build"), + partial_completes_shared_prefix: ("bui", "build"), + full_prefix_completes_nothing: ("build", "build"), + } + + partial_completion_tests! { + name: partial_completion_fuzzy_matches, + completions: ["build.rs", "build-all.sh", "prepare-build.sh"], + + test_cases: + no_shared_prefix_completes_nothing: ("", ""), + shared_prefix_completes_nothing: ("bui", "bui"), + } + + partial_completion_tests! { + name: partial_completion_fuzzy_same_prefix_matches, + completions: ["build.rs", "build-all.sh", "build-all-tests.sh"], + + test_cases: + // assure "all" does not get replaced with shared prefix "build" + completes_no_shared_prefix: ("all", "all"), + } + + struct FakeCompleter { + completions: Vec, + } + + impl FakeCompleter { + fn new(completions: &[&str]) -> Self { + Self { + completions: completions.iter().map(|c| c.to_string()).collect(), + } + } + } + + impl Completer for FakeCompleter { + fn complete(&mut self, _line: &str, pos: usize) -> Vec { + self.completions + .iter() + .map(|c| fake_suggestion(c, pos)) + .collect() + } + } + + fn fake_suggestion(name: &str, pos: usize) -> Suggestion { + Suggestion { + value: name.to_string(), + description: None, + extra: None, + span: Span { start: 0, end: pos }, + append_whitespace: false, + } + } + + #[test] + fn test_menu_replace_backtick() { + // https://github.com/nushell/nushell/issues/7885 + let mut completer = FakeCompleter::new(&["file1.txt", "file2.txt"]); + let mut menu = ColumnarMenu::default().with_name("testmenu"); + let mut editor = Editor::default(); + + // backtick at the end of the line + editor.set_buffer("file1.txt`".to_string(), UndoBehavior::CreateUndoPoint); + + menu.update_values(&mut editor, &mut completer); + + menu.replace_in_buffer(&mut editor); + + // After replacing the editor, make sure insertion_point is at the right spot + assert!( + editor.is_cursor_at_buffer_end(), + "cursor should be at the end after completion" + ); + } +} diff --git a/reedline/src/menu/list_menu.rs b/reedline/src/menu/list_menu.rs new file mode 100644 index 00000000..ecdb810d --- /dev/null +++ b/reedline/src/menu/list_menu.rs @@ -0,0 +1,739 @@ +use std::iter::Sum; + +use nu_ansi_term::{ansi::RESET, Style}; +use unicode_width::UnicodeWidthStr; + +use super::{ + menu_functions::{parse_selection_char, string_difference}, + Menu, MenuEvent, MenuTextStyle, +}; +use crate::{ + core_editor::Editor, + painting::{estimate_single_line_wraps, Painter}, + Completer, Suggestion, UndoBehavior, +}; + +const SELECTION_CHAR: char = '!'; + +struct Page { + size: usize, + full: bool, +} + +impl<'a> Sum<&'a Page> for Page { + fn sum(iter: I) -> Page + where + I: Iterator, + { + iter.fold( + Page { + size: 0, + full: false, + }, + |acc, menu| Page { + size: acc.size + menu.size, + full: acc.full || menu.full, + }, + ) + } +} + +/// Struct to store the menu style +/// Context menu definition +pub struct ListMenu { + /// Menu name + name: String, + /// Menu coloring + color: MenuTextStyle, + /// Number of records pulled until page is full + page_size: usize, + /// Menu marker displayed when the menu is active + marker: String, + /// Menu active status + active: bool, + /// Cached values collected when querying the completer. + /// When collecting chronological values, the menu only caches at least + /// page_size records. + /// When performing a query to the completer, the cached values will + /// be the result from such query + values: Vec, + /// row position in the menu. Starts from 0 + row_position: u16, + /// Max size of the suggestions when querying without a search buffer + query_size: Option, + /// Max number of lines that are shown with large suggestions entries + max_lines: u16, + /// Multiline marker + multiline_marker: String, + /// Registry of the number of entries per page that have been displayed + pages: Vec, + /// Page index + page: usize, + /// Event sent to the menu + event: Option, + /// String collected after the menu is activated + input: Option, + /// Calls the completer using only the line buffer difference difference + /// after the menu was activated + only_buffer_difference: bool, +} + +impl Default for ListMenu { + fn default() -> Self { + Self { + name: "search_menu".to_string(), + color: MenuTextStyle::default(), + page_size: 10, + active: false, + values: Vec::new(), + row_position: 0, + page: 0, + query_size: None, + marker: "? ".to_string(), + max_lines: 5, + multiline_marker: ":::".to_string(), + pages: Vec::new(), + event: None, + input: None, + only_buffer_difference: true, + } + } +} + +// Menu configuration functions +impl ListMenu { + /// Menu builder with new name + #[must_use] + pub fn with_name(mut self, name: &str) -> Self { + self.name = name.into(); + self + } + + /// Menu builder with new value for text style + #[must_use] + pub fn with_text_style(mut self, text_style: Style) -> Self { + self.color.text_style = text_style; + self + } + + /// Menu builder with new value for text style + #[must_use] + pub fn with_selected_text_style(mut self, selected_text_style: Style) -> Self { + self.color.selected_text_style = selected_text_style; + self + } + + /// Menu builder with new value for description style + #[must_use] + pub fn with_description_text_style(mut self, description_text_style: Style) -> Self { + self.color.description_style = description_text_style; + self + } + + /// Menu builder with new page size + #[must_use] + pub fn with_page_size(mut self, page_size: usize) -> Self { + self.page_size = page_size; + self + } + + /// Menu builder with new only buffer difference + #[must_use] + pub fn with_only_buffer_difference(mut self, only_buffer_difference: bool) -> Self { + self.only_buffer_difference = only_buffer_difference; + self + } +} + +// Menu functionality +impl ListMenu { + /// Menu builder with menu marker + #[must_use] + pub fn with_marker(mut self, marker: String) -> Self { + self.marker = marker; + self + } + + /// Menu builder with max entry lines + #[must_use] + pub fn with_max_entry_lines(mut self, max_lines: u16) -> Self { + self.max_lines = max_lines; + self + } + + fn update_row_pos(&mut self, new_pos: Option) { + if let (Some(row), Some(page)) = (new_pos, self.pages.get(self.page)) { + let values_before_page = self.pages.iter().take(self.page).sum::().size; + let row = row.saturating_sub(values_before_page); + if row < page.size { + self.row_position = row as u16; + } + } + } + + /// The number of rows an entry from the menu can take considering wrapping + fn number_of_lines(&self, entry: &str, terminal_columns: u16) -> u16 { + number_of_lines(entry, self.max_lines as usize, terminal_columns) + } + + fn total_values(&self) -> usize { + self.query_size.unwrap_or(self.values.len()) + } + + fn values_until_current_page(&self) -> usize { + self.pages.iter().take(self.page + 1).sum::().size + } + + fn set_actual_page_size(&mut self, printable_entries: usize) { + if let Some(page) = self.pages.get_mut(self.page) { + page.full = page.size > printable_entries || page.full; + page.size = printable_entries; + } + } + + /// Menu index based on column and row position + fn index(&self) -> usize { + self.row_position as usize + } + + /// Get selected value from the menu + fn get_value(&self) -> Option { + self.get_values().get(self.index()).cloned() + } + + /// Reset menu position + fn reset_position(&mut self) { + self.page = 0; + self.row_position = 0; + self.pages = Vec::new(); + } + + fn printable_entries(&self, painter: &Painter) -> usize { + // The number 2 comes from the prompt line and the banner printed at the bottom + // of the menu + let available_lines = painter.screen_height().saturating_sub(2); + let (printable_entries, _) = + self.get_values() + .iter() + .fold( + (0, Some(0)), + |(lines, total_lines), suggestion| match total_lines { + None => (lines, None), + Some(total_lines) => { + let new_total_lines = total_lines + + self.number_of_lines( + &suggestion.value, + // to account for the index and the indicator e.g. 0: XXXX + painter.screen_width().saturating_sub( + self.indicator().width() as u16 + count_digits(lines), + ), + ); + + if new_total_lines < available_lines { + (lines + 1, Some(new_total_lines)) + } else { + (lines, None) + } + }, + }, + ); + + printable_entries + } + + fn no_page_msg(&self, use_ansi_coloring: bool) -> String { + let msg = "PAGE NOT FOUND"; + if use_ansi_coloring { + format!( + "{}{}{}", + self.color.selected_text_style.prefix(), + msg, + RESET + ) + } else { + msg.to_string() + } + } + + fn banner_message(&self, page: &Page, use_ansi_coloring: bool) -> String { + let values_until = self.values_until_current_page().saturating_sub(1); + let value_before = if self.values.is_empty() || self.page == 0 { + 0 + } else { + let page_size = self.pages.get(self.page).map(|page| page.size).unwrap_or(0); + values_until.saturating_sub(page_size) + 1 + }; + + let full_page = if page.full { "[FULL]" } else { "" }; + let status_bar = format!( + "Page {}: records {} - {} total: {} {}", + self.page + 1, + value_before, + values_until, + self.total_values(), + full_page, + ); + + if use_ansi_coloring { + format!( + "{}{}{}", + self.color.selected_text_style.prefix(), + status_bar, + RESET, + ) + } else { + status_bar + } + } + + /// End of line for menu + fn end_of_line() -> &'static str { + "\r\n" + } + + /// Text style for menu + fn text_style(&self, index: usize) -> String { + if index == self.index() { + self.color.selected_text_style.prefix().to_string() + } else { + self.color.text_style.prefix().to_string() + } + } + + /// Creates default string that represents one line from a menu + fn create_string( + &self, + line: &str, + description: Option<&str>, + index: usize, + row_number: &str, + use_ansi_coloring: bool, + ) -> String { + let description = description.map_or("".to_string(), |desc| { + if use_ansi_coloring { + format!( + "{}({}) {}", + self.color.description_style.prefix(), + desc, + RESET + ) + } else { + format!("({desc}) ") + } + }); + + if use_ansi_coloring { + format!( + "{}{}{}{}{}{}", + row_number, + description, + self.text_style(index), + &line, + RESET, + Self::end_of_line(), + ) + } else { + // If no ansi coloring is found, then the selection word is + // the line in uppercase + let line_str = if index == self.index() { + format!("{}{}>{}", row_number, description, line.to_uppercase()) + } else { + format!("{row_number}{description}{line}") + }; + + // Final string with formatting + format!("{}{}", line_str, Self::end_of_line()) + } + } +} + +impl Menu for ListMenu { + fn name(&self) -> &str { + self.name.as_str() + } + + /// Menu indicator + fn indicator(&self) -> &str { + self.marker.as_str() + } + + /// Deactivates context menu + fn is_active(&self) -> bool { + self.active + } + + /// There is no use for quick complete for the menu + fn can_quick_complete(&self) -> bool { + false + } + + /// The menu should not try to auto complete to avoid comparing + /// all registered values + fn can_partially_complete( + &mut self, + _values_updated: bool, + _editor: &mut Editor, + _completer: &mut dyn Completer, + ) -> bool { + false + } + + /// Selects what type of event happened with the menu + fn menu_event(&mut self, event: MenuEvent) { + match &event { + MenuEvent::Activate(_) => self.active = true, + MenuEvent::Deactivate => { + self.active = false; + self.input = None; + }, + _ => {}, + } + + self.event = Some(event); + } + + /// Collecting the value from the completer to be shown in the menu + fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) { + let line_buffer = editor.line_buffer(); + let (start, input) = if self.only_buffer_difference { + match &self.input { + Some(old_string) => { + let (start, input) = string_difference(line_buffer.get_buffer(), old_string); + if input.is_empty() { + (line_buffer.insertion_point(), "") + } else { + (start, input) + } + }, + None => (line_buffer.insertion_point(), ""), + } + } else { + (line_buffer.insertion_point(), line_buffer.get_buffer()) + }; + + let parsed = parse_selection_char(input, SELECTION_CHAR); + self.update_row_pos(parsed.index); + + // If there are no row selector and the menu has an Edit event, this clears + // the position together with the pages vector + if matches!(self.event, Some(MenuEvent::Edit(_))) && parsed.index.is_none() { + self.reset_position(); + } + + self.values = if parsed.remainder.is_empty() { + self.query_size = Some(completer.total_completions(parsed.remainder, start)); + + let skip = self.pages.iter().take(self.page).sum::().size; + let take = self + .pages + .get(self.page) + .map(|page| page.size) + .unwrap_or(self.page_size); + + completer.partial_complete(input, start, skip, take) + } else { + self.query_size = None; + completer.complete(input, start) + } + } + + /// Gets values from cached values that will be displayed in the menu + fn get_values(&self) -> &[Suggestion] { + if self.query_size.is_some() { + // When there is a size value it means that only a chunk of the + // chronological data from the database was collected + &self.values + } else { + // If no record then it means that the values hold the result + // from the query to the database. This slice can be used to get the + // data that will be shown in the menu + if self.values.is_empty() { + return &self.values; + } + + let start = self.pages.iter().take(self.page).sum::().size; + + let end: usize = if self.page >= self.pages.len() { + self.page_size + start + } else { + self.pages.iter().take(self.page + 1).sum::().size + }; + + let end = end.min(self.total_values()); + &self.values[start..end] + } + } + + /// The buffer gets cleared with the actual value + fn replace_in_buffer(&self, editor: &mut Editor) { + if let Some(Suggestion { + mut value, + span, + append_whitespace, + .. + }) = self.get_value() + { + let buffer_len = editor.line_buffer().len(); + let start = span.start.min(buffer_len); + let end = span.end.min(buffer_len); + if append_whitespace { + value.push(' '); + } + let mut line_buffer = editor.line_buffer().clone(); + line_buffer.replace_range(start..end, &value); + + let mut offset = line_buffer.insertion_point(); + offset += value.len().saturating_sub(end.saturating_sub(start)); + line_buffer.set_insertion_point(offset); + editor.set_line_buffer(line_buffer, UndoBehavior::CreateUndoPoint); + } + } + + fn update_working_details( + &mut self, + editor: &mut Editor, + completer: &mut dyn Completer, + painter: &Painter, + ) { + if let Some(event) = self.event.clone() { + match event { + MenuEvent::Activate(_) => { + self.reset_position(); + + self.input = if self.only_buffer_difference { + Some(editor.get_buffer().to_string()) + } else { + None + }; + + self.update_values(editor, completer); + + self.pages.push(Page { + size: self.printable_entries(painter), + full: false, + }); + }, + MenuEvent::Deactivate => { + self.active = false; + self.input = None; + }, + MenuEvent::Edit(_) => { + self.update_values(editor, completer); + self.pages.push(Page { + size: self.printable_entries(painter), + full: false, + }); + }, + MenuEvent::NextElement | MenuEvent::MoveDown | MenuEvent::MoveRight => { + let new_pos = self.row_position + 1; + + if let Some(page) = self.pages.get(self.page) { + if new_pos >= page.size as u16 { + self.event = Some(MenuEvent::NextPage); + self.update_working_details(editor, completer, painter); + } else { + self.row_position = new_pos; + } + } + }, + MenuEvent::PreviousElement | MenuEvent::MoveUp | MenuEvent::MoveLeft => { + if let Some(new_pos) = self.row_position.checked_sub(1) { + self.row_position = new_pos; + } else { + let page = if let Some(page) = self.page.checked_sub(1) { + self.pages.get(page) + } else { + self.pages.get(self.pages.len().saturating_sub(1)) + }; + + if let Some(page) = page { + self.row_position = page.size.saturating_sub(1) as u16; + } + + self.event = Some(MenuEvent::PreviousPage); + self.update_working_details(editor, completer, painter); + } + }, + MenuEvent::NextPage => { + if self.values_until_current_page() <= self.total_values().saturating_sub(1) { + if let Some(page) = self.pages.get_mut(self.page) { + if page.full { + self.row_position = 0; + self.page += 1; + if self.page >= self.pages.len() { + self.pages.push(Page { + size: self.page_size, + full: false, + }); + } + } else { + page.size += self.page_size; + } + } + + self.update_values(editor, completer); + self.set_actual_page_size(self.printable_entries(painter)); + } else { + self.row_position = 0; + self.page = 0; + self.update_values(editor, completer); + } + }, + MenuEvent::PreviousPage => { + match self.page.checked_sub(1) { + Some(page_num) => self.page = page_num, + None => self.page = self.pages.len().saturating_sub(1), + } + self.update_values(editor, completer); + }, + } + + self.event = None; + } + } + + /// Calculates the real required lines for the menu considering how many lines + /// wrap the terminal and if an entry is larger than the remaining lines + fn menu_required_lines(&self, terminal_columns: u16) -> u16 { + let mut entry_index = 0; + self.get_values().iter().fold(0, |total_lines, suggestion| { + // to account for the the index and the indicator e.g. 0: XXXX + let ret = total_lines + + self.number_of_lines( + &suggestion.value, + terminal_columns.saturating_sub( + self.indicator().width() as u16 + count_digits(entry_index), + ), + ); + entry_index += 1; + ret + }) + 1 + } + + /// Creates the menu representation as a string which will be painted by the painter + fn menu_string(&self, _available_lines: u16, use_ansi_coloring: bool) -> String { + let values_before_page = self.pages.iter().take(self.page).sum::().size; + match self.pages.get(self.page) { + Some(page) => { + let lines_string = self + .get_values() + .iter() + .take(page.size) + .enumerate() + .map(|(index, suggestion)| { + // Final string with colors + let line = &suggestion.value; + let line = if line.lines().count() > self.max_lines as usize { + let lines = line + .lines() + .take(self.max_lines as usize) + .map(|string| format!("{}\r\n{}", string, self.multiline_marker)) + .collect::(); + + lines + "..." + } else { + line.replace('\n', &format!("\r\n{}", self.multiline_marker)) + }; + + let row_number = format!("{}: ", index + values_before_page); + + self.create_string( + &line, + suggestion.description.as_deref(), + index, + &row_number, + use_ansi_coloring, + ) + }) + .collect::(); + + format!( + "{}{}", + lines_string, + self.banner_message(page, use_ansi_coloring) + ) + }, + None => self.no_page_msg(use_ansi_coloring), + } + } + + /// Minimum rows that should be displayed by the menu + fn min_rows(&self) -> u16 { + self.max_lines + 1 + } +} + +fn number_of_lines(entry: &str, max_lines: usize, terminal_columns: u16) -> u16 { + let lines = if entry.contains('\n') { + let total_lines = entry.lines().count(); + let printable_lines = if total_lines > max_lines { + // The extra one is there because when printing a large entry and extra line + // is added with ... + max_lines + 1 + } else { + total_lines + }; + + let wrap_lines = entry.lines().take(max_lines).fold(0, |acc, line| { + acc + estimate_single_line_wraps(line, terminal_columns) + }); + + (printable_lines + wrap_lines) as u16 + } else { + 1 + estimate_single_line_wraps(entry, terminal_columns) as u16 + }; + + lines +} + +fn count_digits(mut n: usize) -> u16 { + // count the digits in the number + if n == 0 { + return 1; + } + let mut count = 0; + while n > 0 { + n /= 10; + count += 1; + } + count +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn number_of_lines_test() { + let input = "let a: another:\nsomething\nanother"; + let res = number_of_lines(input, 5, 30); + + // There is an extra line showing ... + assert_eq!(res, 3); + } + + #[test] + fn number_one_line_test() { + let input = "let a: another"; + let res = number_of_lines(input, 5, 30); + + assert_eq!(res, 1); + } + + #[test] + fn lines_with_wrap_test() { + let input = "let a= an1other ver2y large l3ine what 4should wr5ap"; + let res = number_of_lines(input, 5, 10); + + assert_eq!(res, 6); + } + + #[test] + fn number_of_max_lines_test() { + let input = "let a\n: ano\nther:\nsomething\nanother\nmore\nanother\nasdf\nasdfa\n3123"; + let res = number_of_lines(input, 3, 30); + + // There is an extra line showing ... + assert_eq!(res, 4); + } +} diff --git a/reedline/src/menu/menu_functions.rs b/reedline/src/menu/menu_functions.rs new file mode 100644 index 00000000..2accd91e --- /dev/null +++ b/reedline/src/menu/menu_functions.rs @@ -0,0 +1,487 @@ +//! Collection of common functions that can be used to create menus +use crate::Suggestion; + +/// Index result obtained from parsing a string with an index marker +/// For example, the next string: +/// "this is an example :10" +/// +/// Contains an index marker :10. This marker indicates that the user +/// may want to select the 10th element from a list +#[derive(Debug, PartialEq, Eq)] +pub struct ParseResult<'buffer> { + /// Text before the marker + pub remainder: &'buffer str, + /// Parsed value from the marker + pub index: Option, + /// Marker representation as string + pub marker: Option<&'buffer str>, + /// Direction of the search based on the marker + pub action: ParseAction, +} + +/// Direction of the index found in the string +#[derive(Debug, PartialEq, Eq)] +pub enum ParseAction { + /// Forward index search + ForwardSearch, + /// Backward index search + BackwardSearch, + /// Last token + LastToken, + /// Last executed command. + LastCommand, +} + +/// Splits a string that contains a marker character +/// +/// ## Example usage +/// ``` +/// use reedline::menu_functions::{parse_selection_char, ParseAction, ParseResult}; +/// +/// let parsed = parse_selection_char("this is an example!10", '!'); +/// +/// assert_eq!( +/// parsed, +/// ParseResult { +/// remainder: "this is an example", +/// index: Some(10), +/// marker: Some("!10"), +/// action: ParseAction::ForwardSearch +/// } +/// ) +/// +/// ``` +pub fn parse_selection_char(buffer: &str, marker: char) -> ParseResult { + if buffer.is_empty() { + return ParseResult { + remainder: buffer, + index: None, + marker: None, + action: ParseAction::ForwardSearch, + }; + } + + let mut input = buffer.chars().peekable(); + + let mut index = 0; + let mut action = ParseAction::ForwardSearch; + while let Some(char) = input.next() { + if char == marker { + match input.peek() { + #[cfg(feature = "bashisms")] + Some(&x) if x == marker => { + return ParseResult { + remainder: &buffer[0..index], + index: Some(0), + marker: Some(&buffer[index..index + 2]), + action: ParseAction::LastCommand, + } + }, + #[cfg(feature = "bashisms")] + Some(&x) if x == '$' => { + return ParseResult { + remainder: &buffer[0..index], + index: Some(0), + marker: Some(&buffer[index..index + 2]), + action: ParseAction::LastToken, + } + }, + Some(&x) if x.is_ascii_digit() || x == '-' => { + let mut count: usize = 0; + let mut size: usize = 1; + while let Some(&c) = input.peek() { + if c == '-' { + let _ = input.next(); + size += 1; + action = ParseAction::BackwardSearch; + } else if c.is_ascii_digit() { + let c = c.to_digit(10).expect("already checked if is a digit"); + let _ = input.next(); + count *= 10; + count += c as usize; + size += 1; + } else { + return ParseResult { + remainder: &buffer[0..index], + index: Some(count), + marker: Some(&buffer[index..index + size]), + action, + }; + } + } + return ParseResult { + remainder: &buffer[0..index], + index: Some(count), + marker: Some(&buffer[index..index + size]), + action, + }; + }, + None => { + return ParseResult { + remainder: &buffer[0..index], + index: Some(0), + marker: Some(&buffer[index..buffer.len()]), + action, + } + }, + _ => { + index += 1; + continue; + }, + } + } + index += 1; + } + + ParseResult { + remainder: buffer, + index: None, + marker: None, + action, + } +} + +/// Finds index for the common string in a list of suggestions +pub fn find_common_string(values: &[Suggestion]) -> (Option<&Suggestion>, Option) { + let first = values.iter().next(); + + let index = first.and_then(|first| { + values.iter().skip(1).fold(None, |index, suggestion| { + if suggestion.value.starts_with(&first.value) { + Some(first.value.len()) + } else { + first + .value + .char_indices() + .zip(suggestion.value.char_indices()) + .find(|((_, mut lhs), (_, mut rhs))| { + lhs.make_ascii_lowercase(); + rhs.make_ascii_lowercase(); + + lhs != rhs + }) + .map(|((new_index, _), _)| match index { + Some(index) => { + if index <= new_index { + index + } else { + new_index + } + }, + None => new_index, + }) + } + }) + }); + + (first, index) +} + +/// Finds different string between two strings +/// +/// ## Example usage +/// ``` +/// use reedline::menu_functions::string_difference; +/// +/// let new_string = "this is a new string"; +/// let old_string = "this is a string"; +/// +/// let res = string_difference(new_string, old_string); +/// assert_eq!(res, (10, "new ")); +/// ``` +pub fn string_difference<'a>(new_string: &'a str, old_string: &str) -> (usize, &'a str) { + if old_string.is_empty() { + return (0, new_string); + } + + let old_chars = old_string.char_indices().collect::>(); + let new_chars = new_string.char_indices().collect::>(); + + let (_, start, end) = new_chars.iter().enumerate().fold( + (0, None, None), + |(old_char_index, start, end), (new_char_index, (_, c))| { + let equal = if start.is_some() { + if (old_chars.len() - old_char_index) == (new_chars.len() - new_char_index) { + let new_iter = new_chars.iter().skip(new_char_index); + let old_iter = old_chars.iter().skip(old_char_index); + + new_iter + .zip(old_iter) + .all(|((_, new), (_, old))| new == old) + } else { + false + } + } else { + *c == old_chars[old_char_index].1 + }; + + if equal { + let old_char_index = (old_char_index + 1).min(old_chars.len() - 1); + + let end = match (start, end) { + (Some(_), Some(_)) => end, + (Some(_), None) => Some(new_char_index), + _ => None, + }; + + (old_char_index, start, end) + } else { + let start = match start { + Some(_) => start, + None => Some(new_char_index), + }; + + (old_char_index, start, end) + } + }, + ); + + // Convert char index to byte index + let start = start.map(|i| new_chars[i].0); + let end = end.map(|i| new_chars[i].0); + + match (start, end) { + (Some(start), Some(end)) => (start, &new_string[start..end]), + (Some(start), None) => (start, &new_string[start..]), + (None, None) => (new_string.len(), ""), + (None, Some(_)) => unreachable!(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_row_test() { + let input = "search:6"; + let res = parse_selection_char(input, ':'); + + assert_eq!(res.remainder, "search"); + assert_eq!(res.index, Some(6)); + assert_eq!(res.marker, Some(":6")); + } + + #[cfg(feature = "bashisms")] + #[test] + fn parse_double_char() { + let input = "search!!"; + let res = parse_selection_char(input, '!'); + + assert_eq!(res.remainder, "search"); + assert_eq!(res.index, Some(0)); + assert_eq!(res.marker, Some("!!")); + assert!(matches!(res.action, ParseAction::LastCommand)); + } + + #[cfg(feature = "bashisms")] + #[test] + fn parse_last_token() { + let input = "!$"; + let res = parse_selection_char(input, '!'); + + assert_eq!(res.remainder, ""); + assert_eq!(res.index, Some(0)); + assert_eq!(res.marker, Some("!$")); + assert!(matches!(res.action, ParseAction::LastToken)); + } + + #[test] + fn parse_row_other_marker_test() { + let input = "search?9"; + let res = parse_selection_char(input, '?'); + + assert_eq!(res.remainder, "search"); + assert_eq!(res.index, Some(9)); + assert_eq!(res.marker, Some("?9")); + } + + #[test] + fn parse_row_double_test() { + let input = "ls | where:16"; + let res = parse_selection_char(input, ':'); + + assert_eq!(res.remainder, "ls | where"); + assert_eq!(res.index, Some(16)); + assert_eq!(res.marker, Some(":16")); + } + + #[test] + fn parse_row_empty_test() { + let input = ":10"; + let res = parse_selection_char(input, ':'); + + assert_eq!(res.remainder, ""); + assert_eq!(res.index, Some(10)); + assert_eq!(res.marker, Some(":10")); + } + + #[test] + fn parse_row_fake_indicator_test() { + let input = "let a: another :10"; + let res = parse_selection_char(input, ':'); + + assert_eq!(res.remainder, "let a: another "); + assert_eq!(res.index, Some(10)); + assert_eq!(res.marker, Some(":10")); + } + + #[test] + fn parse_row_no_number_test() { + let input = "let a: another:"; + let res = parse_selection_char(input, ':'); + + assert_eq!(res.remainder, "let a: another"); + assert_eq!(res.index, Some(0)); + assert_eq!(res.marker, Some(":")); + } + + #[test] + fn parse_empty_buffer_test() { + let input = ""; + let res = parse_selection_char(input, ':'); + + assert_eq!(res.remainder, ""); + assert_eq!(res.index, None); + assert_eq!(res.marker, None); + } + + #[test] + fn parse_negative_direction() { + let input = "!-2"; + let res = parse_selection_char(input, '!'); + + assert_eq!(res.remainder, ""); + assert_eq!(res.index, Some(2)); + assert_eq!(res.marker, Some("!-2")); + assert!(matches!(res.action, ParseAction::BackwardSearch)); + } + + #[test] + fn string_difference_test() { + let new_string = "this is a new string"; + let old_string = "this is a string"; + + let res = string_difference(new_string, old_string); + assert_eq!(res, (10, "new ")); + } + + #[test] + fn string_difference_new_larger() { + let new_string = "this is a new string"; + let old_string = "this is"; + + let res = string_difference(new_string, old_string); + assert_eq!(res, (7, " a new string")); + } + + #[test] + fn string_difference_new_shorter() { + let new_string = "this is the"; + let old_string = "this is the original"; + + let res = string_difference(new_string, old_string); + assert_eq!(res, (11, "")); + } + + #[test] + fn string_difference_inserting() { + let new_string = "let a = (insert) | "; + let old_string = "let a = () | "; + + let res = string_difference(new_string, old_string); + assert_eq!(res, (9, "insert")); + } + + #[test] + fn string_difference_longer_string() { + let new_string = "this is a new another"; + let old_string = "this is a string"; + + let res = string_difference(new_string, old_string); + assert_eq!(res, (10, "new another")); + } + + #[test] + fn string_difference_start_same() { + let new_string = "this is a new something string"; + let old_string = "this is a string"; + + let res = string_difference(new_string, old_string); + assert_eq!(res, (10, "new something ")); + } + + #[test] + fn string_difference_empty_old() { + let new_string = "this new another"; + let old_string = ""; + + let res = string_difference(new_string, old_string); + assert_eq!(res, (0, "this new another")); + } + + #[test] + fn string_difference_very_difference() { + let new_string = "this new another"; + let old_string = "complete different string"; + + let res = string_difference(new_string, old_string); + assert_eq!(res, (0, "this new another")); + } + + #[test] + fn string_difference_both_equal() { + let new_string = "this new another"; + let old_string = "this new another"; + + let res = string_difference(new_string, old_string); + assert_eq!(res, (16, "")); + } + + #[test] + fn string_difference_with_non_ansi() { + let new_string = "ļ½Žļ½•ļ½“ļ½ˆļ½…ļ½Œļ½Œ"; + let old_string = "ļ½Žļ½•ļ½Œļ½Œ"; + + let res = string_difference(new_string, old_string); + assert_eq!(res, (6, "ļ½“ļ½ˆļ½…")); + } + + #[test] + fn find_common_string_with_ansi() { + use crate::Span; + + let input: Vec<_> = ["nushell", "null"] + .into_iter() + .map(|s| Suggestion { + value: s.into(), + description: None, + extra: None, + span: Span::new(0, s.len()), + append_whitespace: false, + }) + .collect(); + let res = find_common_string(&input); + + assert!(matches!(res, (Some(elem), Some(2)) if elem == &input[0])); + } + + #[test] + fn find_common_string_with_non_ansi() { + use crate::Span; + + let input: Vec<_> = ["ļ½Žļ½•ļ½“ļ½ˆļ½…ļ½Œļ½Œ", "ļ½Žļ½•ļ½Œļ½Œ"] + .into_iter() + .map(|s| Suggestion { + value: s.into(), + description: None, + extra: None, + span: Span::new(0, s.len()), + append_whitespace: false, + }) + .collect(); + let res = find_common_string(&input); + + assert!(matches!(res, (Some(elem), Some(6)) if elem == &input[0])); + } +} diff --git a/reedline/src/menu/mod.rs b/reedline/src/menu/mod.rs new file mode 100644 index 00000000..23e64860 --- /dev/null +++ b/reedline/src/menu/mod.rs @@ -0,0 +1,317 @@ +mod columnar_menu; +mod list_menu; +pub mod menu_functions; + +pub use columnar_menu::ColumnarMenu; +pub use list_menu::ListMenu; +use nu_ansi_term::{Color, Style}; + +use crate::{ + completion::history::HistoryCompleter, core_editor::Editor, painting::Painter, Completer, + History, Suggestion, +}; + +/// Struct to store the menu style +pub struct MenuTextStyle { + /// Text style for selected text in a menu + pub selected_text_style: Style, + /// Text style for not selected text in the menu + pub text_style: Style, + /// Text style for the item description + pub description_style: Style, +} + +impl Default for MenuTextStyle { + fn default() -> Self { + Self { + selected_text_style: Color::Green.bold().reverse(), + text_style: Color::DarkGray.normal(), + description_style: Color::Yellow.normal(), + } + } +} + +/// Defines all possible events that could happen with a menu. +#[derive(Clone)] +pub enum MenuEvent { + /// Activation event for the menu. When the bool is true it means that the values + /// have already being updated. This is true when the option `quick_completions` is true + Activate(bool), + /// Deactivation event + Deactivate, + /// Line buffer edit event. When the bool is true it means that the values + /// have already being updated. This is true when the option `quick_completions` is true + Edit(bool), + /// Selecting next element in the menu + NextElement, + /// Selecting previous element in the menu + PreviousElement, + /// Moving up in the menu + MoveUp, + /// Moving down in the menu + MoveDown, + /// Moving left in the menu + MoveLeft, + /// Moving right in the menu + MoveRight, + /// Move to next page + NextPage, + /// Move to previous page + PreviousPage, +} + +/// Trait that defines how a menu will be printed by the painter +pub trait Menu: Send { + /// Menu name + fn name(&self) -> &str; + + /// Menu indicator + fn indicator(&self) -> &str; + + /// Checks if the menu is active + fn is_active(&self) -> bool; + + /// Selects what type of event happened with the menu + fn menu_event(&mut self, event: MenuEvent); + + /// A menu may not be allowed to quick complete because it needs to stay + /// active even with one element + fn can_quick_complete(&self) -> bool; + + /// The completion menu can try to find the common string and replace it + /// in the given line buffer + fn can_partially_complete( + &mut self, + values_updated: bool, + editor: &mut Editor, + completer: &mut dyn Completer, + ) -> bool; + + /// Updates the values presented in the menu + /// This function needs to be defined in the trait because when the menu is + /// activated or the `quick_completion` option is true, the len of the values + /// is calculated to know if there is only one value so it can be selected + /// immediately + fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer); + + /// The working details of a menu are values that could change based on + /// the menu conditions before it being printed, such as the number or size + /// of columns, etc. + /// In this function should be defined how the menu event is treated since + /// it is called just before painting the menu + fn update_working_details( + &mut self, + editor: &mut Editor, + completer: &mut dyn Completer, + painter: &Painter, + ); + + /// Indicates how to replace in the line buffer the selected value from the menu + fn replace_in_buffer(&self, editor: &mut Editor); + + /// Calculates the real required lines for the menu considering how many lines + /// wrap the terminal or if entries have multiple lines + fn menu_required_lines(&self, terminal_columns: u16) -> u16; + + /// Creates the menu representation as a string which will be painted by the painter + fn menu_string(&self, available_lines: u16, use_ansi_coloring: bool) -> String; + + /// Minimum rows that should be displayed by the menu + fn min_rows(&self) -> u16; + + /// Gets cached values from menu that will be displayed + fn get_values(&self) -> &[Suggestion]; +} + +/// Allowed menus in Reedline +pub enum ReedlineMenu { + /// Menu that uses Reedline's completer to update its values + EngineCompleter(Box), + /// Menu that uses the history as its completer + HistoryMenu(Box), + /// Menu that has its own Completer + WithCompleter { + /// Base menu + menu: Box, + /// External completer defined outside Reedline + completer: Box, + }, +} + +impl ReedlineMenu { + fn as_ref(&self) -> &dyn Menu { + match self { + Self::EngineCompleter(menu) + | Self::HistoryMenu(menu) + | Self::WithCompleter { menu, .. } => menu.as_ref(), + } + } + + fn as_mut(&mut self) -> &mut dyn Menu { + match self { + Self::EngineCompleter(menu) + | Self::HistoryMenu(menu) + | Self::WithCompleter { menu, .. } => menu.as_mut(), + } + } + + pub(crate) fn can_partially_complete( + &mut self, + values_updated: bool, + editor: &mut Editor, + completer: &mut dyn Completer, + history: &dyn History, + ) -> bool { + match self { + Self::EngineCompleter(menu) => { + menu.can_partially_complete(values_updated, editor, completer) + }, + Self::HistoryMenu(menu) => { + let mut history_completer = HistoryCompleter::new(history); + menu.can_partially_complete(values_updated, editor, &mut history_completer) + }, + Self::WithCompleter { + menu, + completer: own_completer, + } => menu.can_partially_complete(values_updated, editor, own_completer.as_mut()), + } + } + + pub(crate) fn update_values( + &mut self, + editor: &mut Editor, + completer: &mut dyn Completer, + history: &dyn History, + ) { + match self { + Self::EngineCompleter(menu) => menu.update_values(editor, completer), + Self::HistoryMenu(menu) => { + let mut history_completer = HistoryCompleter::new(history); + menu.update_values(editor, &mut history_completer); + }, + Self::WithCompleter { + menu, + completer: own_completer, + } => { + menu.update_values(editor, own_completer.as_mut()); + }, + } + } + + pub(crate) fn update_working_details( + &mut self, + editor: &mut Editor, + completer: &mut dyn Completer, + history: &dyn History, + painter: &Painter, + ) { + match self { + Self::EngineCompleter(menu) => { + menu.update_working_details(editor, completer, painter); + }, + Self::HistoryMenu(menu) => { + let mut history_completer = HistoryCompleter::new(history); + menu.update_working_details(editor, &mut history_completer, painter); + }, + Self::WithCompleter { + menu, + completer: own_completer, + } => { + menu.update_working_details(editor, own_completer.as_mut(), painter); + }, + } + } +} + +impl Menu for ReedlineMenu { + fn name(&self) -> &str { + self.as_ref().name() + } + + fn indicator(&self) -> &str { + self.as_ref().indicator() + } + + fn is_active(&self) -> bool { + self.as_ref().is_active() + } + + fn menu_event(&mut self, event: MenuEvent) { + self.as_mut().menu_event(event); + } + + fn can_quick_complete(&self) -> bool { + self.as_ref().can_quick_complete() + } + + fn can_partially_complete( + &mut self, + values_updated: bool, + editor: &mut Editor, + completer: &mut dyn Completer, + ) -> bool { + match self { + Self::EngineCompleter(menu) | Self::HistoryMenu(menu) => { + menu.can_partially_complete(values_updated, editor, completer) + }, + Self::WithCompleter { + menu, + completer: own_completer, + } => menu.can_partially_complete(values_updated, editor, own_completer.as_mut()), + } + } + + fn update_values(&mut self, editor: &mut Editor, completer: &mut dyn Completer) { + match self { + Self::EngineCompleter(menu) | Self::HistoryMenu(menu) => { + menu.update_values(editor, completer); + }, + Self::WithCompleter { + menu, + completer: own_completer, + } => { + menu.update_values(editor, own_completer.as_mut()); + }, + } + } + + fn update_working_details( + &mut self, + editor: &mut Editor, + completer: &mut dyn Completer, + painter: &Painter, + ) { + match self { + Self::EngineCompleter(menu) | Self::HistoryMenu(menu) => { + menu.update_working_details(editor, completer, painter); + }, + Self::WithCompleter { + menu, + completer: own_completer, + } => { + menu.update_working_details(editor, own_completer.as_mut(), painter); + }, + } + } + + fn replace_in_buffer(&self, editor: &mut Editor) { + self.as_ref().replace_in_buffer(editor); + } + + fn menu_required_lines(&self, terminal_columns: u16) -> u16 { + self.as_ref().menu_required_lines(terminal_columns) + } + + fn menu_string(&self, available_lines: u16, use_ansi_coloring: bool) -> String { + self.as_ref() + .menu_string(available_lines, use_ansi_coloring) + } + + fn min_rows(&self) -> u16 { + self.as_ref().min_rows() + } + + fn get_values(&self) -> &[Suggestion] { + self.as_ref().get_values() + } +} diff --git a/reedline/src/painting/mod.rs b/reedline/src/painting/mod.rs new file mode 100644 index 00000000..87c5472a --- /dev/null +++ b/reedline/src/painting/mod.rs @@ -0,0 +1,9 @@ +mod painter; +mod prompt_lines; +mod styled_text; +mod utils; + +pub use painter::Painter; +pub(crate) use prompt_lines::PromptLines; +pub use styled_text::StyledText; +pub(crate) use utils::estimate_single_line_wraps; diff --git a/reedline/src/painting/painter.rs b/reedline/src/painting/painter.rs new file mode 100644 index 00000000..09d0012d --- /dev/null +++ b/reedline/src/painting/painter.rs @@ -0,0 +1,589 @@ +use std::io::Write; + +use crossterm::{ + cursor::{self, MoveTo, RestorePosition, SavePosition}, + style::{Attribute, Print, ResetColor, SetAttribute, SetForegroundColor}, + terminal::{self, Clear, ClearType, ScrollUp}, + QueueableCommand, Result, +}; +#[cfg(feature = "external_printer")] +use {crate::LineBuffer, crossterm::cursor::MoveUp}; + +use super::utils::{coerce_crlf, line_width}; +use crate::{ + menu::{Menu, ReedlineMenu}, + painting::PromptLines, + CursorConfig, Prompt, PromptEditMode, PromptViMode, +}; + +// Returns a string that skips N number of lines with the next offset of lines +// An offset of 0 would return only one line after skipping the required lines +fn skip_buffer_lines(string: &str, skip: usize, offset: Option) -> &str { + let mut matches = string.match_indices('\n'); + let index = if skip == 0 { + 0 + } else { + matches + .clone() + .nth(skip - 1) + .map(|(index, _)| index + 1) + .unwrap_or(string.len()) + }; + + let limit = match offset { + Some(offset) => { + let offset = skip + offset; + matches + .nth(offset) + .map(|(index, _)| index) + .unwrap_or(string.len()) + }, + None => string.len(), + }; + + string[index..limit].trim_end_matches('\n') +} + +/// the type used by crossterm operations +pub type W = std::io::BufWriter; + +/// Implementation of the output to the terminal +pub struct Painter { + // Stdout + stdout: W, + prompt_start_row: u16, + terminal_size: (u16, u16), + last_required_lines: u16, + large_buffer: bool, +} + +impl Painter { + pub(crate) fn new(stdout: W) -> Self { + Painter { + stdout, + prompt_start_row: 0, + terminal_size: (0, 0), + last_required_lines: 0, + large_buffer: false, + } + } + + /// Height of the current terminal window + pub fn screen_height(&self) -> u16 { + self.terminal_size.1 + } + + /// Width of the current terminal window + pub fn screen_width(&self) -> u16 { + self.terminal_size.0 + } + + /// Returns the available lines from the prompt down + pub fn remaining_lines(&self) -> u16 { + self.screen_height() - self.prompt_start_row + } + + /// Sets the prompt origin position and screen size for a new line editor + /// invocation + /// + /// Not to be used for resizes during a running line editor, use + /// [`Painter::handle_resize()`] instead + pub(crate) fn initialize_prompt_position(&mut self) -> Result<()> { + // Update the terminal size + self.terminal_size = { + let size = terminal::size()?; + // if reported size is 0, 0 - + // use a default size to avoid divide by 0 panics + if size == (0, 0) { + (80, 24) + } else { + size + } + }; + // Cursor positions are 0 based here. + let (column, row) = cursor::position()?; + // Assumption: if the cursor is not on the zeroth column, + // there is content we want to leave intact, thus advance to the next row + let new_row = if column > 0 { row + 1 } else { row }; + // If we are on the last line and would move beyond the last line due to + // the condition above, we need to make room for the prompt. + // Otherwise printing the prompt would scroll of the stored prompt + // origin, causing issues after repaints. + let new_row = if new_row == self.screen_height() { + self.print_crlf()?; + new_row.saturating_sub(1) + } else { + new_row + }; + self.prompt_start_row = new_row; + Ok(()) + } + + /// Main pain painter for the prompt and buffer + /// It queues all the actions required to print the prompt together with + /// lines that make the buffer. + /// Using the prompt lines object in this function it is estimated how the + /// prompt should scroll up and how much space is required to print all the + /// lines for the buffer + /// + /// Note. The `ScrollUp` operation in `crossterm` deletes lines from the top of + /// the screen. + pub(crate) fn repaint_buffer( + &mut self, + prompt: &dyn Prompt, + lines: &PromptLines, + prompt_mode: PromptEditMode, + menu: Option<&ReedlineMenu>, + use_ansi_coloring: bool, + cursor_config: &Option, + ) -> Result<()> { + self.stdout.queue(cursor::Hide)?; + + let screen_width = self.screen_width(); + let screen_height = self.screen_height(); + + // Lines and distance parameters + let remaining_lines = self.remaining_lines(); + let required_lines = lines.required_lines(screen_width, menu); + + // Marking the painter state as larger buffer to avoid animations + self.large_buffer = required_lines >= screen_height; + + // Moving the start position of the cursor based on the size of the required lines + if self.large_buffer { + self.prompt_start_row = 0; + } else if required_lines >= remaining_lines { + let extra = required_lines.saturating_sub(remaining_lines); + self.stdout.queue(ScrollUp(extra))?; + self.prompt_start_row = self.prompt_start_row.saturating_sub(extra); + } + + // Moving the cursor to the start of the prompt + // from this position everything will be printed + self.stdout + .queue(cursor::MoveTo(0, self.prompt_start_row))? + .queue(Clear(ClearType::FromCursorDown))?; + + if self.large_buffer { + self.print_large_buffer(prompt, lines, menu, use_ansi_coloring)?; + } else { + self.print_small_buffer(prompt, lines, menu, use_ansi_coloring)?; + } + + // The last_required_lines is used to move the cursor at the end where stdout + // can print without overwriting the things written during the painting + self.last_required_lines = required_lines; + + self.stdout.queue(RestorePosition)?; + + if let Some(shapes) = cursor_config { + let shape = match &prompt_mode { + PromptEditMode::Emacs => shapes.emacs, + PromptEditMode::Vi(PromptViMode::Insert) => shapes.vi_insert, + PromptEditMode::Vi(PromptViMode::Normal) => shapes.vi_normal, + _ => None, + }; + if let Some(shape) = shape { + self.stdout.queue(cursor::SetCursorShape(shape))?; + } + } + self.stdout.queue(cursor::Show)?; + + self.stdout.flush() + } + + fn print_right_prompt(&mut self, lines: &PromptLines) -> Result<()> { + let prompt_length_right = line_width(&lines.prompt_str_right); + let start_position = self + .screen_width() + .saturating_sub(prompt_length_right as u16); + let screen_width = self.screen_width(); + let input_width = lines.estimate_right_prompt_line_width(screen_width); + + let mut row = self.prompt_start_row; + if lines.right_prompt_on_last_line { + row += lines.prompt_lines_with_wrap(screen_width); + } + + if input_width <= start_position { + self.stdout + .queue(SavePosition)? + .queue(cursor::MoveTo(start_position, row))? + .queue(Print(&coerce_crlf(&lines.prompt_str_right)))? + .queue(RestorePosition)?; + } + + Ok(()) + } + + fn print_menu( + &mut self, + menu: &dyn Menu, + lines: &PromptLines, + use_ansi_coloring: bool, + ) -> Result<()> { + let screen_width = self.screen_width(); + let screen_height = self.screen_height(); + let cursor_distance = lines.distance_from_prompt(screen_width); + + // If there is not enough space to print the menu, then the starting + // drawing point for the menu will overwrite the last rows in the buffer + let starting_row = if cursor_distance >= screen_height.saturating_sub(1) { + screen_height.saturating_sub(menu.min_rows()) + } else { + self.prompt_start_row + cursor_distance + 1 + }; + + let remaining_lines = screen_height.saturating_sub(starting_row); + let menu_string = menu.menu_string(remaining_lines, use_ansi_coloring); + self.stdout + .queue(cursor::MoveTo(0, starting_row))? + .queue(Clear(ClearType::FromCursorDown))? + .queue(Print(menu_string.trim_end_matches('\n')))?; + + Ok(()) + } + + fn print_small_buffer( + &mut self, + prompt: &dyn Prompt, + lines: &PromptLines, + menu: Option<&ReedlineMenu>, + use_ansi_coloring: bool, + ) -> Result<()> { + // print our prompt with color + if use_ansi_coloring { + self.stdout + .queue(SetForegroundColor(prompt.get_prompt_color()))? + .queue(SetAttribute(Attribute::Bold))?; + } + + self.stdout + .queue(Print(&coerce_crlf(&lines.prompt_str_left)))?; + + let prompt_indicator = match menu { + Some(menu) => menu.indicator(), + None => &lines.prompt_indicator, + }; + + if use_ansi_coloring { + self.stdout + .queue(SetForegroundColor(prompt.get_indicator_color()))? + .queue(SetAttribute(Attribute::Bold))?; + } + + self.stdout.queue(Print(&coerce_crlf(prompt_indicator)))?; + + if use_ansi_coloring { + self.stdout + .queue(SetForegroundColor(prompt.get_prompt_right_color()))? + .queue(SetAttribute(Attribute::Bold))?; + } + + self.print_right_prompt(lines)?; + + if use_ansi_coloring { + self.stdout.queue(ResetColor)?; + } + + self.stdout + .queue(Print(&lines.before_cursor))? + .queue(SavePosition)? + .queue(Print(&lines.after_cursor))?; + + if let Some(menu) = menu { + self.print_menu(menu, lines, use_ansi_coloring)?; + } else { + self.stdout.queue(Print(&lines.hint))?; + } + + Ok(()) + } + + fn print_large_buffer( + &mut self, + prompt: &dyn Prompt, + lines: &PromptLines, + menu: Option<&ReedlineMenu>, + use_ansi_coloring: bool, + ) -> Result<()> { + let screen_width = self.screen_width(); + let screen_height = self.screen_height(); + let cursor_distance = lines.distance_from_prompt(screen_width); + let remaining_lines = screen_height.saturating_sub(cursor_distance); + + // Calculating the total lines before the cursor + // The -1 in the total_lines_before is there because the at least one line of the prompt + // indicator is printed in the same line as the first line of the buffer + let prompt_lines = lines.prompt_lines_with_wrap(screen_width) as usize; + + let prompt_indicator = match menu { + Some(menu) => menu.indicator(), + None => &lines.prompt_indicator, + }; + + let prompt_indicator_lines = prompt_indicator.lines().count(); + let before_cursor_lines = lines.before_cursor.lines().count(); + let total_lines_before = prompt_lines + prompt_indicator_lines + before_cursor_lines - 1; + + // Extra rows represent how many rows are "above" the visible area in the terminal + let extra_rows = (total_lines_before).saturating_sub(screen_height as usize); + + // print our prompt with color + if use_ansi_coloring { + self.stdout + .queue(SetForegroundColor(prompt.get_prompt_color()))?; + } + + // In case the prompt is made out of multiple lines, the prompt is split by + // lines and only the required ones are printed + let prompt_skipped = skip_buffer_lines(&lines.prompt_str_left, extra_rows, None); + self.stdout.queue(Print(&coerce_crlf(prompt_skipped)))?; + + if extra_rows == 0 { + self.print_right_prompt(lines)?; + } + + // Adjusting extra_rows base on the calculated prompt line size + let extra_rows = extra_rows.saturating_sub(prompt_lines); + + let indicator_skipped = skip_buffer_lines(prompt_indicator, extra_rows, None); + self.stdout.queue(Print(&coerce_crlf(indicator_skipped)))?; + + if use_ansi_coloring { + self.stdout.queue(ResetColor)?; + } + + // The minimum number of lines from the menu are removed from the buffer if there is no more + // space to print the menu. This will only happen if the cursor is at the last line and + // it is a large buffer + let offset = menu.and_then(|menu| { + if cursor_distance >= screen_height.saturating_sub(1) { + let rows = lines + .before_cursor + .lines() + .count() + .saturating_sub(extra_rows) + .saturating_sub(menu.min_rows() as usize); + Some(rows) + } else { + None + } + }); + + // Selecting the lines before the cursor that will be printed + let before_cursor_skipped = skip_buffer_lines(&lines.before_cursor, extra_rows, offset); + self.stdout.queue(Print(before_cursor_skipped))?; + self.stdout.queue(SavePosition)?; + + if let Some(menu) = menu { + // TODO: Also solve the difficult problem of displaying (parts of) + // the content after the cursor with the completion menu + self.print_menu(menu, lines, use_ansi_coloring)?; + } else { + // Selecting lines for the hint + // The -1 subtraction is done because the remaining lines consider the line where the + // cursor is located as a remaining line. That has to be removed to get the correct offset + // for the after-cursor and hint lines + let offset = remaining_lines.saturating_sub(1) as usize; + // Selecting lines after the cursor + let after_cursor_skipped = skip_buffer_lines(&lines.after_cursor, 0, Some(offset)); + self.stdout.queue(Print(after_cursor_skipped))?; + // Hint lines + let hint_skipped = skip_buffer_lines(&lines.hint, 0, Some(offset)); + self.stdout.queue(Print(hint_skipped))?; + } + + Ok(()) + } + + /// Updates prompt origin and offset to handle a screen resize event + pub(crate) fn handle_resize(&mut self, width: u16, height: u16) { + let prev_terminal_size = self.terminal_size; + let prev_prompt_row = self.prompt_start_row; + + self.terminal_size = (width, height); + // TODO properly adjusting prompt_origin on resizing while lines > 1 + + if prev_prompt_row >= (height - 1) { + // Terminal is shrinking up + // FIXME: use actual prompt size at some point + // Note: you can't just subtract the offset from the origin, + // as we could be shrinking so fast that the offset we read back from + // crossterm is past where it would have been. + self.prompt_start_row = height - 2; + } else if prev_terminal_size.1 < height { + // Terminal is growing down, so move the prompt down the same amount to make space + // for history that's on the screen + // Note: if the terminal doesn't have sufficient history, this will leave a trail + // of previous prompts currently. + self.prompt_start_row = prev_prompt_row + (height - prev_terminal_size.1); + } + } + + /// Writes `line` to the terminal with a following carriage return and newline + pub(crate) fn paint_line(&mut self, line: &str) -> Result<()> { + self.stdout.queue(Print(line))?.queue(Print("\r\n"))?; + + self.stdout.flush() + } + + /// Goes to the beginning of the next line + /// + /// Also works in raw mode + pub(crate) fn print_crlf(&mut self) -> Result<()> { + self.stdout.queue(Print("\r\n"))?; + + self.stdout.flush() + } + + /// Clear the screen by printing enough whitespace to start the prompt or + /// other output back at the first line of the terminal. + pub(crate) fn clear_screen(&mut self) -> Result<()> { + self.stdout.queue(cursor::Hide)?; + let (_, num_lines) = terminal::size()?; + for _ in 0..2 * num_lines { + self.stdout.queue(Print("\n"))?; + } + self.stdout.queue(MoveTo(0, 0))?; + self.stdout.queue(cursor::Show)?; + + self.stdout.flush()?; + self.initialize_prompt_position() + } + + pub(crate) fn clear_scrollback(&mut self) -> Result<()> { + self.stdout + .queue(crossterm::terminal::Clear(ClearType::All))? + .queue(crossterm::terminal::Clear(ClearType::Purge))? + .queue(cursor::MoveTo(0, 0))? + .flush()?; + self.initialize_prompt_position() + } + + // The prompt is moved to the end of the buffer after the event was handled + // If the prompt is in the middle of a multiline buffer, then the output to stdout + // could overwrite the buffer writing + pub(crate) fn move_cursor_to_end(&mut self) -> Result<()> { + let final_row = self.prompt_start_row + self.last_required_lines; + let scroll = final_row.saturating_sub(self.screen_height() - 1); + if scroll != 0 { + self.stdout.queue(ScrollUp(scroll))?; + } + self.stdout + .queue(MoveTo(0, final_row.min(self.screen_height() - 1)))?; + + self.stdout.flush() + } + + /// Prints an external message + /// + /// This function doesn't flush the buffer. So buffer should be flushed + /// afterwards perhaps by repainting the prompt via `repaint_buffer()`. + #[cfg(feature = "external_printer")] + pub(crate) fn print_external_message( + &mut self, + messages: Vec, + line_buffer: &LineBuffer, + prompt: &dyn Prompt, + ) -> Result<()> { + // adding 3 seems to be right for first line-wrap + let prompt_len = prompt.render_prompt_right().len() + 3; + let mut buffer_num_lines = 0_u16; + for (i, line) in line_buffer.get_buffer().lines().enumerate() { + let screen_lines = match i { + 0 => { + // the first line has to deal with the prompt + let first_line_len = line.len() + prompt_len; + // at least, it is one line + ((first_line_len as u16) / (self.screen_width())) + 1 + }, + _ => { + // the n-th line, no prompt, at least, it is one line + ((line.len() as u16) / self.screen_width()) + 1 + }, + }; + // count up screen-lines + buffer_num_lines = buffer_num_lines.saturating_add(screen_lines); + } + // move upward to start print if the line-buffer is more than one screen-line + if buffer_num_lines > 1 { + self.stdout.queue(MoveUp(buffer_num_lines - 1))?; + } + let erase_line = format!("\r{}\r", " ".repeat(self.screen_width().into())); + for line in messages { + self.stdout.queue(Print(&erase_line))?; + // Note: we don't use `print_line` here because we don't want to + // flush right now. The subsequent repaint of the prompt will cause + // immediate flush anyways. And if we flush here, every external + // print causes visible flicker. + self.stdout.queue(Print(line))?.queue(Print("\r\n"))?; + let new_start = self.prompt_start_row.saturating_add(1); + let height = self.screen_height(); + if new_start >= height { + self.prompt_start_row = height - 1; + } else { + self.prompt_start_row = new_start; + } + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_skip_lines() { + let string = "sentence1\nsentence2\nsentence3\n"; + + assert_eq!(skip_buffer_lines(string, 1, None), "sentence2\nsentence3"); + assert_eq!(skip_buffer_lines(string, 2, None), "sentence3"); + assert_eq!(skip_buffer_lines(string, 3, None), ""); + assert_eq!(skip_buffer_lines(string, 4, None), ""); + } + + #[test] + fn test_skip_lines_no_newline() { + let string = "sentence1"; + + assert_eq!(skip_buffer_lines(string, 0, None), "sentence1"); + assert_eq!(skip_buffer_lines(string, 1, None), ""); + } + + #[test] + fn test_skip_lines_with_limit() { + let string = "sentence1\nsentence2\nsentence3\nsentence4\nsentence5"; + + assert_eq!( + skip_buffer_lines(string, 1, Some(1)), + "sentence2\nsentence3", + ); + + assert_eq!( + skip_buffer_lines(string, 1, Some(2)), + "sentence2\nsentence3\nsentence4", + ); + + assert_eq!( + skip_buffer_lines(string, 2, Some(1)), + "sentence3\nsentence4", + ); + + assert_eq!( + skip_buffer_lines(string, 1, Some(10)), + "sentence2\nsentence3\nsentence4\nsentence5", + ); + + assert_eq!( + skip_buffer_lines(string, 0, Some(1)), + "sentence1\nsentence2", + ); + + assert_eq!(skip_buffer_lines(string, 0, Some(0)), "sentence1",); + assert_eq!(skip_buffer_lines(string, 1, Some(0)), "sentence2",); + } +} diff --git a/reedline/src/painting/prompt_lines.rs b/reedline/src/painting/prompt_lines.rs new file mode 100644 index 00000000..f319b2ef --- /dev/null +++ b/reedline/src/painting/prompt_lines.rs @@ -0,0 +1,140 @@ +use std::borrow::Cow; + +use super::utils::{coerce_crlf, estimate_required_lines, line_width}; +use crate::{ + menu::{Menu, ReedlineMenu}, + prompt::PromptEditMode, + Prompt, PromptHistorySearch, +}; + +/// Aggregate of prompt and input string used by `Painter` +pub(crate) struct PromptLines<'prompt> { + pub(crate) prompt_str_left: Cow<'prompt, str>, + pub(crate) prompt_str_right: Cow<'prompt, str>, + pub(crate) prompt_indicator: Cow<'prompt, str>, + pub(crate) before_cursor: Cow<'prompt, str>, + pub(crate) after_cursor: Cow<'prompt, str>, + pub(crate) hint: Cow<'prompt, str>, + pub(crate) right_prompt_on_last_line: bool, +} + +impl<'prompt> PromptLines<'prompt> { + /// Splits the strings before and after the cursor as well as the hint + /// This vector with the str are used to calculate how many lines are + /// required to print after the prompt + pub fn new( + prompt: &'prompt dyn Prompt, + prompt_mode: PromptEditMode, + history_indicator: Option, + before_cursor: &'prompt str, + after_cursor: &'prompt str, + hint: &'prompt str, + ) -> Self { + let prompt_str_left = prompt.render_prompt_left(); + let prompt_str_right = prompt.render_prompt_right(); + + let prompt_indicator = match history_indicator { + Some(prompt_search) => prompt.render_prompt_history_search_indicator(prompt_search), + None => prompt.render_prompt_indicator(prompt_mode), + }; + + let before_cursor = coerce_crlf(before_cursor); + let after_cursor = coerce_crlf(after_cursor); + let hint = coerce_crlf(hint); + let right_prompt_on_last_line = prompt.right_prompt_on_last_line(); + + Self { + prompt_str_left, + prompt_str_right, + prompt_indicator, + before_cursor, + after_cursor, + hint, + right_prompt_on_last_line, + } + } + + /// The required lines to paint the buffer are calculated by counting the + /// number of newlines in all the strings that form the prompt and buffer. + /// The plus 1 is to indicate that there should be at least one line. + pub(crate) fn required_lines(&self, terminal_columns: u16, menu: Option<&ReedlineMenu>) -> u16 { + let input = if menu.is_none() { + self.prompt_str_left.to_string() + + &self.prompt_indicator + + &self.before_cursor + + &self.after_cursor + + &self.hint + } else { + self.prompt_str_left.to_string() + + &self.prompt_indicator + + &self.before_cursor + + &self.after_cursor + }; + + let lines = estimate_required_lines(&input, terminal_columns); + + if let Some(menu) = menu { + lines as u16 + menu.menu_required_lines(terminal_columns) + } else { + lines as u16 + } + } + + /// Estimated distance of the cursor to the prompt. + /// This considers line wrapping + pub(crate) fn distance_from_prompt(&self, terminal_columns: u16) -> u16 { + let input = self.prompt_str_left.to_string() + &self.prompt_indicator + &self.before_cursor; + let lines = estimate_required_lines(&input, terminal_columns); + lines.saturating_sub(1) as u16 + } + + /// Total lines that the prompt uses considering that it may wrap the screen + pub(crate) fn prompt_lines_with_wrap(&self, screen_width: u16) -> u16 { + let complete_prompt = self.prompt_str_left.to_string() + &self.prompt_indicator; + let lines = estimate_required_lines(&complete_prompt, screen_width); + lines.saturating_sub(1) as u16 + } + + /// Estimated width of the line where right prompt will be rendered + pub(crate) fn estimate_right_prompt_line_width(&self, terminal_columns: u16) -> u16 { + let first_line_left_prompt = self.prompt_str_left.lines().next(); + let last_line_left_prompt = self.prompt_str_left.lines().last(); + + let prompt_lines_total = self.before_cursor.to_string() + &self.after_cursor + &self.hint; + let prompt_lines_first = prompt_lines_total.lines().next(); + + let mut estimate = 0; // space in front of the input + + if self.right_prompt_on_last_line { + if let Some(last_line_left_prompt) = last_line_left_prompt { + estimate += line_width(last_line_left_prompt); + estimate += line_width(&self.prompt_indicator); + + if let Some(prompt_lines_first) = prompt_lines_first { + estimate += line_width(prompt_lines_first); + } + } + } else { + // Render right prompt on the first line + let required_lines = estimate_required_lines(&self.prompt_str_left, terminal_columns); + if let Some(first_line_left_prompt) = first_line_left_prompt { + estimate += line_width(first_line_left_prompt); + } + + // A single line + if required_lines == 1 { + estimate += line_width(&self.prompt_indicator); + + if let Some(prompt_lines_first) = prompt_lines_first { + estimate += line_width(prompt_lines_first); + } + } + } + + if estimate > u16::MAX as usize { + u16::MAX + } else { + estimate as u16 + } + } +} diff --git a/reedline/src/painting/styled_text.rs b/reedline/src/painting/styled_text.rs new file mode 100644 index 00000000..5e928187 --- /dev/null +++ b/reedline/src/painting/styled_text.rs @@ -0,0 +1,110 @@ +use nu_ansi_term::Style; + +use super::utils::strip_ansi; +use crate::Prompt; + +/// A representation of a buffer with styling, used for doing syntax highlighting +pub struct StyledText { + /// The component, styled parts of the text + pub buffer: Vec<(Style, String)>, +} + +impl Default for StyledText { + fn default() -> Self { + Self::new() + } +} + +impl StyledText { + /// Construct a new `StyledText` + pub fn new() -> Self { + Self { buffer: vec![] } + } + + /// Add a new styled string to the buffer + pub fn push(&mut self, styled_string: (Style, String)) { + self.buffer.push(styled_string); + } + + /// Render the styled string. We use the insertion point to render around so that + /// we can properly write out the styled string to the screen and find the correct + /// place to put the cursor. This assumes a logic that prints the first part of the + /// string, saves the cursor position, prints the second half, and then restores + /// the cursor position + /// + /// Also inserts the multiline continuation prompt + pub fn render_around_insertion_point( + &self, + insertion_point: usize, + prompt: &dyn Prompt, + // multiline_prompt: &str, + use_ansi_coloring: bool, + ) -> (String, String) { + let mut current_idx = 0; + let mut left_string = String::new(); + let mut right_string = String::new(); + + let multiline_prompt = prompt.render_prompt_multiline_indicator(); + let prompt_style = Style::new().fg(prompt.get_prompt_multiline_color()); + + for pair in &self.buffer { + if current_idx >= insertion_point { + right_string.push_str(&render_as_string(pair, &prompt_style, &multiline_prompt)); + } else if pair.1.len() + current_idx <= insertion_point { + left_string.push_str(&render_as_string(pair, &prompt_style, &multiline_prompt)); + } else if pair.1.len() + current_idx > insertion_point { + let offset = insertion_point - current_idx; + + let left_side = pair.1[..offset].to_string(); + let right_side = pair.1[offset..].to_string(); + + left_string.push_str(&render_as_string( + &(pair.0, left_side), + &prompt_style, + &multiline_prompt, + )); + right_string.push_str(&render_as_string( + &(pair.0, right_side), + &prompt_style, + &multiline_prompt, + )); + } + current_idx += pair.1.len(); + } + + if use_ansi_coloring { + (left_string, right_string) + } else { + (strip_ansi(&left_string), strip_ansi(&right_string)) + } + } + + /// Apply the ANSI style formatting to the full string. + pub fn render_simple(&self) -> String { + self.buffer + .iter() + .map(|(style, text)| style.paint(text).to_string()) + .collect() + } + + /// Get the unformatted text as a single continuous string. + pub fn raw_string(&self) -> String { + self.buffer.iter().map(|(_, str)| str.as_str()).collect() + } +} + +fn render_as_string( + renderable: &(Style, String), + prompt_style: &Style, + multiline_prompt: &str, +) -> String { + let mut rendered = String::new(); + let formatted_multiline_prompt = format!("\n{multiline_prompt}"); + for (line_number, line) in renderable.1.split('\n').enumerate() { + if line_number != 0 { + rendered.push_str(&prompt_style.paint(&formatted_multiline_prompt).to_string()); + } + rendered.push_str(&renderable.0.paint(line).to_string()); + } + rendered +} diff --git a/reedline/src/painting/utils.rs b/reedline/src/painting/utils.rs new file mode 100644 index 00000000..8b488b3a --- /dev/null +++ b/reedline/src/painting/utils.rs @@ -0,0 +1,100 @@ +use std::borrow::Cow; + +use unicode_width::UnicodeWidthStr; + +/// Ensures input uses CRLF line endings. +/// +/// Needed for correct output in raw mode. +/// Only replaces solitary LF with CRLF. +pub(crate) fn coerce_crlf(input: &str) -> Cow { + let mut result = Cow::Borrowed(input); + let mut cursor: usize = 0; + for (idx, _) in input.match_indices('\n') { + if !(idx > 0 && input.as_bytes()[idx - 1] == b'\r') { + if let Cow::Borrowed(_) = result { + // Best case 1 allocation, worst case 2 allocations + let mut owned = String::with_capacity(input.len() + 1); + // Optimization to avoid the `AddAssign for Cow` + // optimization for `Cow.is_empty` that would replace the + // preallocation + owned.push_str(&input[cursor..idx]); + result = Cow::Owned(owned); + } else { + result += &input[cursor..idx]; + } + result += "\r\n"; + // Advance beyond the matched LF char (single byte) + cursor = idx + 1; + } + } + if let Cow::Owned(_) = result { + result += &input[cursor..input.len()]; + } + result +} + +/// Returns string with the ANSI escape codes removed +/// +/// If parsing fails silently returns the input string +pub(crate) fn strip_ansi(string: &str) -> String { + strip_ansi_escapes::strip(string) + .map_err(|_| ()) + .and_then(|x| String::from_utf8(x).map_err(|_| ())) + .unwrap_or_else(|_| string.to_owned()) +} + +pub(crate) fn estimate_required_lines(input: &str, screen_width: u16) -> usize { + input.lines().fold(0, |acc, line| { + let wrap = estimate_single_line_wraps(line, screen_width); + + acc + 1 + wrap + }) +} + +/// Reports the additional lines needed due to wrapping for the given line. +/// +/// Does not account for any potential linebreaks in `line` +/// +/// If `line` fits in `terminal_columns` returns 0 +pub(crate) fn estimate_single_line_wraps(line: &str, terminal_columns: u16) -> usize { + let estimated_width = line_width(line); + let terminal_columns: usize = terminal_columns.into(); + + // integer ceiling rounding division for positive divisors + let estimated_line_count = (estimated_width + terminal_columns - 1) / terminal_columns; + + // Any wrapping will add to our overall line count + estimated_line_count.saturating_sub(1) +} + +/// Compute the line width for ANSI escaped text +pub(crate) fn line_width(line: &str) -> usize { + strip_ansi(line).width() +} + +#[cfg(test)] +mod test { + use pretty_assertions::assert_eq; + use rstest::rstest; + + use super::*; + + #[rstest] + #[case("sentence\nsentence", "sentence\r\nsentence")] + #[case("sentence\r\nsentence", "sentence\r\nsentence")] + #[case("sentence\nsentence\n", "sentence\r\nsentence\r\n")] + #[case("šŸ˜‡\nsentence", "šŸ˜‡\r\nsentence")] + #[case("sentence\nšŸ˜‡", "sentence\r\nšŸ˜‡")] + #[case("\n", "\r\n")] + #[case("", "")] + fn test_coerce_crlf(#[case] input: &str, #[case] expected: &str) { + let result = coerce_crlf(input); + + assert_eq!(result, expected); + + assert!( + input != expected || matches!(result, Cow::Borrowed(_)), + "Unnecessary allocation" + ) + } +} diff --git a/reedline/src/prompt/base.rs b/reedline/src/prompt/base.rs new file mode 100644 index 00000000..bd377b4d --- /dev/null +++ b/reedline/src/prompt/base.rs @@ -0,0 +1,125 @@ +use std::{ + borrow::Cow, + fmt::{Display, Formatter}, +}; + +use crossterm::style::Color; +use serde::{Deserialize, Serialize}; +use strum_macros::EnumIter; + +/// The default color for the prompt, indicator, and right prompt +pub static DEFAULT_PROMPT_COLOR: Color = Color::Green; +pub static DEFAULT_PROMPT_MULTILINE_COLOR: nu_ansi_term::Color = nu_ansi_term::Color::LightBlue; +pub static DEFAULT_INDICATOR_COLOR: Color = Color::Cyan; +pub static DEFAULT_PROMPT_RIGHT_COLOR: Color = Color::AnsiValue(5); + +/// The current success/failure of the history search +pub enum PromptHistorySearchStatus { + /// Success for the search + Passing, + + /// Failure to find the search + Failing, +} + +/// A representation of the history search +pub struct PromptHistorySearch { + /// The status of the search + pub status: PromptHistorySearchStatus, + + /// The search term used during the search + pub term: String, +} + +impl PromptHistorySearch { + /// A constructor to create a history search + pub fn new(status: PromptHistorySearchStatus, search_term: String) -> Self { + PromptHistorySearch { + status, + term: search_term, + } + } +} + +/// Modes that the prompt can be in +#[derive(Serialize, Deserialize, Clone, Debug, EnumIter)] +pub enum PromptEditMode { + /// The default mode + Default, + + /// Emacs normal mode + Emacs, + + /// A vi-specific mode + Vi(PromptViMode), + + /// A custom mode + Custom(String), +} + +/// The vi-specific modes that the prompt can be in +#[derive(Serialize, Deserialize, Clone, Debug, EnumIter)] +pub enum PromptViMode { + /// The default mode + Normal, + + /// Insertion mode + Insert, +} + +impl Default for PromptViMode { + fn default() -> Self { + PromptViMode::Normal + } +} + +impl Display for PromptEditMode { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + match self { + PromptEditMode::Default => write!(f, "Default"), + PromptEditMode::Emacs => write!(f, "Emacs"), + PromptEditMode::Vi(_) => write!(f, "Vi_Normal\nVi_Insert"), + PromptEditMode::Custom(s) => write!(f, "Custom_{s}"), + } + } +} +/// API to provide a custom prompt. +/// +/// Implementors have to provide [`str`]-based content which will be +/// displayed before the `LineBuffer` is drawn. +pub trait Prompt: Send { + /// Provide content off the right full prompt + fn render_prompt_left(&self) -> Cow; + /// Provide content off the left full prompt + fn render_prompt_right(&self) -> Cow; + /// Render the prompt indicator (Last part of the prompt that changes based on the editor mode) + fn render_prompt_indicator(&self, prompt_mode: PromptEditMode) -> Cow; + /// Indicator to show before explicit new lines + fn render_prompt_multiline_indicator(&self) -> Cow; + /// Render the prompt indicator for `Ctrl-R` history search + fn render_prompt_history_search_indicator( + &self, + history_search: PromptHistorySearch, + ) -> Cow; + /// Get the default prompt color + fn get_prompt_color(&self) -> Color { + DEFAULT_PROMPT_COLOR + } + /// Get the default multilince prompt color + fn get_prompt_multiline_color(&self) -> nu_ansi_term::Color { + DEFAULT_PROMPT_MULTILINE_COLOR + } + /// Get the default indicator color + fn get_indicator_color(&self) -> Color { + DEFAULT_INDICATOR_COLOR + } + /// Get the default right prompt color + fn get_prompt_right_color(&self) -> Color { + DEFAULT_PROMPT_RIGHT_COLOR + } + + /// Whether to render right prompt on the last line + fn right_prompt_on_last_line(&self) -> bool { + false + } +} diff --git a/reedline/src/prompt/default.rs b/reedline/src/prompt/default.rs new file mode 100644 index 00000000..3f6cf29e --- /dev/null +++ b/reedline/src/prompt/default.rs @@ -0,0 +1,138 @@ +use std::{borrow::Cow, env}; + +use chrono::Local; + +use crate::{Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, PromptViMode}; + +/// The default prompt indicator +pub static DEFAULT_PROMPT_INDICATOR: &str = "怉"; +pub static DEFAULT_VI_INSERT_PROMPT_INDICATOR: &str = ": "; +pub static DEFAULT_VI_NORMAL_PROMPT_INDICATOR: &str = "怉"; +pub static DEFAULT_MULTILINE_INDICATOR: &str = "::: "; + +/// Simple [`Prompt`] displaying a configurable left and a right prompt. +/// For more fine-tuned configuration, implement the [`Prompt`] trait. +/// For the default configuration, use [`DefaultPrompt::default()`] +#[derive(Clone)] +pub struct DefaultPrompt { + /// What segment should be rendered in the left (main) prompt + pub left_prompt: DefaultPromptSegment, + /// What segment should be rendered in the right prompt + pub right_prompt: DefaultPromptSegment, +} + +/// A struct to control the appearance of the left or right prompt in a [`DefaultPrompt`] +#[derive(Clone)] +pub enum DefaultPromptSegment { + /// A basic user-defined prompt (i.e. just text) + Basic(String), + /// The path of the current working directory + WorkingDirectory, + /// The current date and time + CurrentDateTime, + /// An empty prompt segment + Empty, +} + +/// Given a prompt segment, render it to a Cow that we can use to +/// easily implement [`Prompt`]'s `render_prompt_left` and `render_prompt_right` +/// functions. +fn render_prompt_segment(prompt: &DefaultPromptSegment) -> Cow { + match &prompt { + DefaultPromptSegment::Basic(s) => Cow::Borrowed(s), + DefaultPromptSegment::WorkingDirectory => { + let prompt = get_working_dir().unwrap_or_else(|_| String::from("no path")); + Cow::Owned(prompt) + }, + DefaultPromptSegment::CurrentDateTime => Cow::Owned(get_now()), + DefaultPromptSegment::Empty => Cow::Borrowed(""), + } +} + +impl Prompt for DefaultPrompt { + fn render_prompt_left(&self) -> Cow { + render_prompt_segment(&self.left_prompt) + } + + fn render_prompt_right(&self) -> Cow { + render_prompt_segment(&self.right_prompt) + } + + fn render_prompt_indicator(&self, edit_mode: PromptEditMode) -> Cow { + match edit_mode { + PromptEditMode::Default | PromptEditMode::Emacs => DEFAULT_PROMPT_INDICATOR.into(), + PromptEditMode::Vi(vi_mode) => match vi_mode { + PromptViMode::Normal => DEFAULT_VI_NORMAL_PROMPT_INDICATOR.into(), + PromptViMode::Insert => DEFAULT_VI_INSERT_PROMPT_INDICATOR.into(), + }, + PromptEditMode::Custom(str) => format!("({str})").into(), + } + } + + fn render_prompt_multiline_indicator(&self) -> Cow { + Cow::Borrowed(DEFAULT_MULTILINE_INDICATOR) + } + + fn render_prompt_history_search_indicator( + &self, + history_search: PromptHistorySearch, + ) -> Cow { + let prefix = match history_search.status { + PromptHistorySearchStatus::Passing => "", + PromptHistorySearchStatus::Failing => "failing ", + }; + // NOTE: magic strings, given there is logic on how these compose I am not sure if it + // is worth extracting in to static constant + Cow::Owned(format!( + "({}reverse-search: {}) ", + prefix, history_search.term + )) + } +} + +impl Default for DefaultPrompt { + fn default() -> Self { + DefaultPrompt { + left_prompt: DefaultPromptSegment::WorkingDirectory, + right_prompt: DefaultPromptSegment::CurrentDateTime, + } + } +} + +impl DefaultPrompt { + /// Constructor for the default prompt, which takes a configurable left and right prompt. + /// For less customization, use [`DefaultPrompt::default`]. + /// For more fine-tuned configuration, implement the [`Prompt`] trait. + pub fn new( + left_prompt: DefaultPromptSegment, + right_prompt: DefaultPromptSegment, + ) -> DefaultPrompt { + DefaultPrompt { + left_prompt, + right_prompt, + } + } +} + +fn get_working_dir() -> Result { + let path = env::current_dir()?; + let path_str = path.display().to_string(); + let homedir: String = match env::var("USERPROFILE") { + Ok(win_home) => win_home, + Err(_) => match env::var("HOME") { + Ok(maclin_home) => maclin_home, + Err(_) => path_str.clone(), + }, + }; + let new_path = if path_str != homedir { + path_str.replace(&homedir, "~") + } else { + path_str + }; + Ok(new_path) +} + +fn get_now() -> String { + let now = Local::now(); + format!("{:>}", now.format("%m/%d/%Y %I:%M:%S %p")) +} diff --git a/reedline/src/prompt/mod.rs b/reedline/src/prompt/mod.rs new file mode 100644 index 00000000..6159853f --- /dev/null +++ b/reedline/src/prompt/mod.rs @@ -0,0 +1,7 @@ +mod base; +mod default; + +pub use base::{ + Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, PromptViMode, +}; +pub use default::{DefaultPrompt, DefaultPromptSegment}; diff --git a/reedline/src/result.rs b/reedline/src/result.rs new file mode 100644 index 00000000..7a9b7834 --- /dev/null +++ b/reedline/src/result.rs @@ -0,0 +1,44 @@ +use std::fmt::Display; + +use thiserror::Error; + +/// non-public (for now) +#[derive(Error, Debug)] +pub(crate) enum ReedlineErrorVariants { + // todo: we should probably be more specific here + #[cfg(any(feature = "sqlite", feature = "sqlite-dynlib"))] + #[error("error within history database: {0}")] + HistoryDatabaseError(String), + #[error("error within history: {0}")] + OtherHistoryError(&'static str), + #[error("the history {history} does not support feature {feature}")] + HistoryFeatureUnsupported { + history: &'static str, + feature: &'static str, + }, + #[error("I/O error: {0}")] + IOError(std::io::Error), + #[error("public user thrown error")] + DummyError, +} + +/// separate struct to not expose anything to the public (for now) +#[derive(Debug)] +pub struct ReedlineError(pub(crate) ReedlineErrorVariants); + +// hack to allow those not in this crate to implement History trait +impl ReedlineError { + pub fn dummy() -> ReedlineError { + ReedlineError(ReedlineErrorVariants::DummyError) + } +} + +impl Display for ReedlineError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} +impl std::error::Error for ReedlineError {} + +// for now don't expose the above error type to the public +pub type Result = std::result::Result; diff --git a/reedline/src/utils/mod.rs b/reedline/src/utils/mod.rs new file mode 100644 index 00000000..11f7d5f0 --- /dev/null +++ b/reedline/src/utils/mod.rs @@ -0,0 +1,8 @@ +mod query; +pub(crate) mod text_manipulation; + +pub use query::{ + get_reedline_default_keybindings, get_reedline_edit_commands, + get_reedline_keybinding_modifiers, get_reedline_keycodes, get_reedline_prompt_edit_modes, + get_reedline_reedline_events, +}; diff --git a/reedline/src/utils/query.rs b/reedline/src/utils/query.rs new file mode 100644 index 00000000..48bf8b55 --- /dev/null +++ b/reedline/src/utils/query.rs @@ -0,0 +1,134 @@ +use std::fmt::{Display, Formatter}; + +use crossterm::event::KeyCode; +use strum::IntoEnumIterator; + +use crate::{ + default_emacs_keybindings, default_vi_insert_keybindings, default_vi_normal_keybindings, + EditCommand, Keybindings, PromptEditMode, ReedlineEvent, +}; + +struct ReedLineCrossTermKeyCode(crossterm::event::KeyCode); +impl ReedLineCrossTermKeyCode { + fn iterator() -> std::slice::Iter<'static, ReedLineCrossTermKeyCode> { + static KEYCODE: [ReedLineCrossTermKeyCode; 19] = [ + ReedLineCrossTermKeyCode(KeyCode::Backspace), + ReedLineCrossTermKeyCode(KeyCode::Enter), + ReedLineCrossTermKeyCode(KeyCode::Left), + ReedLineCrossTermKeyCode(KeyCode::Right), + ReedLineCrossTermKeyCode(KeyCode::Up), + ReedLineCrossTermKeyCode(KeyCode::Down), + ReedLineCrossTermKeyCode(KeyCode::Home), + ReedLineCrossTermKeyCode(KeyCode::End), + ReedLineCrossTermKeyCode(KeyCode::PageUp), + ReedLineCrossTermKeyCode(KeyCode::PageDown), + ReedLineCrossTermKeyCode(KeyCode::Tab), + ReedLineCrossTermKeyCode(KeyCode::BackTab), + ReedLineCrossTermKeyCode(KeyCode::Delete), + ReedLineCrossTermKeyCode(KeyCode::Insert), + ReedLineCrossTermKeyCode(KeyCode::F(1)), + ReedLineCrossTermKeyCode(KeyCode::Char(' ')), + ReedLineCrossTermKeyCode(KeyCode::Char('a')), + ReedLineCrossTermKeyCode(KeyCode::Null), + ReedLineCrossTermKeyCode(KeyCode::Esc), + ]; + KEYCODE.iter() + } +} + +impl Display for ReedLineCrossTermKeyCode { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + match self { + ReedLineCrossTermKeyCode(kc) => match kc { + KeyCode::Backspace => write!(f, "Backspace"), + KeyCode::Enter => write!(f, "Enter"), + KeyCode::Left => write!(f, "Left"), + KeyCode::Right => write!(f, "Right"), + KeyCode::Up => write!(f, "Up"), + KeyCode::Down => write!(f, "Down"), + KeyCode::Home => write!(f, "Home"), + KeyCode::End => write!(f, "End"), + KeyCode::PageUp => write!(f, "PageUp"), + KeyCode::PageDown => write!(f, "PageDown"), + KeyCode::Tab => write!(f, "Tab"), + KeyCode::BackTab => write!(f, "BackTab"), + KeyCode::Delete => write!(f, "Delete"), + KeyCode::Insert => write!(f, "Insert"), + KeyCode::F(_) => write!(f, "F"), + KeyCode::Char(' ') => write!(f, "Space"), + KeyCode::Char(_) => write!(f, "Char_"), + KeyCode::Null => write!(f, "Null"), + KeyCode::Esc => write!(f, "Esc"), + }, + } + } +} +/// Return a `Vec` of the Reedline Keybinding Modifiers +pub fn get_reedline_keybinding_modifiers() -> Vec { + vec![ + "Alt".to_string(), + "Control".to_string(), + "Shift".to_string(), + "None".to_string(), + ] +} + +/// Return a `Vec` of the Reedline [`PromptEditMode`]s +pub fn get_reedline_prompt_edit_modes() -> Vec { + PromptEditMode::iter().map(|em| em.to_string()).collect() +} + +/// Return a `Vec` of the Reedline `KeyCode`s +pub fn get_reedline_keycodes() -> Vec { + ReedLineCrossTermKeyCode::iterator() + .map(|kc| format!("{kc}")) + .collect() +} + +/// Return a `Vec` of the Reedline [`ReedlineEvent`]s +pub fn get_reedline_reedline_events() -> Vec { + ReedlineEvent::iter().map(|rle| rle.to_string()).collect() +} + +/// Return a `Vec` of the Reedline [`EditCommand`]s +pub fn get_reedline_edit_commands() -> Vec { + EditCommand::iter().map(|edit| edit.to_string()).collect() +} + +/// Get the default keybindings and return a `Vec<(String, String, String, String)>` +/// where String 1 is `mode`, String 2 is `key_modifiers`, String 3 is `key_code`, and +/// Sting 4 is `event` +pub fn get_reedline_default_keybindings() -> Vec<(String, String, String, String)> { + let options = vec![ + ("emacs", default_emacs_keybindings()), + ("vi_normal", default_vi_normal_keybindings()), + ("vi_insert", default_vi_insert_keybindings()), + ]; + + options + .into_iter() + .flat_map(|(mode, keybindings)| get_keybinding_strings(mode, &keybindings)) + .collect() +} + +fn get_keybinding_strings( + mode: &str, + keybindings: &Keybindings, +) -> Vec<(String, String, String, String)> { + let mut data: Vec<(String, String, String, String)> = keybindings + .get_keybindings() + .iter() + .map(|(combination, event)| { + ( + mode.to_string(), + format!("{:?}", combination.modifier), + format!("{:?}", combination.key_code), + format!("{event:?}"), + ) + }) + .collect(); + + data.sort(); + + data +} diff --git a/reedline/src/utils/text_manipulation.rs b/reedline/src/utils/text_manipulation.rs new file mode 100644 index 00000000..295da23a --- /dev/null +++ b/reedline/src/utils/text_manipulation.rs @@ -0,0 +1,39 @@ +use unicode_segmentation::UnicodeSegmentation; + +pub fn remove_last_grapheme(string: &str) -> &str { + let mut it = UnicodeSegmentation::graphemes(string, true); + + if it.next_back().is_some() { + it.as_str() + } else { + "" + } +} + +#[cfg(test)] +mod test { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn remove_last_char_works_with_empty_string() { + let string = ""; + + assert_eq!(remove_last_grapheme(string), ""); + } + + #[test] + fn remove_last_char_works_with_normal_string() { + let string = "this is a string"; + + assert_eq!(remove_last_grapheme(string), "this is a strin"); + } + + #[test] + fn remove_last_char_works_with_string_containing_emojis() { + let string = "this is a šŸ˜žšŸ˜„"; + + assert_eq!(remove_last_grapheme(string), "this is a šŸ˜ž"); + } +} diff --git a/reedline/src/validator/default.rs b/reedline/src/validator/default.rs new file mode 100644 index 00000000..3c28f89c --- /dev/null +++ b/reedline/src/validator/default.rs @@ -0,0 +1,54 @@ +use crate::{ValidationResult, Validator}; + +/// A default validator which checks for mismatched quotes and brackets +pub struct DefaultValidator; + +impl Validator for DefaultValidator { + fn validate(&self, line: &str) -> ValidationResult { + if line.split('"').count() % 2 == 0 || incomplete_brackets(line) { + ValidationResult::Incomplete + } else { + ValidationResult::Complete + } + } +} + +fn incomplete_brackets(line: &str) -> bool { + let mut balance: Vec = Vec::new(); + + for c in line.chars() { + if c == '{' { + balance.push('}'); + } else if c == '[' { + balance.push(']'); + } else if c == '(' { + balance.push(')'); + } else if ['}', ']', ')'].contains(&c) { + if let Some(last) = balance.last() { + if last == &c { + balance.pop(); + } + } + } + } + + !balance.is_empty() +} + +#[cfg(test)] +mod test { + use rstest::rstest; + + use super::*; + + #[rstest] + #[case("(([[]]))", false)] + #[case("(([[]]", true)] + #[case("{[}]", true)] + #[case("{[]}{()}", false)] + fn test_incomplete_brackets(#[case] input: &str, #[case] expected: bool) { + let result = incomplete_brackets(input); + + assert_eq!(result, expected); + } +} diff --git a/reedline/src/validator/mod.rs b/reedline/src/validator/mod.rs new file mode 100644 index 00000000..588e3794 --- /dev/null +++ b/reedline/src/validator/mod.rs @@ -0,0 +1,19 @@ +mod default; +pub use default::DefaultValidator; + +/// The syntax validation trait. Implementers of this trait will check to see if the current input +/// is incomplete and spans multiple lines +pub trait Validator: Send { + /// The action that will handle the current buffer as a line and return the corresponding validation + fn validate(&self, line: &str) -> ValidationResult; +} + +#[derive(Clone, Copy)] +/// Whether or not the validation shows the input was complete +pub enum ValidationResult { + /// An incomplete input which may need to span multiple lines to be complete + Incomplete, + + /// An input that is complete as-is + Complete, +} diff --git a/shrs_lib/Cargo.toml b/shrs_lib/Cargo.toml new file mode 100644 index 00000000..b44302bd --- /dev/null +++ b/shrs_lib/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "shrs" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +authors = ["MrPicklePinosaur"] +description = "modular library to build your own shell in rust" +repository = "https://github.com/MrPicklePinosaur/sh.rs" +build = "build.rs" + +[dependencies] +lalrpop-util = { version = "0.19.8", features = ["lexer"] } +regex = "1" +signal-hook = "0.3" +crossbeam-channel = "0.5" +clap = { version = "4.1", features = ["derive"] } + +reedline = { path = "../reedline" } + +pino_deref = "0.1" + +thiserror = "1" +anyhow = "1" + +[dev-dependencies] +rexpect = "0.5" + +[build-dependencies] +lalrpop = { version = "0.19.8", features = ["lexer"] } + +[[example]] +name = "simple" diff --git a/build.rs b/shrs_lib/build.rs similarity index 100% rename from build.rs rename to shrs_lib/build.rs diff --git a/examples/simple.rs b/shrs_lib/examples/simple.rs similarity index 79% rename from examples/simple.rs rename to shrs_lib/examples/simple.rs index d846072b..77d9b5fb 100644 --- a/examples/simple.rs +++ b/shrs_lib/examples/simple.rs @@ -4,7 +4,7 @@ use shrs::{ alias::Alias, builtin::Builtins, prompt::{hostname, top_pwd, username}, - shell::{self, simple_error, simple_exit_code, Context}, + shell::{self, simple_error, simple_exit_code, Context, Runtime}, }; fn prompt_command() { @@ -36,5 +36,8 @@ fn main() { alias, ..Default::default() }; - myshell.run(&mut ctx); + let mut rt = Runtime { + ..Default::default() + }; + myshell.run(&mut ctx, &mut rt); } diff --git a/examples/tester.rs b/shrs_lib/examples/tester.rs similarity index 100% rename from examples/tester.rs rename to shrs_lib/examples/tester.rs diff --git a/src/alias.rs b/shrs_lib/src/alias.rs similarity index 100% rename from src/alias.rs rename to shrs_lib/src/alias.rs diff --git a/src/ast.rs b/shrs_lib/src/ast.rs similarity index 100% rename from src/ast.rs rename to shrs_lib/src/ast.rs diff --git a/src/builtin/cd.rs b/shrs_lib/src/builtin/cd.rs similarity index 71% rename from src/builtin/cd.rs rename to shrs_lib/src/builtin/cd.rs index e41ed8bc..f1df5a63 100644 --- a/src/builtin/cd.rs +++ b/shrs_lib/src/builtin/cd.rs @@ -1,7 +1,7 @@ use std::{env, path::Path}; use super::BuiltinCmd; -use crate::shell::dummy_child; +use crate::shell::{dummy_child, Context, Runtime}; #[derive(Default)] pub struct CdBuiltin {} @@ -9,7 +9,8 @@ pub struct CdBuiltin {} impl BuiltinCmd for CdBuiltin { fn run( &self, - ctx: &mut crate::shell::Context, + ctx: &mut Context, + rt: &mut Runtime, args: &Vec, ) -> anyhow::Result { // if empty default to root (for now) @@ -20,10 +21,10 @@ impl BuiltinCmd for CdBuiltin { }; let path = Path::new(raw_path); - let new_path = ctx.working_dir.join(path); + let new_path = rt.working_dir.join(path); // env::set_current_dir(path)?; // env current dir should just remain as the directory the shell was started in - ctx.working_dir = new_path.clone(); - ctx.env.set("PWD", new_path.to_str().unwrap()); + rt.working_dir = new_path.clone(); + rt.env.set("PWD", new_path.to_str().unwrap()); // return a dummy command dummy_child() diff --git a/src/builtin/debug.rs b/shrs_lib/src/builtin/debug.rs similarity index 75% rename from src/builtin/debug.rs rename to shrs_lib/src/builtin/debug.rs index 0a2b6cac..5c854a67 100644 --- a/src/builtin/debug.rs +++ b/shrs_lib/src/builtin/debug.rs @@ -8,7 +8,7 @@ use std::{ use clap::{Parser, Subcommand}; use super::BuiltinCmd; -use crate::shell::{dummy_child, Context, Shell}; +use crate::shell::{dummy_child, Context, Runtime, Shell}; #[derive(Parser)] struct Cli { @@ -25,7 +25,12 @@ enum Commands { pub struct DebugBuiltin {} impl BuiltinCmd for DebugBuiltin { - fn run(&self, ctx: &mut Context, args: &Vec) -> anyhow::Result { + fn run( + &self, + ctx: &mut Context, + rt: &mut Runtime, + args: &Vec, + ) -> anyhow::Result { let cli = Cli::parse_from(vec!["debug".to_string()].iter().chain(args.iter())); match &cli.command { @@ -33,7 +38,7 @@ impl BuiltinCmd for DebugBuiltin { println!("debug utility"); }, Some(Commands::Env) => { - for (var, val) in ctx.env.all() { + for (var, val) in rt.env.all() { println!("{} = {}", var, val); } }, diff --git a/src/builtin/exit.rs b/shrs_lib/src/builtin/exit.rs similarity index 72% rename from src/builtin/exit.rs rename to shrs_lib/src/builtin/exit.rs index 418d2b78..8677d8d5 100644 --- a/src/builtin/exit.rs +++ b/shrs_lib/src/builtin/exit.rs @@ -1,4 +1,5 @@ use super::BuiltinCmd; +use crate::shell::{Context, Runtime}; #[derive(Default)] pub struct ExitBuiltin {} @@ -6,7 +7,8 @@ pub struct ExitBuiltin {} impl BuiltinCmd for ExitBuiltin { fn run( &self, - ctx: &mut crate::shell::Context, + ctx: &mut Context, + rt: &mut Runtime, args: &Vec, ) -> anyhow::Result { std::process::exit(0) diff --git a/src/builtin/history.rs b/shrs_lib/src/builtin/history.rs similarity index 71% rename from src/builtin/history.rs rename to shrs_lib/src/builtin/history.rs index 44ecfdee..eb2ef16c 100644 --- a/src/builtin/history.rs +++ b/shrs_lib/src/builtin/history.rs @@ -10,7 +10,7 @@ use std::{ use clap::{Parser, Subcommand}; use super::BuiltinCmd; -use crate::shell::{dummy_child, Context, Shell}; +use crate::shell::{dummy_child, Context, Runtime, Shell}; #[derive(Parser)] struct Cli { @@ -29,17 +29,22 @@ enum Commands { pub struct HistoryBuiltin {} impl BuiltinCmd for HistoryBuiltin { - fn run(&self, ctx: &mut Context, args: &Vec) -> anyhow::Result { + fn run( + &self, + ctx: &mut Context, + rt: &mut Runtime, + args: &Vec, + ) -> anyhow::Result { // TODO hack let cli = Cli::parse_from(vec!["history".to_string()].iter().chain(args.iter())); match &cli.command { None => { - let history = ctx.history.all(); - for (i, h) in history.iter().enumerate() { - print!("{} {}", i, h); - } - stdout().flush()?; + // let history = ctx.history.all(); + // for (i, h) in history.iter().enumerate() { + // print!("{} {}", i, h); + // } + // stdout().flush()?; }, Some(Commands::Clear) => { ctx.history.clear(); diff --git a/src/builtin/mod.rs b/shrs_lib/src/builtin/mod.rs similarity index 84% rename from src/builtin/mod.rs rename to shrs_lib/src/builtin/mod.rs index 56a24de3..153d6056 100644 --- a/src/builtin/mod.rs +++ b/shrs_lib/src/builtin/mod.rs @@ -6,7 +6,7 @@ mod history; use std::process::Child; use self::{cd::CdBuiltin, debug::DebugBuiltin, exit::ExitBuiltin, history::HistoryBuiltin}; -use crate::shell::Context; +use crate::shell::{Context, Runtime}; // TODO could prob just be a map, to support arbritrary (user defined even) number of builtin commands // just provide an easy way to override the default ones @@ -29,5 +29,6 @@ impl Default for Builtins { } pub trait BuiltinCmd { - fn run(&self, ctx: &mut Context, args: &Vec) -> anyhow::Result; + fn run(&self, ctx: &mut Context, rt: &mut Runtime, args: &Vec) + -> anyhow::Result; } diff --git a/src/env.rs b/shrs_lib/src/env.rs similarity index 100% rename from src/env.rs rename to shrs_lib/src/env.rs diff --git a/src/grammar.lalrpop b/shrs_lib/src/grammar.lalrpop similarity index 100% rename from src/grammar.lalrpop rename to shrs_lib/src/grammar.lalrpop diff --git a/shrs_lib/src/history.rs b/shrs_lib/src/history.rs new file mode 100644 index 00000000..137905b8 --- /dev/null +++ b/shrs_lib/src/history.rs @@ -0,0 +1,98 @@ +// TODO could make a history trait so users can implement their own history handlers + +// TODO configuration for history like max history length and if duplicates should be stored + +/// Simple history that keeps the history only for as long as program is running +#[derive(Clone)] +pub struct MemHistory { + // consider storing the parsed version of the command + data: Vec, +} + +// TODO sketch up a better History library (this current one is stupid and is just a wrapper for a vec) +impl MemHistory { + pub fn new() -> MemHistory { + Self { data: vec![] } + } + + /// Append command to history + pub fn add(&mut self, cmd: String) { + self.data.push(cmd); + } + + /// Wipe history + pub fn clear(&mut self) { + self.data.clear(); + } + + /// Get the last command that was executed + pub fn latest(&self) -> Option<&String> { + if self.data.is_empty() { + return None; + } + self.data.get(self.data.len() - 1) + } + + /// Get entire history + pub fn all(&self) -> &Vec { + &self.data + } + + /// Query history with filters and tags + pub fn search(&self, query: &str) { + todo!() + } +} + +use reedline::{HistoryItem, Result}; +impl reedline::History for MemHistory { + fn save(&mut self, h: reedline::HistoryItem) -> Result { + // TODO make use of HistoryItemId + self.add(h.command_line.clone()); + Ok(HistoryItem { + id: None, + start_timestamp: None, + command_line: h.command_line, + session_id: None, + hostname: None, + cwd: None, + duration: None, + exit_status: None, + more_info: None, // this seems to be some private type we can't use + }) + } + + fn load(&self, id: reedline::HistoryItemId) -> Result { + todo!() + } + + fn count(&self, query: reedline::SearchQuery) -> Result { + todo!() + } + + fn search(&self, query: reedline::SearchQuery) -> Result> { + todo!() + } + + fn update( + &mut self, + id: reedline::HistoryItemId, + updater: &dyn Fn(reedline::HistoryItem) -> reedline::HistoryItem, + ) -> Result<()> { + todo!() + } + + fn clear(&mut self) -> Result<()> { + self.clear(); + Ok(()) + } + + fn delete(&mut self, h: reedline::HistoryItemId) -> Result<()> { + // TODO curently NO OP + Ok(()) + } + + fn sync(&mut self) -> std::io::Result<()> { + Ok(()) + } +} diff --git a/src/lexer.rs b/shrs_lib/src/lexer.rs similarity index 100% rename from src/lexer.rs rename to shrs_lib/src/lexer.rs diff --git a/src/lib.rs b/shrs_lib/src/lib.rs similarity index 100% rename from src/lib.rs rename to shrs_lib/src/lib.rs diff --git a/src/parser.rs b/shrs_lib/src/parser.rs similarity index 100% rename from src/parser.rs rename to shrs_lib/src/parser.rs diff --git a/src/prompt.rs b/shrs_lib/src/prompt.rs similarity index 100% rename from src/prompt.rs rename to shrs_lib/src/prompt.rs diff --git a/src/runtime.rs b/shrs_lib/src/runtime.rs similarity index 100% rename from src/runtime.rs rename to shrs_lib/src/runtime.rs diff --git a/src/shell.rs b/shrs_lib/src/shell.rs similarity index 81% rename from src/shell.rs rename to shrs_lib/src/shell.rs index 57719257..238d7929 100644 --- a/src/shell.rs +++ b/shrs_lib/src/shell.rs @@ -8,13 +8,14 @@ use std::{ }; use anyhow::anyhow; +use reedline::{History, HistoryItem}; use crate::{ alias::Alias, ast::{self, Assign}, builtin::Builtins, env::Env, - history::History, + history::MemHistory, lexer::Lexer, parser, prompt::CustomPrompt, @@ -56,35 +57,46 @@ pub struct Shell { pub prompt: CustomPrompt, } -// Runtime context for the shell -#[derive(Clone)] +// (shared) shell context pub struct Context { - pub history: History, - pub env: Env, + pub history: Box, pub alias: Alias, - pub working_dir: PathBuf, } impl Default for Context { fn default() -> Self { Context { - history: History::new(), - env: Env::new(), + history: Box::new(MemHistory::new()), alias: Alias::new(), + } + } +} + +// Runtime context for the shell +#[derive(Clone)] +pub struct Runtime { + pub working_dir: PathBuf, + pub env: Env, +} + +impl Default for Runtime { + fn default() -> Self { + Runtime { + env: Env::new(), working_dir: std::env::current_dir().unwrap(), } } } impl Shell { - pub fn run(&self, ctx: &mut Context) -> anyhow::Result<()> { + pub fn run(&self, ctx: &mut Context, rt: &mut Runtime) -> anyhow::Result<()> { use reedline::{ default_vi_insert_keybindings, default_vi_normal_keybindings, Reedline, Signal, Vi, }; // init stuff sig_handler()?; - ctx.env.load(); + rt.env.load(); let mut line_editor = Reedline::create().with_edit_mode(Box::new(Vi::new( default_vi_insert_keybindings(), @@ -106,9 +118,6 @@ impl Shell { // attempt to expand alias let expanded = ctx.alias.get(&line).unwrap_or(&line).clone(); - // wether the command pre-alias expansion or post should be added to history could be a configuration option - ctx.history.add(expanded.clone()); - // TODO rewrite the error handling here better let lexer = Lexer::new(&expanded); let mut parser = parser::ParserContext::new(); @@ -120,7 +129,7 @@ impl Shell { }, }; let cmd_handle = - match self.eval_command(ctx, &cmd, Stdio::inherit(), Stdio::piped(), None) { + match self.eval_command(ctx, rt, &cmd, Stdio::inherit(), Stdio::piped(), None) { Ok(cmd_handle) => cmd_handle, Err(e) => { eprintln!("{}", e); @@ -136,6 +145,7 @@ impl Shell { pub fn eval_command( &self, ctx: &mut Context, + rt: &mut Runtime, cmd: &ast::Command, stdin: Stdio, stdout: Stdio, @@ -225,23 +235,23 @@ impl Shell { // TODO currently don't support assignment for builtins (should it be supported even?) match cmd_name.as_str() { - "cd" => self.builtins.cd.run(ctx, &args), - "exit" => self.builtins.exit.run(ctx, &args), - "history" => self.builtins.history.run(ctx, &args), - "debug" => self.builtins.debug.run(ctx, &args), + "cd" => self.builtins.cd.run(ctx, rt, &args), + "exit" => self.builtins.exit.run(ctx, rt, &args), + "history" => self.builtins.history.run(ctx, rt, &args), + "debug" => self.builtins.debug.run(ctx, rt, &args), _ => self.run_external_command( - ctx, &cmd_name, &args, cur_stdin, cur_stdout, None, assigns, + ctx, rt, &cmd_name, &args, cur_stdin, cur_stdout, None, assigns, ), } }, ast::Command::Pipeline(a_cmd, b_cmd) => { // TODO double check that pgid works properly for pipelines that are longer than one pipe (left recursiveness of parser might mess this up) let mut a_cmd_handle = - self.eval_command(ctx, a_cmd, stdin, Stdio::piped(), None)?; + self.eval_command(ctx, rt, a_cmd, stdin, Stdio::piped(), None)?; let piped_stdin = Stdio::from(a_cmd_handle.stdout.take().unwrap()); let pgid = a_cmd_handle.id(); let b_cmd_handle = - self.eval_command(ctx, b_cmd, piped_stdin, stdout, Some(pgid as i32))?; + self.eval_command(ctx, rt, b_cmd, piped_stdin, stdout, Some(pgid as i32))?; Ok(b_cmd_handle) }, ast::Command::Or(a_cmd, b_cmd) | ast::Command::And(a_cmd, b_cmd) => { @@ -252,7 +262,7 @@ impl Shell { }; // TODO double check if these stdin and stdou params are correct let a_cmd_handle = - self.eval_command(ctx, a_cmd, Stdio::inherit(), Stdio::piped(), None)?; + self.eval_command(ctx, rt, a_cmd, Stdio::inherit(), Stdio::piped(), None)?; if let Some(output) = self.command_output(a_cmd_handle)? { if output.status.success() ^ negate { // TODO return something better (indicate that command failed with exit code) @@ -260,23 +270,29 @@ impl Shell { } } let b_cmd_handle = - self.eval_command(ctx, b_cmd, Stdio::inherit(), Stdio::piped(), None)?; + self.eval_command(ctx, rt, b_cmd, Stdio::inherit(), Stdio::piped(), None)?; Ok(b_cmd_handle) }, ast::Command::Not(cmd) => { // TODO exit status negate - let cmd_handle = self.eval_command(ctx, cmd, stdin, stdout, None)?; + let cmd_handle = self.eval_command(ctx, rt, cmd, stdin, stdout, None)?; Ok(cmd_handle) }, ast::Command::AsyncList(a_cmd, b_cmd) => { let a_cmd_handle = - self.eval_command(ctx, a_cmd, Stdio::inherit(), Stdio::piped(), None)?; + self.eval_command(ctx, rt, a_cmd, Stdio::inherit(), Stdio::piped(), None)?; match b_cmd { None => Ok(a_cmd_handle), Some(b_cmd) => { - let b_cmd_handle = - self.eval_command(ctx, b_cmd, Stdio::inherit(), Stdio::piped(), None)?; + let b_cmd_handle = self.eval_command( + ctx, + rt, + b_cmd, + Stdio::inherit(), + Stdio::piped(), + None, + )?; Ok(b_cmd_handle) }, } @@ -284,14 +300,20 @@ impl Shell { ast::Command::SeqList(a_cmd, b_cmd) => { // TODO very similar to AsyncList let a_cmd_handle = - self.eval_command(ctx, a_cmd, Stdio::inherit(), Stdio::piped(), None)?; + self.eval_command(ctx, rt, a_cmd, Stdio::inherit(), Stdio::piped(), None)?; match b_cmd { None => Ok(a_cmd_handle), Some(b_cmd) => { self.command_output(a_cmd_handle)?; - let b_cmd_handle = - self.eval_command(ctx, b_cmd, Stdio::inherit(), Stdio::piped(), None)?; + let b_cmd_handle = self.eval_command( + ctx, + rt, + b_cmd, + Stdio::inherit(), + Stdio::piped(), + None, + )?; Ok(b_cmd_handle) }, } @@ -299,9 +321,15 @@ impl Shell { ast::Command::Subshell(cmd) => { // TODO rn history is being copied too, history (and also alias?) really should be global // maybe seperate out global context and runtime context into two structs? - let mut new_ctx = ctx.clone(); - let cmd_handle = - self.eval_command(&mut new_ctx, cmd, Stdio::inherit(), Stdio::piped(), None)?; + let mut new_rt = rt.clone(); + let cmd_handle = self.eval_command( + ctx, + &mut new_rt, + cmd, + Stdio::inherit(), + Stdio::piped(), + None, + )?; Ok(cmd_handle) }, ast::Command::If { conds, else_part } => { @@ -310,12 +338,13 @@ impl Shell { for ast::Condition { cond, body } in conds { let cond_handle = - self.eval_command(ctx, cond, Stdio::inherit(), Stdio::piped(), None)?; + self.eval_command(ctx, rt, cond, Stdio::inherit(), Stdio::piped(), None)?; // TODO sorta similar to and statements if let Some(output) = self.command_output(cond_handle)? { if output.status.success() { let body_handle = self.eval_command( ctx, + rt, body, Stdio::inherit(), Stdio::piped(), @@ -327,8 +356,14 @@ impl Shell { } if let Some(else_part) = else_part { - let else_handle = - self.eval_command(ctx, else_part, Stdio::inherit(), Stdio::piped(), None)?; + let else_handle = self.eval_command( + ctx, + rt, + else_part, + Stdio::inherit(), + Stdio::piped(), + None, + )?; return Ok(else_handle); } @@ -343,12 +378,13 @@ impl Shell { loop { let cond_handle = - self.eval_command(ctx, cond, Stdio::inherit(), Stdio::piped(), None)?; + self.eval_command(ctx, rt, cond, Stdio::inherit(), Stdio::piped(), None)?; // TODO sorta similar to if statements if let Some(output) = self.command_output(cond_handle)? { if output.status.success() ^ negate { let body_handle = self.eval_command( ctx, + rt, body, Stdio::inherit(), Stdio::piped(), @@ -371,6 +407,7 @@ impl Shell { fn run_external_command( &self, ctx: &mut Context, + rt: &mut Runtime, cmd: &str, args: &Vec, stdin: Stdio, @@ -387,7 +424,7 @@ impl Shell { .stdin(stdin) .stdout(stdout) .process_group(pgid.unwrap_or(0)) // pgid of 0 means use own pid as pgid - .current_dir(ctx.working_dir.to_str().unwrap()) + .current_dir(rt.working_dir.to_str().unwrap()) .envs(envs) .spawn()?; diff --git a/src/signal.rs b/shrs_lib/src/signal.rs similarity index 100% rename from src/signal.rs rename to shrs_lib/src/signal.rs diff --git a/src/history.rs b/src/history.rs deleted file mode 100644 index 20172316..00000000 --- a/src/history.rs +++ /dev/null @@ -1,44 +0,0 @@ -// TODO could make a history trait so users can implement their own history handlers - -// TODO configuration for history like max history length and if duplicates should be stored - -#[derive(Clone)] -pub struct History { - // consider storing the parsed version of the command - data: Vec, -} - -// TODO sketch up a better History library (this current one is stupid and is just a wrapper for a vec) -impl History { - pub fn new() -> History { - Self { data: vec![] } - } - - /// Append command to history - pub fn add(&mut self, cmd: String) { - self.data.push(cmd); - } - - /// Wipe history - pub fn clear(&mut self) { - self.data.clear(); - } - - /// Get the last command that was executed - pub fn latest(&self) -> Option<&String> { - if self.data.is_empty() { - return None; - } - self.data.get(self.data.len() - 1) - } - - /// Get entire history - pub fn all(&self) -> &Vec { - &self.data - } - - /// Query history with filters and tags - pub fn search(&self, query: &str) { - todo!() - } -}